Commit d1a92435b3d71dc5fdcdfe5790b040491d3b895b

Authored by 刘宏哲
1 parent 704e3916

feat(app): bug fixed

1 import 'dart:math' as math; 1 import 'dart:math' as math;
  2 +import 'dart:ui' as ui;
2 3
3 import 'package:fl_chart/fl_chart.dart'; 4 import 'package:fl_chart/fl_chart.dart';
4 import 'package:flutter/material.dart'; 5 import 'package:flutter/material.dart';
@@ -30,6 +31,11 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -30,6 +31,11 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
30 static const _bottomAxisLabelHeight = 20.0; 31 static const _bottomAxisLabelHeight = 20.0;
31 static const _axisHeight = 32 static const _axisHeight =
32 _chartHeight - _topAxisLabelHeight - _bottomAxisLabelHeight; 33 _chartHeight - _topAxisLabelHeight - _bottomAxisLabelHeight;
  34 + static const _bottomTitleStyle = TextStyle(
  35 + color: _h3,
  36 + fontSize: 10,
  37 + fontWeight: FontWeight.w400,
  38 + );
33 39
34 @override 40 @override
35 Widget build(BuildContext context) { 41 Widget build(BuildContext context) {
@@ -195,14 +201,17 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -195,14 +201,17 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
195 getTitlesWidget: (value, meta) { 201 getTitlesWidget: (value, meta) {
196 final label = _bottomLabel(value, start, axis.maxX); 202 final label = _bottomLabel(value, start, axis.maxX);
197 if (label == null) return const SizedBox.shrink(); 203 if (label == null) return const SizedBox.shrink();
  204 + final isFirstLabel = value == 0;
198 return Padding( 205 return Padding(
199 padding: const EdgeInsets.only(top: 3), 206 padding: const EdgeInsets.only(top: 3),
200 - child: Text(  
201 - label,  
202 - style: const TextStyle(  
203 - color: _h3,  
204 - fontSize: 10,  
205 - fontWeight: FontWeight.w400, 207 + child: Transform.translate(
  208 + offset: Offset(
  209 + isFirstLabel ? _textWidth(label, _bottomTitleStyle) / 2 : 0,
  210 + 0,
  211 + ),
  212 + child: Text(
  213 + label,
  214 + style: _bottomTitleStyle,
206 ), 215 ),
207 ), 216 ),
208 ); 217 );
@@ -409,6 +418,15 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -409,6 +418,15 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
409 final time = start.add(Duration(minutes: minutes)); 418 final time = start.add(Duration(minutes: minutes));
410 return DateFormat('HH:mm').format(time); 419 return DateFormat('HH:mm').format(time);
411 } 420 }
  421 +
  422 + double _textWidth(String text, TextStyle style) {
  423 + final painter = TextPainter(
  424 + text: TextSpan(text: text, style: style),
  425 + textDirection: ui.TextDirection.ltr,
  426 + maxLines: 1,
  427 + )..layout();
  428 + return painter.width;
  429 + }
412 } 430 }
413 431
414 class _HeartRateAxis { 432 class _HeartRateAxis {
@@ -204,13 +204,13 @@ class _MonthRingCard extends StatelessWidget { @@ -204,13 +204,13 @@ class _MonthRingCard extends StatelessWidget {
204 Widget build(BuildContext context) { 204 Widget build(BuildContext context) {
205 final hasData = report.hasData; 205 final hasData = report.hasData;
206 return Container( 206 return Container(
207 - height: 493,  
208 - padding: const EdgeInsets.fromLTRB(20, 16, 20, 14), 207 + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
209 decoration: BoxDecoration( 208 decoration: BoxDecoration(
210 color: Colors.white, 209 color: Colors.white,
211 borderRadius: BorderRadius.circular(12), 210 borderRadius: BorderRadius.circular(12),
212 ), 211 ),
213 child: Column( 212 child: Column(
  213 + mainAxisSize: MainAxisSize.min,
214 children: [ 214 children: [
215 Row( 215 Row(
216 children: [ 216 children: [
@@ -274,7 +274,7 @@ class _MonthRingCard extends StatelessWidget { @@ -274,7 +274,7 @@ class _MonthRingCard extends StatelessWidget {
274 ], 274 ],
275 ), 275 ),
276 const SizedBox(height: 10), 276 const SizedBox(height: 10),
277 - Expanded(child: _MonthGrid(report: report)), 277 + _MonthGrid(report: report),
278 ], 278 ],
279 ), 279 ),
280 ); 280 );
@@ -383,9 +383,10 @@ class _MonthGrid extends StatelessWidget { @@ -383,9 +383,10 @@ class _MonthGrid extends StatelessWidget {
383 dateOf: (day) => day.date, 383 dateOf: (day) => day.date,
384 mainAxisSpacing: 8, 384 mainAxisSpacing: 8,
385 crossAxisSpacing: 11, 385 crossAxisSpacing: 11,
386 - childAspectRatio: 0.62,  
387 - shrinkWrap: false, 386 + mainAxisExtent: 46,
  387 + shrinkWrap: true,
388 dayBuilder: (context, day) => Column( 388 dayBuilder: (context, day) => Column(
  389 + mainAxisSize: MainAxisSize.min,
389 children: [ 390 children: [
390 ActivityBurnRing(report: day, size: 32), 391 ActivityBurnRing(report: day, size: 32),
391 const SizedBox(height: 4), 392 const SizedBox(height: 4),
@@ -660,6 +661,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -660,6 +661,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
660 fontSize: 16, 661 fontSize: 16,
661 fontWeight: FontWeight.w600, 662 fontWeight: FontWeight.w600,
662 ), 663 ),
  664 + textAlign: TextAlign.start,
663 children: [ 665 children: [
664 TextSpan( 666 TextSpan(
665 text: _tooltipDate(report.date), 667 text: _tooltipDate(report.date),
1 -import 'package:flutter/material.dart'; 1 +import 'package:doublefeel_flutter/core/util/size_extensions.dart';
2 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 2 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
  3 +import 'package:flutter/material.dart';
3 4
4 import '../models/activity_burn_report_models.dart'; 5 import '../models/activity_burn_report_models.dart';
5 import 'activity_burn_duration_text.dart'; 6 import 'activity_burn_duration_text.dart';
@@ -28,7 +29,7 @@ class ActivityBurnSummaryCard extends StatelessWidget { @@ -28,7 +29,7 @@ class ActivityBurnSummaryCard extends StatelessWidget {
28 ); 29 );
29 return Container( 30 return Container(
30 height: 90, 31 height: 90,
31 - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18), 32 + padding: EdgeInsets.only(left: 20.w),
32 decoration: BoxDecoration( 33 decoration: BoxDecoration(
33 color: Colors.white, 34 color: Colors.white,
34 borderRadius: BorderRadius.circular(12), 35 borderRadius: BorderRadius.circular(12),
@@ -36,6 +37,7 @@ class ActivityBurnSummaryCard extends StatelessWidget { @@ -36,6 +37,7 @@ class ActivityBurnSummaryCard extends StatelessWidget {
36 child: Row( 37 child: Row(
37 children: [ 38 children: [
38 Expanded( 39 Expanded(
  40 + flex: 1,
39 child: _MetricColumn( 41 child: _MetricColumn(
40 title: context.l10n.activityMove, 42 title: context.l10n.activityMove,
41 color: _active, 43 color: _active,
@@ -43,7 +45,9 @@ class ActivityBurnSummaryCard extends StatelessWidget { @@ -43,7 +45,9 @@ class ActivityBurnSummaryCard extends StatelessWidget {
43 unit: context.l10n.reportUnitKcal, 45 unit: context.l10n.reportUnitKcal,
44 ), 46 ),
45 ), 47 ),
  48 + SizedBox(width: 13.w),
46 Expanded( 49 Expanded(
  50 + flex: 1,
47 child: _MetricColumn( 51 child: _MetricColumn(
48 title: context.l10n.activityExercise, 52 title: context.l10n.activityExercise,
49 color: _exercise, 53 color: _exercise,
@@ -51,7 +55,9 @@ class ActivityBurnSummaryCard extends StatelessWidget { @@ -51,7 +55,9 @@ class ActivityBurnSummaryCard extends StatelessWidget {
51 unit: exercise.unit, 55 unit: exercise.unit,
52 ), 56 ),
53 ), 57 ),
  58 + SizedBox(width: 13.w),
54 Expanded( 59 Expanded(
  60 + flex: 1,
55 child: _MetricColumn( 61 child: _MetricColumn(
56 title: context.l10n.activityStand, 62 title: context.l10n.activityStand,
57 color: _stand, 63 color: _stand,
@@ -80,47 +86,53 @@ class _MetricColumn extends StatelessWidget { @@ -80,47 +86,53 @@ class _MetricColumn extends StatelessWidget {
80 86
81 @override 87 @override
82 Widget build(BuildContext context) { 88 Widget build(BuildContext context) {
83 - return Column(  
84 - crossAxisAlignment: CrossAxisAlignment.start,  
85 - children: [  
86 - Text(  
87 - title,  
88 - style: TextStyle(  
89 - color: color,  
90 - fontSize: 12,  
91 - fontWeight: FontWeight.w500,  
92 - height: 1.2,  
93 - ),  
94 - ),  
95 - const SizedBox(height: 3),  
96 - Row(  
97 - crossAxisAlignment: CrossAxisAlignment.end,  
98 - children: [  
99 - Text(  
100 - value,  
101 - style: const TextStyle(  
102 - color: Color(0xFF0F0F11),  
103 - fontSize: 26,  
104 - fontWeight: FontWeight.w600,  
105 - height: 1.1,  
106 - ), 89 + return Align(
  90 + alignment: Alignment.centerLeft,
  91 + child: Column(
  92 + mainAxisSize: MainAxisSize.max,
  93 + mainAxisAlignment: MainAxisAlignment.center,
  94 + crossAxisAlignment: CrossAxisAlignment.start,
  95 + children: [
  96 + Text(
  97 + title,
  98 + style: TextStyle(
  99 + color: color,
  100 + fontSize: 12,
  101 + fontWeight: FontWeight.w500,
  102 + height: 1.2,
107 ), 103 ),
108 - const SizedBox(width: 4),  
109 - Padding(  
110 - padding: const EdgeInsets.only(bottom: 3),  
111 - child: Text(  
112 - unit, 104 + ),
  105 + const SizedBox(height: 3),
  106 + Row(
  107 + mainAxisSize: MainAxisSize.min,
  108 + crossAxisAlignment: CrossAxisAlignment.end,
  109 + children: [
  110 + Text(
  111 + value,
113 style: const TextStyle( 112 style: const TextStyle(
114 - color: Color(0xFFB0B0B6),  
115 - fontSize: 12,  
116 - fontWeight: FontWeight.w500,  
117 - height: 1.2, 113 + color: Color(0xFF0F0F11),
  114 + fontSize: 26,
  115 + fontWeight: FontWeight.w600,
  116 + height: 1.1,
118 ), 117 ),
119 ), 118 ),
120 - ),  
121 - ],  
122 - ),  
123 - ], 119 + const SizedBox(width: 4),
  120 + Padding(
  121 + padding: const EdgeInsets.only(bottom: 3),
  122 + child: Text(
  123 + unit,
  124 + style: const TextStyle(
  125 + color: Color(0xFFB0B0B6),
  126 + fontSize: 12,
  127 + fontWeight: FontWeight.w500,
  128 + height: 1.2,
  129 + ),
  130 + ),
  131 + ),
  132 + ],
  133 + ),
  134 + ],
  135 + ),
124 ); 136 );
125 } 137 }
126 } 138 }
@@ -205,13 +205,13 @@ class _RingOverviewCard extends StatelessWidget { @@ -205,13 +205,13 @@ class _RingOverviewCard extends StatelessWidget {
205 @override 205 @override
206 Widget build(BuildContext context) { 206 Widget build(BuildContext context) {
207 return Container( 207 return Container(
208 - height: 229,  
209 - padding: const EdgeInsets.fromLTRB(20, 16, 20, 14), 208 + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20),
210 decoration: BoxDecoration( 209 decoration: BoxDecoration(
211 color: Colors.white, 210 color: Colors.white,
212 borderRadius: BorderRadius.circular(12), 211 borderRadius: BorderRadius.circular(12),
213 ), 212 ),
214 child: Column( 213 child: Column(
  214 + mainAxisSize: MainAxisSize.min,
215 children: [ 215 children: [
216 Row( 216 Row(
217 children: [ 217 children: [
@@ -266,7 +266,7 @@ class _RingOverviewCard extends StatelessWidget { @@ -266,7 +266,7 @@ class _RingOverviewCard extends StatelessWidget {
266 ), 266 ),
267 ], 267 ],
268 ), 268 ),
269 - const Spacer(), 269 + const SizedBox(height: 20),
270 Row( 270 Row(
271 mainAxisAlignment: MainAxisAlignment.spaceBetween, 271 mainAxisAlignment: MainAxisAlignment.spaceBetween,
272 children: [ 272 children: [
@@ -638,6 +638,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -638,6 +638,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
638 fontSize: 16, 638 fontSize: 16,
639 fontWeight: FontWeight.w600, 639 fontWeight: FontWeight.w600,
640 ), 640 ),
  641 + textAlign: TextAlign.start,
641 children: [ 642 children: [
642 TextSpan( 643 TextSpan(
643 text: _tooltipDate(report.date), 644 text: _tooltipDate(report.date),
@@ -64,8 +64,7 @@ class FriendTrendView extends GetView<FriendTrendController> { @@ -64,8 +64,7 @@ class FriendTrendView extends GetView<FriendTrendController> {
64 queryForType: controller.queryForType, 64 queryForType: controller.queryForType,
65 selectedTypeIndex: controller.selectedTypeIndex.value, 65 selectedTypeIndex: controller.selectedTypeIndex.value,
66 onTypeChanged: controller.changeType, 66 onTypeChanged: controller.changeType,
67 - onPeriodChanged: controller.changePeriod,  
68 - onDateChanged: controller.changeDate, 67 + onQueryChanged: controller.changeQuery,
69 ), 68 ),
70 ), 69 ),
71 ), 70 ),
@@ -50,7 +50,8 @@ class FriendHealthCard extends StatelessWidget { @@ -50,7 +50,8 @@ class FriendHealthCard extends StatelessWidget {
50 50
51 @override 51 @override
52 Widget build(BuildContext context) { 52 Widget build(BuildContext context) {
53 - final sleepQualityLevel = sleepQualityLevelFromScore(sleepQualityScore); 53 + final sleepQualityLevel =
  54 + sleepQualityLevelFromSleepEvaluate(sleepQualityScore);
54 55
55 return GestureDetector( 56 return GestureDetector(
56 onTap: onTap, 57 onTap: onTap,
@@ -35,6 +35,16 @@ mixin HealthTrendControl on GetxController { @@ -35,6 +35,16 @@ mixin HealthTrendControl on GetxController {
35 selectedPeriod.value = nextPeriod; 35 selectedPeriod.value = nextPeriod;
36 } 36 }
37 37
  38 + void changeQuery(ReportPeriod period, DateTime date) {
  39 + final typeIndex = normalizeTypeIndex(selectedTypeIndex.value);
  40 + final nextPeriod = normalizePeriod(typeIndex, period);
  41 + final nextDate = ReportDateRangeConfig.clampDate(date);
  42 + _periodsByType[typeIndex] = nextPeriod;
  43 + _datesByType[typeIndex] = nextDate;
  44 + selectedPeriod.value = nextPeriod;
  45 + selectedDate.value = nextDate;
  46 + }
  47 +
38 void changeDate(DateTime date) { 48 void changeDate(DateTime date) {
39 final typeIndex = normalizeTypeIndex(selectedTypeIndex.value); 49 final typeIndex = normalizeTypeIndex(selectedTypeIndex.value);
40 final nextDate = ReportDateRangeConfig.clampDate(date); 50 final nextDate = ReportDateRangeConfig.clampDate(date);
@@ -22,8 +22,7 @@ class HealthTrendContent extends StatefulWidget { @@ -22,8 +22,7 @@ class HealthTrendContent extends StatefulWidget {
22 required this.selectedTypeIndex, 22 required this.selectedTypeIndex,
23 this.refreshToken = 0, 23 this.refreshToken = 0,
24 required this.onTypeChanged, 24 required this.onTypeChanged,
25 - required this.onPeriodChanged,  
26 - required this.onDateChanged, 25 + required this.onQueryChanged,
27 this.queryForType, 26 this.queryForType,
28 }); 27 });
29 28
@@ -31,8 +30,7 @@ class HealthTrendContent extends StatefulWidget { @@ -31,8 +30,7 @@ class HealthTrendContent extends StatefulWidget {
31 final int selectedTypeIndex; 30 final int selectedTypeIndex;
32 final int refreshToken; 31 final int refreshToken;
33 final ValueChanged<int> onTypeChanged; 32 final ValueChanged<int> onTypeChanged;
34 - final ValueChanged<ReportPeriod> onPeriodChanged;  
35 - final ValueChanged<DateTime> onDateChanged; 33 + final void Function(ReportPeriod period, DateTime date) onQueryChanged;
36 final HealthReportQuery Function(int typeIndex)? queryForType; 34 final HealthReportQuery Function(int typeIndex)? queryForType;
37 35
38 @override 36 @override
@@ -123,8 +121,7 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -123,8 +121,7 @@ class _HealthTrendContentState extends State<HealthTrendContent>
123 isSelected: widget.selectedTypeIndex == 0, 121 isSelected: widget.selectedTypeIndex == 0,
124 isVip: isVip, 122 isVip: isVip,
125 onSubscribe: openPurchase, 123 onSubscribe: openPurchase,
126 - onPeriodChanged: widget.onPeriodChanged,  
127 - onDateChanged: widget.onDateChanged, 124 + onQueryChanged: widget.onQueryChanged,
128 ), 125 ),
129 ), 126 ),
130 _KeepAliveWrapper( 127 _KeepAliveWrapper(
@@ -134,8 +131,7 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -134,8 +131,7 @@ class _HealthTrendContentState extends State<HealthTrendContent>
134 isSelected: widget.selectedTypeIndex == 1, 131 isSelected: widget.selectedTypeIndex == 1,
135 isVip: isVip, 132 isVip: isVip,
136 onSubscribe: openPurchase, 133 onSubscribe: openPurchase,
137 - onPeriodChanged: widget.onPeriodChanged,  
138 - onDateChanged: widget.onDateChanged, 134 + onQueryChanged: widget.onQueryChanged,
139 ), 135 ),
140 ), 136 ),
141 _KeepAliveWrapper( 137 _KeepAliveWrapper(
@@ -145,8 +141,7 @@ class _HealthTrendContentState extends State<HealthTrendContent> @@ -145,8 +141,7 @@ class _HealthTrendContentState extends State<HealthTrendContent>
145 isSelected: widget.selectedTypeIndex == 2, 141 isSelected: widget.selectedTypeIndex == 2,
146 isVip: isVip, 142 isVip: isVip,
147 onSubscribe: openPurchase, 143 onSubscribe: openPurchase,
148 - onPeriodChanged: widget.onPeriodChanged,  
149 - onDateChanged: widget.onDateChanged, 144 + onQueryChanged: widget.onQueryChanged,
150 ), 145 ),
151 ), 146 ),
152 ], 147 ],
@@ -166,8 +161,7 @@ class _HrvTrendSection extends StatefulWidget { @@ -166,8 +161,7 @@ class _HrvTrendSection extends StatefulWidget {
166 required this.isSelected, 161 required this.isSelected,
167 required this.isVip, 162 required this.isVip,
168 required this.onSubscribe, 163 required this.onSubscribe,
169 - required this.onPeriodChanged,  
170 - required this.onDateChanged, 164 + required this.onQueryChanged,
171 }); 165 });
172 166
173 final HealthReportQuery query; 167 final HealthReportQuery query;
@@ -175,8 +169,7 @@ class _HrvTrendSection extends StatefulWidget { @@ -175,8 +169,7 @@ class _HrvTrendSection extends StatefulWidget {
175 final bool isSelected; 169 final bool isSelected;
176 final bool isVip; 170 final bool isVip;
177 final ValueChanged<String> onSubscribe; 171 final ValueChanged<String> onSubscribe;
178 - final ValueChanged<ReportPeriod> onPeriodChanged;  
179 - final ValueChanged<DateTime> onDateChanged; 172 + final void Function(ReportPeriod period, DateTime date) onQueryChanged;
180 173
181 @override 174 @override
182 State<_HrvTrendSection> createState() => _HrvTrendSectionState(); 175 State<_HrvTrendSection> createState() => _HrvTrendSectionState();
@@ -187,6 +180,7 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { @@ -187,6 +180,7 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
187 late final Worker _periodWorker; 180 late final Worker _periodWorker;
188 late final Worker _dateWorker; 181 late final Worker _dateWorker;
189 var _syncingExternalQuery = false; 182 var _syncingExternalQuery = false;
  183 + var _queryNotificationScheduled = false;
190 184
191 @override 185 @override
192 void initState() { 186 void initState() {
@@ -194,10 +188,10 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { @@ -194,10 +188,10 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
194 _logic = HrvReportLogic(initialTargetUserId: widget.query.targetUserId); 188 _logic = HrvReportLogic(initialTargetUserId: widget.query.targetUserId);
195 _logic.initializeQuery(widget.query.period, widget.query.date); 189 _logic.initializeQuery(widget.query.period, widget.query.date);
196 _periodWorker = ever(_logic.selectedPeriod, (period) { 190 _periodWorker = ever(_logic.selectedPeriod, (period) {
197 - if (!_syncingExternalQuery) widget.onPeriodChanged(period); 191 + _scheduleQueryNotification();
198 }); 192 });
199 _dateWorker = ever(_logic.selectedDate, (date) { 193 _dateWorker = ever(_logic.selectedDate, (date) {
200 - if (!_syncingExternalQuery) widget.onDateChanged(date); 194 + _scheduleQueryNotification();
201 }); 195 });
202 _logic.loadReport(); 196 _logic.loadReport();
203 } 197 }
@@ -223,6 +217,23 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> { @@ -223,6 +217,23 @@ class _HrvTrendSectionState extends State<_HrvTrendSection> {
223 } 217 }
224 } 218 }
225 219
  220 + void _scheduleQueryNotification() {
  221 + if (_syncingExternalQuery ||
  222 + !widget.isSelected ||
  223 + _queryNotificationScheduled) {
  224 + return;
  225 + }
  226 + _queryNotificationScheduled = true;
  227 + Future.microtask(() {
  228 + _queryNotificationScheduled = false;
  229 + if (!mounted || _syncingExternalQuery || !widget.isSelected) return;
  230 + widget.onQueryChanged(
  231 + _logic.selectedPeriod.value,
  232 + _logic.selectedDate.value,
  233 + );
  234 + });
  235 + }
  236 +
226 @override 237 @override
227 void dispose() { 238 void dispose() {
228 _periodWorker.dispose(); 239 _periodWorker.dispose();
@@ -246,8 +257,7 @@ class _ActivityBurnTrendSection extends StatefulWidget { @@ -246,8 +257,7 @@ class _ActivityBurnTrendSection extends StatefulWidget {
246 required this.isSelected, 257 required this.isSelected,
247 required this.isVip, 258 required this.isVip,
248 required this.onSubscribe, 259 required this.onSubscribe,
249 - required this.onPeriodChanged,  
250 - required this.onDateChanged, 260 + required this.onQueryChanged,
251 }); 261 });
252 262
253 final HealthReportQuery query; 263 final HealthReportQuery query;
@@ -255,8 +265,7 @@ class _ActivityBurnTrendSection extends StatefulWidget { @@ -255,8 +265,7 @@ class _ActivityBurnTrendSection extends StatefulWidget {
255 final bool isSelected; 265 final bool isSelected;
256 final bool isVip; 266 final bool isVip;
257 final ValueChanged<String> onSubscribe; 267 final ValueChanged<String> onSubscribe;
258 - final ValueChanged<ReportPeriod> onPeriodChanged;  
259 - final ValueChanged<DateTime> onDateChanged; 268 + final void Function(ReportPeriod period, DateTime date) onQueryChanged;
260 269
261 @override 270 @override
262 State<_ActivityBurnTrendSection> createState() => 271 State<_ActivityBurnTrendSection> createState() =>
@@ -268,6 +277,7 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { @@ -268,6 +277,7 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
268 late final Worker _periodWorker; 277 late final Worker _periodWorker;
269 late final Worker _dateWorker; 278 late final Worker _dateWorker;
270 var _syncingExternalQuery = false; 279 var _syncingExternalQuery = false;
  280 + var _queryNotificationScheduled = false;
271 281
272 @override 282 @override
273 void initState() { 283 void initState() {
@@ -277,10 +287,10 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { @@ -277,10 +287,10 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
277 ); 287 );
278 _logic.initializeQuery(widget.query.period, widget.query.date); 288 _logic.initializeQuery(widget.query.period, widget.query.date);
279 _periodWorker = ever(_logic.selectedPeriod, (period) { 289 _periodWorker = ever(_logic.selectedPeriod, (period) {
280 - if (!_syncingExternalQuery) widget.onPeriodChanged(period); 290 + _scheduleQueryNotification();
281 }); 291 });
282 _dateWorker = ever(_logic.selectedDate, (date) { 292 _dateWorker = ever(_logic.selectedDate, (date) {
283 - if (!_syncingExternalQuery) widget.onDateChanged(date); 293 + _scheduleQueryNotification();
284 }); 294 });
285 _logic.loadReport(); 295 _logic.loadReport();
286 } 296 }
@@ -306,6 +316,23 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> { @@ -306,6 +316,23 @@ class _ActivityBurnTrendSectionState extends State<_ActivityBurnTrendSection> {
306 } 316 }
307 } 317 }
308 318
  319 + void _scheduleQueryNotification() {
  320 + if (_syncingExternalQuery ||
  321 + !widget.isSelected ||
  322 + _queryNotificationScheduled) {
  323 + return;
  324 + }
  325 + _queryNotificationScheduled = true;
  326 + Future.microtask(() {
  327 + _queryNotificationScheduled = false;
  328 + if (!mounted || _syncingExternalQuery || !widget.isSelected) return;
  329 + widget.onQueryChanged(
  330 + _logic.selectedPeriod.value,
  331 + _logic.selectedDate.value,
  332 + );
  333 + });
  334 + }
  335 +
309 @override 336 @override
310 void dispose() { 337 void dispose() {
311 _periodWorker.dispose(); 338 _periodWorker.dispose();
@@ -329,8 +356,7 @@ class _SleepTrendSection extends StatefulWidget { @@ -329,8 +356,7 @@ class _SleepTrendSection extends StatefulWidget {
329 required this.isSelected, 356 required this.isSelected,
330 required this.isVip, 357 required this.isVip,
331 required this.onSubscribe, 358 required this.onSubscribe,
332 - required this.onPeriodChanged,  
333 - required this.onDateChanged, 359 + required this.onQueryChanged,
334 }); 360 });
335 361
336 final HealthReportQuery query; 362 final HealthReportQuery query;
@@ -338,8 +364,7 @@ class _SleepTrendSection extends StatefulWidget { @@ -338,8 +364,7 @@ class _SleepTrendSection extends StatefulWidget {
338 final bool isSelected; 364 final bool isSelected;
339 final bool isVip; 365 final bool isVip;
340 final ValueChanged<String> onSubscribe; 366 final ValueChanged<String> onSubscribe;
341 - final ValueChanged<ReportPeriod> onPeriodChanged;  
342 - final ValueChanged<DateTime> onDateChanged; 367 + final void Function(ReportPeriod period, DateTime date) onQueryChanged;
343 368
344 @override 369 @override
345 State<_SleepTrendSection> createState() => _SleepTrendSectionState(); 370 State<_SleepTrendSection> createState() => _SleepTrendSectionState();
@@ -350,6 +375,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { @@ -350,6 +375,7 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
350 late final Worker _periodWorker; 375 late final Worker _periodWorker;
351 late final Worker _dateWorker; 376 late final Worker _dateWorker;
352 var _syncingExternalQuery = false; 377 var _syncingExternalQuery = false;
  378 + var _queryNotificationScheduled = false;
353 379
354 @override 380 @override
355 void initState() { 381 void initState() {
@@ -359,10 +385,10 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { @@ -359,10 +385,10 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
359 ); 385 );
360 _logic.initializeQuery(widget.query.period, widget.query.date); 386 _logic.initializeQuery(widget.query.period, widget.query.date);
361 _periodWorker = ever(_logic.selectedPeriod, (period) { 387 _periodWorker = ever(_logic.selectedPeriod, (period) {
362 - if (!_syncingExternalQuery) widget.onPeriodChanged(period); 388 + _scheduleQueryNotification();
363 }); 389 });
364 _dateWorker = ever(_logic.selectedDate, (date) { 390 _dateWorker = ever(_logic.selectedDate, (date) {
365 - if (!_syncingExternalQuery) widget.onDateChanged(date); 391 + _scheduleQueryNotification();
366 }); 392 });
367 _logic.loadReport(); 393 _logic.loadReport();
368 } 394 }
@@ -388,6 +414,23 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> { @@ -388,6 +414,23 @@ class _SleepTrendSectionState extends State<_SleepTrendSection> {
388 } 414 }
389 } 415 }
390 416
  417 + void _scheduleQueryNotification() {
  418 + if (_syncingExternalQuery ||
  419 + !widget.isSelected ||
  420 + _queryNotificationScheduled) {
  421 + return;
  422 + }
  423 + _queryNotificationScheduled = true;
  424 + Future.microtask(() {
  425 + _queryNotificationScheduled = false;
  426 + if (!mounted || _syncingExternalQuery || !widget.isSelected) return;
  427 + widget.onQueryChanged(
  428 + _logic.selectedPeriod.value,
  429 + _logic.selectedDate.value,
  430 + );
  431 + });
  432 + }
  433 +
391 @override 434 @override
392 void dispose() { 435 void dispose() {
393 _periodWorker.dispose(); 436 _periodWorker.dispose();
@@ -37,8 +37,7 @@ class _HomeTrendBody extends GetView<TrendController> { @@ -37,8 +37,7 @@ class _HomeTrendBody extends GetView<TrendController> {
37 selectedTypeIndex: controller.selectedTypeIndex.value, 37 selectedTypeIndex: controller.selectedTypeIndex.value,
38 refreshToken: controller.refreshToken.value, 38 refreshToken: controller.refreshToken.value,
39 onTypeChanged: controller.changeType, 39 onTypeChanged: controller.changeType,
40 - onPeriodChanged: controller.changePeriod,  
41 - onDateChanged: controller.changeDate, 40 + onQueryChanged: controller.changeQuery,
42 ), 41 ),
43 ); 42 );
44 } 43 }
@@ -33,24 +33,6 @@ class HrvReportLogic extends ReportPeriodLogic { @@ -33,24 +33,6 @@ class HrvReportLogic extends ReportPeriodLogic {
33 ]; 33 ];
34 34
35 @override 35 @override
36 - Future<void> selectPeriod(  
37 - ReportPeriod period, {  
38 - bool? forceRefresh,  
39 - }) {  
40 - final nextPeriod = normalizePeriod(period);  
41 - if (selectedPeriod.value == ReportPeriod.year &&  
42 - (nextPeriod == ReportPeriod.week || nextPeriod == ReportPeriod.month)) {  
43 - final now = DateTime.now();  
44 - final selectedYear = selectedDate.value.year;  
45 - final date = selectedYear == now.year  
46 - ? DateTime(now.year, now.month, now.day)  
47 - : DateTime(selectedYear);  
48 - return selectQuery(nextPeriod, date, forceRefresh: forceRefresh);  
49 - }  
50 - return super.selectPeriod(nextPeriod, forceRefresh: forceRefresh);  
51 - }  
52 -  
53 - @override  
54 Future<void> loadReport() async { 36 Future<void> loadReport() async {
55 isLoading.value = true; 37 isLoading.value = true;
56 try { 38 try {
@@ -108,9 +108,9 @@ class WeeklyHrvReport implements HrvPeriodReport { @@ -108,9 +108,9 @@ class WeeklyHrvReport implements HrvPeriodReport {
108 daysWithData.length; 108 daysWithData.length;
109 109
110 @override 110 @override
111 - int countFor(HrvStressLevel level) =>  
112 - distributionCounts?[level] ??  
113 - daysWithData.where((day) => day.level == level).length; 111 + int countFor(HrvStressLevel level) => distributionCounts == null
  112 + ? daysWithData.where((day) => day.level == level).length
  113 + : distributionCounts![level] ?? 0;
114 114
115 @override 115 @override
116 int get relaxedDays => 116 int get relaxedDays =>
@@ -186,9 +186,9 @@ class MonthlyHrvReport implements HrvPeriodReport { @@ -186,9 +186,9 @@ class MonthlyHrvReport implements HrvPeriodReport {
186 daysWithData.length; 186 daysWithData.length;
187 187
188 @override 188 @override
189 - int countFor(HrvStressLevel level) =>  
190 - distributionCounts?[level] ??  
191 - daysWithData.where((day) => day.level == level).length; 189 + int countFor(HrvStressLevel level) => distributionCounts == null
  190 + ? daysWithData.where((day) => day.level == level).length
  191 + : distributionCounts![level] ?? 0;
192 192
193 @override 193 @override
194 int get relaxedDays => 194 int get relaxedDays =>
@@ -266,9 +266,9 @@ class YearlyHrvReport implements HrvPeriodReport { @@ -266,9 +266,9 @@ class YearlyHrvReport implements HrvPeriodReport {
266 daysWithData.length; 266 daysWithData.length;
267 267
268 @override 268 @override
269 - int countFor(HrvStressLevel level) =>  
270 - distributionCounts?[level] ??  
271 - daysWithData.where((day) => day.level == level).length; 269 + int countFor(HrvStressLevel level) => distributionCounts == null
  270 + ? daysWithData.where((day) => day.level == level).length
  271 + : distributionCounts![level] ?? 0;
272 272
273 @override 273 @override
274 int get relaxedDays => 274 int get relaxedDays =>
  1 +import 'package:flutter/material.dart';
  2 +
  3 +class HrvDistributionBarSegment {
  4 + const HrvDistributionBarSegment({
  5 + required this.color,
  6 + required this.flex,
  7 + });
  8 +
  9 + final Color color;
  10 + final int flex;
  11 +}
  12 +
  13 +class HrvDistributionBar extends StatelessWidget {
  14 + const HrvDistributionBar({
  15 + super.key,
  16 + required this.segments,
  17 + required this.backgroundColor,
  18 + required this.radius,
  19 + });
  20 +
  21 + final List<HrvDistributionBarSegment> segments;
  22 + final Color backgroundColor;
  23 + final double radius;
  24 +
  25 + @override
  26 + Widget build(BuildContext context) {
  27 + return SizedBox.expand(
  28 + child: CustomPaint(
  29 + painter: _HrvDistributionBarPainter(
  30 + segments: segments,
  31 + backgroundColor: backgroundColor,
  32 + radius: radius,
  33 + ),
  34 + ),
  35 + );
  36 + }
  37 +}
  38 +
  39 +class _HrvDistributionBarPainter extends CustomPainter {
  40 + const _HrvDistributionBarPainter({
  41 + required this.segments,
  42 + required this.backgroundColor,
  43 + required this.radius,
  44 + });
  45 +
  46 + final List<HrvDistributionBarSegment> segments;
  47 + final Color backgroundColor;
  48 + final double radius;
  49 + static const borderWidth = 1.0;
  50 + static const overlapHeight = 16.0;
  51 +
  52 + @override
  53 + void paint(Canvas canvas, Size size) {
  54 + final clip = RRect.fromRectAndRadius(
  55 + Offset.zero & size,
  56 + Radius.circular(radius),
  57 + );
  58 + canvas.save();
  59 + canvas.clipRRect(clip);
  60 +
  61 + if (segments.isEmpty) {
  62 + _drawLayeredSegment(
  63 + canvas,
  64 + Offset.zero & size,
  65 + color: backgroundColor,
  66 + radius: radius,
  67 + );
  68 + canvas.restore();
  69 + return;
  70 + }
  71 +
  72 + final visibleHeights = _visibleSegmentHeightsFor(size.height, segments);
  73 + var visibleTop = 0.0;
  74 + for (var index = 0; index < segments.length; index++) {
  75 + final segment = segments[index];
  76 + final isBottomSegment = index == segments.length - 1;
  77 + final visibleBottom = visibleTop + visibleHeights[index];
  78 + final drawBottom = visibleBottom + (isBottomSegment ? 0 : overlapHeight);
  79 + _drawLayeredSegment(
  80 + canvas,
  81 + Rect.fromLTRB(0, visibleTop, size.width, drawBottom),
  82 + color: segment.color,
  83 + radius: radius,
  84 + );
  85 + visibleTop = visibleBottom;
  86 + }
  87 + canvas.restore();
  88 + }
  89 +
  90 + static List<double> _visibleSegmentHeightsFor(
  91 + double height,
  92 + List<HrvDistributionBarSegment> segments,
  93 + ) {
  94 + final totalDays =
  95 + segments.fold<int>(0, (sum, segment) => sum + segment.flex);
  96 + if (totalDays == 0) return const [];
  97 +
  98 + final dayHeight = height / totalDays;
  99 + return [
  100 + for (final segment in segments) segment.flex * dayHeight,
  101 + ];
  102 + }
  103 +
  104 + void _drawLayeredSegment(
  105 + Canvas canvas,
  106 + Rect rect, {
  107 + required Color color,
  108 + required double radius,
  109 + }) {
  110 + final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius));
  111 + canvas.drawRRect(
  112 + rrect,
  113 + Paint()
  114 + ..color = color
  115 + ..style = PaintingStyle.fill,
  116 + );
  117 + canvas.drawRRect(
  118 + rrect.deflate(borderWidth / 2),
  119 + Paint()
  120 + ..color = Colors.white
  121 + ..style = PaintingStyle.stroke
  122 + ..strokeWidth = borderWidth,
  123 + );
  124 + }
  125 +
  126 + @override
  127 + bool shouldRepaint(covariant _HrvDistributionBarPainter oldDelegate) {
  128 + if (backgroundColor != oldDelegate.backgroundColor ||
  129 + radius != oldDelegate.radius ||
  130 + segments.length != oldDelegate.segments.length) {
  131 + return true;
  132 + }
  133 + for (var i = 0; i < segments.length; i++) {
  134 + final segment = segments[i];
  135 + final oldSegment = oldDelegate.segments[i];
  136 + if (segment.color != oldSegment.color ||
  137 + segment.flex != oldSegment.flex) {
  138 + return true;
  139 + }
  140 + }
  141 + return false;
  142 + }
  143 +}
@@ -8,6 +8,7 @@ import '../../report_common/utils/report_localization.dart'; @@ -8,6 +8,7 @@ import '../../report_common/utils/report_localization.dart';
8 import '../../report_common/widgets/chart_selection_line_overlay.dart'; 8 import '../../report_common/widgets/chart_selection_line_overlay.dart';
9 import '../../report_common/widgets/health_report_subject_scope.dart'; 9 import '../../report_common/widgets/health_report_subject_scope.dart';
10 import '../models/hrv_report_models.dart'; 10 import '../models/hrv_report_models.dart';
  11 +import 'hrv_distribution_bar.dart';
11 12
12 class HrvWeekReportView extends StatelessWidget { 13 class HrvWeekReportView extends StatelessWidget {
13 const HrvWeekReportView({ 14 const HrvWeekReportView({
@@ -643,58 +644,90 @@ class _DistributionCard extends StatelessWidget { @@ -643,58 +644,90 @@ class _DistributionCard extends StatelessWidget {
643 final HrvPeriodReport report; 644 final HrvPeriodReport report;
644 final bool showExample; 645 final bool showExample;
645 final VoidCallback? onTap; 646 final VoidCallback? onTap;
  647 + static const _cardHeight = 425.0;
  648 + static const _barRight = 45.0;
  649 + static const _barWidth = 42.0;
  650 + static const _barHeight = 220.0;
646 651
647 @override 652 @override
648 Widget build(BuildContext context) { 653 Widget build(BuildContext context) {
649 return _Card( 654 return _Card(
650 onTap: onTap, 655 onTap: onTap,
651 - child: Column(  
652 - crossAxisAlignment: CrossAxisAlignment.start,  
653 - children: [  
654 - _ReportTitle(  
655 - title: HealthReportSubjectScope.titleOf(  
656 - context,  
657 - context.l10n.hrvStressDistribution,  
658 - ),  
659 - showExample: showExample,  
660 - ),  
661 - const SizedBox(height: 17),  
662 - Row(  
663 - crossAxisAlignment: CrossAxisAlignment.end,  
664 - children: [  
665 - Expanded(  
666 - child: Column(  
667 - crossAxisAlignment: CrossAxisAlignment.start,  
668 - children: [  
669 - Text(context.l10n.hrvValidDays,  
670 - style: const TextStyle(  
671 - color: Color(0xFFB0B0B6), fontSize: 11)),  
672 - const SizedBox(height: 2),  
673 - _ValueWithUnit(  
674 - value: '${report.validDays}',  
675 - unit: context.l10n.reportUnitDay, 656 + padding: EdgeInsets.zero,
  657 + child: ConstrainedBox(
  658 + constraints: const BoxConstraints(minHeight: _cardHeight),
  659 + child: Stack(
  660 + children: [
  661 + Padding(
  662 + padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
  663 + child: Column(
  664 + crossAxisAlignment: CrossAxisAlignment.start,
  665 + children: [
  666 + _ReportTitle(
  667 + title: HealthReportSubjectScope.titleOf(
  668 + context,
  669 + context.l10n.hrvStressDistribution,
  670 + ),
  671 + showExample: showExample,
  672 + fontSize: 16,
  673 + ),
  674 + const SizedBox(height: 20),
  675 + Text(
  676 + context.l10n.hrvValidDays,
  677 + style: const TextStyle(
  678 + color: Color(0xFFB0B0B6),
  679 + fontSize: 12,
676 ), 680 ),
677 - const SizedBox(height: 18),  
678 - _DistributionGrid(report: report),  
679 - ], 681 + ),
  682 + const SizedBox(height: 2),
  683 + _ValueWithUnit(
  684 + value: '${report.validDays}',
  685 + unit: context.l10n.reportUnitDay,
  686 + ),
  687 + const SizedBox(height: 28),
  688 + SizedBox(
  689 + width: 216,
  690 + child: _DistributionGrid(report: report),
  691 + ),
  692 + const SizedBox(height: 20),
  693 + const Divider(height: 1, color: Color(0xFFF3F3F3)),
  694 + const SizedBox(height: 20),
  695 + Row(
  696 + children: [
  697 + Expanded(
  698 + child: _ExtremeHrv(
  699 + title: context.l10n.hrvLowest,
  700 + day: report.minDay,
  701 + ),
  702 + ),
  703 + Expanded(
  704 + child: _ExtremeHrv(
  705 + title: context.l10n.hrvHighest,
  706 + day: report.maxDay,
  707 + ),
  708 + ),
  709 + ],
  710 + ),
  711 + ],
  712 + ),
  713 + ),
  714 + Positioned(
  715 + top: 0,
  716 + right: _barRight,
  717 + width: _barWidth,
  718 + child: Container(
  719 + alignment: Alignment.topCenter,
  720 + padding: EdgeInsets.only(top: 50.h),
  721 + child: SizedBox(
  722 + height: _barHeight,
  723 + child: IgnorePointer(
  724 + child: _DistributionBar(report: report),
  725 + ),
680 ), 726 ),
681 ), 727 ),
682 - _DistributionBar(report: report),  
683 - const SizedBox(width: 20),  
684 - ],  
685 - ),  
686 - const Divider(height: 29, color: Color(0xFFF3F3F3)),  
687 - Row(  
688 - children: [  
689 - Expanded(  
690 - child: _ExtremeHrv(  
691 - title: context.l10n.hrvLowest, day: report.minDay)),  
692 - Expanded(  
693 - child: _ExtremeHrv(  
694 - title: context.l10n.hrvHighest, day: report.maxDay)),  
695 - ],  
696 - ),  
697 - ], 728 + ),
  729 + ],
  730 + ),
698 ), 731 ),
699 ); 732 );
700 } 733 }
@@ -710,14 +743,13 @@ class _DistributionGrid extends StatelessWidget { @@ -710,14 +743,13 @@ class _DistributionGrid extends StatelessWidget {
710 return LayoutBuilder( 743 return LayoutBuilder(
711 builder: (context, constraints) { 744 builder: (context, constraints) {
712 const spacing = 16.0; 745 const spacing = 16.0;
713 - final itemWidth = (constraints.maxWidth - spacing) / 2;  
714 return Wrap( 746 return Wrap(
715 spacing: spacing, 747 spacing: spacing,
716 - runSpacing: 13, 748 + runSpacing: 16,
717 children: [ 749 children: [
718 for (final level in HrvStressLevel.values) 750 for (final level in HrvStressLevel.values)
719 SizedBox( 751 SizedBox(
720 - width: itemWidth, 752 + width: 100,
721 child: _DistributionItem(level: level, report: report), 753 child: _DistributionItem(level: level, report: report),
722 ), 754 ),
723 ], 755 ],
@@ -743,24 +775,25 @@ class _DistributionItem extends StatelessWidget { @@ -743,24 +775,25 @@ class _DistributionItem extends StatelessWidget {
743 children: [ 775 children: [
744 Row(children: [ 776 Row(children: [
745 Container( 777 Container(
746 - width: 9,  
747 - height: 9, 778 + width: 12,
  779 + height: 12,
748 decoration: BoxDecoration( 780 decoration: BoxDecoration(
749 color: Color(level.colorValue), shape: BoxShape.circle)), 781 color: Color(level.colorValue), shape: BoxShape.circle)),
750 - const SizedBox(width: 3), 782 + const SizedBox(width: 4),
751 Expanded( 783 Expanded(
752 child: Text( 784 child: Text(
753 level.label, 785 level.label,
754 - maxLines: 2, 786 + maxLines: 1,
755 overflow: TextOverflow.ellipsis, 787 overflow: TextOverflow.ellipsis,
756 style: const TextStyle( 788 style: const TextStyle(
757 - fontSize: 10, 789 + fontSize: 12,
758 fontWeight: FontWeight.w500, 790 fontWeight: FontWeight.w500,
  791 + height: 1.2,
759 ), 792 ),
760 ), 793 ),
761 ), 794 ),
762 ]), 795 ]),
763 - const SizedBox(height: 3), 796 + const SizedBox(height: 8),
764 Text( 797 Text(
765 report.hasData 798 report.hasData
766 ? '$count${context.l10n.reportUnitDay}' 799 ? '$count${context.l10n.reportUnitDay}'
@@ -769,10 +802,16 @@ class _DistributionItem extends StatelessWidget { @@ -769,10 +802,16 @@ class _DistributionItem extends StatelessWidget {
769 color: report.hasData 802 color: report.hasData
770 ? const Color(0xFF0F0F11) 803 ? const Color(0xFF0F0F11)
771 : const Color(0xFFB0B0B6), 804 : const Color(0xFFB0B0B6),
772 - fontSize: report.hasData ? 20 : 14,  
773 - fontWeight: FontWeight.w500)), 805 + fontSize: report.hasData ? 20 : 18,
  806 + fontWeight: FontWeight.w500,
  807 + height: 1.2)),
  808 + const SizedBox(height: 1),
774 Text('$percent%', 809 Text('$percent%',
775 - style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10)), 810 + style: const TextStyle(
  811 + color: Color(0xFFB0B0B6),
  812 + fontSize: 12,
  813 + height: 1.2,
  814 + )),
776 ], 815 ],
777 ); 816 );
778 } 817 }
@@ -785,170 +824,22 @@ class _DistributionBar extends StatelessWidget { @@ -785,170 +824,22 @@ class _DistributionBar extends StatelessWidget {
785 824
786 @override 825 @override
787 Widget build(BuildContext context) { 826 Widget build(BuildContext context) {
788 - const radius = 7.0;  
789 final segments = [ 827 final segments = [
790 for (final level in HrvStressLevel.values) 828 for (final level in HrvStressLevel.values)
791 if (report.countFor(level) > 0) 829 if (report.countFor(level) > 0)
792 - _DistributionBarSegment( 830 + HrvDistributionBarSegment(
793 color: Color(level.colorValue), 831 color: Color(level.colorValue),
794 flex: report.countFor(level), 832 flex: report.countFor(level),
795 ), 833 ),
796 ]; 834 ];
797 - final hasSegments = report.hasData && segments.isNotEmpty;  
798 - final height = hasSegments  
799 - ? _DistributionBarPainter.visibleHeightFor(segments)  
800 - : 180.0;  
801 - final paintSize = Size(44.w, height);  
802 -  
803 - return SizedBox.fromSize(  
804 - size: const Size(33, 180),  
805 - child: OverflowBox(  
806 - alignment: Alignment.bottomLeft,  
807 - minWidth: paintSize.width,  
808 - maxWidth: paintSize.width,  
809 - minHeight: paintSize.height,  
810 - maxHeight: paintSize.height,  
811 - child: Transform.translate(  
812 - offset: const Offset(0, -12),  
813 - child: SizedBox.fromSize(  
814 - size: paintSize,  
815 - child: CustomPaint(  
816 - painter: _DistributionBarPainter(  
817 - segments: hasSegments ? segments : const [],  
818 - backgroundColor: const Color(0xFFF3F3F3),  
819 - radius: radius,  
820 - ),  
821 - ),  
822 - ),  
823 - ),  
824 - ), 835 + return HrvDistributionBar(
  836 + segments: report.hasData ? segments : const [],
  837 + backgroundColor: const Color(0xFFF3F3F3),
  838 + radius: 8,
825 ); 839 );
826 } 840 }
827 } 841 }
828 842
829 -class _DistributionBarSegment {  
830 - const _DistributionBarSegment({required this.color, required this.flex});  
831 -  
832 - final Color color;  
833 - final int flex;  
834 -}  
835 -  
836 -class _DistributionBarPainter extends CustomPainter {  
837 - const _DistributionBarPainter({  
838 - required this.segments,  
839 - required this.backgroundColor,  
840 - required this.radius,  
841 - });  
842 -  
843 - final List<_DistributionBarSegment> segments;  
844 - final Color backgroundColor;  
845 - final double radius;  
846 - static const maxVisibleHeight = 220.0;  
847 - static const _borderWidth = 1.0;  
848 - static const _overlapHeight = 16.0;  
849 - static const _weekDayCount = 7;  
850 -  
851 - static double visibleHeightFor(List<_DistributionBarSegment> segments) {  
852 - if (segments.isEmpty) return 0;  
853 - final totalFlex =  
854 - segments.fold<int>(0, (sum, segment) => sum + segment.flex);  
855 - return maxVisibleHeight * totalFlex / _weekDayCount;  
856 - }  
857 -  
858 - @override  
859 - void paint(Canvas canvas, Size size) {  
860 - final clip = RRect.fromRectAndRadius(  
861 - Offset.zero & size,  
862 - Radius.circular(radius),  
863 - );  
864 - canvas.save();  
865 - canvas.clipRRect(clip);  
866 -  
867 - final total = segments.fold<int>(0, (sum, segment) => sum + segment.flex);  
868 - if (total == 0) {  
869 - canvas.drawRRect(  
870 - clip,  
871 - Paint()  
872 - ..color = backgroundColor  
873 - ..style = PaintingStyle.fill,  
874 - );  
875 - canvas.restore();  
876 - return;  
877 - }  
878 -  
879 - final visibleHeights = _visibleSegmentHeightsFor(size.height, segments);  
880 - var bottom = 0.0;  
881 - for (var index = 0; index < segments.length; index++) {  
882 - final segment = segments[index];  
883 - final isBottomSegment = index == segments.length - 1;  
884 - final drawTop = index == 0 ? 0.0 : bottom - _overlapHeight;  
885 - final drawBottom = drawTop +  
886 - visibleHeights[index] +  
887 - (isBottomSegment ? 0 : _overlapHeight);  
888 - _drawLayeredSegment(  
889 - canvas,  
890 - Rect.fromLTRB(0, drawTop, size.width, drawBottom),  
891 - color: segment.color,  
892 - radius: radius,  
893 - );  
894 - bottom = drawBottom;  
895 - }  
896 - canvas.restore();  
897 - }  
898 -  
899 - static List<double> _visibleSegmentHeightsFor(  
900 - double visibleHeight,  
901 - List<_DistributionBarSegment> segments,  
902 - ) {  
903 - final totalFlex =  
904 - segments.fold<int>(0, (sum, segment) => sum + segment.flex);  
905 - final dayHeight = visibleHeight / totalFlex;  
906 - return [  
907 - for (final segment in segments) segment.flex * dayHeight,  
908 - ];  
909 - }  
910 -  
911 - void _drawLayeredSegment(  
912 - Canvas canvas,  
913 - Rect rect, {  
914 - required Color color,  
915 - required double radius,  
916 - }) {  
917 - final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius));  
918 - canvas.drawRRect(  
919 - rrect,  
920 - Paint()  
921 - ..color = color  
922 - ..style = PaintingStyle.fill,  
923 - );  
924 - canvas.drawRRect(  
925 - rrect.deflate(_borderWidth / 2),  
926 - Paint()  
927 - ..color = Colors.white  
928 - ..style = PaintingStyle.stroke  
929 - ..strokeWidth = _borderWidth,  
930 - );  
931 - }  
932 -  
933 - @override  
934 - bool shouldRepaint(covariant _DistributionBarPainter oldDelegate) {  
935 - if (backgroundColor != oldDelegate.backgroundColor ||  
936 - radius != oldDelegate.radius ||  
937 - segments.length != oldDelegate.segments.length) {  
938 - return true;  
939 - }  
940 - for (var i = 0; i < segments.length; i++) {  
941 - final segment = segments[i];  
942 - final oldSegment = oldDelegate.segments[i];  
943 - if (segment.color != oldSegment.color ||  
944 - segment.flex != oldSegment.flex) {  
945 - return true;  
946 - }  
947 - }  
948 - return false;  
949 - }  
950 -}  
951 -  
952 class _ExtremeHrv extends StatelessWidget { 843 class _ExtremeHrv extends StatelessWidget {
953 const _ExtremeHrv({required this.title, required this.day}); 844 const _ExtremeHrv({required this.title, required this.day});
954 845
@@ -961,15 +852,15 @@ class _ExtremeHrv extends StatelessWidget { @@ -961,15 +852,15 @@ class _ExtremeHrv extends StatelessWidget {
961 crossAxisAlignment: CrossAxisAlignment.start, 852 crossAxisAlignment: CrossAxisAlignment.start,
962 children: [ 853 children: [
963 Text(title, 854 Text(title,
964 - style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)), 855 + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)),
965 const SizedBox(height: 5), 856 const SizedBox(height: 5),
966 _ValueWithUnit( 857 _ValueWithUnit(
967 value: day == null ? '-' : '${day!.averageHrv!.round()}', 858 value: day == null ? '-' : '${day!.averageHrv!.round()}',
968 unit: 'ms'), 859 unit: 'ms'),
969 - const SizedBox(height: 3), 860 + const SizedBox(height: 2),
970 Text( 861 Text(
971 day == null ? '-月-日' : reportMonthDay(day!.date), 862 day == null ? '-月-日' : reportMonthDay(day!.date),
972 - style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10), 863 + style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 12),
973 ), 864 ),
974 ], 865 ],
975 ); 866 );
@@ -989,10 +880,10 @@ class _ValueWithUnit extends StatelessWidget { @@ -989,10 +880,10 @@ class _ValueWithUnit extends StatelessWidget {
989 children: [ 880 children: [
990 Text(value, 881 Text(value,
991 style: const TextStyle( 882 style: const TextStyle(
992 - fontSize: 25, fontWeight: FontWeight.w600, height: 1)), 883 + fontSize: 26, fontWeight: FontWeight.w600, height: 1)),
993 Padding( 884 Padding(
994 padding: const EdgeInsets.only(left: 3, bottom: 2), 885 padding: const EdgeInsets.only(left: 3, bottom: 2),
995 - child: Text(unit, style: const TextStyle(fontSize: 10)), 886 + child: Text(unit, style: const TextStyle(fontSize: 12)),
996 ), 887 ),
997 ], 888 ],
998 ); 889 );
@@ -1000,10 +891,15 @@ class _ValueWithUnit extends StatelessWidget { @@ -1000,10 +891,15 @@ class _ValueWithUnit extends StatelessWidget {
1000 } 891 }
1001 892
1002 class _ReportTitle extends StatelessWidget { 893 class _ReportTitle extends StatelessWidget {
1003 - const _ReportTitle({required this.title, required this.showExample}); 894 + const _ReportTitle({
  895 + required this.title,
  896 + required this.showExample,
  897 + this.fontSize = 15,
  898 + });
1004 899
1005 final String title; 900 final String title;
1006 final bool showExample; 901 final bool showExample;
  902 + final double fontSize;
1007 903
1008 @override 904 @override
1009 Widget build(BuildContext context) { 905 Widget build(BuildContext context) {
@@ -1011,7 +907,7 @@ class _ReportTitle extends StatelessWidget { @@ -1011,7 +907,7 @@ class _ReportTitle extends StatelessWidget {
1011 children: [ 907 children: [
1012 Text( 908 Text(
1013 showExample ? context.l10n.reportExampleTitle(title) : title, 909 showExample ? context.l10n.reportExampleTitle(title) : title,
1014 - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), 910 + style: TextStyle(fontSize: fontSize, fontWeight: FontWeight.w600),
1015 ), 911 ),
1016 if (showExample) ...[ 912 if (showExample) ...[
1017 Image.asset( 913 Image.asset(
@@ -7,6 +7,7 @@ import '../../report_common/widgets/health_report_subject_scope.dart'; @@ -7,6 +7,7 @@ import '../../report_common/widgets/health_report_subject_scope.dart';
7 import '../../report_common/widgets/report_month_calendar_grid.dart'; 7 import '../../report_common/widgets/report_month_calendar_grid.dart';
8 import '../../report_common/utils/report_localization.dart'; 8 import '../../report_common/utils/report_localization.dart';
9 import '../models/hrv_report_models.dart'; 9 import '../models/hrv_report_models.dart';
  10 +import 'hrv_distribution_bar.dart';
10 11
11 class HrvYearReportView extends StatelessWidget { 12 class HrvYearReportView extends StatelessWidget {
12 const HrvYearReportView({super.key, required this.report}); 13 const HrvYearReportView({super.key, required this.report});
@@ -655,32 +656,26 @@ class _LevelItem extends StatelessWidget { @@ -655,32 +656,26 @@ class _LevelItem extends StatelessWidget {
655 class _StackedBar extends StatelessWidget { 656 class _StackedBar extends StatelessWidget {
656 const _StackedBar({required this.report}); 657 const _StackedBar({required this.report});
657 final YearlyHrvReport report; 658 final YearlyHrvReport report;
  659 + static const _height = 180.0;
658 660
659 @override 661 @override
660 Widget build(BuildContext context) { 662 Widget build(BuildContext context) {
661 - return Container( 663 + final segments = [
  664 + for (final level in HrvStressLevel.values)
  665 + if (report.countFor(level) > 0)
  666 + HrvDistributionBarSegment(
  667 + color: Color(level.colorValue),
  668 + flex: report.countFor(level),
  669 + ),
  670 + ];
  671 + return SizedBox(
662 width: 33, 672 width: 33,
663 - height: 180,  
664 - clipBehavior: Clip.antiAlias,  
665 - decoration:  
666 - BoxDecoration(color: _grid, borderRadius: BorderRadius.circular(7)),  
667 - child: report.hasData  
668 - ? Column(  
669 - children: [  
670 - for (final level in HrvStressLevel.values)  
671 - if (report.countFor(level) > 0)  
672 - Expanded(  
673 - flex: report.countFor(level),  
674 - child: Container(  
675 - decoration: BoxDecoration(  
676 - color: Color(level.colorValue),  
677 - border: Border.all(color: Colors.white, width: .5),  
678 - ),  
679 - ),  
680 - ),  
681 - ],  
682 - )  
683 - : null, 673 + height: _height,
  674 + child: HrvDistributionBar(
  675 + segments: report.hasData ? segments : const [],
  676 + backgroundColor: _grid,
  677 + radius: 7,
  678 + ),
684 ); 679 );
685 } 680 }
686 } 681 }
@@ -16,7 +16,7 @@ abstract class ReportPeriodLogic { @@ -16,7 +16,7 @@ abstract class ReportPeriodLogic {
16 final selectedPeriod = ReportPeriod.day.obs; 16 final selectedPeriod = ReportPeriod.day.obs;
17 final selectedDate = _today().obs; 17 final selectedDate = _today().obs;
18 final isLoading = false.obs; 18 final isLoading = false.obs;
19 - final Map<ReportPeriod, DateTime> _datesByPeriod = {}; 19 + var _anchorDate = _today();
20 20
21 List<ReportPeriod> get supportedPeriods => ReportPeriod.values; 21 List<ReportPeriod> get supportedPeriods => ReportPeriod.values;
22 22
@@ -49,9 +49,10 @@ abstract class ReportPeriodLogic { @@ -49,9 +49,10 @@ abstract class ReportPeriodLogic {
49 49
50 void initializeQuery(ReportPeriod period, DateTime date) { 50 void initializeQuery(ReportPeriod period, DateTime date) {
51 final nextPeriod = normalizePeriod(period); 51 final nextPeriod = normalizePeriod(period);
52 - _seedPeriodDates(date); 52 + final nextDate = normalizeDate(nextPeriod, date);
  53 + _anchorDate = _anchorDateFor(nextPeriod, date);
53 selectedPeriod.value = nextPeriod; 54 selectedPeriod.value = nextPeriod;
54 - selectedDate.value = _datesByPeriod[nextPeriod]!; 55 + selectedDate.value = nextDate;
55 } 56 }
56 57
57 Future<void> selectQuery( 58 Future<void> selectQuery(
@@ -62,15 +63,16 @@ abstract class ReportPeriodLogic { @@ -62,15 +63,16 @@ abstract class ReportPeriodLogic {
62 final nextPeriod = normalizePeriod(period); 63 final nextPeriod = normalizePeriod(period);
63 final nextDate = normalizeDate(nextPeriod, date); 64 final nextDate = normalizeDate(nextPeriod, date);
64 final shouldRefresh = forceRefresh ?? this.forceRefresh; 65 final shouldRefresh = forceRefresh ?? this.forceRefresh;
65 - if (!shouldRefresh &&  
66 - selectedPeriod.value == nextPeriod &&  
67 - selectedDate.value == nextDate) { 66 + final sameQuery =
  67 + selectedPeriod.value == nextPeriod && selectedDate.value == nextDate;
  68 + if (!shouldRefresh && sameQuery) {
68 return Future.value(); 69 return Future.value();
69 } 70 }
70 - _datesByPeriod[selectedPeriod.value] = selectedDate.value; 71 + if (!sameQuery) {
  72 + _anchorDate = _anchorDateFor(nextPeriod, date);
  73 + }
71 selectedPeriod.value = nextPeriod; 74 selectedPeriod.value = nextPeriod;
72 selectedDate.value = nextDate; 75 selectedDate.value = nextDate;
73 - _datesByPeriod[nextPeriod] = nextDate;  
74 return loadReport(); 76 return loadReport();
75 } 77 }
76 78
@@ -130,11 +132,9 @@ abstract class ReportPeriodLogic { @@ -130,11 +132,9 @@ abstract class ReportPeriodLogic {
130 if (!shouldRefresh && selectedPeriod.value == nextPeriod) { 132 if (!shouldRefresh && selectedPeriod.value == nextPeriod) {
131 return Future.value(); 133 return Future.value();
132 } 134 }
133 - _datesByPeriod[selectedPeriod.value] = selectedDate.value; 135 + final nextDate = normalizeDate(nextPeriod, _anchorDate);
134 selectedPeriod.value = nextPeriod; 136 selectedPeriod.value = nextPeriod;
135 - selectedDate.value = _datesByPeriod[nextPeriod] ??  
136 - normalizeDate(nextPeriod, selectedDate.value);  
137 - _datesByPeriod[nextPeriod] = selectedDate.value; 137 + selectedDate.value = nextDate;
138 return loadReport(); 138 return loadReport();
139 } 139 }
140 140
@@ -144,9 +144,9 @@ abstract class ReportPeriodLogic { @@ -144,9 +144,9 @@ abstract class ReportPeriodLogic {
144 }) { 144 }) {
145 final shouldRefresh = forceRefresh ?? this.forceRefresh; 145 final shouldRefresh = forceRefresh ?? this.forceRefresh;
146 final nextDate = ReportDateRangeConfig.clampDate(date); 146 final nextDate = ReportDateRangeConfig.clampDate(date);
  147 + _anchorDate = _anchorDateFor(ReportPeriod.day, nextDate);
147 if (!shouldRefresh && selectedDate.value == nextDate) return Future.value(); 148 if (!shouldRefresh && selectedDate.value == nextDate) return Future.value();
148 selectedDate.value = nextDate; 149 selectedDate.value = nextDate;
149 - _datesByPeriod[selectedPeriod.value] = nextDate;  
150 return loadReport(); 150 return loadReport();
151 } 151 }
152 152
@@ -160,9 +160,9 @@ abstract class ReportPeriodLogic { @@ -160,9 +160,9 @@ abstract class ReportPeriodLogic {
160 ReportDateRangeConfig.firstWeekStart(), 160 ReportDateRangeConfig.firstWeekStart(),
161 ReportDateRangeConfig.lastWeekStart(), 161 ReportDateRangeConfig.lastWeekStart(),
162 ); 162 );
  163 + _anchorDate = _anchorDateFor(ReportPeriod.week, nextWeekStart);
163 if (!shouldRefresh && weekStart == nextWeekStart) return Future.value(); 164 if (!shouldRefresh && weekStart == nextWeekStart) return Future.value();
164 selectedDate.value = nextWeekStart; 165 selectedDate.value = nextWeekStart;
165 - _datesByPeriod[selectedPeriod.value] = nextWeekStart;  
166 return loadReport(); 166 return loadReport();
167 } 167 }
168 168
@@ -177,9 +177,9 @@ abstract class ReportPeriodLogic { @@ -177,9 +177,9 @@ abstract class ReportPeriodLogic {
177 ReportDateRangeConfig.firstMonth(), 177 ReportDateRangeConfig.firstMonth(),
178 ReportDateRangeConfig.lastMonth(), 178 ReportDateRangeConfig.lastMonth(),
179 ); 179 );
  180 + _anchorDate = _anchorDateFor(ReportPeriod.month, nextMonth);
180 if (!shouldRefresh && monthStart == nextMonth) return Future.value(); 181 if (!shouldRefresh && monthStart == nextMonth) return Future.value();
181 selectedDate.value = nextMonth; 182 selectedDate.value = nextMonth;
182 - _datesByPeriod[selectedPeriod.value] = nextMonth;  
183 return loadReport(); 183 return loadReport();
184 } 184 }
185 185
@@ -195,8 +195,9 @@ abstract class ReportPeriodLogic { @@ -195,8 +195,9 @@ abstract class ReportPeriodLogic {
195 if (!shouldRefresh && selectedDate.value.year == nextYear) { 195 if (!shouldRefresh && selectedDate.value.year == nextYear) {
196 return Future.value(); 196 return Future.value();
197 } 197 }
198 - selectedDate.value = DateTime(nextYear);  
199 - _datesByPeriod[selectedPeriod.value] = selectedDate.value; 198 + final nextDate = DateTime(nextYear);
  199 + _anchorDate = _anchorDateFor(ReportPeriod.year, nextDate);
  200 + selectedDate.value = nextDate;
200 return loadReport(); 201 return loadReport();
201 } 202 }
202 203
@@ -223,16 +224,16 @@ abstract class ReportPeriodLogic { @@ -223,16 +224,16 @@ abstract class ReportPeriodLogic {
223 Future<void> previousDay() { 224 Future<void> previousDay() {
224 final candidate = _previousCandidate; 225 final candidate = _previousCandidate;
225 if (_isBeforeRange(candidate)) return Future.value(); 226 if (_isBeforeRange(candidate)) return Future.value();
  227 + _anchorDate = _anchorDateFor(selectedPeriod.value, candidate);
226 selectedDate.value = candidate; 228 selectedDate.value = candidate;
227 - _datesByPeriod[selectedPeriod.value] = candidate;  
228 return loadReport(); 229 return loadReport();
229 } 230 }
230 231
231 Future<void> nextDay() { 232 Future<void> nextDay() {
232 final candidate = _nextCandidate; 233 final candidate = _nextCandidate;
233 if (_isAfterRange(candidate)) return Future.value(); 234 if (_isAfterRange(candidate)) return Future.value();
  235 + _anchorDate = _anchorDateFor(selectedPeriod.value, candidate);
234 selectedDate.value = candidate; 236 selectedDate.value = candidate;
235 - _datesByPeriod[selectedPeriod.value] = candidate;  
236 return loadReport(); 237 return loadReport();
237 } 238 }
238 239
@@ -240,10 +241,17 @@ abstract class ReportPeriodLogic { @@ -240,10 +241,17 @@ abstract class ReportPeriodLogic {
240 241
241 void dispose() {} 242 void dispose() {}
242 243
243 - void _seedPeriodDates(DateTime date) {  
244 - for (final period in supportedPeriods) {  
245 - _datesByPeriod[period] = normalizeDate(period, date);  
246 - } 244 + DateTime _anchorDateFor(ReportPeriod period, DateTime date) {
  245 + final normalizedPeriod = normalizePeriod(period);
  246 + final normalizedDate = normalizeDate(normalizedPeriod, date);
  247 + return switch (normalizedPeriod) {
  248 + ReportPeriod.day => normalizedDate,
  249 + ReportPeriod.week => ReportDateRangeConfig.clampDate(
  250 + normalizedDate.add(const Duration(days: 3)),
  251 + ),
  252 + ReportPeriod.month => normalizedDate,
  253 + ReportPeriod.year => normalizedDate,
  254 + };
247 } 255 }
248 256
249 DateTime get _previousCandidate => switch (selectedPeriod.value) { 257 DateTime get _previousCandidate => switch (selectedPeriod.value) {
@@ -10,6 +10,7 @@ class ReportMonthCalendarGrid<T> extends StatelessWidget { @@ -10,6 +10,7 @@ class ReportMonthCalendarGrid<T> extends StatelessWidget {
10 this.mainAxisSpacing = 8, 10 this.mainAxisSpacing = 8,
11 this.crossAxisSpacing = 8, 11 this.crossAxisSpacing = 8,
12 this.childAspectRatio = 1, 12 this.childAspectRatio = 1,
  13 + this.mainAxisExtent,
13 this.shrinkWrap = true, 14 this.shrinkWrap = true,
14 }); 15 });
15 16
@@ -20,6 +21,7 @@ class ReportMonthCalendarGrid<T> extends StatelessWidget { @@ -20,6 +21,7 @@ class ReportMonthCalendarGrid<T> extends StatelessWidget {
20 final double mainAxisSpacing; 21 final double mainAxisSpacing;
21 final double crossAxisSpacing; 22 final double crossAxisSpacing;
22 final double childAspectRatio; 23 final double childAspectRatio;
  24 + final double? mainAxisExtent;
23 final bool shrinkWrap; 25 final bool shrinkWrap;
24 26
25 @override 27 @override
@@ -43,6 +45,7 @@ class ReportMonthCalendarGrid<T> extends StatelessWidget { @@ -43,6 +45,7 @@ class ReportMonthCalendarGrid<T> extends StatelessWidget {
43 mainAxisSpacing: mainAxisSpacing, 45 mainAxisSpacing: mainAxisSpacing,
44 crossAxisSpacing: crossAxisSpacing, 46 crossAxisSpacing: crossAxisSpacing,
45 childAspectRatio: childAspectRatio, 47 childAspectRatio: childAspectRatio,
  48 + mainAxisExtent: mainAxisExtent,
46 ), 49 ),
47 itemBuilder: (context, index) { 50 itemBuilder: (context, index) {
48 final day = cells[index]; 51 final day = cells[index];
@@ -55,6 +55,15 @@ SleepQualityLevel sleepQualityLevelFromScore(int? score) { @@ -55,6 +55,15 @@ SleepQualityLevel sleepQualityLevelFromScore(int? score) {
55 return SleepQualityLevel.poor; 55 return SleepQualityLevel.poor;
56 } 56 }
57 57
  58 +SleepQualityLevel sleepQualityLevelFromSleepEvaluate(int? sleepEvaluate) {
  59 + return switch (sleepEvaluate) {
  60 + 1 => SleepQualityLevel.excellent,
  61 + 2 => SleepQualityLevel.good,
  62 + 3 => SleepQualityLevel.poor,
  63 + _ => SleepQualityLevel.unknown,
  64 + };
  65 +}
  66 +
58 class SleepDuration { 67 class SleepDuration {
59 final int minutes; 68 final int minutes;
60 69
  1 +import 'package:doublefeel_flutter/core/util/size_extensions.dart';
1 import 'package:fl_chart/fl_chart.dart'; 2 import 'package:fl_chart/fl_chart.dart';
2 import 'package:flutter/material.dart'; 3 import 'package:flutter/material.dart';
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
@@ -29,7 +30,7 @@ class SleepHeartRateCard extends StatelessWidget { @@ -29,7 +30,7 @@ class SleepHeartRateCard extends StatelessWidget {
29 Widget build(BuildContext context) { 30 Widget build(BuildContext context) {
30 return Container( 31 return Container(
31 height: 358, 32 height: 358,
32 - padding: const EdgeInsets.fromLTRB(20, 20, 20, 16), 33 + padding: EdgeInsets.fromLTRB(20, 20.h, 20, 16),
33 decoration: BoxDecoration( 34 decoration: BoxDecoration(
34 color: Colors.white, 35 color: Colors.white,
35 borderRadius: BorderRadius.circular(16), 36 borderRadius: BorderRadius.circular(16),
@@ -100,6 +100,7 @@ class _SummaryColumn extends StatelessWidget { @@ -100,6 +100,7 @@ class _SummaryColumn extends StatelessWidget {
100 @override 100 @override
101 Widget build(BuildContext context) { 101 Widget build(BuildContext context) {
102 return Column( 102 return Column(
  103 + mainAxisAlignment: MainAxisAlignment.center,
103 crossAxisAlignment: CrossAxisAlignment.start, 104 crossAxisAlignment: CrossAxisAlignment.start,
104 children: [ 105 children: [
105 Row( 106 Row(
  1 +import 'package:flutter/foundation.dart';
1 import 'package:flutter/material.dart'; 2 import 'package:flutter/material.dart';
2 import 'package:flutter/services.dart'; 3 import 'package:flutter/services.dart';
3 import 'app_colors_extension.dart'; 4 import 'app_colors_extension.dart';
@@ -6,6 +7,9 @@ import 'app_colors_extension.dart'; @@ -6,6 +7,9 @@ import 'app_colors_extension.dart';
6 /// Automatically hooks up our custom Figma colors system as a ThemeExtension. 7 /// Automatically hooks up our custom Figma colors system as a ThemeExtension.
7 class AppTheme { 8 class AppTheme {
8 AppTheme._(); 9 AppTheme._();
  10 + static String? get _platformFontFamily =>
  11 + defaultTargetPlatform == TargetPlatform.iOS ? 'PingFang SC' : null;
  12 +
9 static const systemUiOverlayStyle = SystemUiOverlayStyle( 13 static const systemUiOverlayStyle = SystemUiOverlayStyle(
10 statusBarColor: Colors.transparent, 14 statusBarColor: Colors.transparent,
11 statusBarIconBrightness: Brightness.dark, 15 statusBarIconBrightness: Brightness.dark,
@@ -20,6 +24,7 @@ class AppTheme { @@ -20,6 +24,7 @@ class AppTheme {
20 24
21 return ThemeData( 25 return ThemeData(
22 useMaterial3: true, 26 useMaterial3: true,
  27 + fontFamily: _platformFontFamily,
23 brightness: Brightness.light, 28 brightness: Brightness.light,
24 primaryColor: colors.primary, 29 primaryColor: colors.primary,
25 scaffoldBackgroundColor: colors.backgroundPage, 30 scaffoldBackgroundColor: colors.backgroundPage,
@@ -34,6 +39,7 @@ class AppTheme { @@ -34,6 +39,7 @@ class AppTheme {
34 iconTheme: IconThemeData(color: colors.textPrimary), 39 iconTheme: IconThemeData(color: colors.textPrimary),
35 actionsIconTheme: IconThemeData(color: colors.textPrimary), 40 actionsIconTheme: IconThemeData(color: colors.textPrimary),
36 titleTextStyle: TextStyle( 41 titleTextStyle: TextStyle(
  42 + fontFamily: _platformFontFamily,
37 color: Colors.black, 43 color: Colors.black,
38 fontSize: 16, 44 fontSize: 16,
39 fontWeight: FontWeight.w500, 45 fontWeight: FontWeight.w500,
@@ -64,6 +70,7 @@ class AppTheme { @@ -64,6 +70,7 @@ class AppTheme {
64 final colors = AppColorsExtension.dark(); 70 final colors = AppColorsExtension.dark();
65 return ThemeData( 71 return ThemeData(
66 useMaterial3: true, 72 useMaterial3: true,
  73 + fontFamily: _platformFontFamily,
67 brightness: Brightness.dark, 74 brightness: Brightness.dark,
68 primaryColor: colors.primary, 75 primaryColor: colors.primary,
69 scaffoldBackgroundColor: colors.backgroundLight, 76 scaffoldBackgroundColor: colors.backgroundLight,
@@ -76,6 +83,7 @@ class AppTheme { @@ -76,6 +83,7 @@ class AppTheme {
76 iconTheme: IconThemeData(color: colors.textPrimary), 83 iconTheme: IconThemeData(color: colors.textPrimary),
77 actionsIconTheme: IconThemeData(color: colors.textPrimary), 84 actionsIconTheme: IconThemeData(color: colors.textPrimary),
78 titleTextStyle: TextStyle( 85 titleTextStyle: TextStyle(
  86 + fontFamily: _platformFontFamily,
79 color: Colors.black, 87 color: Colors.black,
80 fontSize: 16, 88 fontSize: 16,
81 fontWeight: FontWeight.w500, 89 fontWeight: FontWeight.w500,