Showing
21 changed files
with
1431 additions
and
542 deletions
| @@ -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 => '自定义主题'; |
-
Please register or login to post a comment