Commit ee15cd7517d3ff66f9d365c281f67feee4bfa8bc

Authored by 刘宏哲
1 parent 72c74ab4

feat(app): update ui

@@ -22,12 +22,14 @@ class ActivityBurnReportView extends StatelessWidget { @@ -22,12 +22,14 @@ class ActivityBurnReportView extends StatelessWidget {
22 required this.isVip, 22 required this.isVip,
23 required this.onSubscribe, 23 required this.onSubscribe,
24 this.loadingIndicator = const ReportLoadingIndicator(), 24 this.loadingIndicator = const ReportLoadingIndicator(),
  25 + this.tooltipDismissDelayMs = 4000,
25 }); 26 });
26 27
27 final ActivityBurnReportLogic logic; 28 final ActivityBurnReportLogic logic;
28 final bool isVip; 29 final bool isVip;
29 final ValueChanged<String> onSubscribe; 30 final ValueChanged<String> onSubscribe;
30 final Widget loadingIndicator; 31 final Widget loadingIndicator;
  32 + final int tooltipDismissDelayMs;
31 33
32 @override 34 @override
33 Widget build(BuildContext context) { 35 Widget build(BuildContext context) {
@@ -75,12 +77,14 @@ class ActivityBurnReportView extends StatelessWidget { @@ -75,12 +77,14 @@ class ActivityBurnReportView extends StatelessWidget {
75 ActivityBurnWeekReportView( 77 ActivityBurnWeekReportView(
76 report: logic.weeklyReport.value, 78 report: logic.weeklyReport.value,
77 weekStart: logic.weekStart, 79 weekStart: logic.weekStart,
  80 + tooltipDismissDelayMs: tooltipDismissDelayMs,
78 ) 81 )
79 else if (logic.selectedPeriod.value == 82 else if (logic.selectedPeriod.value ==
80 ReportPeriod.month) 83 ReportPeriod.month)
81 ActivityBurnMonthReportView( 84 ActivityBurnMonthReportView(
82 report: logic.monthlyReport.value, 85 report: logic.monthlyReport.value,
83 monthStart: logic.monthStart, 86 monthStart: logic.monthStart,
  87 + tooltipDismissDelayMs: tooltipDismissDelayMs,
84 ) 88 )
85 else 89 else
86 _DailyReportContent(report: logic.report.value), 90 _DailyReportContent(report: logic.report.value),
  1 +import 'dart:async';
1 import 'dart:math' as math; 2 import 'dart:math' as math;
2 3
3 import 'package:fl_chart/fl_chart.dart'; 4 import 'package:fl_chart/fl_chart.dart';
@@ -19,10 +20,12 @@ class ActivityBurnMonthReportView extends StatelessWidget { @@ -19,10 +20,12 @@ class ActivityBurnMonthReportView extends StatelessWidget {
19 super.key, 20 super.key,
20 required this.report, 21 required this.report,
21 required this.monthStart, 22 required this.monthStart,
  23 + this.tooltipDismissDelayMs = 0,
22 }); 24 });
23 25
24 final MonthlyActivityBurnReport? report; 26 final MonthlyActivityBurnReport? report;
25 final DateTime monthStart; 27 final DateTime monthStart;
  28 + final int tooltipDismissDelayMs;
26 29
27 static const _h1 = Color(0xFF0F0F11); 30 static const _h1 = Color(0xFF0F0F11);
28 static const _h2 = Color(0xFF78787D); 31 static const _h2 = Color(0xFF78787D);
@@ -40,7 +43,10 @@ class ActivityBurnMonthReportView extends StatelessWidget { @@ -40,7 +43,10 @@ class ActivityBurnMonthReportView extends StatelessWidget {
40 const SizedBox(height: 16), 43 const SizedBox(height: 16),
41 _MonthRingCard(report: monthly), 44 _MonthRingCard(report: monthly),
42 const SizedBox(height: 12), 45 const SizedBox(height: 12),
43 - _MonthEnergyTrendCard(report: monthly), 46 + _MonthEnergyTrendCard(
  47 + report: monthly,
  48 + tooltipDismissDelayMs: tooltipDismissDelayMs,
  49 + ),
44 ], 50 ],
45 ); 51 );
46 } 52 }
@@ -406,9 +412,13 @@ class _MonthGrid extends StatelessWidget { @@ -406,9 +412,13 @@ class _MonthGrid extends StatelessWidget {
406 } 412 }
407 413
408 class _MonthEnergyTrendCard extends StatefulWidget { 414 class _MonthEnergyTrendCard extends StatefulWidget {
409 - const _MonthEnergyTrendCard({required this.report}); 415 + const _MonthEnergyTrendCard({
  416 + required this.report,
  417 + required this.tooltipDismissDelayMs,
  418 + });
410 419
411 final MonthlyActivityBurnReport report; 420 final MonthlyActivityBurnReport report;
  421 + final int tooltipDismissDelayMs;
412 422
413 @override 423 @override
414 State<_MonthEnergyTrendCard> createState() => _MonthEnergyTrendCardState(); 424 State<_MonthEnergyTrendCard> createState() => _MonthEnergyTrendCardState();
@@ -417,9 +427,38 @@ class _MonthEnergyTrendCard extends StatefulWidget { @@ -417,9 +427,38 @@ class _MonthEnergyTrendCard extends StatefulWidget {
417 class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { 427 class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
418 static const _chartTopInset = 8.0; 428 static const _chartTopInset = 8.0;
419 static const _barColor = Color(0xFFFF5279); 429 static const _barColor = Color(0xFFFF5279);
  430 + static const _barWidth = 4.0;
  431 + static const _xAxisLeftInset = 16.0;
  432 + static const _xAxisRightInset = 30.0;
  433 + static const _tooltipAnchorRatio = 1;
  434 + static const _tooltipMargin = 6.0;
420 435
421 int? _touchedIndex; 436 int? _touchedIndex;
422 - Offset? _touchedOffset; 437 + Timer? _tooltipDismissTimer;
  438 +
  439 + @override
  440 + void dispose() {
  441 + _tooltipDismissTimer?.cancel();
  442 + super.dispose();
  443 + }
  444 +
  445 + void _clearTooltip() {
  446 + if (_touchedIndex == null) return;
  447 + setState(() => _touchedIndex = null);
  448 + }
  449 +
  450 + void _scheduleTooltipDismissal() {
  451 + final delayMs = widget.tooltipDismissDelayMs;
  452 + if (delayMs <= 0) {
  453 + _clearTooltip();
  454 + return;
  455 + }
  456 + if (_touchedIndex == null) return;
  457 + _tooltipDismissTimer?.cancel();
  458 + _tooltipDismissTimer = Timer(Duration(milliseconds: delayMs), () {
  459 + if (mounted) _clearTooltip();
  460 + });
  461 + }
423 462
424 @override 463 @override
425 Widget build(BuildContext context) { 464 Widget build(BuildContext context) {
@@ -507,6 +546,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -507,6 +546,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
507 Expanded( 546 Expanded(
508 child: Stack( 547 child: Stack(
509 alignment: Alignment.center, 548 alignment: Alignment.center,
  549 + clipBehavior: Clip.none,
510 children: [ 550 children: [
511 Positioned( 551 Positioned(
512 left: 0, 552 left: 0,
@@ -518,34 +558,55 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -518,34 +558,55 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
518 average: widget.report.averageDailyActiveEnergy.toDouble(), 558 average: widget.report.averageDailyActiveEnergy.toDouble(),
519 target: widget.report.activeEnergyGoal.toDouble(), 559 target: widget.report.activeEnergyGoal.toDouble(),
520 child: LayoutBuilder( 560 child: LayoutBuilder(
521 - builder: (context, constraints) => BarChart(  
522 - _chartData(maxY, constraints.maxWidth), 561 + builder: (context, constraints) => Listener(
  562 + behavior: HitTestBehavior.opaque,
  563 + onPointerDown: (event) => _selectTooltipAt(
  564 + event.localPosition.dx,
  565 + constraints.maxWidth,
  566 + ),
  567 + onPointerMove: (event) => _selectTooltipAt(
  568 + event.localPosition.dx,
  569 + constraints.maxWidth,
  570 + ),
  571 + onPointerUp: (_) => _scheduleTooltipDismissal(),
  572 + onPointerCancel: (_) => _scheduleTooltipDismissal(),
  573 + child: Transform.translate(
  574 + offset: const Offset(
  575 + (_xAxisLeftInset - _xAxisRightInset) / 2,
  576 + 0,
  577 + ),
  578 + child: BarChart(
  579 + _chartData(maxY, constraints.maxWidth),
  580 + ),
  581 + ),
523 ), 582 ),
524 ), 583 ),
525 ), 584 ),
526 ), 585 ),
527 - if (_touchedOffset != null) 586 + if (_touchedIndex != null)
528 Positioned.fill( 587 Positioned.fill(
529 top: _chartTopInset, 588 top: _chartTopInset,
530 child: LayoutBuilder( 589 child: LayoutBuilder(
531 builder: (context, constraints) { 590 builder: (context, constraints) {
532 return ChartSelectionLineOverlay( 591 return ChartSelectionLineOverlay(
533 - offset: _touchedOffset!, 592 + offset: Offset.zero,
534 color: ActivityBurnMonthReportView._h3, 593 color: ActivityBurnMonthReportView._h3,
535 bottomTitleHeight: 594 bottomTitleHeight:
536 ActivityBurnTrendPlotFrame.bottomTitleHeight, 595 ActivityBurnTrendPlotFrame.bottomTitleHeight,
537 - tooltipMargin: 6,  
538 - plotLeft: ActivityBurnTrendPlotFrame.horizontalInset,  
539 - plotRight: ActivityBurnTrendPlotFrame.rightAxisWidth,  
540 - lineX: _lineXForIndex(constraints.maxWidth, 4), 596 + tooltipMargin: _tooltipMargin,
  597 + plotLeft: _xAxisLeftInset,
  598 + plotRight: _xAxisRightInset,
  599 + lineX: _lineXForIndex(constraints.maxWidth),
  600 + lineTop:
  601 + _tooltipBottomForHeight(constraints.maxHeight),
541 ); 602 );
542 }, 603 },
543 ), 604 ),
544 ), 605 ),
545 if (!hasChartData) 606 if (!hasChartData)
546 Positioned( 607 Positioned(
547 - left: ActivityBurnTrendPlotFrame.horizontalInset,  
548 - right: ActivityBurnTrendPlotFrame.rightAxisWidth, 608 + left: _xAxisLeftInset,
  609 + right: _xAxisRightInset,
549 top: _chartTopInset, 610 top: _chartTopInset,
550 bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight, 611 bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
551 child: Center( 612 child: Center(
@@ -601,53 +662,91 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -601,53 +662,91 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
601 return (value / 5).ceil() * 5.0; 662 return (value / 5).ceil() * 5.0;
602 } 663 }
603 664
604 - double? _lineXForIndex(double width, double barWidth) { 665 + double? _lineXForIndex(double width) {
605 final index = _touchedIndex; 666 final index = _touchedIndex;
606 final count = widget.report.days.length; 667 final count = widget.report.days.length;
607 if (index == null || index < 0 || index >= count || count == 0) { 668 if (index == null || index < 0 || index >= count || count == 0) {
608 return null; 669 return null;
609 } 670 }
610 - final plotLeft = ActivityBurnTrendPlotFrame.horizontalInset;  
611 - final plotRight = ActivityBurnTrendPlotFrame.rightAxisWidth; 671 + const plotLeft = _xAxisLeftInset;
  672 + const plotRight = _xAxisRightInset;
612 final plotWidth = width - plotLeft - plotRight; 673 final plotWidth = width - plotLeft - plotRight;
613 if (plotWidth <= 0) return null; 674 if (plotWidth <= 0) return null;
614 if (count == 1) return plotLeft + plotWidth / 2; 675 if (count == 1) return plotLeft + plotWidth / 2;
615 - final groupsSpace = _barGroupsSpace(plotWidth, count, barWidth);  
616 - return plotLeft + barWidth / 2 + index * (barWidth + groupsSpace); 676 + final groupsSpace = _groupsSpace(plotWidth, count);
  677 + final chartWidth = count * _barWidth + (count - 1) * groupsSpace;
  678 + final chartLeft = plotLeft + (plotWidth - chartWidth) / 2;
  679 + return chartLeft +
  680 + _barWidth / 2 +
  681 + index * (_barWidth + groupsSpace);
  682 + }
  683 +
  684 + double _tooltipBottomForHeight(double height) {
  685 + final plotHeight =
  686 + height - ActivityBurnTrendPlotFrame.bottomTitleHeight;
  687 + return plotHeight * (1 - _tooltipAnchorRatio) - _tooltipMargin;
  688 + }
  689 +
  690 + int? _indexForTouch(double? dx, double plotWidth) {
  691 + if (dx == null || plotWidth <= 0 || widget.report.days.isEmpty) {
  692 + return null;
  693 + }
  694 + final count = widget.report.days.length;
  695 + if (count == 1) return 0;
  696 + final chartWidth = plotWidth - _xAxisLeftInset - _xAxisRightInset;
  697 + if (chartWidth <= 0) return null;
  698 + final groupsSpace = _groupsSpace(chartWidth, count);
  699 + const chartLeft = _xAxisLeftInset;
  700 + final index =
  701 + ((dx - chartLeft - _barWidth / 2) / (_barWidth + groupsSpace)).round();
  702 + return index.clamp(0, count - 1).toInt();
  703 + }
  704 +
  705 + void _selectTooltipAt(double? dx, double plotWidth) {
  706 + _tooltipDismissTimer?.cancel();
  707 + final touchedIndex = _indexForTouch(dx, plotWidth);
  708 + final hasTouchedData = touchedIndex != null &&
  709 + (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0;
  710 + final nextIndex = hasTouchedData ? touchedIndex : null;
  711 + if (_touchedIndex == nextIndex) return;
  712 + setState(() => _touchedIndex = nextIndex);
  713 + }
  714 +
  715 + double _groupsSpace(double plotWidth, int count) {
  716 + if (count <= 1) return 0;
  717 + final space = (plotWidth - _barWidth * count) / (count - 1);
  718 + return math.max(0.0, space);
617 } 719 }
618 720
619 BarChartData _chartData(double maxY, double plotWidth) { 721 BarChartData _chartData(double maxY, double plotWidth) {
620 final hasData = widget.report.hasData; 722 final hasData = widget.report.hasData;
  723 + final chartWidth =
  724 + math.max(0.0, plotWidth - _xAxisLeftInset - _xAxisRightInset);
621 return BarChartData( 725 return BarChartData(
622 minY: 0, 726 minY: 0,
623 maxY: maxY, 727 maxY: maxY,
624 - alignment: BarChartAlignment.spaceBetween,  
625 - groupsSpace: _barGroupsSpace(plotWidth, widget.report.days.length, 4), 728 + alignment: BarChartAlignment.center,
  729 + groupsSpace: _groupsSpace(chartWidth, widget.report.days.length),
626 gridData: FlGridData( 730 gridData: FlGridData(
627 show: false, 731 show: false,
628 ), 732 ),
629 borderData: FlBorderData(show: false), 733 borderData: FlBorderData(show: false),
630 barTouchData: BarTouchData( 734 barTouchData: BarTouchData(
631 enabled: hasData, 735 enabled: hasData,
632 - touchCallback: (event, response) {  
633 - final touchedIndex = response?.spot?.touchedBarGroupIndex;  
634 - if (!event.isInterestedForInteractions || touchedIndex == null) {  
635 - if (_touchedIndex != null || _touchedOffset != null) {  
636 - setState(() {  
637 - _touchedIndex = null;  
638 - _touchedOffset = null;  
639 - });  
640 - } 736 + handleBuiltInTouches: false,
  737 + touchExtraThreshold: const EdgeInsets.symmetric(
  738 + horizontal: 32,
  739 + vertical: 80,
  740 + ),
  741 + touchCallback: (event, _) {
  742 + if (!event.isInterestedForInteractions) {
  743 + _scheduleTooltipDismissal();
641 return; 744 return;
642 } 745 }
643 - final touchedOffset = response?.spot?.offset;  
644 - if (_touchedIndex != touchedIndex ||  
645 - _touchedOffset != touchedOffset) {  
646 - setState(() {  
647 - _touchedIndex = touchedIndex;  
648 - _touchedOffset = touchedOffset;  
649 - });  
650 - } 746 + _selectTooltipAt(
  747 + event.localPosition?.dx,
  748 + plotWidth,
  749 + );
651 }, 750 },
652 touchTooltipData: BarTouchTooltipData( 751 touchTooltipData: BarTouchTooltipData(
653 tooltipRoundedRadius: 4, 752 tooltipRoundedRadius: 4,
@@ -655,8 +754,10 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -655,8 +754,10 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
655 horizontal: 10, 754 horizontal: 10,
656 vertical: 7, 755 vertical: 7,
657 ), 756 ),
658 - tooltipMargin: 6, 757 + tooltipMargin: _tooltipMargin,
659 getTooltipColor: (_) => const Color(0xFFF3F3F3), 758 getTooltipColor: (_) => const Color(0xFFF3F3F3),
  759 + fitInsideHorizontally: true,
  760 + fitInsideVertically: false,
660 getTooltipItem: (group, groupIndex, rod, rodIndex) { 761 getTooltipItem: (group, groupIndex, rod, rodIndex) {
661 final report = widget.report.days[groupIndex]; 762 final report = widget.report.days[groupIndex];
662 return BarTooltipItem( 763 return BarTooltipItem(
@@ -713,9 +814,13 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -713,9 +814,13 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
713 sideTitles: SideTitles( 814 sideTitles: SideTitles(
714 showTitles: true, 815 showTitles: true,
715 reservedSize: ActivityBurnTrendPlotFrame.bottomTitleHeight, 816 reservedSize: ActivityBurnTrendPlotFrame.bottomTitleHeight,
716 - interval: 5, 817 + interval: 1,
717 getTitlesWidget: (value, meta) { 818 getTitlesWidget: (value, meta) {
718 - final day = value.toInt() + 1; 819 + final index = value.toInt();
  820 + if (index < 0 || index >= widget.report.days.length) {
  821 + return const SizedBox.shrink();
  822 + }
  823 + final day = widget.report.days[index].date.day;
719 if (!_shouldShowBottomTitle(day)) { 824 if (!_shouldShowBottomTitle(day)) {
720 return const SizedBox.shrink(); 825 return const SizedBox.shrink();
721 } 826 }
@@ -739,6 +844,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -739,6 +844,8 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
739 for (var i = 0; i < widget.report.days.length; i++) 844 for (var i = 0; i < widget.report.days.length; i++)
740 BarChartGroupData( 845 BarChartGroupData(
741 x: i, 846 x: i,
  847 + groupVertically: true,
  848 + showingTooltipIndicators: _touchedIndex == i ? [1] : [],
742 barRods: [ 849 barRods: [
743 BarChartRodData( 850 BarChartRodData(
744 fromY: 0, 851 fromY: 0,
@@ -746,11 +853,18 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -746,11 +853,18 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
746 widget.report.days[i].activeEnergy?.value ?? 0, 853 widget.report.days[i].activeEnergy?.value ?? 0,
747 maxY, 854 maxY,
748 ), 855 ),
749 - width: 4, 856 + width: _barWidth,
750 borderRadius: 857 borderRadius:
751 const BorderRadius.vertical(top: Radius.circular(4)), 858 const BorderRadius.vertical(top: Radius.circular(4)),
752 color: _barColor, 859 color: _barColor,
753 ), 860 ),
  861 + // Anchor the tooltip at a constant height, independent of the
  862 + // visible bar's value.
  863 + BarChartRodData(
  864 + toY: maxY * _tooltipAnchorRatio,
  865 + width: 0,
  866 + color: Colors.transparent,
  867 + ),
754 ], 868 ],
755 ), 869 ),
756 ], 870 ],
@@ -772,12 +886,6 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -772,12 +886,6 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
772 } 886 }
773 } 887 }
774 888
775 -double _barGroupsSpace(double plotWidth, int count, double barWidth) {  
776 - if (count <= 1) return 0;  
777 - final space = (plotWidth - barWidth * count) / (count - 1);  
778 - return space < 0 ? 0 : space;  
779 -}  
780 -  
781 class _TrendLegend extends StatelessWidget { 889 class _TrendLegend extends StatelessWidget {
782 const _TrendLegend({ 890 const _TrendLegend({
783 required this.color, 891 required this.color,
@@ -72,13 +72,7 @@ class ActivityBurnTrendPlotFrame extends StatelessWidget { @@ -72,13 +72,7 @@ class ActivityBurnTrendPlotFrame extends StatelessWidget {
72 ), 72 ),
73 ), 73 ),
74 ), 74 ),
75 - Padding(  
76 - padding: const EdgeInsets.only(  
77 - left: horizontalInset,  
78 - right: rightAxisWidth,  
79 - ),  
80 - child: child,  
81 - ), 75 + child,
82 Positioned.fill( 76 Positioned.fill(
83 bottom: bottomTitleHeight, 77 bottom: bottomTitleHeight,
84 child: _ActivityBurnRightAxisLabels( 78 child: _ActivityBurnRightAxisLabels(
  1 +import 'dart:async';
1 import 'dart:math' as math; 2 import 'dart:math' as math;
2 3
3 import 'package:fl_chart/fl_chart.dart'; 4 import 'package:fl_chart/fl_chart.dart';
@@ -18,10 +19,12 @@ class ActivityBurnWeekReportView extends StatelessWidget { @@ -18,10 +19,12 @@ class ActivityBurnWeekReportView extends StatelessWidget {
18 super.key, 19 super.key,
19 required this.report, 20 required this.report,
20 required this.weekStart, 21 required this.weekStart,
  22 + this.tooltipDismissDelayMs = 0,
21 }); 23 });
22 24
23 final WeeklyActivityBurnReport? report; 25 final WeeklyActivityBurnReport? report;
24 final DateTime weekStart; 26 final DateTime weekStart;
  27 + final int tooltipDismissDelayMs;
25 28
26 static const _h1 = Color(0xFF0F0F11); 29 static const _h1 = Color(0xFF0F0F11);
27 static const _h2 = Color(0xFF78787D); 30 static const _h2 = Color(0xFF78787D);
@@ -40,7 +43,10 @@ class ActivityBurnWeekReportView extends StatelessWidget { @@ -40,7 +43,10 @@ class ActivityBurnWeekReportView extends StatelessWidget {
40 const SizedBox(height: 16), 43 const SizedBox(height: 16),
41 _RingOverviewCard(report: weekly), 44 _RingOverviewCard(report: weekly),
42 const SizedBox(height: 12), 45 const SizedBox(height: 12),
43 - _EnergyTrendCard(report: weekly), 46 + _EnergyTrendCard(
  47 + report: weekly,
  48 + tooltipDismissDelayMs: tooltipDismissDelayMs,
  49 + ),
44 ], 50 ],
45 ); 51 );
46 } 52 }
@@ -383,9 +389,13 @@ class _DayRing extends StatelessWidget { @@ -383,9 +389,13 @@ class _DayRing extends StatelessWidget {
383 } 389 }
384 390
385 class _EnergyTrendCard extends StatefulWidget { 391 class _EnergyTrendCard extends StatefulWidget {
386 - const _EnergyTrendCard({required this.report}); 392 + const _EnergyTrendCard({
  393 + required this.report,
  394 + required this.tooltipDismissDelayMs,
  395 + });
387 396
388 final WeeklyActivityBurnReport report; 397 final WeeklyActivityBurnReport report;
  398 + final int tooltipDismissDelayMs;
389 399
390 @override 400 @override
391 State<_EnergyTrendCard> createState() => _EnergyTrendCardState(); 401 State<_EnergyTrendCard> createState() => _EnergyTrendCardState();
@@ -394,9 +404,36 @@ class _EnergyTrendCard extends StatefulWidget { @@ -394,9 +404,36 @@ class _EnergyTrendCard extends StatefulWidget {
394 class _EnergyTrendCardState extends State<_EnergyTrendCard> { 404 class _EnergyTrendCardState extends State<_EnergyTrendCard> {
395 static const _chartTopInset = 8.0; 405 static const _chartTopInset = 8.0;
396 static const _barColor = Color(0xFFFF5279); 406 static const _barColor = Color(0xFFFF5279);
  407 + static const _barGap = 28.0;
  408 + static const _tooltipAnchorRatio = 1;
  409 + static const _tooltipMargin = 6.0;
397 410
398 int? _touchedIndex; 411 int? _touchedIndex;
399 - Offset? _touchedOffset; 412 + Timer? _tooltipDismissTimer;
  413 +
  414 + @override
  415 + void dispose() {
  416 + _tooltipDismissTimer?.cancel();
  417 + super.dispose();
  418 + }
  419 +
  420 + void _clearTooltip() {
  421 + if (_touchedIndex == null) return;
  422 + setState(() => _touchedIndex = null);
  423 + }
  424 +
  425 + void _scheduleTooltipDismissal() {
  426 + final delayMs = widget.tooltipDismissDelayMs;
  427 + if (delayMs <= 0) {
  428 + _clearTooltip();
  429 + return;
  430 + }
  431 + if (_touchedIndex == null) return;
  432 + _tooltipDismissTimer?.cancel();
  433 + _tooltipDismissTimer = Timer(Duration(milliseconds: delayMs), () {
  434 + if (mounted) _clearTooltip();
  435 + });
  436 + }
400 437
401 @override 438 @override
402 Widget build(BuildContext context) { 439 Widget build(BuildContext context) {
@@ -484,6 +521,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -484,6 +521,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
484 Expanded( 521 Expanded(
485 child: Stack( 522 child: Stack(
486 alignment: Alignment.center, 523 alignment: Alignment.center,
  524 + clipBehavior: Clip.none,
487 children: [ 525 children: [
488 Positioned( 526 Positioned(
489 left: 0, 527 left: 0,
@@ -495,34 +533,51 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -495,34 +533,51 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
495 average: widget.report.averageDailyActiveEnergy.toDouble(), 533 average: widget.report.averageDailyActiveEnergy.toDouble(),
496 target: widget.report.activeEnergyGoal.toDouble(), 534 target: widget.report.activeEnergyGoal.toDouble(),
497 child: LayoutBuilder( 535 child: LayoutBuilder(
498 - builder: (context, constraints) => BarChart(  
499 - _chartData(maxY, constraints.maxWidth), 536 + builder: (context, constraints) => Listener(
  537 + behavior: HitTestBehavior.opaque,
  538 + onPointerDown: (event) => _selectTooltipAt(
  539 + event.localPosition.dx,
  540 + constraints.maxWidth,
  541 + 16,
  542 + ),
  543 + onPointerMove: (event) => _selectTooltipAt(
  544 + event.localPosition.dx,
  545 + constraints.maxWidth,
  546 + 16,
  547 + ),
  548 + onPointerUp: (_) => _scheduleTooltipDismissal(),
  549 + onPointerCancel: (_) => _scheduleTooltipDismissal(),
  550 + child: BarChart(
  551 + _chartData(maxY, constraints.maxWidth),
  552 + ),
500 ), 553 ),
501 ), 554 ),
502 ), 555 ),
503 ), 556 ),
504 - if (_touchedOffset != null) 557 + if (_touchedIndex != null)
505 Positioned.fill( 558 Positioned.fill(
506 top: _chartTopInset, 559 top: _chartTopInset,
507 child: LayoutBuilder( 560 child: LayoutBuilder(
508 builder: (context, constraints) { 561 builder: (context, constraints) {
509 return ChartSelectionLineOverlay( 562 return ChartSelectionLineOverlay(
510 - offset: _touchedOffset!, 563 + offset: Offset.zero,
511 color: ActivityBurnWeekReportView._h3, 564 color: ActivityBurnWeekReportView._h3,
512 bottomTitleHeight: 565 bottomTitleHeight:
513 ActivityBurnTrendPlotFrame.bottomTitleHeight, 566 ActivityBurnTrendPlotFrame.bottomTitleHeight,
514 - tooltipMargin: 6,  
515 - plotLeft: ActivityBurnTrendPlotFrame.horizontalInset,  
516 - plotRight: ActivityBurnTrendPlotFrame.rightAxisWidth, 567 + tooltipMargin: _tooltipMargin,
  568 + plotLeft: 0,
  569 + plotRight: 0,
517 lineX: _lineXForIndex(constraints.maxWidth, 16), 570 lineX: _lineXForIndex(constraints.maxWidth, 16),
  571 + lineTop:
  572 + _tooltipBottomForHeight(constraints.maxHeight),
518 ); 573 );
519 }, 574 },
520 ), 575 ),
521 ), 576 ),
522 if (!hasChartData) 577 if (!hasChartData)
523 Positioned( 578 Positioned(
524 - left: ActivityBurnTrendPlotFrame.horizontalInset,  
525 - right: ActivityBurnTrendPlotFrame.rightAxisWidth, 579 + left: 0,
  580 + right: 0,
526 top: _chartTopInset, 581 top: _chartTopInset,
527 bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight, 582 bottom: ActivityBurnTrendPlotFrame.bottomTitleHeight,
528 child: Center( 583 child: Center(
@@ -584,13 +639,41 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -584,13 +639,41 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
584 if (index == null || index < 0 || index >= count || count == 0) { 639 if (index == null || index < 0 || index >= count || count == 0) {
585 return null; 640 return null;
586 } 641 }
587 - final plotLeft = ActivityBurnTrendPlotFrame.horizontalInset;  
588 - final plotRight = ActivityBurnTrendPlotFrame.rightAxisWidth; 642 + const plotLeft = 0.0;
  643 + const plotRight = 0.0;
589 final plotWidth = width - plotLeft - plotRight; 644 final plotWidth = width - plotLeft - plotRight;
590 if (plotWidth <= 0) return null; 645 if (plotWidth <= 0) return null;
591 - if (count == 1) return plotLeft + plotWidth / 2;  
592 - final groupsSpace = _barGroupsSpace(plotWidth, count, barWidth);  
593 - return plotLeft + barWidth / 2 + index * (barWidth + groupsSpace); 646 + final chartWidth = count * barWidth + (count - 1) * _barGap;
  647 + final chartLeft = plotLeft + (plotWidth - chartWidth) / 2;
  648 + return chartLeft + barWidth / 2 + index * (barWidth + _barGap);
  649 + }
  650 +
  651 + double _tooltipBottomForHeight(double height) {
  652 + final plotHeight =
  653 + height - ActivityBurnTrendPlotFrame.bottomTitleHeight;
  654 + return plotHeight * (1 - _tooltipAnchorRatio) - _tooltipMargin;
  655 + }
  656 +
  657 + int? _indexForTouch(double? dx, double plotWidth, double barWidth) {
  658 + if (dx == null || plotWidth <= 0 || widget.report.days.isEmpty) {
  659 + return null;
  660 + }
  661 + final count = widget.report.days.length;
  662 + final chartWidth = count * barWidth + (count - 1) * _barGap;
  663 + final chartLeft = (plotWidth - chartWidth) / 2;
  664 + final index =
  665 + ((dx - chartLeft - barWidth / 2) / (barWidth + _barGap)).round();
  666 + return index.clamp(0, count - 1).toInt();
  667 + }
  668 +
  669 + void _selectTooltipAt(double? dx, double plotWidth, double barWidth) {
  670 + _tooltipDismissTimer?.cancel();
  671 + final touchedIndex = _indexForTouch(dx, plotWidth, barWidth);
  672 + final hasTouchedData = touchedIndex != null &&
  673 + (widget.report.days[touchedIndex].activeEnergy?.value ?? 0) > 0;
  674 + final nextIndex = hasTouchedData ? touchedIndex : null;
  675 + if (_touchedIndex == nextIndex) return;
  676 + setState(() => _touchedIndex = nextIndex);
594 } 677 }
595 678
596 BarChartData _chartData(double maxY, double plotWidth) { 679 BarChartData _chartData(double maxY, double plotWidth) {
@@ -598,33 +681,29 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -598,33 +681,29 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
598 return BarChartData( 681 return BarChartData(
599 minY: 0, 682 minY: 0,
600 maxY: maxY, 683 maxY: maxY,
601 - alignment: BarChartAlignment.spaceBetween,  
602 - groupsSpace: _barGroupsSpace(plotWidth, widget.report.days.length, 16), 684 + alignment: BarChartAlignment.center,
  685 + groupsSpace: _barGap,
603 gridData: FlGridData( 686 gridData: FlGridData(
604 show: false, 687 show: false,
605 ), 688 ),
606 borderData: FlBorderData(show: false), 689 borderData: FlBorderData(show: false),
607 barTouchData: BarTouchData( 690 barTouchData: BarTouchData(
608 enabled: hasData, 691 enabled: hasData,
609 - touchCallback: (event, response) {  
610 - final touchedIndex = response?.spot?.touchedBarGroupIndex;  
611 - if (!event.isInterestedForInteractions || touchedIndex == null) {  
612 - if (_touchedIndex != null || _touchedOffset != null) {  
613 - setState(() {  
614 - _touchedIndex = null;  
615 - _touchedOffset = null;  
616 - });  
617 - } 692 + handleBuiltInTouches: false,
  693 + touchExtraThreshold: const EdgeInsets.symmetric(
  694 + horizontal: 32,
  695 + vertical: 80,
  696 + ),
  697 + touchCallback: (event, _) {
  698 + if (!event.isInterestedForInteractions) {
  699 + _scheduleTooltipDismissal();
618 return; 700 return;
619 } 701 }
620 - final touchedOffset = response?.spot?.offset;  
621 - if (_touchedIndex != touchedIndex ||  
622 - _touchedOffset != touchedOffset) {  
623 - setState(() {  
624 - _touchedIndex = touchedIndex;  
625 - _touchedOffset = touchedOffset;  
626 - });  
627 - } 702 + _selectTooltipAt(
  703 + event.localPosition?.dx,
  704 + plotWidth,
  705 + 16,
  706 + );
628 }, 707 },
629 touchTooltipData: BarTouchTooltipData( 708 touchTooltipData: BarTouchTooltipData(
630 tooltipRoundedRadius: 4, 709 tooltipRoundedRadius: 4,
@@ -632,8 +711,10 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -632,8 +711,10 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
632 horizontal: 10, 711 horizontal: 10,
633 vertical: 7, 712 vertical: 7,
634 ), 713 ),
635 - tooltipMargin: 6, 714 + tooltipMargin: _tooltipMargin,
636 getTooltipColor: (_) => const Color(0xFFF3F3F3), 715 getTooltipColor: (_) => const Color(0xFFF3F3F3),
  716 + fitInsideHorizontally: true,
  717 + fitInsideVertically: false,
637 getTooltipItem: (group, groupIndex, rod, rodIndex) { 718 getTooltipItem: (group, groupIndex, rod, rodIndex) {
638 final report = widget.report.days[groupIndex]; 719 final report = widget.report.days[groupIndex];
639 return BarTooltipItem( 720 return BarTooltipItem(
@@ -729,6 +810,8 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -729,6 +810,8 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
729 for (var i = 0; i < widget.report.days.length; i++) 810 for (var i = 0; i < widget.report.days.length; i++)
730 BarChartGroupData( 811 BarChartGroupData(
731 x: i, 812 x: i,
  813 + groupVertically: true,
  814 + showingTooltipIndicators: _touchedIndex == i ? [1] : [],
732 barRods: [ 815 barRods: [
733 BarChartRodData( 816 BarChartRodData(
734 fromY: 0, 817 fromY: 0,
@@ -742,6 +825,13 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -742,6 +825,13 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
742 color: _barColor, 825 color: _barColor,
743 backDrawRodData: BackgroundBarChartRodData(show: false), 826 backDrawRodData: BackgroundBarChartRodData(show: false),
744 ), 827 ),
  828 + // Anchor the tooltip at a constant height, independent of the
  829 + // visible bar's value.
  830 + BarChartRodData(
  831 + toY: maxY * _tooltipAnchorRatio,
  832 + width: 0,
  833 + color: Colors.transparent,
  834 + ),
745 ], 835 ],
746 ), 836 ),
747 ], 837 ],
@@ -756,12 +846,6 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -756,12 +846,6 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
756 } 846 }
757 } 847 }
758 848
759 -double _barGroupsSpace(double plotWidth, int count, double barWidth) {  
760 - if (count <= 1) return 0;  
761 - final space = (plotWidth - barWidth * count) / (count - 1);  
762 - return space < 0 ? 0 : space;  
763 -}  
764 -  
765 class _TrendLegend extends StatelessWidget { 849 class _TrendLegend extends StatelessWidget {
766 const _TrendLegend({ 850 const _TrendLegend({
767 required this.color, 851 required this.color,
@@ -24,6 +24,7 @@ class HealthTrendContent extends StatefulWidget { @@ -24,6 +24,7 @@ class HealthTrendContent extends StatefulWidget {
24 required this.query, 24 required this.query,
25 required this.selectedTypeIndex, 25 required this.selectedTypeIndex,
26 this.refreshToken = 0, 26 this.refreshToken = 0,
  27 + this.allowTabSwipe = false,
27 required this.onTypeChanged, 28 required this.onTypeChanged,
28 required this.onQueryChanged, 29 required this.onQueryChanged,
29 this.queryForType, 30 this.queryForType,
@@ -32,6 +33,9 @@ class HealthTrendContent extends StatefulWidget { @@ -32,6 +33,9 @@ class HealthTrendContent extends StatefulWidget {
32 final HealthReportQuery query; 33 final HealthReportQuery query;
33 final int selectedTypeIndex; 34 final int selectedTypeIndex;
34 final int refreshToken; 35 final int refreshToken;
  36 + /// Whether users can switch trend types by horizontally swiping the content.
  37 + /// Tapping a tab remains available when this is false.
  38 + final bool allowTabSwipe;
35 final ValueChanged<int> onTypeChanged; 39 final ValueChanged<int> onTypeChanged;
36 final void Function(ReportPeriod period, DateTime date) onQueryChanged; 40 final void Function(ReportPeriod period, DateTime date) onQueryChanged;
37 final HealthReportQuery Function(int typeIndex)? queryForType; 41 final HealthReportQuery Function(int typeIndex)? queryForType;
@@ -116,6 +120,9 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -116,6 +120,9 @@ class _HealthTrendContentState extends State<HealthTrendContent>
116 Expanded( 120 Expanded(
117 child: TabBarView( 121 child: TabBarView(
118 controller: _tabController, 122 controller: _tabController,
  123 + physics: widget.allowTabSwipe
  124 + ? null
  125 + : const NeverScrollableScrollPhysics(),
119 children: [ 126 children: [
120 _KeepAliveWrapper( 127 _KeepAliveWrapper(
121 child: _HrvTrendSection( 128 child: _HrvTrendSection(
@@ -393,6 +400,7 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { @@ -393,6 +400,7 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
393 isVip: widget.isVip, 400 isVip: widget.isVip,
394 onSubscribe: widget.onSubscribe, 401 onSubscribe: widget.onSubscribe,
395 loadingIndicator: const _HealthTrendLoadingIndicator(), 402 loadingIndicator: const _HealthTrendLoadingIndicator(),
  403 + tooltipDismissDelayMs: 4000,
396 ); 404 );
397 } 405 }
398 406
@@ -126,6 +126,11 @@ class HomeController extends GetxController { @@ -126,6 +126,11 @@ class HomeController extends GetxController {
126 final trendController = Get.find<TrendController>(); 126 final trendController = Get.find<TrendController>();
127 final friendsController = Get.find<FriendsController>(); 127 final friendsController = Get.find<FriendsController>();
128 128
  129 + if (index == trendTabIndex &&
  130 + trendEntrySource == TrendEntrySource.bottomTab) {
  131 + trendController.selectSelf(refresh: false);
  132 + }
  133 +
129 trendController.setPageVisible( 134 trendController.setPageVisible(
130 index == trendTabIndex, 135 index == trendTabIndex,
131 resetQueryOnShow: trendEntrySource == TrendEntrySource.bottomTab, 136 resetQueryOnShow: trendEntrySource == TrendEntrySource.bottomTab,
@@ -74,10 +74,12 @@ class TrendController extends GetxController with HealthTrendControl { @@ -74,10 +74,12 @@ class TrendController extends GetxController with HealthTrendControl {
74 } 74 }
75 } 75 }
76 76
77 - void changeTargetUser(int? userId) { 77 + void changeTargetUser(int? userId, {bool refresh = true}) {
78 if (userId == targetUserId.value) return; 78 if (userId == targetUserId.value) return;
79 targetUserId.value = userId; 79 targetUserId.value = userId;
80 - refreshToken.value++; 80 + if (refresh) {
  81 + refreshToken.value++;
  82 + }
81 } 83 }
82 84
83 void selectFriend(FriendItem friendInfo) { 85 void selectFriend(FriendItem friendInfo) {
@@ -88,12 +90,12 @@ class TrendController extends GetxController with HealthTrendControl { @@ -88,12 +90,12 @@ class TrendController extends GetxController with HealthTrendControl {
88 changeTargetUser(friendInfo.friendUserId); 90 changeTargetUser(friendInfo.friendUserId);
89 } 91 }
90 92
91 - void selectSelf() { 93 + void selectSelf({bool refresh = true}) {
92 if (targetFriendInfo.value == null && targetUserId.value == null) { 94 if (targetFriendInfo.value == null && targetUserId.value == null) {
93 return; 95 return;
94 } 96 }
95 targetFriendInfo.value = null; 97 targetFriendInfo.value = null;
96 - changeTargetUser(null); 98 + changeTargetUser(null, refresh: refresh);
97 } 99 }
98 100
99 void showFriendListBottomSheet() { 101 void showFriendListBottomSheet() {
@@ -81,20 +81,25 @@ class HrvReportView extends StatelessWidget { @@ -81,20 +81,25 @@ class HrvReportView extends StatelessWidget {
81 padding: const EdgeInsets.fromLTRB(16, 20, 16, 0), 81 padding: const EdgeInsets.fromLTRB(16, 20, 16, 0),
82 children: [ 82 children: [
83 if (logic.selectedPeriod.value == ReportPeriod.year) 83 if (logic.selectedPeriod.value == ReportPeriod.year)
84 - HrvYearReportView(report: logic.yearlyReport.value) 84 + HrvYearReportView(
  85 + report: logic.yearlyReport.value,
  86 + tooltipDismissDelayMs: 4000,
  87 + )
85 else if (logic.selectedPeriod.value == 88 else if (logic.selectedPeriod.value ==
86 ReportPeriod.month) 89 ReportPeriod.month)
87 - HrvMonthReportView(report: logic.monthlyReport.value) 90 + HrvMonthReportView(
  91 + report: logic.monthlyReport.value,
  92 + tooltipDismissDelayMs: 4000)
88 else 93 else
89 HrvWeekReportView( 94 HrvWeekReportView(
90 - report: isVip  
91 - ? logic.weeklyReport.value  
92 - : WeeklyHrvReport.premiumPreview(  
93 - logic.weekStart,  
94 - ),  
95 - showExample: !isVip,  
96 - onSubscribe: () => onSubscribe('解锁每日压力-HRV心率'),  
97 - ), 95 + report: isVip
  96 + ? logic.weeklyReport.value
  97 + : WeeklyHrvReport.premiumPreview(
  98 + logic.weekStart,
  99 + ),
  100 + showExample: !isVip,
  101 + onSubscribe: () => onSubscribe('解锁每日压力-HRV心率'),
  102 + tooltipDismissDelayMs: 4000),
98 ReportBottomSlogan(bottomSpacing: isVip ? 16 : 88), 103 ReportBottomSlogan(bottomSpacing: isVip ? 16 : 88),
99 ], 104 ],
100 ); 105 );
  1 +import 'dart:async';
  2 +
1 import 'package:doublefeel_flutter/core/util/size_extensions.dart'; 3 import 'package:doublefeel_flutter/core/util/size_extensions.dart';
2 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
3 import 'package:fl_chart/fl_chart.dart'; 5 import 'package:fl_chart/fl_chart.dart';
@@ -16,11 +18,13 @@ class HrvWeekReportView extends StatelessWidget { @@ -16,11 +18,13 @@ class HrvWeekReportView extends StatelessWidget {
16 required this.report, 18 required this.report,
17 this.showExample = false, 19 this.showExample = false,
18 this.onSubscribe, 20 this.onSubscribe,
  21 + this.tooltipDismissDelayMs = 0,
19 }); 22 });
20 23
21 final WeeklyHrvReport? report; 24 final WeeklyHrvReport? report;
22 final bool showExample; 25 final bool showExample;
23 final VoidCallback? onSubscribe; 26 final VoidCallback? onSubscribe;
  27 + final int tooltipDismissDelayMs;
24 28
25 @override 29 @override
26 Widget build(BuildContext context) { 30 Widget build(BuildContext context) {
@@ -35,21 +39,31 @@ class HrvWeekReportView extends StatelessWidget { @@ -35,21 +39,31 @@ class HrvWeekReportView extends StatelessWidget {
35 isMonth: false, 39 isMonth: false,
36 showExample: showExample, 40 showExample: showExample,
37 onSubscribe: onSubscribe, 41 onSubscribe: onSubscribe,
  42 + tooltipDismissDelayMs: tooltipDismissDelayMs,
38 ); 43 );
39 } 44 }
40 } 45 }
41 46
42 class HrvMonthReportView extends StatelessWidget { 47 class HrvMonthReportView extends StatelessWidget {
43 - const HrvMonthReportView({super.key, required this.report}); 48 + const HrvMonthReportView({
  49 + super.key,
  50 + required this.report,
  51 + this.tooltipDismissDelayMs = 0,
  52 + });
44 53
45 final MonthlyHrvReport? report; 54 final MonthlyHrvReport? report;
  55 + final int tooltipDismissDelayMs;
46 56
47 @override 57 @override
48 Widget build(BuildContext context) { 58 Widget build(BuildContext context) {
49 final now = DateTime.now(); 59 final now = DateTime.now();
50 final data = 60 final data =
51 report ?? MonthlyHrvReport.empty(DateTime(now.year, now.month)); 61 report ?? MonthlyHrvReport.empty(DateTime(now.year, now.month));
52 - return _HrvPeriodReportView(report: data, isMonth: true); 62 + return _HrvPeriodReportView(
  63 + report: data,
  64 + isMonth: true,
  65 + tooltipDismissDelayMs: tooltipDismissDelayMs,
  66 + );
53 } 67 }
54 } 68 }
55 69
@@ -59,12 +73,14 @@ class _HrvPeriodReportView extends StatelessWidget { @@ -59,12 +73,14 @@ class _HrvPeriodReportView extends StatelessWidget {
59 required this.isMonth, 73 required this.isMonth,
60 this.showExample = false, 74 this.showExample = false,
61 this.onSubscribe, 75 this.onSubscribe,
  76 + this.tooltipDismissDelayMs = 0,
62 }); 77 });
63 78
64 final HrvPeriodReport report; 79 final HrvPeriodReport report;
65 final bool isMonth; 80 final bool isMonth;
66 final bool showExample; 81 final bool showExample;
67 final VoidCallback? onSubscribe; 82 final VoidCallback? onSubscribe;
  83 + final int tooltipDismissDelayMs;
68 84
69 @override 85 @override
70 Widget build(BuildContext context) { 86 Widget build(BuildContext context) {
@@ -75,6 +91,7 @@ class _HrvPeriodReportView extends StatelessWidget { @@ -75,6 +91,7 @@ class _HrvPeriodReportView extends StatelessWidget {
75 isMonth: isMonth, 91 isMonth: isMonth,
76 showExample: showExample, 92 showExample: showExample,
77 onTap: showExample ? onSubscribe : null, 93 onTap: showExample ? onSubscribe : null,
  94 + tooltipDismissDelayMs: tooltipDismissDelayMs,
78 ), 95 ),
79 const SizedBox(height: 12), 96 const SizedBox(height: 12),
80 _DistributionCard( 97 _DistributionCard(
@@ -93,12 +110,14 @@ class _DailyStressCard extends StatelessWidget { @@ -93,12 +110,14 @@ class _DailyStressCard extends StatelessWidget {
93 required this.isMonth, 110 required this.isMonth,
94 required this.showExample, 111 required this.showExample,
95 this.onTap, 112 this.onTap,
  113 + required this.tooltipDismissDelayMs,
96 }); 114 });
97 115
98 final HrvPeriodReport report; 116 final HrvPeriodReport report;
99 final bool isMonth; 117 final bool isMonth;
100 final bool showExample; 118 final bool showExample;
101 final VoidCallback? onTap; 119 final VoidCallback? onTap;
  120 + final int tooltipDismissDelayMs;
102 121
103 @override 122 @override
104 Widget build(BuildContext context) { 123 Widget build(BuildContext context) {
@@ -118,7 +137,11 @@ class _DailyStressCard extends StatelessWidget { @@ -118,7 +137,11 @@ class _DailyStressCard extends StatelessWidget {
118 const SizedBox(height: 18), 137 const SizedBox(height: 18),
119 SizedBox( 138 SizedBox(
120 height: isMonth ? 160 : 174, 139 height: isMonth ? 160 : 174,
121 - child: _HrvBarChart(report: report, isMonth: isMonth), 140 + child: _HrvBarChart(
  141 + report: report,
  142 + isMonth: isMonth,
  143 + tooltipDismissDelayMs: tooltipDismissDelayMs,
  144 + ),
122 ), 145 ),
123 const Divider(height: 25, color: Color(0xFFF3F3F3)), 146 const Divider(height: 25, color: Color(0xFFF3F3F3)),
124 Row( 147 Row(
@@ -154,10 +177,15 @@ class _DailyStressCard extends StatelessWidget { @@ -154,10 +177,15 @@ class _DailyStressCard extends StatelessWidget {
154 } 177 }
155 178
156 class _HrvBarChart extends StatefulWidget { 179 class _HrvBarChart extends StatefulWidget {
157 - const _HrvBarChart({required this.report, required this.isMonth}); 180 + const _HrvBarChart({
  181 + required this.report,
  182 + required this.isMonth,
  183 + required this.tooltipDismissDelayMs,
  184 + });
158 185
159 final HrvPeriodReport report; 186 final HrvPeriodReport report;
160 final bool isMonth; 187 final bool isMonth;
  188 + final int tooltipDismissDelayMs;
161 189
162 @override 190 @override
163 State<_HrvBarChart> createState() => _HrvBarChartState(); 191 State<_HrvBarChart> createState() => _HrvBarChartState();
@@ -173,9 +201,39 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -173,9 +201,39 @@ class _HrvBarChartState extends State<_HrvBarChart> {
173 static const _tooltipBackground = Color(0xFFF3F3F3); 201 static const _tooltipBackground = Color(0xFFF3F3F3);
174 static const _tooltipDateColor = Color(0xFF78787D); 202 static const _tooltipDateColor = Color(0xFF78787D);
175 static const _tooltipMetaColor = Color(0xFFB0B0B6); 203 static const _tooltipMetaColor = Color(0xFFB0B0B6);
  204 + static const _tooltipAnchorY = 80.0;
  205 + static const _tooltipMargin = 16.0;
176 206
177 int? _touchedIndex; 207 int? _touchedIndex;
178 Offset? _touchedOffset; 208 Offset? _touchedOffset;
  209 + Timer? _tooltipDismissTimer;
  210 +
  211 + @override
  212 + void dispose() {
  213 + _tooltipDismissTimer?.cancel();
  214 + super.dispose();
  215 + }
  216 +
  217 + void _clearTooltip() {
  218 + if (_touchedIndex == null && _touchedOffset == null) return;
  219 + setState(() {
  220 + _touchedIndex = null;
  221 + _touchedOffset = null;
  222 + });
  223 + }
  224 +
  225 + void _scheduleTooltipDismissal() {
  226 + final delayMs = widget.tooltipDismissDelayMs;
  227 + if (delayMs <= 0) {
  228 + _clearTooltip();
  229 + return;
  230 + }
  231 + if (_touchedIndex == null) return;
  232 + _tooltipDismissTimer?.cancel();
  233 + _tooltipDismissTimer = Timer(Duration(milliseconds: delayMs), () {
  234 + if (mounted) _clearTooltip();
  235 + });
  236 + }
179 237
180 @override 238 @override
181 Widget build(BuildContext context) { 239 Widget build(BuildContext context) {
@@ -185,6 +243,8 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -185,6 +243,8 @@ class _HrvBarChartState extends State<_HrvBarChart> {
185 bars.add( 243 bars.add(
186 BarChartGroupData( 244 BarChartGroupData(
187 x: i, 245 x: i,
  246 + groupVertically: true,
  247 + showingTooltipIndicators: _touchedIndex == i ? [1] : [],
188 barRods: [ 248 barRods: [
189 BarChartRodData( 249 BarChartRodData(
190 toY: day.averageHrv ?? 0, 250 toY: day.averageHrv ?? 0,
@@ -196,6 +256,13 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -196,6 +256,13 @@ class _HrvBarChartState extends State<_HrvBarChart> {
196 top: Radius.circular(7), 256 top: Radius.circular(7),
197 ), 257 ),
198 ), 258 ),
  259 + // Keep every tooltip at the same height without affecting the
  260 + // visible bar or the spacing between groups.
  261 + BarChartRodData(
  262 + toY: _tooltipAnchorY,
  263 + width: 0,
  264 + color: Colors.transparent,
  265 + ),
199 ], 266 ],
200 ), 267 ),
201 ); 268 );
@@ -203,94 +270,108 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -203,94 +270,108 @@ class _HrvBarChartState extends State<_HrvBarChart> {
203 270
204 return Stack( 271 return Stack(
205 alignment: Alignment.center, 272 alignment: Alignment.center,
  273 + clipBehavior: Clip.none,
206 children: [ 274 children: [
207 Padding( 275 Padding(
208 padding: const EdgeInsets.only(left: _plotLeft), 276 padding: const EdgeInsets.only(left: _plotLeft),
209 - child: BarChart(  
210 - BarChartData(  
211 - minY: 0,  
212 - maxY: 110,  
213 - alignment: widget.isMonth  
214 - ? BarChartAlignment.spaceAround  
215 - : BarChartAlignment.center,  
216 - groupsSpace: widget.isMonth ? 16 : _weekBarGap,  
217 - barGroups: bars,  
218 - borderData: FlBorderData(show: false),  
219 - gridData: FlGridData(  
220 - show: true,  
221 - drawVerticalLine: false,  
222 - horizontalInterval: 27.5,  
223 - getDrawingHorizontalLine: (_) => const FlLine(  
224 - color: Color(0xFFF3F3F3),  
225 - strokeWidth: 1,  
226 - dashArray: [2, 2],  
227 - ),  
228 - ),  
229 - extraLinesData: ExtraLinesData(  
230 - extraLinesOnTop: false,  
231 - horizontalLines: [  
232 - HorizontalLine(  
233 - y: 0,  
234 - color: const Color(0xFFF3F3F3),  
235 - strokeWidth: 1,  
236 - dashArray: [2, 2], 277 + child: LayoutBuilder(
  278 + builder: (context, constraints) {
  279 + return BarChart(
  280 + BarChartData(
  281 + minY: 0,
  282 + maxY: 110,
  283 + alignment: widget.isMonth
  284 + ? BarChartAlignment.spaceAround
  285 + : BarChartAlignment.center,
  286 + groupsSpace: widget.isMonth ? 16 : _weekBarGap,
  287 + barGroups: bars,
  288 + borderData: FlBorderData(show: false),
  289 + gridData: FlGridData(
  290 + show: true,
  291 + drawVerticalLine: false,
  292 + horizontalInterval: 27.5,
  293 + getDrawingHorizontalLine: (_) => const FlLine(
  294 + color: Color(0xFFF3F3F3),
  295 + strokeWidth: 1,
  296 + dashArray: [2, 2],
  297 + ),
237 ), 298 ),
238 - ],  
239 - ),  
240 - titlesData: FlTitlesData(  
241 - topTitles:  
242 - const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
243 - rightTitles:  
244 - const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
245 - leftTitles:  
246 - const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
247 - bottomTitles: AxisTitles(  
248 - sideTitles: SideTitles(  
249 - showTitles: true,  
250 - reservedSize: _bottomTitleHeight,  
251 - interval: 1,  
252 - getTitlesWidget: (_, __) => const SizedBox.shrink(), 299 + extraLinesData: ExtraLinesData(
  300 + extraLinesOnTop: false,
  301 + horizontalLines: [
  302 + HorizontalLine(
  303 + y: 0,
  304 + color: const Color(0xFFF3F3F3),
  305 + strokeWidth: 1,
  306 + dashArray: [2, 2],
  307 + ),
  308 + ],
  309 + ),
  310 + titlesData: FlTitlesData(
  311 + topTitles: const AxisTitles(
  312 + sideTitles: SideTitles(showTitles: false)),
  313 + rightTitles: const AxisTitles(
  314 + sideTitles: SideTitles(showTitles: false)),
  315 + leftTitles: const AxisTitles(
  316 + sideTitles: SideTitles(showTitles: false)),
  317 + bottomTitles: AxisTitles(
  318 + sideTitles: SideTitles(
  319 + showTitles: true,
  320 + reservedSize: _bottomTitleHeight,
  321 + interval: 1,
  322 + getTitlesWidget: (_, __) => const SizedBox.shrink(),
  323 + ),
  324 + ),
  325 + ),
  326 + barTouchData: BarTouchData(
  327 + enabled: widget.report.hasData,
  328 + handleBuiltInTouches: false,
  329 + touchExtraThreshold: const EdgeInsets.symmetric(
  330 + horizontal: 16,
  331 + vertical: 40,
  332 + ),
  333 + touchCallback: (event, response) {
  334 + if (!event.isInterestedForInteractions) {
  335 + _scheduleTooltipDismissal();
  336 + return;
  337 + }
  338 + _tooltipDismissTimer?.cancel();
  339 + final touchedIndex = _indexForTouch(
  340 + event.localPosition?.dx,
  341 + constraints.maxWidth,
  342 + );
  343 + final hasTouchedData = touchedIndex != null &&
  344 + widget.report.days[touchedIndex].averageHrv != null;
  345 + final nextIndex = hasTouchedData ? touchedIndex : null;
  346 + final nextOffset =
  347 + nextIndex == null ? null : response?.spot?.offset;
  348 + if (_touchedIndex != nextIndex ||
  349 + _touchedOffset != nextOffset) {
  350 + setState(() {
  351 + _touchedIndex = nextIndex;
  352 + _touchedOffset = nextOffset;
  353 + });
  354 + }
  355 + },
  356 + touchTooltipData: BarTouchTooltipData(
  357 + getTooltipColor: (_) => _tooltipBackground,
  358 + tooltipRoundedRadius: 8,
  359 + tooltipBorder: BorderSide.none,
  360 + tooltipPadding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
  361 + tooltipMargin: _tooltipMargin,
  362 + maxContentWidth: 107,
  363 + fitInsideHorizontally: true,
  364 + fitInsideVertically: false,
  365 + direction: TooltipDirection.top,
  366 + getTooltipItem: (group, groupIndex, rod, rodIndex) {
  367 + final day = widget.report.days[group.x];
  368 + return _tooltipItem(context, day);
  369 + },
  370 + ),
253 ), 371 ),
254 ), 372 ),
255 - ),  
256 - barTouchData: BarTouchData(  
257 - enabled: widget.report.hasData,  
258 - touchCallback: (event, response) {  
259 - final touchedIndex = response?.spot?.touchedBarGroupIndex;  
260 - final hasTouchedData = touchedIndex != null &&  
261 - touchedIndex >= 0 &&  
262 - touchedIndex < widget.report.days.length &&  
263 - widget.report.days[touchedIndex].averageHrv != null;  
264 - final nextIndex =  
265 - event.isInterestedForInteractions && hasTouchedData  
266 - ? touchedIndex  
267 - : null;  
268 - final nextOffset =  
269 - nextIndex == null ? null : response?.spot?.offset;  
270 - if (_touchedIndex != nextIndex ||  
271 - _touchedOffset != nextOffset) {  
272 - setState(() {  
273 - _touchedIndex = nextIndex;  
274 - _touchedOffset = nextOffset;  
275 - });  
276 - }  
277 - },  
278 - touchTooltipData: BarTouchTooltipData(  
279 - getTooltipColor: (_) => _tooltipBackground,  
280 - tooltipRoundedRadius: 8,  
281 - tooltipBorder: BorderSide.none,  
282 - tooltipPadding: const EdgeInsets.fromLTRB(12, 8, 12, 4),  
283 - tooltipMargin: 2,  
284 - maxContentWidth: 107,  
285 - fitInsideHorizontally: true,  
286 - fitInsideVertically: false,  
287 - getTooltipItem: (group, groupIndex, rod, rodIndex) {  
288 - final day = widget.report.days[group.x];  
289 - return _tooltipItem(context, day);  
290 - },  
291 - ),  
292 - ),  
293 - ), 373 + );
  374 + },
294 ), 375 ),
295 ), 376 ),
296 const Positioned( 377 const Positioned(
@@ -313,17 +394,18 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -313,17 +394,18 @@ class _HrvBarChartState extends State<_HrvBarChart> {
313 ), 394 ),
314 ), 395 ),
315 ), 396 ),
316 - if (_touchedOffset != null) 397 + if (_touchedIndex != null)
317 Positioned.fill( 398 Positioned.fill(
318 child: LayoutBuilder( 399 child: LayoutBuilder(
319 builder: (context, constraints) { 400 builder: (context, constraints) {
320 return ChartSelectionLineOverlay( 401 return ChartSelectionLineOverlay(
321 - offset: _touchedOffset!, 402 + offset: Offset.zero,
322 color: const Color(0xFFB0B0B6), 403 color: const Color(0xFFB0B0B6),
323 bottomTitleHeight: _bottomTitleHeight, 404 bottomTitleHeight: _bottomTitleHeight,
324 plotLeft: _plotLeft, 405 plotLeft: _plotLeft,
325 plotRight: _plotRight, 406 plotRight: _plotRight,
326 lineX: _lineXForIndex(constraints.maxWidth), 407 lineX: _lineXForIndex(constraints.maxWidth),
  408 + lineTop: _tooltipBottomForHeight(constraints.maxHeight),
327 ); 409 );
328 }, 410 },
329 ), 411 ),
@@ -368,6 +450,7 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -368,6 +450,7 @@ class _HrvBarChartState extends State<_HrvBarChart> {
368 ), 450 ),
369 ), 451 ),
370 ], 452 ],
  453 + textAlign: TextAlign.start,
371 ); 454 );
372 } 455 }
373 456
@@ -388,6 +471,27 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -388,6 +471,27 @@ class _HrvBarChartState extends State<_HrvBarChart> {
388 } 471 }
389 return plotStart + plotWidth * ((index + 0.5) / widget.report.days.length); 472 return plotStart + plotWidth * ((index + 0.5) / widget.report.days.length);
390 } 473 }
  474 +
  475 + double _tooltipBottomForHeight(double height) {
  476 + final plotHeight = height - _bottomTitleHeight;
  477 + return plotHeight * (1 - _tooltipAnchorY / 110) - _tooltipMargin;
  478 + }
  479 +
  480 + int? _indexForTouch(double? dx, double chartWidth) {
  481 + if (dx == null || chartWidth <= 0 || widget.report.days.isEmpty) {
  482 + return null;
  483 + }
  484 + final count = widget.report.days.length;
  485 + if (widget.isMonth) {
  486 + return ((dx / chartWidth) * count).floor().clamp(0, count - 1).toInt();
  487 + }
  488 + final chartWidthUsed = count * _weekBarWidth + (count - 1) * _weekBarGap;
  489 + final chartLeft = (chartWidth - chartWidthUsed) / 2;
  490 + final index =
  491 + ((dx - chartLeft - _weekBarWidth / 2) / (_weekBarWidth + _weekBarGap))
  492 + .round();
  493 + return index.clamp(0, count - 1).toInt();
  494 + }
391 } 495 }
392 496
393 enum _TrendMetricType { relaxed, stressed } 497 enum _TrendMetricType { relaxed, stressed }
  1 +import 'dart:async';
  2 +
1 import 'package:fl_chart/fl_chart.dart'; 3 import 'package:fl_chart/fl_chart.dart';
2 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
@@ -10,16 +12,24 @@ import '../models/hrv_report_models.dart'; @@ -10,16 +12,24 @@ import '../models/hrv_report_models.dart';
10 import 'hrv_distribution_bar.dart'; 12 import 'hrv_distribution_bar.dart';
11 13
12 class HrvYearReportView extends StatelessWidget { 14 class HrvYearReportView extends StatelessWidget {
13 - const HrvYearReportView({super.key, required this.report}); 15 + const HrvYearReportView({
  16 + super.key,
  17 + required this.report,
  18 + this.tooltipDismissDelayMs = 0,
  19 + });
14 20
15 final YearlyHrvReport? report; 21 final YearlyHrvReport? report;
  22 + final int tooltipDismissDelayMs;
16 23
17 @override 24 @override
18 Widget build(BuildContext context) { 25 Widget build(BuildContext context) {
19 final data = report ?? YearlyHrvReport.empty(DateTime.now().year); 26 final data = report ?? YearlyHrvReport.empty(DateTime.now().year);
20 return Column( 27 return Column(
21 children: [ 28 children: [
22 - _YearTrendCard(report: data), 29 + _YearTrendCard(
  30 + report: data,
  31 + tooltipDismissDelayMs: tooltipDismissDelayMs,
  32 + ),
23 const SizedBox(height: 12), 33 const SizedBox(height: 12),
24 _YearDistributionCard(report: data), 34 _YearDistributionCard(report: data),
25 const SizedBox(height: 12), 35 const SizedBox(height: 12),
@@ -30,8 +40,12 @@ class HrvYearReportView extends StatelessWidget { @@ -30,8 +40,12 @@ class HrvYearReportView extends StatelessWidget {
30 } 40 }
31 41
32 class _YearTrendCard extends StatelessWidget { 42 class _YearTrendCard extends StatelessWidget {
33 - const _YearTrendCard({required this.report}); 43 + const _YearTrendCard({
  44 + required this.report,
  45 + required this.tooltipDismissDelayMs,
  46 + });
34 final YearlyHrvReport report; 47 final YearlyHrvReport report;
  48 + final int tooltipDismissDelayMs;
35 49
36 @override 50 @override
37 Widget build(BuildContext context) { 51 Widget build(BuildContext context) {
@@ -49,7 +63,13 @@ class _YearTrendCard extends StatelessWidget { @@ -49,7 +63,13 @@ class _YearTrendCard extends StatelessWidget {
49 style: _titleStyle, 63 style: _titleStyle,
50 ), 64 ),
51 const SizedBox(height: 18), 65 const SizedBox(height: 18),
52 - SizedBox(height: 174, child: _YearBarChart(report: report)), 66 + SizedBox(
  67 + height: 174,
  68 + child: _YearBarChart(
  69 + report: report,
  70 + tooltipDismissDelayMs: tooltipDismissDelayMs,
  71 + ),
  72 + ),
53 const Divider(height: 25, color: _grid), 73 const Divider(height: 25, color: _grid),
54 Row( 74 Row(
55 children: [ 75 children: [
@@ -74,8 +94,12 @@ class _YearTrendCard extends StatelessWidget { @@ -74,8 +94,12 @@ class _YearTrendCard extends StatelessWidget {
74 } 94 }
75 95
76 class _YearBarChart extends StatefulWidget { 96 class _YearBarChart extends StatefulWidget {
77 - const _YearBarChart({required this.report}); 97 + const _YearBarChart({
  98 + required this.report,
  99 + required this.tooltipDismissDelayMs,
  100 + });
78 final YearlyHrvReport report; 101 final YearlyHrvReport report;
  102 + final int tooltipDismissDelayMs;
79 103
80 @override 104 @override
81 State<_YearBarChart> createState() => _YearBarChartState(); 105 State<_YearBarChart> createState() => _YearBarChartState();
@@ -86,150 +110,165 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -86,150 +110,165 @@ class _YearBarChartState extends State<_YearBarChart> {
86 static const _plotLeft = _axisLabelWidth; 110 static const _plotLeft = _axisLabelWidth;
87 static const _plotRight = 0.0; 111 static const _plotRight = 0.0;
88 static const _bottomTitleHeight = 24.0; 112 static const _bottomTitleHeight = 24.0;
  113 + static const _tooltipAnchorY = 72.0;
  114 + static const _tooltipMargin = 16.0;
89 115
90 int? _touchedMonth; 116 int? _touchedMonth;
91 Offset? _touchedOffset; 117 Offset? _touchedOffset;
  118 + Timer? _tooltipDismissTimer;
  119 +
  120 + @override
  121 + void dispose() {
  122 + _tooltipDismissTimer?.cancel();
  123 + super.dispose();
  124 + }
  125 +
  126 + void _clearTooltip() {
  127 + if (_touchedMonth == null && _touchedOffset == null) return;
  128 + setState(() {
  129 + _touchedMonth = null;
  130 + _touchedOffset = null;
  131 + });
  132 + }
  133 +
  134 + void _scheduleTooltipDismissal() {
  135 + final delayMs = widget.tooltipDismissDelayMs;
  136 + if (delayMs <= 0) {
  137 + _clearTooltip();
  138 + return;
  139 + }
  140 + if (_touchedMonth == null) return;
  141 + _tooltipDismissTimer?.cancel();
  142 + _tooltipDismissTimer = Timer(Duration(milliseconds: delayMs), () {
  143 + if (mounted) _clearTooltip();
  144 + });
  145 + }
92 146
93 @override 147 @override
94 Widget build(BuildContext context) { 148 Widget build(BuildContext context) {
95 return Stack( 149 return Stack(
96 alignment: Alignment.center, 150 alignment: Alignment.center,
  151 + clipBehavior: Clip.none,
97 children: [ 152 children: [
98 Padding( 153 Padding(
99 padding: const EdgeInsets.only(left: _plotLeft), 154 padding: const EdgeInsets.only(left: _plotLeft),
100 - child: BarChart(  
101 - BarChartData(  
102 - minY: 0,  
103 - maxY: 100,  
104 - alignment: BarChartAlignment.spaceAround,  
105 - barGroups: [  
106 - for (var month = 1; month <= 12; month++)  
107 - BarChartGroupData(  
108 - x: month,  
109 - barRods: [  
110 - BarChartRodData(  
111 - toY: widget.report.averageForMonth(month) ?? 0,  
112 - width: 8,  
113 - color: _colorForMonth(month),  
114 - borderRadius: const BorderRadius.vertical(  
115 - top: Radius.circular(5),  
116 - ), 155 + child: LayoutBuilder(
  156 + builder: (context, constraints) {
  157 + return BarChart(
  158 + BarChartData(
  159 + minY: 0,
  160 + maxY: 100,
  161 + alignment: BarChartAlignment.spaceAround,
  162 + barGroups: [
  163 + for (var month = 1; month <= 12; month++)
  164 + BarChartGroupData(
  165 + x: month,
  166 + groupVertically: true,
  167 + showingTooltipIndicators:
  168 + _touchedMonth == month ? [1] : [],
  169 + barRods: [
  170 + BarChartRodData(
  171 + toY: widget.report.averageForMonth(month) ?? 0,
  172 + width: 8,
  173 + color: _colorForMonth(month),
  174 + borderRadius: const BorderRadius.vertical(
  175 + top: Radius.circular(5),
  176 + ),
  177 + ),
  178 + // Anchor every tooltip to the chart top while keeping
  179 + // the visible monthly bar unchanged.
  180 + BarChartRodData(
  181 + toY: _tooltipAnchorY,
  182 + width: 0,
  183 + color: Colors.transparent,
  184 + ),
  185 + ],
  186 + ),
  187 + ],
  188 + borderData: FlBorderData(show: false),
  189 + gridData: FlGridData(
  190 + show: true,
  191 + drawVerticalLine: false,
  192 + horizontalInterval: 25,
  193 + getDrawingHorizontalLine: (_) => const FlLine(
  194 + color: _grid,
  195 + dashArray: [2, 2],
  196 + ),
  197 + ),
  198 + extraLinesData: ExtraLinesData(
  199 + extraLinesOnTop: false,
  200 + horizontalLines: [
  201 + HorizontalLine(
  202 + y: 0,
  203 + color: _grid,
  204 + strokeWidth: 1,
  205 + dashArray: [2, 2],
117 ), 206 ),
118 ], 207 ],
119 ), 208 ),
120 - ],  
121 - borderData: FlBorderData(show: false),  
122 - gridData: FlGridData(  
123 - show: true,  
124 - drawVerticalLine: false,  
125 - horizontalInterval: 25,  
126 - getDrawingHorizontalLine: (_) => const FlLine(  
127 - color: _grid,  
128 - dashArray: [2, 2],  
129 - ),  
130 - ),  
131 - extraLinesData: ExtraLinesData(  
132 - extraLinesOnTop: false,  
133 - horizontalLines: [  
134 - HorizontalLine(  
135 - y: 0,  
136 - color: _grid,  
137 - strokeWidth: 1,  
138 - dashArray: [2, 2], 209 + titlesData: FlTitlesData(
  210 + topTitles: const AxisTitles(
  211 + sideTitles: SideTitles(showTitles: false)),
  212 + rightTitles: const AxisTitles(
  213 + sideTitles: SideTitles(showTitles: false)),
  214 + leftTitles: const AxisTitles(
  215 + sideTitles: SideTitles(showTitles: false)),
  216 + bottomTitles: AxisTitles(
  217 + sideTitles: SideTitles(
  218 + showTitles: true,
  219 + reservedSize: _bottomTitleHeight,
  220 + interval: 1,
  221 + getTitlesWidget: (_, __) => const SizedBox.shrink(),
  222 + ),
  223 + ),
139 ), 224 ),
140 - ],  
141 - ),  
142 - titlesData: FlTitlesData(  
143 - topTitles:  
144 - const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
145 - rightTitles:  
146 - const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
147 - leftTitles:  
148 - const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
149 - bottomTitles: AxisTitles(  
150 - sideTitles: SideTitles(  
151 - showTitles: true,  
152 - reservedSize: _bottomTitleHeight,  
153 - interval: 1,  
154 - getTitlesWidget: (_, __) => const SizedBox.shrink(), 225 + barTouchData: BarTouchData(
  226 + enabled: widget.report.hasData,
  227 + handleBuiltInTouches: false,
  228 + touchExtraThreshold: const EdgeInsets.symmetric(
  229 + horizontal: 16,
  230 + vertical: 40,
  231 + ),
  232 + touchCallback: (event, response) {
  233 + if (!event.isInterestedForInteractions) {
  234 + _scheduleTooltipDismissal();
  235 + return;
  236 + }
  237 + _tooltipDismissTimer?.cancel();
  238 + final month = _monthForTouch(
  239 + event.localPosition?.dx,
  240 + constraints.maxWidth,
  241 + );
  242 + final hasTouchedData = month != null &&
  243 + widget.report.averageForMonth(month) != null;
  244 + final nextMonth = hasTouchedData ? month : null;
  245 + final nextOffset =
  246 + nextMonth == null ? null : response?.spot?.offset;
  247 + if (_touchedMonth != nextMonth ||
  248 + _touchedOffset != nextOffset) {
  249 + setState(() {
  250 + _touchedMonth = nextMonth;
  251 + _touchedOffset = nextOffset;
  252 + });
  253 + }
  254 + },
  255 + touchTooltipData: BarTouchTooltipData(
  256 + getTooltipColor: (_) => const Color(0xFFF3F3F3),
  257 + tooltipRoundedRadius: 8,
  258 + tooltipBorder: BorderSide.none,
  259 + tooltipPadding: const EdgeInsets.fromLTRB(9, 7, 9, 7),
  260 + tooltipMargin: _tooltipMargin,
  261 + maxContentWidth: 99,
  262 + fitInsideHorizontally: true,
  263 + fitInsideVertically: false,
  264 + direction: TooltipDirection.top,
  265 + getTooltipItem: (group, groupIndex, rod, rodIndex) =>
  266 + _tooltipItem(context, group.x),
  267 + ),
155 ), 268 ),
156 ), 269 ),
157 - ),  
158 - barTouchData: BarTouchData(  
159 - enabled: widget.report.hasData,  
160 - touchCallback: (event, response) {  
161 - final month = response?.spot?.touchedBarGroup.x;  
162 - final hasTouchedData = month != null &&  
163 - widget.report.averageForMonth(month) != null;  
164 - final nextMonth =  
165 - event.isInterestedForInteractions && hasTouchedData  
166 - ? month  
167 - : null;  
168 - final nextOffset =  
169 - nextMonth == null ? null : response?.spot?.offset;  
170 - if (_touchedMonth != nextMonth ||  
171 - _touchedOffset != nextOffset) {  
172 - setState(() {  
173 - _touchedMonth = nextMonth;  
174 - _touchedOffset = nextOffset;  
175 - });  
176 - }  
177 - },  
178 - touchTooltipData: BarTouchTooltipData(  
179 - getTooltipColor: (_) => const Color(0xFFF3F3F3),  
180 - tooltipRoundedRadius: 8,  
181 - tooltipBorder: BorderSide.none,  
182 - tooltipPadding: const EdgeInsets.fromLTRB(9, 7, 9, 7),  
183 - tooltipMargin: 2,  
184 - maxContentWidth: 99,  
185 - fitInsideHorizontally: true,  
186 - fitInsideVertically: false,  
187 - getTooltipItem: (group, groupIndex, rod, rodIndex) {  
188 - final month = group.x;  
189 - if (widget.report.averageForMonth(month) == null) {  
190 - return null;  
191 - }  
192 - final stressedDays = widget.report.stressedDaysForMonth(  
193 - month,  
194 - );  
195 - final relaxedDays = widget.report.relaxedDaysForMonth(  
196 - month,  
197 - );  
198 - return BarTooltipItem(  
199 - '${reportMonth(month)}\n',  
200 - const TextStyle(  
201 - color: _h1,  
202 - fontSize: 16,  
203 - fontWeight: FontWeight.w600,  
204 - height: 1.35,  
205 - ),  
206 - children: [  
207 - TextSpan(  
208 - text:  
209 - '$stressedDays${context.l10n.reportUnitDay}${context.l10n.hrvMoreStressed}\n',  
210 - style: const TextStyle(  
211 - color: _h2,  
212 - fontSize: 12,  
213 - fontWeight: FontWeight.w400,  
214 - height: 1.6,  
215 - ),  
216 - ),  
217 - TextSpan(  
218 - text:  
219 - '$relaxedDays${context.l10n.reportUnitDay}${context.l10n.hrvMoreRelaxed}',  
220 - style: const TextStyle(  
221 - color: _h2,  
222 - fontSize: 12,  
223 - fontWeight: FontWeight.w400,  
224 - height: 1.35,  
225 - ),  
226 - ),  
227 - ],  
228 - );  
229 - },  
230 - ),  
231 - ),  
232 - ), 270 + );
  271 + },
233 ), 272 ),
234 ), 273 ),
235 const Positioned( 274 const Positioned(
@@ -245,17 +284,18 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -245,17 +284,18 @@ class _YearBarChartState extends State<_YearBarChart> {
245 height: _bottomTitleHeight, 284 height: _bottomTitleHeight,
246 child: const IgnorePointer(child: _YearXAxisLabels()), 285 child: const IgnorePointer(child: _YearXAxisLabels()),
247 ), 286 ),
248 - if (_touchedOffset != null) 287 + if (_touchedMonth != null)
249 Positioned.fill( 288 Positioned.fill(
250 child: LayoutBuilder( 289 child: LayoutBuilder(
251 builder: (context, constraints) { 290 builder: (context, constraints) {
252 return ChartSelectionLineOverlay( 291 return ChartSelectionLineOverlay(
253 - offset: _touchedOffset!, 292 + offset: Offset.zero,
254 color: _h3, 293 color: _h3,
255 bottomTitleHeight: _bottomTitleHeight, 294 bottomTitleHeight: _bottomTitleHeight,
256 plotLeft: _plotLeft, 295 plotLeft: _plotLeft,
257 plotRight: _plotRight, 296 plotRight: _plotRight,
258 lineX: _lineXForMonth(constraints.maxWidth), 297 lineX: _lineXForMonth(constraints.maxWidth),
  298 + lineTop: _tooltipBottomForHeight(constraints.maxHeight),
259 ); 299 );
260 }, 300 },
261 ), 301 ),
@@ -281,6 +321,54 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -281,6 +321,54 @@ class _YearBarChartState extends State<_YearBarChart> {
281 if (plotWidth <= 0) return null; 321 if (plotWidth <= 0) return null;
282 return plotStart + plotWidth * ((month - 0.5) / 12); 322 return plotStart + plotWidth * ((month - 0.5) / 12);
283 } 323 }
  324 +
  325 + BarTooltipItem? _tooltipItem(BuildContext context, int month) {
  326 + if (widget.report.averageForMonth(month) == null) return null;
  327 + final stressedDays = widget.report.stressedDaysForMonth(month);
  328 + final relaxedDays = widget.report.relaxedDaysForMonth(month);
  329 + return BarTooltipItem(
  330 + '${reportMonth(month)}\n',
  331 + const TextStyle(
  332 + color: _h1,
  333 + fontSize: 16,
  334 + fontWeight: FontWeight.w600,
  335 + height: 1.35,
  336 + ),
  337 + children: [
  338 + TextSpan(
  339 + text:
  340 + '$stressedDays${context.l10n.reportUnitDay}${context.l10n.hrvMoreStressed}\n',
  341 + style: const TextStyle(
  342 + color: _h2,
  343 + fontSize: 12,
  344 + fontWeight: FontWeight.w400,
  345 + height: 1.6,
  346 + ),
  347 + ),
  348 + TextSpan(
  349 + text:
  350 + '$relaxedDays${context.l10n.reportUnitDay}${context.l10n.hrvMoreRelaxed}',
  351 + style: const TextStyle(
  352 + color: _h2,
  353 + fontSize: 12,
  354 + fontWeight: FontWeight.w400,
  355 + height: 1.35,
  356 + ),
  357 + ),
  358 + ],
  359 + textAlign: TextAlign.start,
  360 + );
  361 + }
  362 +
  363 + double _tooltipBottomForHeight(double height) {
  364 + final plotHeight = height - _bottomTitleHeight;
  365 + return plotHeight * (1 - _tooltipAnchorY / 100) - _tooltipMargin;
  366 + }
  367 +
  368 + int? _monthForTouch(double? dx, double chartWidth) {
  369 + if (dx == null || chartWidth <= 0) return null;
  370 + return ((dx / chartWidth) * 12).floor().clamp(0, 11).toInt() + 1;
  371 + }
284 } 372 }
285 373
286 class _MonthExtreme extends StatelessWidget { 374 class _MonthExtreme extends StatelessWidget {
@@ -8,6 +8,7 @@ class ChartSelectionLineOverlay extends StatelessWidget { @@ -8,6 +8,7 @@ class ChartSelectionLineOverlay extends StatelessWidget {
8 required this.bottomTitleHeight, 8 required this.bottomTitleHeight,
9 this.tooltipMargin = 2, 9 this.tooltipMargin = 2,
10 this.lineX, 10 this.lineX,
  11 + this.lineTop,
11 this.plotLeft = 0, 12 this.plotLeft = 0,
12 this.plotRight = 0, 13 this.plotRight = 0,
13 }); 14 });
@@ -17,6 +18,7 @@ class ChartSelectionLineOverlay extends StatelessWidget { @@ -17,6 +18,7 @@ class ChartSelectionLineOverlay extends StatelessWidget {
17 final double bottomTitleHeight; 18 final double bottomTitleHeight;
18 final double tooltipMargin; 19 final double tooltipMargin;
19 final double? lineX; 20 final double? lineX;
  21 + final double? lineTop;
20 final double plotLeft; 22 final double plotLeft;
21 final double plotRight; 23 final double plotRight;
22 24
@@ -30,6 +32,7 @@ class ChartSelectionLineOverlay extends StatelessWidget { @@ -30,6 +32,7 @@ class ChartSelectionLineOverlay extends StatelessWidget {
30 bottomTitleHeight: bottomTitleHeight, 32 bottomTitleHeight: bottomTitleHeight,
31 tooltipMargin: tooltipMargin, 33 tooltipMargin: tooltipMargin,
32 lineX: lineX, 34 lineX: lineX,
  35 + lineTop: lineTop,
33 plotLeft: plotLeft, 36 plotLeft: plotLeft,
34 plotRight: plotRight, 37 plotRight: plotRight,
35 ), 38 ),
@@ -45,6 +48,7 @@ class _ChartSelectionLinePainter extends CustomPainter { @@ -45,6 +48,7 @@ class _ChartSelectionLinePainter extends CustomPainter {
45 required this.bottomTitleHeight, 48 required this.bottomTitleHeight,
46 required this.tooltipMargin, 49 required this.tooltipMargin,
47 required this.lineX, 50 required this.lineX,
  51 + required this.lineTop,
48 required this.plotLeft, 52 required this.plotLeft,
49 required this.plotRight, 53 required this.plotRight,
50 }); 54 });
@@ -54,6 +58,7 @@ class _ChartSelectionLinePainter extends CustomPainter { @@ -54,6 +58,7 @@ class _ChartSelectionLinePainter extends CustomPainter {
54 final double bottomTitleHeight; 58 final double bottomTitleHeight;
55 final double tooltipMargin; 59 final double tooltipMargin;
56 final double? lineX; 60 final double? lineX;
  61 + final double? lineTop;
57 final double plotLeft; 62 final double plotLeft;
58 final double plotRight; 63 final double plotRight;
59 64
@@ -68,11 +73,16 @@ class _ChartSelectionLinePainter extends CustomPainter { @@ -68,11 +73,16 @@ class _ChartSelectionLinePainter extends CustomPainter {
68 73
69 final resolvedLineX = 74 final resolvedLineX =
70 (lineX ?? offset.dx).clamp(plotLeft, size.width - plotRight).toDouble(); 75 (lineX ?? offset.dx).clamp(plotLeft, size.width - plotRight).toDouble();
71 - final lineTop = (offset.dy - tooltipMargin).clamp(0, plotHeight).toDouble();  
72 - if (lineTop >= plotHeight) return; 76 + // An explicit line top may sit above the plot when a tooltip is anchored
  77 + // at the chart top. Keep that coordinate so the line begins at the
  78 + // tooltip's bottom edge instead of being forced down to y = 0.
  79 + final resolvedLineTop = lineTop != null
  80 + ? lineTop!.clamp(-size.height, plotHeight).toDouble()
  81 + : (offset.dy - tooltipMargin).clamp(0, plotHeight).toDouble();
  82 + if (resolvedLineTop >= plotHeight) return;
73 83
74 canvas.drawLine( 84 canvas.drawLine(
75 - Offset(resolvedLineX, lineTop), 85 + Offset(resolvedLineX, resolvedLineTop),
76 Offset(resolvedLineX, plotHeight), 86 Offset(resolvedLineX, plotHeight),
77 Paint() 87 Paint()
78 ..color = color 88 ..color = color
@@ -87,6 +97,7 @@ class _ChartSelectionLinePainter extends CustomPainter { @@ -87,6 +97,7 @@ class _ChartSelectionLinePainter extends CustomPainter {
87 oldDelegate.bottomTitleHeight != bottomTitleHeight || 97 oldDelegate.bottomTitleHeight != bottomTitleHeight ||
88 oldDelegate.tooltipMargin != tooltipMargin || 98 oldDelegate.tooltipMargin != tooltipMargin ||
89 oldDelegate.lineX != lineX || 99 oldDelegate.lineX != lineX ||
  100 + oldDelegate.lineTop != lineTop ||
90 oldDelegate.plotLeft != plotLeft || 101 oldDelegate.plotLeft != plotLeft ||
91 oldDelegate.plotRight != plotRight; 102 oldDelegate.plotRight != plotRight;
92 } 103 }
@@ -276,6 +276,8 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -276,6 +276,8 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
276 dailyTrend, 276 dailyTrend,
277 asleepTime, 277 asleepTime,
278 points, 278 points,
  279 + sleepDurationSeconds: dailyTrend.totalTime,
  280 + targetDurationSeconds: data.sleepTargetDuration,
279 averageBpm: data.avgHr?.toInt(), 281 averageBpm: data.avgHr?.toInt(),
280 maxBpm: data.maxHr?.toInt(), 282 maxBpm: data.maxHr?.toInt(),
281 minBpm: data.minHr?.toInt(), 283 minBpm: data.minHr?.toInt(),
@@ -344,6 +346,8 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -344,6 +346,8 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
344 SleepTrendList? trend, 346 SleepTrendList? trend,
345 DateTime? asleepTime, 347 DateTime? asleepTime,
346 List<SleepHeartRatePoint> points, { 348 List<SleepHeartRatePoint> points, {
  349 + num? sleepDurationSeconds,
  350 + num? targetDurationSeconds,
347 int? averageBpm, 351 int? averageBpm,
348 int? maxBpm, 352 int? maxBpm,
349 int? minBpm, 353 int? minBpm,
@@ -357,6 +361,8 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -357,6 +361,8 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
357 duration: durationSeconds == null 361 duration: durationSeconds == null
358 ? null 362 ? null
359 : SleepDuration(minutes: durationSeconds ~/ 60), 363 : SleepDuration(minutes: durationSeconds ~/ 60),
  364 + sleepDurationSeconds: sleepDurationSeconds,
  365 + targetDurationSeconds: targetDurationSeconds,
360 qualityScore: trend?.score?.toInt(), 366 qualityScore: trend?.score?.toInt(),
361 heartRate: _heartRateSummary( 367 heartRate: _heartRateSummary(
362 points, 368 points,
@@ -470,6 +476,8 @@ class MockSleepReportDataSource implements SleepReportDataSource { @@ -470,6 +476,8 @@ class MockSleepReportDataSource implements SleepReportDataSource {
470 return SleepReport( 476 return SleepReport(
471 date: date, 477 date: date,
472 duration: const SleepDuration(minutes: 405), 478 duration: const SleepDuration(minutes: 405),
  479 + sleepDurationSeconds: 405 * 60,
  480 + targetDurationSeconds: 28800,
473 qualityScore: 86, 481 qualityScore: 86,
474 heartRate: _buildHeartRateSummary( 482 heartRate: _buildHeartRateSummary(
475 points: points, 483 points: points,
@@ -165,12 +165,16 @@ class SleepHeartRateSummary { @@ -165,12 +165,16 @@ class SleepHeartRateSummary {
165 class SleepReport { 165 class SleepReport {
166 final DateTime date; 166 final DateTime date;
167 final SleepDuration? duration; 167 final SleepDuration? duration;
  168 + final num? sleepDurationSeconds;
  169 + final num? targetDurationSeconds;
168 final int? qualityScore; 170 final int? qualityScore;
169 final SleepHeartRateSummary heartRate; 171 final SleepHeartRateSummary heartRate;
170 172
171 const SleepReport({ 173 const SleepReport({
172 required this.date, 174 required this.date,
173 this.duration, 175 this.duration,
  176 + this.sleepDurationSeconds,
  177 + this.targetDurationSeconds,
174 this.qualityScore, 178 this.qualityScore,
175 this.heartRate = const SleepHeartRateSummary(), 179 this.heartRate = const SleepHeartRateSummary(),
176 }); 180 });
@@ -197,6 +201,8 @@ class SleepReport { @@ -197,6 +201,8 @@ class SleepReport {
197 duration: json['duration'] == null 201 duration: json['duration'] == null
198 ? null 202 ? null
199 : SleepDuration.fromJson(json['duration'] as Map<String, dynamic>), 203 : SleepDuration.fromJson(json['duration'] as Map<String, dynamic>),
  204 + sleepDurationSeconds: json['sleepDurationSeconds'] as num?,
  205 + targetDurationSeconds: json['targetDurationSeconds'] as num?,
200 qualityScore: json['qualityScore'] as int?, 206 qualityScore: json['qualityScore'] as int?,
201 heartRate: json['heartRate'] == null 207 heartRate: json['heartRate'] == null
202 ? const SleepHeartRateSummary() 208 ? const SleepHeartRateSummary()
@@ -209,6 +215,8 @@ class SleepReport { @@ -209,6 +215,8 @@ class SleepReport {
209 Map<String, dynamic> toJson() => { 215 Map<String, dynamic> toJson() => {
210 'date': date.toIso8601String(), 216 'date': date.toIso8601String(),
211 'duration': duration?.toJson(), 217 'duration': duration?.toJson(),
  218 + 'sleepDurationSeconds': sleepDurationSeconds,
  219 + 'targetDurationSeconds': targetDurationSeconds,
212 'qualityScore': qualityScore, 220 'qualityScore': qualityScore,
213 'heartRate': heartRate.toJson(), 221 'heartRate': heartRate.toJson(),
214 }; 222 };
@@ -2,12 +2,12 @@ import 'package:flutter/material.dart'; @@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
2 import 'package:get/get.dart'; 2 import 'package:get/get.dart';
3 3
4 import '../../report_common/models/report_period.dart'; 4 import '../../report_common/models/report_period.dart';
  5 +import '../../report_common/widgets/health_report_subject_scope.dart';
5 import '../../report_common/widgets/report_bottom_slogan.dart'; 6 import '../../report_common/widgets/report_bottom_slogan.dart';
6 import '../../report_common/widgets/report_date_picker_sheet.dart'; 7 import '../../report_common/widgets/report_date_picker_sheet.dart';
7 import '../../report_common/widgets/report_date_switcher.dart'; 8 import '../../report_common/widgets/report_date_switcher.dart';
8 import '../../report_common/widgets/report_loading_indicator.dart'; 9 import '../../report_common/widgets/report_loading_indicator.dart';
9 import '../../report_common/widgets/report_period_tab_bar.dart'; 10 import '../../report_common/widgets/report_period_tab_bar.dart';
10 -import '../../report_common/widgets/health_report_subject_scope.dart';  
11 import '../controllers/sleep_report_logic.dart'; 11 import '../controllers/sleep_report_logic.dart';
12 import '../models/sleep_report_models.dart'; 12 import '../models/sleep_report_models.dart';
13 import '../widgets/sleep_heart_rate_card.dart'; 13 import '../widgets/sleep_heart_rate_card.dart';
@@ -133,12 +133,14 @@ class _SleepReportViewState extends State<SleepReportView> { @@ -133,12 +133,14 @@ class _SleepReportViewState extends State<SleepReportView> {
133 ReportPeriod.week) 133 ReportPeriod.week)
134 SleepWeekReportView( 134 SleepWeekReportView(
135 report: widget.logic.weeklyReport.value, 135 report: widget.logic.weeklyReport.value,
  136 + tooltipAutoDismissMs: 4000,
136 ) 137 )
137 else if (widget.logic.selectedPeriod.value == 138 else if (widget.logic.selectedPeriod.value ==
138 ReportPeriod.month) 139 ReportPeriod.month)
139 SleepMonthReportView( 140 SleepMonthReportView(
140 monthStart: widget.logic.monthStart, 141 monthStart: widget.logic.monthStart,
141 report: widget.logic.monthlyReport.value, 142 report: widget.logic.monthlyReport.value,
  143 + tooltipAutoDismissMs: 4000,
142 ) 144 )
143 else 145 else
144 _DailyReportContent( 146 _DailyReportContent(
@@ -19,11 +19,22 @@ class SleepQualityRing extends StatefulWidget { @@ -19,11 +19,22 @@ class SleepQualityRing extends StatefulWidget {
19 class _SleepQualityRingState extends State<SleepQualityRing> 19 class _SleepQualityRingState extends State<SleepQualityRing>
20 with SingleTickerProviderStateMixin { 20 with SingleTickerProviderStateMixin {
21 late final AnimationController _controller; 21 late final AnimationController _controller;
22 - late Animation<double> _progress; 22 + late Animation<double> _durationProgress;
  23 + late Animation<double> _qualityProgress;
23 24
24 - double get _targetProgress => 25 + double get _targetQualityProgress =>
25 ((widget.report?.validQualityScore ?? 0).clamp(0, 100) / 100).toDouble(); 26 ((widget.report?.validQualityScore ?? 0).clamp(0, 100) / 100).toDouble();
26 27
  28 + double get _targetDurationProgress {
  29 + final durationSeconds = widget.report?.sleepDurationSeconds ??
  30 + (widget.report?.duration?.minutes ?? 0) * 60;
  31 + final targetSeconds = widget.report?.targetDurationSeconds;
  32 + if (durationSeconds <= 0 || targetSeconds == null || targetSeconds <= 0) {
  33 + return 0;
  34 + }
  35 + return (durationSeconds / targetSeconds).clamp(0, 1).toDouble();
  36 + }
  37 +
27 @override 38 @override
28 void initState() { 39 void initState() {
29 super.initState(); 40 super.initState();
@@ -31,23 +42,49 @@ class _SleepQualityRingState extends State<SleepQualityRing> @@ -31,23 +42,49 @@ class _SleepQualityRingState extends State<SleepQualityRing>
31 vsync: this, 42 vsync: this,
32 duration: const Duration(seconds: 1), 43 duration: const Duration(seconds: 1),
33 ); 44 );
34 - _animateTo(_targetProgress, from: 0); 45 + _animateTo(durationFrom: 0, qualityFrom: 0);
35 } 46 }
36 47
37 @override 48 @override
38 void didUpdateWidget(covariant SleepQualityRing oldWidget) { 49 void didUpdateWidget(covariant SleepQualityRing oldWidget) {
39 super.didUpdateWidget(oldWidget); 50 super.didUpdateWidget(oldWidget);
40 - final oldProgress = 51 + final oldQualityProgress =
41 ((oldWidget.report?.validQualityScore ?? 0).clamp(0, 100) / 100) 52 ((oldWidget.report?.validQualityScore ?? 0).clamp(0, 100) / 100)
42 .toDouble(); 53 .toDouble();
43 - if (oldProgress != _targetProgress) {  
44 - _animateTo(_targetProgress, from: _progress.value); 54 + final oldDurationSeconds = oldWidget.report?.sleepDurationSeconds ??
  55 + (oldWidget.report?.duration?.minutes ?? 0) * 60;
  56 + final oldTargetSeconds = oldWidget.report?.targetDurationSeconds;
  57 + final oldDurationProgress = oldDurationSeconds <= 0 ||
  58 + oldTargetSeconds == null ||
  59 + oldTargetSeconds <= 0
  60 + ? 0.0
  61 + : (oldDurationSeconds / oldTargetSeconds).clamp(0, 1).toDouble();
  62 + if (oldQualityProgress != _targetQualityProgress ||
  63 + oldDurationProgress != _targetDurationProgress) {
  64 + _animateTo(
  65 + durationFrom: _durationProgress.value,
  66 + qualityFrom: _qualityProgress.value,
  67 + );
45 } 68 }
46 } 69 }
47 70
48 - void _animateTo(double target, {required double from}) {  
49 - _progress = Tween<double>(begin: from, end: target).animate(  
50 - CurvedAnimation(parent: _controller, curve: Curves.easeInOutQuad), 71 + void _animateTo({
  72 + required double durationFrom,
  73 + required double qualityFrom,
  74 + }) {
  75 + final curve = CurvedAnimation(
  76 + parent: _controller,
  77 + curve: Curves.easeInOutQuad,
  78 + );
  79 + _durationProgress = Tween<double>(
  80 + begin: durationFrom,
  81 + end: _targetDurationProgress,
  82 + ).animate(curve);
  83 + _qualityProgress = Tween<double>(
  84 + begin: qualityFrom,
  85 + end: _targetQualityProgress,
  86 + ).animate(
  87 + curve,
51 ); 88 );
52 _controller.forward(from: 0); 89 _controller.forward(from: 0);
53 } 90 }
@@ -64,11 +101,13 @@ class _SleepQualityRingState extends State<SleepQualityRing> @@ -64,11 +101,13 @@ class _SleepQualityRingState extends State<SleepQualityRing>
64 width: 200, 101 width: 200,
65 height: 200, 102 height: 200,
66 child: AnimatedBuilder( 103 child: AnimatedBuilder(
67 - animation: _progress, 104 + animation: _controller,
68 builder: (context, child) => CustomPaint( 105 builder: (context, child) => CustomPaint(
69 painter: _SleepQualityRingPainter( 106 painter: _SleepQualityRingPainter(
70 - progress: _progress.value,  
71 - hasData: widget.report?.validQualityScore != null, 107 + durationProgress: _durationProgress.value,
  108 + qualityProgress: _qualityProgress.value,
  109 + hasDurationTarget: _targetDurationProgress > 0,
  110 + hasQuality: widget.report?.validQualityScore != null,
72 ), 111 ),
73 ), 112 ),
74 ), 113 ),
@@ -78,12 +117,16 @@ class _SleepQualityRingState extends State<SleepQualityRing> @@ -78,12 +117,16 @@ class _SleepQualityRingState extends State<SleepQualityRing>
78 117
79 class _SleepQualityRingPainter extends CustomPainter { 118 class _SleepQualityRingPainter extends CustomPainter {
80 const _SleepQualityRingPainter({ 119 const _SleepQualityRingPainter({
81 - required this.progress,  
82 - required this.hasData, 120 + required this.durationProgress,
  121 + required this.qualityProgress,
  122 + required this.hasDurationTarget,
  123 + required this.hasQuality,
83 }); 124 });
84 125
85 - final double progress;  
86 - final bool hasData; 126 + final double durationProgress;
  127 + final double qualityProgress;
  128 + final bool hasDurationTarget;
  129 + final bool hasQuality;
87 130
88 static const _trackOuter = Colors.white; 131 static const _trackOuter = Colors.white;
89 static const _purple = Color(0xFF8565F2); 132 static const _purple = Color(0xFF8565F2);
@@ -110,8 +153,6 @@ class _SleepQualityRingPainter extends CustomPainter { @@ -110,8 +153,6 @@ class _SleepQualityRingPainter extends CustomPainter {
110 canvas.drawArc(outerRect, 0, math.pi * 2, false, outerTrack); 153 canvas.drawArc(outerRect, 0, math.pi * 2, false, outerTrack);
111 canvas.drawArc(innerRect, 0, math.pi * 2, false, innerTrack); 154 canvas.drawArc(innerRect, 0, math.pi * 2, false, innerTrack);
112 155
113 - if (!hasData) return;  
114 -  
115 final outerProgress = Paint() 156 final outerProgress = Paint()
116 ..color = _purple 157 ..color = _purple
117 ..style = PaintingStyle.stroke 158 ..style = PaintingStyle.stroke
@@ -123,24 +164,31 @@ class _SleepQualityRingPainter extends CustomPainter { @@ -123,24 +164,31 @@ class _SleepQualityRingPainter extends CustomPainter {
123 ..strokeWidth = 22 164 ..strokeWidth = 22
124 ..strokeCap = StrokeCap.round; 165 ..strokeCap = StrokeCap.round;
125 166
126 - canvas.drawArc(  
127 - outerRect,  
128 - startAngle,  
129 - math.pi * 2 * progress,  
130 - false,  
131 - outerProgress,  
132 - );  
133 - canvas.drawArc(  
134 - innerRect,  
135 - startAngle,  
136 - math.pi * 2 * (progress * 0.86),  
137 - false,  
138 - innerProgress,  
139 - ); 167 + if (hasDurationTarget) {
  168 + canvas.drawArc(
  169 + outerRect,
  170 + startAngle,
  171 + math.pi * 2 * durationProgress,
  172 + false,
  173 + outerProgress,
  174 + );
  175 + }
  176 + if (hasQuality) {
  177 + canvas.drawArc(
  178 + innerRect,
  179 + startAngle,
  180 + math.pi * 2 * qualityProgress,
  181 + false,
  182 + innerProgress,
  183 + );
  184 + }
140 } 185 }
141 186
142 @override 187 @override
143 bool shouldRepaint(covariant _SleepQualityRingPainter oldDelegate) { 188 bool shouldRepaint(covariant _SleepQualityRingPainter oldDelegate) {
144 - return oldDelegate.progress != progress || oldDelegate.hasData != hasData; 189 + return oldDelegate.durationProgress != durationProgress ||
  190 + oldDelegate.qualityProgress != qualityProgress ||
  191 + oldDelegate.hasDurationTarget != hasDurationTarget ||
  192 + oldDelegate.hasQuality != hasQuality;
145 } 193 }
146 } 194 }
  1 +import 'dart:async';
  2 +
1 import 'package:doublefeel_flutter/r.dart'; 3 import 'package:doublefeel_flutter/r.dart';
2 import 'package:fl_chart/fl_chart.dart'; 4 import 'package:fl_chart/fl_chart.dart';
3 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
5 7
6 import '../../report_common/widgets/health_report_subject_scope.dart'; 8 import '../../report_common/widgets/health_report_subject_scope.dart';
  9 +import '../../report_common/widgets/chart_selection_line_overlay.dart';
7 import '../../report_common/utils/report_localization.dart'; 10 import '../../report_common/utils/report_localization.dart';
8 import '../models/sleep_report_models.dart'; 11 import '../models/sleep_report_models.dart';
9 import 'sleep_report_text_styles.dart'; 12 import 'sleep_report_text_styles.dart';
@@ -12,9 +15,11 @@ class SleepWeekReportView extends StatelessWidget { @@ -12,9 +15,11 @@ class SleepWeekReportView extends StatelessWidget {
12 const SleepWeekReportView({ 15 const SleepWeekReportView({
13 super.key, 16 super.key,
14 required this.report, 17 required this.report,
  18 + this.tooltipAutoDismissMs = 0,
15 }); 19 });
16 20
17 final WeeklySleepReport? report; 21 final WeeklySleepReport? report;
  22 + final int tooltipAutoDismissMs;
18 23
19 static const brand = Color(0xFF845EEE); 24 static const brand = Color(0xFF845EEE);
20 static const blue = Color(0xFF7B9BFB); 25 static const blue = Color(0xFF7B9BFB);
@@ -54,6 +59,7 @@ class SleepWeekReportView extends StatelessWidget { @@ -54,6 +59,7 @@ class SleepWeekReportView extends StatelessWidget {
54 bestSleepOverride: weekly?.bestSleepOverride, 59 bestSleepOverride: weekly?.bestSleepOverride,
55 worstSleepOverride: weekly?.worstSleepOverride, 60 worstSleepOverride: weekly?.worstSleepOverride,
56 comparisonLabel: context.l10n.sleepPreviousWeek, 61 comparisonLabel: context.l10n.sleepPreviousWeek,
  62 + tooltipAutoDismissMs: tooltipAutoDismissMs,
57 ); 63 );
58 } 64 }
59 } 65 }
@@ -63,10 +69,12 @@ class SleepMonthReportView extends StatelessWidget { @@ -63,10 +69,12 @@ class SleepMonthReportView extends StatelessWidget {
63 super.key, 69 super.key,
64 required this.monthStart, 70 required this.monthStart,
65 required this.report, 71 required this.report,
  72 + this.tooltipAutoDismissMs = 0,
66 }); 73 });
67 74
68 final DateTime monthStart; 75 final DateTime monthStart;
69 final MonthlySleepReport? report; 76 final MonthlySleepReport? report;
  77 + final int tooltipAutoDismissMs;
70 78
71 @override 79 @override
72 Widget build(BuildContext context) { 80 Widget build(BuildContext context) {
@@ -85,6 +93,7 @@ class SleepMonthReportView extends StatelessWidget { @@ -85,6 +93,7 @@ class SleepMonthReportView extends StatelessWidget {
85 bestSleepOverride: monthly.bestSleepOverride, 93 bestSleepOverride: monthly.bestSleepOverride,
86 worstSleepOverride: monthly.worstSleepOverride, 94 worstSleepOverride: monthly.worstSleepOverride,
87 comparisonLabel: context.l10n.sleepPreviousMonth, 95 comparisonLabel: context.l10n.sleepPreviousMonth,
  96 + tooltipAutoDismissMs: tooltipAutoDismissMs,
88 ); 97 );
89 } 98 }
90 } 99 }
@@ -104,6 +113,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -104,6 +113,7 @@ class _SleepPeriodTrendContent extends StatelessWidget {
104 required this.bestSleepOverride, 113 required this.bestSleepOverride,
105 required this.worstSleepOverride, 114 required this.worstSleepOverride,
106 required this.comparisonLabel, 115 required this.comparisonLabel,
  116 + required this.tooltipAutoDismissMs,
107 }); 117 });
108 118
109 final List<SleepReport> days; 119 final List<SleepReport> days;
@@ -119,6 +129,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -119,6 +129,7 @@ class _SleepPeriodTrendContent extends StatelessWidget {
119 final SleepReport? bestSleepOverride; 129 final SleepReport? bestSleepOverride;
120 final SleepReport? worstSleepOverride; 130 final SleepReport? worstSleepOverride;
121 final String comparisonLabel; 131 final String comparisonLabel;
  132 + final int tooltipAutoDismissMs;
122 133
123 @override 134 @override
124 Widget build(BuildContext context) { 135 Widget build(BuildContext context) {
@@ -144,6 +155,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -144,6 +155,7 @@ class _SleepPeriodTrendContent extends StatelessWidget {
144 compact: compact, 155 compact: compact,
145 averageDurationMinutes: averageDuration, 156 averageDurationMinutes: averageDuration,
146 targetDurationSeconds: targetDurationSeconds, 157 targetDurationSeconds: targetDurationSeconds,
  158 + tooltipAutoDismissMs: tooltipAutoDismissMs,
147 ), 159 ),
148 leftMetric: _ExtremeMetric.duration( 160 leftMetric: _ExtremeMetric.duration(
149 title: context.l10n.sleepLongest, 161 title: context.l10n.sleepLongest,
@@ -165,6 +177,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -165,6 +177,7 @@ class _SleepPeriodTrendContent extends StatelessWidget {
165 days: days, 177 days: days,
166 compact: compact, 178 compact: compact,
167 averageScore: averageScore, 179 averageScore: averageScore,
  180 + tooltipAutoDismissMs: tooltipAutoDismissMs,
168 ), 181 ),
169 leftMetric: _ExtremeMetric.score( 182 leftMetric: _ExtremeMetric.score(
170 title: context.l10n.sleepBestQuality, 183 title: context.l10n.sleepBestQuality,
@@ -181,7 +194,11 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -181,7 +194,11 @@ class _SleepPeriodTrendContent extends StatelessWidget {
181 _TrendCard( 194 _TrendCard(
182 title: HealthReportSubjectScope.titleOf( 195 title: HealthReportSubjectScope.titleOf(
183 context, context.l10n.sleepBedtime), 196 context, context.l10n.sleepBedtime),
184 - chart: _BedtimeChart(days: days, compact: compact), 197 + chart: _BedtimeChart(
  198 + days: days,
  199 + compact: compact,
  200 + tooltipAutoDismissMs: tooltipAutoDismissMs,
  201 + ),
185 leftMetric: _ExtremeMetric.time( 202 leftMetric: _ExtremeMetric.time(
186 title: context.l10n.sleepEarliestBedtime, 203 title: context.l10n.sleepEarliestBedtime,
187 color: SleepWeekReportView.green, 204 color: SleepWeekReportView.green,
@@ -445,19 +462,29 @@ class _DurationChart extends StatefulWidget { @@ -445,19 +462,29 @@ class _DurationChart extends StatefulWidget {
445 required this.compact, 462 required this.compact,
446 required this.averageDurationMinutes, 463 required this.averageDurationMinutes,
447 required this.targetDurationSeconds, 464 required this.targetDurationSeconds,
  465 + required this.tooltipAutoDismissMs,
448 }); 466 });
449 467
450 final List<SleepReport> days; 468 final List<SleepReport> days;
451 final bool compact; 469 final bool compact;
452 final int? averageDurationMinutes; 470 final int? averageDurationMinutes;
453 final num? targetDurationSeconds; 471 final num? targetDurationSeconds;
  472 + final int tooltipAutoDismissMs;
454 473
455 @override 474 @override
456 State<_DurationChart> createState() => _DurationChartState(); 475 State<_DurationChart> createState() => _DurationChartState();
457 } 476 }
458 477
459 class _DurationChartState extends State<_DurationChart> { 478 class _DurationChartState extends State<_DurationChart> {
  479 + int? _selectedIndex;
460 Offset? _selectedOffset; 480 Offset? _selectedOffset;
  481 + Timer? _tooltipTimer;
  482 +
  483 + @override
  484 + void dispose() {
  485 + _tooltipTimer?.cancel();
  486 + super.dispose();
  487 + }
461 488
462 double? get _targetDurationHours { 489 double? get _targetDurationHours {
463 final seconds = widget.targetDurationSeconds; 490 final seconds = widget.targetDurationSeconds;
@@ -491,10 +518,27 @@ class _DurationChartState extends State<_DurationChart> { @@ -491,10 +518,27 @@ class _DurationChartState extends State<_DurationChart> {
491 Expanded( 518 Expanded(
492 child: Stack( 519 child: Stack(
493 alignment: Alignment.center, 520 alignment: Alignment.center,
  521 + clipBehavior: Clip.none,
494 children: [ 522 children: [
  523 + if (_selectedIndex != null && _selectedOffset != null)
  524 + Positioned.fill(
  525 + child: LayoutBuilder(
  526 + builder: (context, constraints) =>
  527 + ChartSelectionLineOverlay(
  528 + offset: Offset.zero,
  529 + color: SleepWeekReportView.h3,
  530 + bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,
  531 + plotLeft: 0,
  532 + plotRight: 0,
  533 + lineX: _selectedOffset!.dx,
  534 + lineTop: _fixedTooltipBottom,
  535 + ),
  536 + ),
  537 + ),
495 _ChartPlotFrame( 538 _ChartPlotFrame(
496 maxY: _maxY, 539 maxY: _maxY,
497 interval: 2, 540 interval: 2,
  541 + insetChildHorizontally: false,
498 rightLabel: (value) => value.toInt().toString(), 542 rightLabel: (value) => value.toInt().toString(),
499 referenceLines: [ 543 referenceLines: [
500 if (widget.averageDurationMinutes != null) 544 if (widget.averageDurationMinutes != null)
@@ -510,18 +554,27 @@ class _DurationChartState extends State<_DurationChart> { @@ -510,18 +554,27 @@ class _DurationChartState extends State<_DurationChart> {
510 ), 554 ),
511 ], 555 ],
512 child: LayoutBuilder( 556 child: LayoutBuilder(
513 - builder: (context, constraints) => BarChart(  
514 - _chartData(constraints.maxWidth), 557 + builder: (context, constraints) => Listener(
  558 + behavior: HitTestBehavior.opaque,
  559 + onPointerDown: (event) => _selectAt(
  560 + event.localPosition.dx,
  561 + constraints.maxWidth,
  562 + ),
  563 + onPointerMove: (event) => _selectAt(
  564 + event.localPosition.dx,
  565 + constraints.maxWidth,
  566 + ),
  567 + onPointerUp: (_) => _onSelected(null, null),
  568 + onPointerCancel: (_) => _onSelected(null, null),
  569 + child: BarChart(
  570 + _chartData(
  571 + constraints.maxWidth,
  572 + constraints.maxHeight,
  573 + ),
  574 + ),
515 ), 575 ),
516 ), 576 ),
517 ), 577 ),
518 - if (_selectedOffset != null)  
519 - Positioned.fill(  
520 - child: _BarSelectionLineOverlay(  
521 - offset: _selectedOffset!,  
522 - horizontalInset: _ChartPlotFrame.horizontalInset,  
523 - ),  
524 - ),  
525 if (!hasData) const _WaitingDataText(), 578 if (!hasData) const _WaitingDataText(),
526 ], 579 ],
527 ), 580 ),
@@ -530,11 +583,11 @@ class _DurationChartState extends State<_DurationChart> { @@ -530,11 +583,11 @@ class _DurationChartState extends State<_DurationChart> {
530 ); 583 );
531 } 584 }
532 585
533 - BarChartData _chartData(double plotWidth) { 586 + BarChartData _chartData(double plotWidth, double chartHeight) {
534 return BarChartData( 587 return BarChartData(
535 minY: 0, 588 minY: 0,
536 maxY: _maxY, 589 maxY: _maxY,
537 - alignment: BarChartAlignment.spaceBetween, 590 + alignment: BarChartAlignment.center,
538 groupsSpace: _barGroupsSpace( 591 groupsSpace: _barGroupsSpace(
539 plotWidth, 592 plotWidth,
540 widget.days.length, 593 widget.days.length,
@@ -542,9 +595,11 @@ class _DurationChartState extends State<_DurationChart> { @@ -542,9 +595,11 @@ class _DurationChartState extends State<_DurationChart> {
542 ), 595 ),
543 barTouchData: _barTouchData( 596 barTouchData: _barTouchData(
544 days: widget.days, 597 days: widget.days,
  598 + chartWidth: plotWidth,
  599 + compact: widget.compact,
545 dataBuilder: _ChartSummaryData.duration, 600 dataBuilder: _ChartSummaryData.duration,
546 hasData: (report) => report.hasSleepData, 601 hasData: (report) => report.hasSleepData,
547 - onSelected: (_, offset) => setState(() => _selectedOffset = offset), 602 + onSelected: _onSelected,
548 ), 603 ),
549 gridData: const FlGridData(show: false), 604 gridData: const FlGridData(show: false),
550 borderData: FlBorderData(show: false), 605 borderData: FlBorderData(show: false),
@@ -556,6 +611,8 @@ class _DurationChartState extends State<_DurationChart> { @@ -556,6 +611,8 @@ class _DurationChartState extends State<_DurationChart> {
556 for (var i = 0; i < widget.days.length; i++) 611 for (var i = 0; i < widget.days.length; i++)
557 BarChartGroupData( 612 BarChartGroupData(
558 x: i, 613 x: i,
  614 + groupVertically: true,
  615 + showingTooltipIndicators: _selectedIndex == i ? [1] : [],
559 barRods: [ 616 barRods: [
560 BarChartRodData( 617 BarChartRodData(
561 toY: ((widget.days[i].duration?.minutes ?? 0) / 60) 618 toY: ((widget.days[i].duration?.minutes ?? 0) / 60)
@@ -566,11 +623,77 @@ class _DurationChartState extends State<_DurationChart> { @@ -566,11 +623,77 @@ class _DurationChartState extends State<_DurationChart> {
566 top: Radius.circular(8), 623 top: Radius.circular(8),
567 ), 624 ),
568 ), 625 ),
  626 + BarChartRodData(
  627 + toY: _tooltipAnchorValue(
  628 + maxY: _maxY,
  629 + chartHeight: chartHeight,
  630 + tooltipMargin: _barTooltipMargin,
  631 + ),
  632 + width: 0,
  633 + color: Colors.transparent,
  634 + ),
569 ], 635 ],
570 ), 636 ),
571 ], 637 ],
572 ); 638 );
573 } 639 }
  640 +
  641 + void _selectAt(double dx, double chartWidth) {
  642 + final index = _barIndexForTouch(
  643 + dx,
  644 + chartWidth,
  645 + widget.days.length,
  646 + widget.compact,
  647 + );
  648 + if (index == null || !widget.days[index].hasSleepData) {
  649 + _onSelected(null, null);
  650 + return;
  651 + }
  652 + _onSelected(
  653 + index,
  654 + Offset(
  655 + _barCenterForIndex(
  656 + index,
  657 + chartWidth,
  658 + widget.days.length,
  659 + widget.compact,
  660 + ),
  661 + 0,
  662 + ),
  663 + );
  664 + }
  665 +
  666 + void _onSelected(int? index, Offset? offset) {
  667 + if (index != null) {
  668 + _tooltipTimer?.cancel();
  669 + if (_selectedIndex == index && _selectedOffset == offset) return;
  670 + setState(() {
  671 + _selectedIndex = index;
  672 + _selectedOffset = offset;
  673 + });
  674 + return;
  675 + }
  676 + if (_selectedIndex == null) return;
  677 + if (widget.tooltipAutoDismissMs <= 0) {
  678 + setState(() {
  679 + _selectedIndex = null;
  680 + _selectedOffset = null;
  681 + });
  682 + return;
  683 + }
  684 + _tooltipTimer?.cancel();
  685 + _tooltipTimer = Timer(
  686 + Duration(milliseconds: widget.tooltipAutoDismissMs),
  687 + () {
  688 + if (mounted) {
  689 + setState(() {
  690 + _selectedIndex = null;
  691 + _selectedOffset = null;
  692 + });
  693 + }
  694 + },
  695 + );
  696 + }
574 } 697 }
575 698
576 class _QualityChart extends StatefulWidget { 699 class _QualityChart extends StatefulWidget {
@@ -578,18 +701,28 @@ class _QualityChart extends StatefulWidget { @@ -578,18 +701,28 @@ class _QualityChart extends StatefulWidget {
578 required this.days, 701 required this.days,
579 required this.compact, 702 required this.compact,
580 required this.averageScore, 703 required this.averageScore,
  704 + required this.tooltipAutoDismissMs,
581 }); 705 });
582 706
583 final List<SleepReport> days; 707 final List<SleepReport> days;
584 final bool compact; 708 final bool compact;
585 final int? averageScore; 709 final int? averageScore;
  710 + final int tooltipAutoDismissMs;
586 711
587 @override 712 @override
588 State<_QualityChart> createState() => _QualityChartState(); 713 State<_QualityChart> createState() => _QualityChartState();
589 } 714 }
590 715
591 class _QualityChartState extends State<_QualityChart> { 716 class _QualityChartState extends State<_QualityChart> {
  717 + int? _selectedIndex;
592 Offset? _selectedOffset; 718 Offset? _selectedOffset;
  719 + Timer? _tooltipTimer;
  720 +
  721 + @override
  722 + void dispose() {
  723 + _tooltipTimer?.cancel();
  724 + super.dispose();
  725 + }
593 726
594 @override 727 @override
595 Widget build(BuildContext context) { 728 Widget build(BuildContext context) {
@@ -603,10 +736,27 @@ class _QualityChartState extends State<_QualityChart> { @@ -603,10 +736,27 @@ class _QualityChartState extends State<_QualityChart> {
603 Expanded( 736 Expanded(
604 child: Stack( 737 child: Stack(
605 alignment: Alignment.center, 738 alignment: Alignment.center,
  739 + clipBehavior: Clip.none,
606 children: [ 740 children: [
  741 + if (_selectedIndex != null && _selectedOffset != null)
  742 + Positioned.fill(
  743 + child: LayoutBuilder(
  744 + builder: (context, constraints) =>
  745 + ChartSelectionLineOverlay(
  746 + offset: Offset.zero,
  747 + color: SleepWeekReportView.h3,
  748 + bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,
  749 + plotLeft: 0,
  750 + plotRight: 0,
  751 + lineX: _selectedOffset!.dx,
  752 + lineTop: _fixedTooltipBottom,
  753 + ),
  754 + ),
  755 + ),
607 _ChartPlotFrame( 756 _ChartPlotFrame(
608 maxY: 100, 757 maxY: 100,
609 interval: 25, 758 interval: 25,
  759 + insetChildHorizontally: false,
610 rightLabel: (value) => value.toInt().toString(), 760 rightLabel: (value) => value.toInt().toString(),
611 referenceLines: [ 761 referenceLines: [
612 if (widget.averageScore != null) 762 if (widget.averageScore != null)
@@ -616,18 +766,27 @@ class _QualityChartState extends State<_QualityChart> { @@ -616,18 +766,27 @@ class _QualityChartState extends State<_QualityChart> {
616 ), 766 ),
617 ], 767 ],
618 child: LayoutBuilder( 768 child: LayoutBuilder(
619 - builder: (context, constraints) => BarChart(  
620 - _chartData(constraints.maxWidth), 769 + builder: (context, constraints) => Listener(
  770 + behavior: HitTestBehavior.opaque,
  771 + onPointerDown: (event) => _selectAt(
  772 + event.localPosition.dx,
  773 + constraints.maxWidth,
  774 + ),
  775 + onPointerMove: (event) => _selectAt(
  776 + event.localPosition.dx,
  777 + constraints.maxWidth,
  778 + ),
  779 + onPointerUp: (_) => _onSelected(null, null),
  780 + onPointerCancel: (_) => _onSelected(null, null),
  781 + child: BarChart(
  782 + _chartData(
  783 + constraints.maxWidth,
  784 + constraints.maxHeight,
  785 + ),
  786 + ),
621 ), 787 ),
622 ), 788 ),
623 ), 789 ),
624 - if (_selectedOffset != null)  
625 - Positioned.fill(  
626 - child: _BarSelectionLineOverlay(  
627 - offset: _selectedOffset!,  
628 - horizontalInset: _ChartPlotFrame.horizontalInset,  
629 - ),  
630 - ),  
631 if (!hasData) const _WaitingDataText(), 790 if (!hasData) const _WaitingDataText(),
632 ], 791 ],
633 ), 792 ),
@@ -636,11 +795,11 @@ class _QualityChartState extends State<_QualityChart> { @@ -636,11 +795,11 @@ class _QualityChartState extends State<_QualityChart> {
636 ); 795 );
637 } 796 }
638 797
639 - BarChartData _chartData(double plotWidth) { 798 + BarChartData _chartData(double plotWidth, double chartHeight) {
640 return BarChartData( 799 return BarChartData(
641 minY: 0, 800 minY: 0,
642 maxY: 100, 801 maxY: 100,
643 - alignment: BarChartAlignment.spaceBetween, 802 + alignment: BarChartAlignment.center,
644 groupsSpace: _barGroupsSpace( 803 groupsSpace: _barGroupsSpace(
645 plotWidth, 804 plotWidth,
646 widget.days.length, 805 widget.days.length,
@@ -648,9 +807,11 @@ class _QualityChartState extends State<_QualityChart> { @@ -648,9 +807,11 @@ class _QualityChartState extends State<_QualityChart> {
648 ), 807 ),
649 barTouchData: _barTouchData( 808 barTouchData: _barTouchData(
650 days: widget.days, 809 days: widget.days,
  810 + chartWidth: plotWidth,
  811 + compact: widget.compact,
651 dataBuilder: _ChartSummaryData.quality, 812 dataBuilder: _ChartSummaryData.quality,
652 hasData: (report) => report.validQualityScore != null, 813 hasData: (report) => report.validQualityScore != null,
653 - onSelected: (_, offset) => setState(() => _selectedOffset = offset), 814 + onSelected: _onSelected,
654 ), 815 ),
655 gridData: const FlGridData(show: false), 816 gridData: const FlGridData(show: false),
656 borderData: FlBorderData(show: false), 817 borderData: FlBorderData(show: false),
@@ -662,6 +823,8 @@ class _QualityChartState extends State<_QualityChart> { @@ -662,6 +823,8 @@ class _QualityChartState extends State<_QualityChart> {
662 for (var i = 0; i < widget.days.length; i++) 823 for (var i = 0; i < widget.days.length; i++)
663 BarChartGroupData( 824 BarChartGroupData(
664 x: i, 825 x: i,
  826 + groupVertically: true,
  827 + showingTooltipIndicators: _selectedIndex == i ? [1] : [],
665 barRods: [ 828 barRods: [
666 BarChartRodData( 829 BarChartRodData(
667 toY: (widget.days[i].validQualityScore ?? 0).toDouble(), 830 toY: (widget.days[i].validQualityScore ?? 0).toDouble(),
@@ -671,91 +834,307 @@ class _QualityChartState extends State<_QualityChart> { @@ -671,91 +834,307 @@ class _QualityChartState extends State<_QualityChart> {
671 top: Radius.circular(8), 834 top: Radius.circular(8),
672 ), 835 ),
673 ), 836 ),
  837 + BarChartRodData(
  838 + toY: _tooltipAnchorValue(
  839 + maxY: 100,
  840 + chartHeight: chartHeight,
  841 + tooltipMargin: _barTooltipMargin,
  842 + ),
  843 + width: 0,
  844 + color: Colors.transparent,
  845 + ),
674 ], 846 ],
675 ), 847 ),
676 ], 848 ],
677 ); 849 );
678 } 850 }
  851 +
  852 + void _selectAt(double dx, double chartWidth) {
  853 + final index = _barIndexForTouch(
  854 + dx,
  855 + chartWidth,
  856 + widget.days.length,
  857 + widget.compact,
  858 + );
  859 + if (index == null || widget.days[index].validQualityScore == null) {
  860 + _onSelected(null, null);
  861 + return;
  862 + }
  863 + _onSelected(
  864 + index,
  865 + Offset(
  866 + _barCenterForIndex(
  867 + index,
  868 + chartWidth,
  869 + widget.days.length,
  870 + widget.compact,
  871 + ),
  872 + 0,
  873 + ),
  874 + );
  875 + }
  876 +
  877 + void _onSelected(int? index, Offset? offset) {
  878 + if (index != null) {
  879 + _tooltipTimer?.cancel();
  880 + if (_selectedIndex == index && _selectedOffset == offset) return;
  881 + setState(() {
  882 + _selectedIndex = index;
  883 + _selectedOffset = offset;
  884 + });
  885 + return;
  886 + }
  887 + if (_selectedIndex == null) return;
  888 + if (widget.tooltipAutoDismissMs <= 0) {
  889 + setState(() {
  890 + _selectedIndex = null;
  891 + _selectedOffset = null;
  892 + });
  893 + return;
  894 + }
  895 + _tooltipTimer?.cancel();
  896 + _tooltipTimer = Timer(
  897 + Duration(milliseconds: widget.tooltipAutoDismissMs),
  898 + () {
  899 + if (mounted) {
  900 + setState(() {
  901 + _selectedIndex = null;
  902 + _selectedOffset = null;
  903 + });
  904 + }
  905 + },
  906 + );
  907 + }
679 } 908 }
680 909
681 -class _BedtimeChart extends StatelessWidget { 910 +class _BedtimeChart extends StatefulWidget {
682 const _BedtimeChart({ 911 const _BedtimeChart({
683 required this.days, 912 required this.days,
684 required this.compact, 913 required this.compact,
  914 + required this.tooltipAutoDismissMs,
685 }); 915 });
686 916
687 final List<SleepReport> days; 917 final List<SleepReport> days;
688 final bool compact; 918 final bool compact;
  919 + final int tooltipAutoDismissMs;
  920 +
  921 + @override
  922 + State<_BedtimeChart> createState() => _BedtimeChartState();
  923 +}
  924 +
  925 +class _BedtimeChartState extends State<_BedtimeChart> {
  926 + int? _selectedDayIndex;
  927 + Timer? _tooltipTimer;
  928 +
  929 + @override
  930 + void didUpdateWidget(covariant _BedtimeChart oldWidget) {
  931 + super.didUpdateWidget(oldWidget);
  932 + if (widget.days != oldWidget.days) {
  933 + _tooltipTimer?.cancel();
  934 + _selectedDayIndex = null;
  935 + }
  936 + }
  937 +
  938 + @override
  939 + void dispose() {
  940 + _tooltipTimer?.cancel();
  941 + super.dispose();
  942 + }
689 943
690 @override 944 @override
691 Widget build(BuildContext context) { 945 Widget build(BuildContext context) {
692 - final hasData = days.any( 946 + final hasData = widget.days.any(
693 (day) => day.hasSleepData && day.heartRate.sleepStart != null, 947 (day) => day.hasSleepData && day.heartRate.sleepStart != null,
694 ); 948 );
695 - final axis = _BedtimeAxis.fromReports(days);  
696 - return Stack(  
697 - alignment: Alignment.center,  
698 - children: [  
699 - _ChartPlotFrame(  
700 - maxY: axis.maxHour.toDouble(),  
701 - minY: axis.minHour.toDouble(),  
702 - interval: 1,  
703 - topInset: 16,  
704 - rightLabel: _timeAxisLabel,  
705 - child: LineChart(_chartData(axis)),  
706 - ),  
707 - if (!hasData) const _WaitingDataText(),  
708 - ], 949 + final axis = _BedtimeAxis.fromReports(widget.days);
  950 + return LayoutBuilder(
  951 + builder: (context, constraints) => Stack(
  952 + alignment: Alignment.center,
  953 + clipBehavior: Clip.none,
  954 + children: [
  955 + if (_selectedDayIndex != null)
  956 + Positioned.fill(
  957 + child: ChartSelectionLineOverlay(
  958 + offset: Offset.zero,
  959 + color: SleepWeekReportView.h3,
  960 + bottomTitleHeight: _ChartPlotFrame.bottomTitleHeight,
  961 + plotLeft: 0,
  962 + plotRight: 0,
  963 + lineX: _lineXForDay(
  964 + constraints.maxWidth,
  965 + _selectedDayIndex!,
  966 + ),
  967 + lineTop: _fixedTooltipBottom,
  968 + ),
  969 + ),
  970 + _ChartPlotFrame(
  971 + maxY: axis.maxHour.toDouble(),
  972 + minY: axis.minHour.toDouble(),
  973 + interval: 1,
  974 + topInset: 16,
  975 + insetChildHorizontally: false,
  976 + rightLabel: _timeAxisLabel,
  977 + child: LayoutBuilder(
  978 + builder: (context, constraints) => Listener(
  979 + behavior: HitTestBehavior.opaque,
  980 + onPointerDown: (event) => _selectSpotAt(
  981 + event.localPosition.dx,
  982 + constraints.maxWidth,
  983 + ),
  984 + onPointerMove: (event) => _selectSpotAt(
  985 + event.localPosition.dx,
  986 + constraints.maxWidth,
  987 + ),
  988 + onPointerUp: (_) => _scheduleTooltipDismissal(),
  989 + onPointerCancel: (_) => _scheduleTooltipDismissal(),
  990 + child: LineChart(
  991 + _chartData(
  992 + axis,
  993 + constraints.maxWidth,
  994 + constraints.maxHeight,
  995 + ),
  996 + ),
  997 + ),
  998 + ),
  999 + ),
  1000 + if (!hasData) const _WaitingDataText(),
  1001 + ],
  1002 + ),
709 ); 1003 );
710 } 1004 }
711 1005
712 - LineChartData _chartData(_BedtimeAxis axis) { 1006 + double _lineXForDay(double width, int dayIndex) {
  1007 + return _barCenterForIndex(
  1008 + dayIndex,
  1009 + width,
  1010 + widget.days.length,
  1011 + widget.compact,
  1012 + );
  1013 + }
  1014 +
  1015 + LineChartData _chartData(
  1016 + _BedtimeAxis axis,
  1017 + double chartWidth,
  1018 + double chartHeight,
  1019 + ) {
713 final points = <FlSpot>[ 1020 final points = <FlSpot>[
714 - for (var i = 0; i < days.length; i++)  
715 - if (days[i].hasSleepData && days[i].heartRate.sleepStart != null) 1021 + for (var i = 0; i < widget.days.length; i++)
  1022 + if (widget.days[i].hasSleepData &&
  1023 + widget.days[i].heartRate.sleepStart != null)
716 FlSpot( 1024 FlSpot(
717 i.toDouble(), 1025 i.toDouble(),
718 - _bedtimeY(days[i].heartRate.sleepStart!), 1026 + _normalizedBedtimeY(
  1027 + widget.days[i].heartRate.sleepStart!,
  1028 + axis,
  1029 + ),
719 ), 1030 ),
720 ]; 1031 ];
  1032 + final lineData = LineChartBarData(
  1033 + spots: points,
  1034 + color: SleepWeekReportView.brand,
  1035 + barWidth: 2,
  1036 + isCurved: false,
  1037 + showingIndicators: _selectedDayIndex == null
  1038 + ? const []
  1039 + : [points.indexWhere((spot) => spot.x == _selectedDayIndex)],
  1040 + dotData: FlDotData(
  1041 + show: true,
  1042 + getDotPainter: (_, __, ___, ____) => FlDotCirclePainter(
  1043 + radius: 3,
  1044 + color: Colors.white,
  1045 + strokeWidth: 2,
  1046 + strokeColor: SleepWeekReportView.brand,
  1047 + ),
  1048 + ),
  1049 + );
  1050 + final anchorData = LineChartBarData(
  1051 + spots: [
  1052 + for (final point in points)
  1053 + FlSpot(
  1054 + point.x,
  1055 + _tooltipAnchorValue(
  1056 + maxY: _tooltipChartMaxY,
  1057 + chartHeight: chartHeight,
  1058 + tooltipMargin: _tooltipMargin,
  1059 + chartTopInset: 16,
  1060 + ),
  1061 + ),
  1062 + ],
  1063 + color: Colors.transparent,
  1064 + barWidth: 0,
  1065 + dotData: const FlDotData(show: false),
  1066 + );
  1067 + FlSpot? anchorSpot;
  1068 + for (final spot in anchorData.spots) {
  1069 + if (spot.x == _selectedDayIndex) {
  1070 + anchorSpot = spot;
  1071 + break;
  1072 + }
  1073 + }
  1074 +
  1075 + final count = widget.days.length;
  1076 + final dataMaxX = count <= 1 ? 0.0 : (count - 1).toDouble();
  1077 + final horizontalInset = count <= 1
  1078 + ? chartWidth / 2
  1079 + : _barCenterForIndex(0, chartWidth, count, widget.compact);
  1080 + final dataWidth = chartWidth - horizontalInset * 2;
  1081 + final xPadding = count <= 1 || dataWidth <= 0
  1082 + ? 0.5
  1083 + : horizontalInset * (count - 1) / dataWidth;
721 1084
722 return LineChartData( 1085 return LineChartData(
723 - minX: 0,  
724 - maxX: (days.length - 1).clamp(1, 31).toDouble(),  
725 - minY: axis.minHour.toDouble(),  
726 - maxY: axis.maxHour.toDouble(), 1086 + minX: -xPadding,
  1087 + maxX: dataMaxX + xPadding,
  1088 + minY: 0,
  1089 + maxY: _tooltipChartMaxY,
727 gridData: const FlGridData(show: false), 1090 gridData: const FlGridData(show: false),
728 borderData: FlBorderData(show: false), 1091 borderData: FlBorderData(show: false),
729 lineTouchData: LineTouchData( 1092 lineTouchData: LineTouchData(
730 - handleBuiltInTouches: true,  
731 - touchSpotThreshold: 14, 1093 + enabled: false,
  1094 + handleBuiltInTouches: false,
732 getTouchedSpotIndicator: _lineTouchedIndicators, 1095 getTouchedSpotIndicator: _lineTouchedIndicators,
733 touchTooltipData: _lineTooltipData( 1096 touchTooltipData: _lineTooltipData(
734 - days: days, 1097 + days: widget.days,
735 dataBuilder: _ChartSummaryData.bedtime, 1098 dataBuilder: _ChartSummaryData.bedtime,
736 ), 1099 ),
737 ), 1100 ),
738 titlesData: _SleepTrendXAxis.titlesData( 1101 titlesData: _SleepTrendXAxis.titlesData(
739 - days: days,  
740 - compact: compact, 1102 + days: widget.days,
  1103 + compact: widget.compact,
741 ), 1104 ),
742 - lineBarsData: [  
743 - LineChartBarData(  
744 - spots: points,  
745 - color: SleepWeekReportView.brand,  
746 - barWidth: 2,  
747 - isCurved: false,  
748 - dotData: FlDotData(  
749 - show: true,  
750 - getDotPainter: (_, __, ___, ____) => FlDotCirclePainter(  
751 - radius: 3,  
752 - color: Colors.white,  
753 - strokeWidth: 2,  
754 - strokeColor: SleepWeekReportView.brand,  
755 - ),  
756 - ),  
757 - ),  
758 - ], 1105 + lineBarsData: [lineData, anchorData],
  1106 + showingTooltipIndicators: anchorSpot == null
  1107 + ? const []
  1108 + : [
  1109 + ShowingTooltipIndicators([LineBarSpot(anchorData, 1, anchorSpot)])
  1110 + ],
  1111 + );
  1112 + }
  1113 +
  1114 + void _selectSpotAt(double dx, double chartWidth) {
  1115 + _tooltipTimer?.cancel();
  1116 + final index = _barIndexForTouch(
  1117 + dx,
  1118 + chartWidth,
  1119 + widget.days.length,
  1120 + widget.compact,
  1121 + );
  1122 + final hasData = index != null &&
  1123 + widget.days[index].hasSleepData &&
  1124 + widget.days[index].heartRate.sleepStart != null;
  1125 + final nextIndex = hasData ? index : null;
  1126 + if (_selectedDayIndex == nextIndex) return;
  1127 + setState(() => _selectedDayIndex = nextIndex);
  1128 + }
  1129 +
  1130 + void _scheduleTooltipDismissal() {
  1131 + if (_selectedDayIndex == null || widget.tooltipAutoDismissMs <= 0) return;
  1132 + _tooltipTimer?.cancel();
  1133 + _tooltipTimer = Timer(
  1134 + Duration(milliseconds: widget.tooltipAutoDismissMs),
  1135 + () {
  1136 + if (mounted) setState(() => _selectedDayIndex = null);
  1137 + },
759 ); 1138 );
760 } 1139 }
761 1140
@@ -763,6 +1142,13 @@ class _BedtimeChart extends StatelessWidget { @@ -763,6 +1142,13 @@ class _BedtimeChart extends StatelessWidget {
763 return SleepWeekReportView.minutesFromEvening(time) / 60; 1142 return SleepWeekReportView.minutesFromEvening(time) / 60;
764 } 1143 }
765 1144
  1145 + double _normalizedBedtimeY(DateTime time, _BedtimeAxis axis) {
  1146 + final range = axis.maxHour - axis.minHour;
  1147 + if (range <= 0) return 0;
  1148 + return ((_bedtimeY(time) - axis.minHour) / range * _tooltipChartMaxY)
  1149 + .clamp(0, _tooltipChartMaxY);
  1150 + }
  1151 +
766 String _timeAxisLabel(double value) { 1152 String _timeAxisLabel(double value) {
767 final hour = value.toInt() % 24; 1153 final hour = value.toInt() % 24;
768 return '${hour.toString().padLeft(2, '0')}:00'; 1154 return '${hour.toString().padLeft(2, '0')}:00';
@@ -770,11 +1156,33 @@ class _BedtimeChart extends StatelessWidget { @@ -770,11 +1156,33 @@ class _BedtimeChart extends StatelessWidget {
770 } 1156 }
771 1157
772 double _barWidth(bool compact) => compact ? 4 : 16; 1158 double _barWidth(bool compact) => compact ? 4 : 16;
  1159 +const _weekBarGap = 28.0;
  1160 +const _monthHorizontalInsets = 46.0;
  1161 +
  1162 +const _tooltipChartMaxY = 110.0;
  1163 +const _tooltipMargin = 16.0;
  1164 +const _barTooltipMargin = 6.0;
  1165 +const _fixedTooltipBottom = 64.0;
  1166 +
  1167 +double _tooltipAnchorValue({
  1168 + required double maxY,
  1169 + required double chartHeight,
  1170 + required double tooltipMargin,
  1171 + double chartTopInset = 0,
  1172 +}) {
  1173 + final plotHeight = chartHeight - _ChartPlotFrame.bottomTitleHeight;
  1174 + if (plotHeight <= 0) return maxY;
  1175 + final anchorY = (_fixedTooltipBottom - chartTopInset + tooltipMargin)
  1176 + .clamp(0, plotHeight);
  1177 + return (maxY * (1 - anchorY / plotHeight)).clamp(0, maxY);
  1178 +}
773 1179
774 double _barGroupsSpace(double plotWidth, int count, bool compact) { 1180 double _barGroupsSpace(double plotWidth, int count, bool compact) {
775 if (count <= 1) return 0; 1181 if (count <= 1) return 0;
  1182 + if (!compact) return _weekBarGap;
776 final barWidth = _barWidth(compact); 1183 final barWidth = _barWidth(compact);
777 - final space = (plotWidth - barWidth * count) / (count - 1); 1184 + final availableWidth = plotWidth - _monthHorizontalInsets;
  1185 + final space = (availableWidth - barWidth * count) / (count - 1);
778 return space < 0 ? 0 : space; 1186 return space < 0 ? 0 : space;
779 } 1187 }
780 1188
@@ -817,97 +1225,59 @@ class _BedtimeAxis { @@ -817,97 +1225,59 @@ class _BedtimeAxis {
817 } 1225 }
818 } 1226 }
819 1227
820 -class _BarSelectionLineOverlay extends StatelessWidget {  
821 - const _BarSelectionLineOverlay({  
822 - required this.offset,  
823 - required this.horizontalInset,  
824 - });  
825 -  
826 - final Offset offset;  
827 - final double horizontalInset;  
828 -  
829 - @override  
830 - Widget build(BuildContext context) {  
831 - return IgnorePointer(  
832 - child: CustomPaint(  
833 - painter: _BarSelectionLinePainter(  
834 - offset: offset,  
835 - horizontalInset: horizontalInset,  
836 - ),  
837 - ),  
838 - );  
839 - }  
840 -}  
841 -  
842 -class _BarSelectionLinePainter extends CustomPainter {  
843 - const _BarSelectionLinePainter({  
844 - required this.offset,  
845 - required this.horizontalInset,  
846 - });  
847 -  
848 - final Offset offset;  
849 - final double horizontalInset;  
850 -  
851 - @override  
852 - void paint(Canvas canvas, Size size) {  
853 - const tooltipMargin = 2.0;  
854 - const bottomTitleHeight = 28.0;  
855 - final plotHeight =  
856 - (size.height - bottomTitleHeight).clamp(0, double.infinity).toDouble();  
857 - if (plotHeight <= 0) return;  
858 -  
859 - final lineX = (offset.dx + horizontalInset).clamp(0, size.width).toDouble();  
860 - final lineTop = (offset.dy - tooltipMargin).clamp(0, plotHeight).toDouble();  
861 - if (lineTop >= plotHeight) return;  
862 - final paint = Paint()  
863 - ..color = SleepWeekReportView.h3  
864 - ..strokeWidth = 2;  
865 -  
866 - canvas.drawLine(  
867 - Offset(lineX, lineTop),  
868 - Offset(lineX, plotHeight),  
869 - paint,  
870 - );  
871 - }  
872 -  
873 - @override  
874 - bool shouldRepaint(covariant _BarSelectionLinePainter oldDelegate) {  
875 - return oldDelegate.offset != offset ||  
876 - oldDelegate.horizontalInset != horizontalInset;  
877 - }  
878 -}  
879 -  
880 BarTouchData _barTouchData({ 1228 BarTouchData _barTouchData({
881 required List<SleepReport> days, 1229 required List<SleepReport> days,
  1230 + required double chartWidth,
  1231 + required bool compact,
882 required _ChartSummaryData Function(SleepReport report) dataBuilder, 1232 required _ChartSummaryData Function(SleepReport report) dataBuilder,
883 required bool Function(SleepReport report) hasData, 1233 required bool Function(SleepReport report) hasData,
884 required void Function(int? index, Offset? offset) onSelected, 1234 required void Function(int? index, Offset? offset) onSelected,
885 }) { 1235 }) {
886 return BarTouchData( 1236 return BarTouchData(
887 - handleBuiltInTouches: true,  
888 - touchCallback: (event, response) {  
889 - if (!event.isInterestedForInteractions) {  
890 - onSelected(null, null);  
891 - return;  
892 - }  
893 - final index = response?.spot?.touchedBarGroupIndex;  
894 - if (index == null ||  
895 - index < 0 ||  
896 - index >= days.length ||  
897 - !hasData(days[index])) { 1237 + // Tooltips are driven by the invisible top rod in [barGroups], so the
  1238 + // built-in touch state must not replace that selected indicator.
  1239 + handleBuiltInTouches: false,
  1240 + touchExtraThreshold: const EdgeInsets.symmetric(
  1241 + horizontal: 32,
  1242 + vertical: 80,
  1243 + ),
  1244 + touchCallback: (event, _) {
  1245 + // Resolve selection from the horizontal touch position, just like the
  1246 + // HRV daily-stress trend. This makes the complete day column tappable,
  1247 + // including the empty area above a short bar.
  1248 + final index = _barIndexForTouch(
  1249 + event.localPosition?.dx,
  1250 + chartWidth,
  1251 + days.length,
  1252 + compact,
  1253 + );
  1254 + final isValidSelection = event.isInterestedForInteractions &&
  1255 + index != null &&
  1256 + hasData(days[index]);
  1257 + if (!isValidSelection) {
898 onSelected(null, null); 1258 onSelected(null, null);
899 return; 1259 return;
900 } 1260 }
901 - onSelected(index, response?.spot?.offset); 1261 + onSelected(
  1262 + index,
  1263 + Offset(
  1264 + _barCenterForIndex(index, chartWidth, days.length, compact),
  1265 + 34,
  1266 + ),
  1267 + );
902 }, 1268 },
903 touchTooltipData: BarTouchTooltipData( 1269 touchTooltipData: BarTouchTooltipData(
904 - tooltipRoundedRadius: 8, 1270 + tooltipRoundedRadius: 4,
905 tooltipBorder: BorderSide.none, 1271 tooltipBorder: BorderSide.none,
906 - tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6),  
907 - tooltipMargin: 2, 1272 + tooltipPadding: const EdgeInsets.symmetric(
  1273 + horizontal: 10,
  1274 + vertical: 7,
  1275 + ),
  1276 + tooltipMargin: _barTooltipMargin,
908 maxContentWidth: 128, 1277 maxContentWidth: 128,
909 fitInsideHorizontally: true, 1278 fitInsideHorizontally: true,
910 fitInsideVertically: false, 1279 fitInsideVertically: false,
  1280 + direction: TooltipDirection.top,
911 getTooltipColor: (_) => SleepWeekReportView.grid, 1281 getTooltipColor: (_) => SleepWeekReportView.grid,
912 getTooltipItem: (_, groupIndex, __, ___) { 1282 getTooltipItem: (_, groupIndex, __, ___) {
913 if (groupIndex < 0 || groupIndex >= days.length) return null; 1283 if (groupIndex < 0 || groupIndex >= days.length) return null;
@@ -919,6 +1289,40 @@ BarTouchData _barTouchData({ @@ -919,6 +1289,40 @@ BarTouchData _barTouchData({
919 ); 1289 );
920 } 1290 }
921 1291
  1292 +int? _barIndexForTouch(
  1293 + double? dx,
  1294 + double chartWidth,
  1295 + int count,
  1296 + bool compact,
  1297 +) {
  1298 + if (dx == null || chartWidth <= 0 || count <= 0) return null;
  1299 + if (count == 1) return 0;
  1300 + final barWidth = _barWidth(compact);
  1301 + final groupsSpace = _barGroupsSpace(chartWidth, count, compact);
  1302 + final step = barWidth + groupsSpace;
  1303 + if (step <= 0) return null;
  1304 + final usedWidth = count * barWidth + (count - 1) * groupsSpace;
  1305 + final chartLeft = (chartWidth - usedWidth) / 2;
  1306 + return ((dx - chartLeft - barWidth / 2) / step)
  1307 + .round()
  1308 + .clamp(0, count - 1)
  1309 + .toInt();
  1310 +}
  1311 +
  1312 +double _barCenterForIndex(
  1313 + int index,
  1314 + double chartWidth,
  1315 + int count,
  1316 + bool compact,
  1317 +) {
  1318 + if (count <= 1) return chartWidth / 2;
  1319 + final barWidth = _barWidth(compact);
  1320 + final groupsSpace = _barGroupsSpace(chartWidth, count, compact);
  1321 + final usedWidth = count * barWidth + (count - 1) * groupsSpace;
  1322 + final chartLeft = (chartWidth - usedWidth) / 2;
  1323 + return chartLeft + barWidth / 2 + (barWidth + groupsSpace) * index;
  1324 +}
  1325 +
922 BarTooltipItem _barTooltipItem(_ChartSummaryData data) { 1326 BarTooltipItem _barTooltipItem(_ChartSummaryData data) {
923 return BarTooltipItem( 1327 return BarTooltipItem(
924 data.title, 1328 data.title,
@@ -947,9 +1351,10 @@ LineTouchTooltipData _lineTooltipData({ @@ -947,9 +1351,10 @@ LineTouchTooltipData _lineTooltipData({
947 tooltipBorder: BorderSide.none, 1351 tooltipBorder: BorderSide.none,
948 getTooltipColor: (_) => SleepWeekReportView.grid, 1352 getTooltipColor: (_) => SleepWeekReportView.grid,
949 tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6), 1353 tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6),
950 - tooltipMargin: 2, 1354 + tooltipMargin: 16,
951 fitInsideHorizontally: true, 1355 fitInsideHorizontally: true,
952 fitInsideVertically: false, 1356 fitInsideVertically: false,
  1357 + showOnTopOfTheChartBoxArea: false,
953 getTooltipItems: (spots) { 1358 getTooltipItems: (spots) {
954 return spots.map((spot) { 1359 return spots.map((spot) {
955 final index = spot.x.toInt(); 1360 final index = spot.x.toInt();
@@ -1177,6 +1582,7 @@ class _ChartPlotFrame extends StatelessWidget { @@ -1177,6 +1582,7 @@ class _ChartPlotFrame extends StatelessWidget {
1177 required this.rightLabel, 1582 required this.rightLabel,
1178 this.minY = 0, 1583 this.minY = 0,
1179 this.topInset = 0, 1584 this.topInset = 0,
  1585 + this.insetChildHorizontally = true,
1180 this.referenceLines = const [], 1586 this.referenceLines = const [],
1181 }); 1587 });
1182 1588
@@ -1189,6 +1595,7 @@ class _ChartPlotFrame extends StatelessWidget { @@ -1189,6 +1595,7 @@ class _ChartPlotFrame extends StatelessWidget {
1189 final double maxY; 1595 final double maxY;
1190 final double interval; 1596 final double interval;
1191 final double topInset; 1597 final double topInset;
  1598 + final bool insetChildHorizontally;
1192 final String Function(double value) rightLabel; 1599 final String Function(double value) rightLabel;
1193 final List<_ChartReferenceLine> referenceLines; 1600 final List<_ChartReferenceLine> referenceLines;
1194 1601
@@ -1222,8 +1629,8 @@ class _ChartPlotFrame extends StatelessWidget { @@ -1222,8 +1629,8 @@ class _ChartPlotFrame extends StatelessWidget {
1222 Padding( 1629 Padding(
1223 padding: EdgeInsets.only( 1630 padding: EdgeInsets.only(
1224 top: topInset, 1631 top: topInset,
1225 - left: horizontalInset,  
1226 - right: rightAxisWidth, 1632 + left: insetChildHorizontally ? horizontalInset : 0,
  1633 + right: insetChildHorizontally ? rightAxisWidth : 0,
1227 ), 1634 ),
1228 child: child, 1635 child: child,
1229 ), 1636 ),
@@ -1396,7 +1803,10 @@ class _SleepTrendXAxis { @@ -1396,7 +1803,10 @@ class _SleepTrendXAxis {
1396 reservedSize: reservedHeight, 1803 reservedSize: reservedHeight,
1397 interval: 1, 1804 interval: 1,
1398 getTitlesWidget: (value, meta) { 1805 getTitlesWidget: (value, meta) {
1399 - final index = value.toInt(); 1806 + final index = value.round();
  1807 + if ((value - index).abs() > 0.001) {
  1808 + return const SizedBox.shrink();
  1809 + }
1400 if (index < 0 || index >= days.length) { 1810 if (index < 0 || index >= days.length) {
1401 return const SizedBox.shrink(); 1811 return const SizedBox.shrink();
1402 } 1812 }
@@ -432,10 +432,10 @@ @@ -432,10 +432,10 @@
432 "friendsSleepQualityAttention": "Slept poorly", 432 "friendsSleepQualityAttention": "Slept poorly",
433 "friendsRemove": "Remove", 433 "friendsRemove": "Remove",
434 "friendsEditRemark": "Edit nickname", 434 "friendsEditRemark": "Edit nickname",
435 - "friendsShowOnWatchFace": "Show on watch face",  
436 - "friendsShownOnWatchFace": "Shown on watch face", 435 + "friendsShowOnWatchFace": "Show on watch",
  436 + "friendsShownOnWatchFace": "Shown on watch",
437 "friendsSelect": "Select a friend", 437 "friendsSelect": "Select a friend",
438 - "friendsSelectAndSync": "Select and sync to watch face", 438 + "friendsSelectAndSync": "Select and sync to watch",
439 "friendsBack": "Back", 439 "friendsBack": "Back",
440 "friendsTrendTitle": "{name}'s Trends", 440 "friendsTrendTitle": "{name}'s Trends",
441 "friendsAddAction": "Add", 441 "friendsAddAction": "Add",
@@ -615,7 +615,7 @@ @@ -615,7 +615,7 @@
615 "watchThemeNoWatchMessage": "Pair an Apple Watch and try again", 615 "watchThemeNoWatchMessage": "Pair an Apple Watch and try again",
616 "watchThemeOk": "OK", 616 "watchThemeOk": "OK",
617 "watchThemeSelectFriend": "Select Friend", 617 "watchThemeSelectFriend": "Select Friend",
618 - "watchThemeSelectAndSync": "Select and Sync to Watch Face", 618 + "watchThemeSelectAndSync": "Select and Sync to Watch",
619 "watchThemeCustomTheme": "Custom Themes", 619 "watchThemeCustomTheme": "Custom Themes",
620 "watchThemeCustomDescription": "Capture every mood with your creativity and make a watch face that's uniquely yours.", 620 "watchThemeCustomDescription": "Capture every mood with your creativity and make a watch face that's uniquely yours.",
621 "watchThemeCreateTheme": "Create Theme", 621 "watchThemeCreateTheme": "Create Theme",
@@ -787,10 +787,10 @@ @@ -787,10 +787,10 @@
787 "friendsSleepQualityAttention": "睡得差", 787 "friendsSleepQualityAttention": "睡得差",
788 "friendsRemove": "删除Ta", 788 "friendsRemove": "删除Ta",
789 "friendsEditRemark": "修改备注", 789 "friendsEditRemark": "修改备注",
790 - "friendsShowOnWatchFace": "在表盘显示",  
791 - "friendsShownOnWatchFace": "已在表盘显示Ta", 790 + "friendsShowOnWatchFace": "在手表显示",
  791 + "friendsShownOnWatchFace": "已在手表显示Ta",
792 "friendsSelect": "选择好友", 792 "friendsSelect": "选择好友",
793 - "friendsSelectAndSync": "选择并同步至表盘", 793 + "friendsSelectAndSync": "选择并同步至手表",
794 "friendsBack": "返回", 794 "friendsBack": "返回",
795 "friendsTrendTitle": "{name}的趋势", 795 "friendsTrendTitle": "{name}的趋势",
796 "@friendsTrendTitle": { 796 "@friendsTrendTitle": {
@@ -994,7 +994,7 @@ @@ -994,7 +994,7 @@
994 "watchThemeNoWatchMessage": "请配对苹果手表后再尝试", 994 "watchThemeNoWatchMessage": "请配对苹果手表后再尝试",
995 "watchThemeOk": "好的", 995 "watchThemeOk": "好的",
996 "watchThemeSelectFriend": "选择好友", 996 "watchThemeSelectFriend": "选择好友",
997 - "watchThemeSelectAndSync": "选择并同步至表盘", 997 + "watchThemeSelectAndSync": "选择并同步至手表",
998 "watchThemeCustomTheme": "自定义主题", 998 "watchThemeCustomTheme": "自定义主题",
999 "watchThemeCustomDescription": "用创意记录每一次情绪波动,打造懂你的专属表盘吧~", 999 "watchThemeCustomDescription": "用创意记录每一次情绪波动,打造懂你的专属表盘吧~",
1000 "watchThemeCreateTheme": "创作主题", 1000 "watchThemeCreateTheme": "创作主题",
@@ -1076,4 +1076,4 @@ @@ -1076,4 +1076,4 @@
1076 "feedbackSubmitSuccessTitle": "反馈提交成功", 1076 "feedbackSubmitSuccessTitle": "反馈提交成功",
1077 "feedbackSubmitSuccessMessage": "谢谢您的反馈。如需进一步沟通,我们会尽快通过您留下的邮箱地址与您联系,请留意查收邮件。", 1077 "feedbackSubmitSuccessMessage": "谢谢您的反馈。如需进一步沟通,我们会尽快通过您留下的邮箱地址与您联系,请留意查收邮件。",
1078 "feedbackSubmitSuccessConfirm": "好的" 1078 "feedbackSubmitSuccessConfirm": "好的"
1079 -}  
  1079 +}
@@ -2694,13 +2694,13 @@ abstract class AppLocalizations { @@ -2694,13 +2694,13 @@ abstract class AppLocalizations {
2694 /// No description provided for @friendsShowOnWatchFace. 2694 /// No description provided for @friendsShowOnWatchFace.
2695 /// 2695 ///
2696 /// In zh, this message translates to: 2696 /// In zh, this message translates to:
2697 - /// **'在表盘显示'** 2697 + /// **'在手表显示'**
2698 String get friendsShowOnWatchFace; 2698 String get friendsShowOnWatchFace;
2699 2699
2700 /// No description provided for @friendsShownOnWatchFace. 2700 /// No description provided for @friendsShownOnWatchFace.
2701 /// 2701 ///
2702 /// In zh, this message translates to: 2702 /// In zh, this message translates to:
2703 - /// **'已在表盘显示Ta'** 2703 + /// **'已在手表显示Ta'**
2704 String get friendsShownOnWatchFace; 2704 String get friendsShownOnWatchFace;
2705 2705
2706 /// No description provided for @friendsSelect. 2706 /// No description provided for @friendsSelect.
@@ -2712,7 +2712,7 @@ abstract class AppLocalizations { @@ -2712,7 +2712,7 @@ abstract class AppLocalizations {
2712 /// No description provided for @friendsSelectAndSync. 2712 /// No description provided for @friendsSelectAndSync.
2713 /// 2713 ///
2714 /// In zh, this message translates to: 2714 /// In zh, this message translates to:
2715 - /// **'选择并同步至表盘'** 2715 + /// **'选择并同步至手表'**
2716 String get friendsSelectAndSync; 2716 String get friendsSelectAndSync;
2717 2717
2718 /// No description provided for @friendsBack. 2718 /// No description provided for @friendsBack.
@@ -3648,7 +3648,7 @@ abstract class AppLocalizations { @@ -3648,7 +3648,7 @@ abstract class AppLocalizations {
3648 /// No description provided for @watchThemeSelectAndSync. 3648 /// No description provided for @watchThemeSelectAndSync.
3649 /// 3649 ///
3650 /// In zh, this message translates to: 3650 /// In zh, this message translates to:
3651 - /// **'选择并同步至表盘'** 3651 + /// **'选择并同步至手表'**
3652 String get watchThemeSelectAndSync; 3652 String get watchThemeSelectAndSync;
3653 3653
3654 /// No description provided for @watchThemeCustomTheme. 3654 /// No description provided for @watchThemeCustomTheme.
@@ -1504,16 +1504,16 @@ class AppLocalizationsEn extends AppLocalizations { @@ -1504,16 +1504,16 @@ class AppLocalizationsEn extends AppLocalizations {
1504 String get friendsEditRemark => 'Edit nickname'; 1504 String get friendsEditRemark => 'Edit nickname';
1505 1505
1506 @override 1506 @override
1507 - String get friendsShowOnWatchFace => 'Show on watch face'; 1507 + String get friendsShowOnWatchFace => 'Show on watch';
1508 1508
1509 @override 1509 @override
1510 - String get friendsShownOnWatchFace => 'Shown on watch face'; 1510 + String get friendsShownOnWatchFace => 'Shown on watch';
1511 1511
1512 @override 1512 @override
1513 String get friendsSelect => 'Select a friend'; 1513 String get friendsSelect => 'Select a friend';
1514 1514
1515 @override 1515 @override
1516 - String get friendsSelectAndSync => 'Select and sync to watch face'; 1516 + String get friendsSelectAndSync => 'Select and sync to watch';
1517 1517
1518 @override 1518 @override
1519 String get friendsBack => 'Back'; 1519 String get friendsBack => 'Back';
@@ -2049,7 +2049,7 @@ class AppLocalizationsEn extends AppLocalizations { @@ -2049,7 +2049,7 @@ class AppLocalizationsEn extends AppLocalizations {
2049 String get watchThemeSelectFriend => 'Select Friend'; 2049 String get watchThemeSelectFriend => 'Select Friend';
2050 2050
2051 @override 2051 @override
2052 - String get watchThemeSelectAndSync => 'Select and Sync to Watch Face'; 2052 + String get watchThemeSelectAndSync => 'Select and Sync to Watch';
2053 2053
2054 @override 2054 @override
2055 String get watchThemeCustomTheme => 'Custom Themes'; 2055 String get watchThemeCustomTheme => 'Custom Themes';
@@ -1438,16 +1438,16 @@ class AppLocalizationsZh extends AppLocalizations { @@ -1438,16 +1438,16 @@ class AppLocalizationsZh extends AppLocalizations {
1438 String get friendsEditRemark => '修改备注'; 1438 String get friendsEditRemark => '修改备注';
1439 1439
1440 @override 1440 @override
1441 - String get friendsShowOnWatchFace => '在表盘显示'; 1441 + String get friendsShowOnWatchFace => '在手表显示';
1442 1442
1443 @override 1443 @override
1444 - String get friendsShownOnWatchFace => '已在表盘显示Ta'; 1444 + String get friendsShownOnWatchFace => '已在手表显示Ta';
1445 1445
1446 @override 1446 @override
1447 String get friendsSelect => '选择好友'; 1447 String get friendsSelect => '选择好友';
1448 1448
1449 @override 1449 @override
1450 - String get friendsSelectAndSync => '选择并同步至表盘'; 1450 + String get friendsSelectAndSync => '选择并同步至手表';
1451 1451
1452 @override 1452 @override
1453 String get friendsBack => '返回'; 1453 String get friendsBack => '返回';
@@ -1948,7 +1948,7 @@ class AppLocalizationsZh extends AppLocalizations { @@ -1948,7 +1948,7 @@ class AppLocalizationsZh extends AppLocalizations {
1948 String get watchThemeSelectFriend => '选择好友'; 1948 String get watchThemeSelectFriend => '选择好友';
1949 1949
1950 @override 1950 @override
1951 - String get watchThemeSelectAndSync => '选择并同步至表盘'; 1951 + String get watchThemeSelectAndSync => '选择并同步至手表';
1952 1952
1953 @override 1953 @override
1954 String get watchThemeCustomTheme => '自定义主题'; 1954 String get watchThemeCustomTheme => '自定义主题';