Commit 845b2b8e03a3f6cc690a670e125d7cadd8a8f50e

Authored by 刘宏哲
1 parent c0004b57

feat(app): bug fixed

@@ -65,7 +65,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -65,7 +65,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
65 days: _periodReports(start, 7, data), 65 days: _periodReports(start, 7, data),
66 totalActiveEnergyOverride: data.totalMove?.round(), 66 totalActiveEnergyOverride: data.totalMove?.round(),
67 totalExerciseMinutesOverride: _durationMinutes(data.totalExercise), 67 totalExerciseMinutesOverride: _durationMinutes(data.totalExercise),
68 - totalStandHoursOverride: _durationMinutes(data.totalStand), 68 + totalStandHoursOverride: _hours(data.totalStand),
69 averageDailyActiveEnergyOverride: data.avgMove?.round(), 69 averageDailyActiveEnergyOverride: data.avgMove?.round(),
70 activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(), 70 activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),
71 previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.round(), 71 previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.round(),
@@ -93,7 +93,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -93,7 +93,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
93 days: _periodReports(start, end.day, data), 93 days: _periodReports(start, end.day, data),
94 totalActiveEnergyOverride: data.totalMove?.round(), 94 totalActiveEnergyOverride: data.totalMove?.round(),
95 totalExerciseMinutesOverride: _durationMinutes(data.totalExercise), 95 totalExerciseMinutesOverride: _durationMinutes(data.totalExercise),
96 - totalStandHoursOverride: _durationMinutes(data.totalStand), 96 + totalStandHoursOverride: _hours(data.totalStand),
97 averageDailyActiveEnergyOverride: data.avgMove?.round(), 97 averageDailyActiveEnergyOverride: data.avgMove?.round(),
98 activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(), 98 activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),
99 previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.round(), 99 previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.round(),
@@ -258,17 +258,19 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -258,17 +258,19 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
258 ); 258 );
259 } 259 }
260 260
261 - ActivityBurnMetric? _standDurationMetric(num? seconds, num? target) {  
262 - if (seconds == null) return null; 261 + ActivityBurnMetric? _standDurationMetric(num? hours, num? target) {
  262 + if (hours == null) return null;
263 return ActivityBurnMetric( 263 return ActivityBurnMetric(
264 - value: _secondsToWholeMinutes(seconds),  
265 - goal: _standTargetMinutes(target), 264 + value: _hours(hours) ?? 0,
  265 + goal: _hours(target) ?? 0,
266 ); 266 );
267 } 267 }
268 268
269 int? _durationMinutes(num? seconds) => 269 int? _durationMinutes(num? seconds) =>
270 seconds == null ? null : _secondsToWholeMinutes(seconds); 270 seconds == null ? null : _secondsToWholeMinutes(seconds);
271 271
  272 + int? _hours(num? hours) => hours?.round();
  273 +
272 int _secondsToWholeMinutes(num? seconds) { 274 int _secondsToWholeMinutes(num? seconds) {
273 if (seconds == null || seconds < 60) return 0; 275 if (seconds == null || seconds < 60) return 0;
274 return seconds ~/ 60; 276 return seconds ~/ 60;
@@ -279,11 +281,6 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -279,11 +281,6 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
279 return target >= 60 ? _secondsToWholeMinutes(target) : target.round(); 281 return target >= 60 ? _secondsToWholeMinutes(target) : target.round();
280 } 282 }
281 283
282 - int _standTargetMinutes(num? target) {  
283 - if (target == null || target <= 0) return 0;  
284 - return target >= 60 ? _secondsToWholeMinutes(target) : target.round() * 60;  
285 - }  
286 -  
287 DateTime? _parseDateTime(Object? value, {DateTime? fallbackDate}) { 284 DateTime? _parseDateTime(Object? value, {DateTime? fallbackDate}) {
288 if (value is num) { 285 if (value is num) {
289 if (value >= 1000000000) { 286 if (value >= 1000000000) {
@@ -32,3 +32,13 @@ ActivityBurnDurationText activityBurnDurationText( @@ -32,3 +32,13 @@ ActivityBurnDurationText activityBurnDurationText(
32 unit: context.l10n.reportUnitMinute, 32 unit: context.l10n.reportUnitMinute,
33 ); 33 );
34 } 34 }
  35 +
  36 +ActivityBurnDurationText activityBurnHoursText(
  37 + BuildContext context,
  38 + int? hours,
  39 +) {
  40 + return ActivityBurnDurationText(
  41 + value: hours?.toString() ?? '-',
  42 + unit: context.l10n.reportUnitHour,
  43 + );
  44 +}
@@ -58,7 +58,7 @@ class _MonthlySummary extends StatelessWidget { @@ -58,7 +58,7 @@ class _MonthlySummary extends StatelessWidget {
58 context, 58 context,
59 hasData ? report.totalExerciseMinutes : null, 59 hasData ? report.totalExerciseMinutes : null,
60 ); 60 );
61 - final stand = activityBurnDurationText( 61 + final stand = activityBurnHoursText(
62 context, 62 context,
63 hasData ? report.totalStandHours : null, 63 hasData ? report.totalStandHours : null,
64 ); 64 );
@@ -525,14 +525,19 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -525,14 +525,19 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
525 if (_touchedOffset != null) 525 if (_touchedOffset != null)
526 Positioned.fill( 526 Positioned.fill(
527 top: _chartTopInset, 527 top: _chartTopInset,
528 - child: ChartSelectionLineOverlay(  
529 - offset: _touchedOffset!,  
530 - color: ActivityBurnMonthReportView._h3,  
531 - bottomTitleHeight:  
532 - ActivityBurnTrendPlotFrame.bottomTitleHeight,  
533 - tooltipMargin: 6,  
534 - plotLeft: ActivityBurnTrendPlotFrame.horizontalInset,  
535 - plotRight: ActivityBurnTrendPlotFrame.rightAxisWidth, 528 + child: LayoutBuilder(
  529 + builder: (context, constraints) {
  530 + return ChartSelectionLineOverlay(
  531 + offset: _touchedOffset!,
  532 + color: ActivityBurnMonthReportView._h3,
  533 + bottomTitleHeight:
  534 + ActivityBurnTrendPlotFrame.bottomTitleHeight,
  535 + tooltipMargin: 6,
  536 + plotLeft: ActivityBurnTrendPlotFrame.horizontalInset,
  537 + plotRight: ActivityBurnTrendPlotFrame.rightAxisWidth,
  538 + lineX: _lineXForIndex(constraints.maxWidth, 4),
  539 + );
  540 + },
536 ), 541 ),
537 ), 542 ),
538 if (!hasChartReference) 543 if (!hasChartReference)
@@ -590,6 +595,21 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -590,6 +595,21 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
590 return (value / 5).ceil() * 5.0; 595 return (value / 5).ceil() * 5.0;
591 } 596 }
592 597
  598 + double? _lineXForIndex(double width, double barWidth) {
  599 + final index = _touchedIndex;
  600 + final count = widget.report.days.length;
  601 + if (index == null || index < 0 || index >= count || count == 0) {
  602 + return null;
  603 + }
  604 + final plotLeft = ActivityBurnTrendPlotFrame.horizontalInset;
  605 + final plotRight = ActivityBurnTrendPlotFrame.rightAxisWidth;
  606 + final plotWidth = width - plotLeft - plotRight;
  607 + if (plotWidth <= 0) return null;
  608 + if (count == 1) return plotLeft + plotWidth / 2;
  609 + final groupsSpace = _barGroupsSpace(plotWidth, count, barWidth);
  610 + return plotLeft + barWidth / 2 + index * (barWidth + groupsSpace);
  611 + }
  612 +
593 BarChartData _chartData(double maxY, double plotWidth) { 613 BarChartData _chartData(double maxY, double plotWidth) {
594 final hasData = widget.report.hasData; 614 final hasData = widget.report.hasData;
595 return BarChartData( 615 return BarChartData(
@@ -22,7 +22,7 @@ class ActivityBurnSummaryCard extends StatelessWidget { @@ -22,7 +22,7 @@ class ActivityBurnSummaryCard extends StatelessWidget {
22 context, 22 context,
23 report?.exerciseMinutes?.value, 23 report?.exerciseMinutes?.value,
24 ); 24 );
25 - final stand = activityBurnDurationText( 25 + final stand = activityBurnHoursText(
26 context, 26 context,
27 report?.standHours?.value, 27 report?.standHours?.value,
28 ); 28 );
@@ -57,7 +57,7 @@ class _WeeklySummary extends StatelessWidget { @@ -57,7 +57,7 @@ class _WeeklySummary extends StatelessWidget {
57 context, 57 context,
58 report.hasData ? report.totalExerciseMinutes : null, 58 report.hasData ? report.totalExerciseMinutes : null,
59 ); 59 );
60 - final stand = activityBurnDurationText( 60 + final stand = activityBurnHoursText(
61 context, 61 context,
62 report.hasData ? report.totalStandHours : null, 62 report.hasData ? report.totalStandHours : null,
63 ); 63 );
@@ -503,14 +503,19 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -503,14 +503,19 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
503 if (_touchedOffset != null) 503 if (_touchedOffset != null)
504 Positioned.fill( 504 Positioned.fill(
505 top: _chartTopInset, 505 top: _chartTopInset,
506 - child: ChartSelectionLineOverlay(  
507 - offset: _touchedOffset!,  
508 - color: ActivityBurnWeekReportView._h3,  
509 - bottomTitleHeight:  
510 - ActivityBurnTrendPlotFrame.bottomTitleHeight,  
511 - tooltipMargin: 6,  
512 - plotLeft: ActivityBurnTrendPlotFrame.horizontalInset,  
513 - plotRight: ActivityBurnTrendPlotFrame.rightAxisWidth, 506 + child: LayoutBuilder(
  507 + builder: (context, constraints) {
  508 + return ChartSelectionLineOverlay(
  509 + offset: _touchedOffset!,
  510 + color: ActivityBurnWeekReportView._h3,
  511 + bottomTitleHeight:
  512 + ActivityBurnTrendPlotFrame.bottomTitleHeight,
  513 + tooltipMargin: 6,
  514 + plotLeft: ActivityBurnTrendPlotFrame.horizontalInset,
  515 + plotRight: ActivityBurnTrendPlotFrame.rightAxisWidth,
  516 + lineX: _lineXForIndex(constraints.maxWidth, 16),
  517 + );
  518 + },
514 ), 519 ),
515 ), 520 ),
516 if (!hasChartReference) 521 if (!hasChartReference)
@@ -568,6 +573,21 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -568,6 +573,21 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
568 return (value / 5).ceil() * 5.0; 573 return (value / 5).ceil() * 5.0;
569 } 574 }
570 575
  576 + double? _lineXForIndex(double width, double barWidth) {
  577 + final index = _touchedIndex;
  578 + final count = widget.report.days.length;
  579 + if (index == null || index < 0 || index >= count || count == 0) {
  580 + return null;
  581 + }
  582 + final plotLeft = ActivityBurnTrendPlotFrame.horizontalInset;
  583 + final plotRight = ActivityBurnTrendPlotFrame.rightAxisWidth;
  584 + final plotWidth = width - plotLeft - plotRight;
  585 + if (plotWidth <= 0) return null;
  586 + if (count == 1) return plotLeft + plotWidth / 2;
  587 + final groupsSpace = _barGroupsSpace(plotWidth, count, barWidth);
  588 + return plotLeft + barWidth / 2 + index * (barWidth + groupsSpace);
  589 + }
  590 +
571 BarChartData _chartData(double maxY, double plotWidth) { 591 BarChartData _chartData(double maxY, double plotWidth) {
572 final hasData = widget.report.hasData; 592 final hasData = widget.report.hasData;
573 return BarChartData( 593 return BarChartData(
@@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_mode @@ -2,6 +2,7 @@ import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_mode
2 import 'package:doublefeel_flutter/app/modules/home/widgets/df_tab_bar.dart'; 2 import 'package:doublefeel_flutter/app/modules/home/widgets/df_tab_bar.dart';
3 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 3 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
4 import 'package:doublefeel_flutter/core/constants/intent_keys.dart'; 4 import 'package:doublefeel_flutter/core/constants/intent_keys.dart';
  5 +import 'package:doublefeel_flutter/core/util/app_toast.dart';
5 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 6 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
6 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 7 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
7 import 'package:doublefeel_flutter/r.dart'; 8 import 'package:doublefeel_flutter/r.dart';
@@ -144,7 +145,9 @@ class FriendsTab extends GetView<FriendsController> { @@ -144,7 +145,9 @@ class FriendsTab extends GetView<FriendsController> {
144 selfHealthCard, 145 selfHealthCard,
145 _EmptyFriendsView( 146 _EmptyFriendsView(
146 enabled: !isFull, 147 enabled: !isFull,
147 - onTap: isFull ? null : _handleAddFriend, 148 + onTap: isFull
  149 + ? () => _showFriendsLimitReached(context)
  150 + : _handleAddFriend,
148 ), 151 ),
149 ], 152 ],
150 ), 153 ),
@@ -205,7 +208,9 @@ class FriendsTab extends GetView<FriendsController> { @@ -205,7 +208,9 @@ class FriendsTab extends GetView<FriendsController> {
205 enabled: !isFull, 208 enabled: !isFull,
206 friendCount: friends.length, 209 friendCount: friends.length,
207 maxFriends: FriendsController.maxFriends, 210 maxFriends: FriendsController.maxFriends,
208 - onTap: isFull ? null : _handleAddFriend, 211 + onTap: isFull
  212 + ? () => _showFriendsLimitReached(context)
  213 + : _handleAddFriend,
209 ), 214 ),
210 if (isFull) const _FullFriendsTip(), 215 if (isFull) const _FullFriendsTip(),
211 ], 216 ],
@@ -244,6 +249,10 @@ class FriendsTab extends GetView<FriendsController> { @@ -244,6 +249,10 @@ class FriendsTab extends GetView<FriendsController> {
244 await controller.loadFriends(); 249 await controller.loadFriends();
245 } 250 }
246 251
  252 + void _showFriendsLimitReached(BuildContext context) {
  253 + AppToast.show(context.l10n.friendsLimitReached);
  254 + }
  255 +
247 void _openFriendHome(FriendHealthData friend) { 256 void _openFriendHome(FriendHealthData friend) {
248 Get.toNamed( 257 Get.toNamed(
249 Routes.FRIEND_HOME, 258 Routes.FRIEND_HOME,
@@ -201,7 +201,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource { @@ -201,7 +201,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
201 Map<DateTime, HrvStressLevel> levelsByDate, 201 Map<DateTime, HrvStressLevel> levelsByDate,
202 ) { 202 ) {
203 final trend = trendsByDate[date]; 203 final trend = trendsByDate[date];
204 - final level = levelsByDate[date]; 204 + final level = _stressLevelFromApiId(trend?.state) ?? levelsByDate[date];
205 return HrvDayReport( 205 return HrvDayReport(
206 date: date, 206 date: date,
207 averageHrv: trend?.hrvAverage?.toDouble(), 207 averageHrv: trend?.hrvAverage?.toDouble(),
@@ -234,7 +234,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource { @@ -234,7 +234,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
234 date: date, 234 date: date,
235 averageHrv: value.toDouble(), 235 averageHrv: value.toDouble(),
236 averageHeartRate: trend?.hrAverage?.round(), 236 averageHeartRate: trend?.hrAverage?.round(),
237 - level: levelsByDate[date], 237 + level: _stressLevelFromApiId(trend?.state) ?? levelsByDate[date],
238 ); 238 );
239 } 239 }
240 240
@@ -309,6 +309,23 @@ class YearlyHrvReport implements HrvPeriodReport { @@ -309,6 +309,23 @@ class YearlyHrvReport implements HrvPeriodReport {
309 List<HrvDayReport> daysForMonth(int month) => 309 List<HrvDayReport> daysForMonth(int month) =>
310 days.where((day) => day.date.month == month).toList(); 310 days.where((day) => day.date.month == month).toList();
311 311
  312 + List<HrvDayReport> levelDaysForMonth(int month) =>
  313 + daysForMonth(month).where((day) => day.level != null).toList();
  314 +
  315 + int stressedDaysForMonth(int month) {
  316 + return levelDaysForMonth(month).where((day) {
  317 + return day.level == HrvStressLevel.attention ||
  318 + day.level == HrvStressLevel.overload;
  319 + }).length;
  320 + }
  321 +
  322 + int relaxedDaysForMonth(int month) {
  323 + return levelDaysForMonth(month).where((day) {
  324 + return day.level == HrvStressLevel.excellent ||
  325 + day.level == HrvStressLevel.normal;
  326 + }).length;
  327 + }
  328 +
312 double? averageForMonth(int month) { 329 double? averageForMonth(int month) {
313 final values = daysForMonth(month) 330 final values = daysForMonth(month)
314 .map((day) => day.averageHrv) 331 .map((day) => day.averageHrv)
@@ -318,11 +335,80 @@ class YearlyHrvReport implements HrvPeriodReport { @@ -318,11 +335,80 @@ class YearlyHrvReport implements HrvPeriodReport {
318 return values.reduce((sum, value) => sum + value) / values.length; 335 return values.reduce((sum, value) => sum + value) / values.length;
319 } 336 }
320 337
  338 + HrvStressLevel? levelForMonth(int month) {
  339 + final levels = daysForMonth(month)
  340 + .where((day) => day.averageHrv != null)
  341 + .map((day) => day.level)
  342 + .whereType<HrvStressLevel>()
  343 + .toList();
  344 + if (levels.isEmpty) return null;
  345 + return levels.first;
  346 + }
  347 +
321 List<int> get monthsWithAverage => [ 348 List<int> get monthsWithAverage => [
322 for (var month = 1; month <= 12; month++) 349 for (var month = 1; month <= 12; month++)
323 if (averageForMonth(month) != null) month, 350 if (averageForMonth(month) != null) month,
324 ]; 351 ];
325 352
  353 + ({List<int> mostStressed, List<int> leastStressed})
  354 + averageStressExtremeMonths() {
  355 + final values = [
  356 + for (final month in monthsWithAverage)
  357 + if (averageForMonth(month) case final average?)
  358 + MapEntry(month, average),
  359 + ];
  360 + values.sort(_compareByStressDescending);
  361 +
  362 + final mostStressed = <int>[];
  363 + final leastStressed = <int>[];
  364 + if (values.isEmpty) {
  365 + return (mostStressed: mostStressed, leastStressed: leastStressed);
  366 + }
  367 +
  368 + if (values.length == 1) {
  369 + final entry = values.single;
  370 + if (_isStressedAverage(entry.value)) {
  371 + mostStressed.add(entry.key);
  372 + } else {
  373 + leastStressed.add(entry.key);
  374 + }
  375 + return (mostStressed: mostStressed, leastStressed: leastStressed);
  376 + }
  377 +
  378 + mostStressed.add(values.first.key);
  379 + leastStressed.add(values.last.key);
  380 +
  381 + if (values.length == 3) {
  382 + final middle = values[1];
  383 + if (_isStressedAverage(middle.value)) {
  384 + mostStressed.add(middle.key);
  385 + } else {
  386 + leastStressed.add(middle.key);
  387 + }
  388 + } else if (values.length >= 4) {
  389 + mostStressed.add(values[1].key);
  390 + leastStressed.add(values[values.length - 2].key);
  391 + }
  392 +
  393 + mostStressed.sort();
  394 + leastStressed.sort();
  395 + return (mostStressed: mostStressed, leastStressed: leastStressed);
  396 + }
  397 +
  398 + int _compareByStressDescending(
  399 + MapEntry<int, double> a,
  400 + MapEntry<int, double> b,
  401 + ) {
  402 + final valueComparison = a.value.compareTo(b.value);
  403 + return valueComparison == 0 ? a.key.compareTo(b.key) : valueComparison;
  404 + }
  405 +
  406 + bool _isStressedAverage(double average) {
  407 + final level = HrvStressLevel.fromAverageHrv(average);
  408 + return level == HrvStressLevel.attention ||
  409 + level == HrvStressLevel.overload;
  410 + }
  411 +
326 List<int> monthsAtExtreme({required bool maximum}) { 412 List<int> monthsAtExtreme({required bool maximum}) {
327 final values = [ 413 final values = [
328 for (var month = 1; month <= 12; month++) 414 for (var month = 1; month <= 12; month++)
@@ -167,6 +167,9 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -167,6 +167,9 @@ class _HrvBarChartState extends State<_HrvBarChart> {
167 static const _plotLeft = _axisLabelWidth; 167 static const _plotLeft = _axisLabelWidth;
168 static const _plotRight = 0.0; 168 static const _plotRight = 0.0;
169 static const _bottomTitleHeight = 31.0; 169 static const _bottomTitleHeight = 31.0;
  170 + static const _tooltipBackground = Color(0xFFF3F3F3);
  171 + static const _tooltipDateColor = Color(0xFF78787D);
  172 + static const _tooltipMetaColor = Color(0xFFB0B0B6);
170 173
171 int? _touchedIndex; 174 int? _touchedIndex;
172 Offset? _touchedOffset; 175 Offset? _touchedOffset;
@@ -267,41 +270,17 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -267,41 +270,17 @@ class _HrvBarChartState extends State<_HrvBarChart> {
267 } 270 }
268 }, 271 },
269 touchTooltipData: BarTouchTooltipData( 272 touchTooltipData: BarTouchTooltipData(
270 - getTooltipColor: (_) => const Color(0xFFF3F3F3), 273 + getTooltipColor: (_) => _tooltipBackground,
271 tooltipRoundedRadius: 8, 274 tooltipRoundedRadius: 8,
272 tooltipBorder: BorderSide.none, 275 tooltipBorder: BorderSide.none,
273 - tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6), 276 + tooltipPadding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
274 tooltipMargin: 2, 277 tooltipMargin: 2,
275 - maxContentWidth: 128, 278 + maxContentWidth: 107,
276 fitInsideHorizontally: true, 279 fitInsideHorizontally: true,
277 fitInsideVertically: false, 280 fitInsideVertically: false,
278 getTooltipItem: (group, groupIndex, rod, rodIndex) { 281 getTooltipItem: (group, groupIndex, rod, rodIndex) {
279 final day = widget.report.days[group.x]; 282 final day = widget.report.days[group.x];
280 - if (day.averageHrv == null) return null;  
281 - return BarTooltipItem(  
282 - '${reportMonthDay(day.date)}\n',  
283 - const TextStyle(color: Color(0xFF78787D), fontSize: 10),  
284 - children: [  
285 - TextSpan(  
286 - text: '${day.level?.label ?? ''}\n',  
287 - style: TextStyle(  
288 - color: Color(  
289 - day.level?.colorValue ?? 0xFFB0B0B6,  
290 - ),  
291 - fontSize: 14,  
292 - fontWeight: FontWeight.w500,  
293 - ),  
294 - ),  
295 - TextSpan(  
296 - text:  
297 - '${day.averageHrv?.round() ?? '-'}ms · ${day.averageHeartRate ?? '-'}bpm',  
298 - style: const TextStyle(  
299 - color: Color(0xFFB0B0B6),  
300 - fontSize: 10,  
301 - ),  
302 - ),  
303 - ],  
304 - ); 283 + return _tooltipItem(context, day);
305 }, 284 },
306 ), 285 ),
307 ), 286 ),
@@ -350,6 +329,40 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -350,6 +329,40 @@ class _HrvBarChartState extends State<_HrvBarChart> {
350 ); 329 );
351 } 330 }
352 331
  332 + BarTooltipItem? _tooltipItem(BuildContext context, HrvDayReport day) {
  333 + if (day.averageHrv == null) return null;
  334 + return BarTooltipItem(
  335 + '${reportMonthDayWithWeekday(day.date)}\n',
  336 + const TextStyle(
  337 + color: _tooltipDateColor,
  338 + fontSize: 10,
  339 + fontWeight: FontWeight.w400,
  340 + height: 1.7,
  341 + ),
  342 + children: [
  343 + TextSpan(
  344 + text: '${day.level?.label ?? ''}\n',
  345 + style: TextStyle(
  346 + color: Color(day.level?.colorValue ?? 0xFFB0B0B6),
  347 + fontSize: 14,
  348 + fontWeight: FontWeight.w600,
  349 + height: 1.55,
  350 + ),
  351 + ),
  352 + TextSpan(
  353 + text:
  354 + '${day.averageHrv?.round() ?? '-'}ms · ${day.averageHeartRate ?? '-'}bpm',
  355 + style: const TextStyle(
  356 + color: _tooltipMetaColor,
  357 + fontSize: 10,
  358 + fontWeight: FontWeight.w400,
  359 + height: 1.35,
  360 + ),
  361 + ),
  362 + ],
  363 + );
  364 + }
  365 +
353 double? _lineXForIndex(double width) { 366 double? _lineXForIndex(double width) {
354 final index = _touchedIndex; 367 final index = _touchedIndex;
355 if (index == null || widget.report.days.isEmpty) return null; 368 if (index == null || widget.report.days.isEmpty) return null;
@@ -34,19 +34,7 @@ class _YearTrendCard extends StatelessWidget { @@ -34,19 +34,7 @@ class _YearTrendCard extends StatelessWidget {
34 34
35 @override 35 @override
36 Widget build(BuildContext context) { 36 Widget build(BuildContext context) {
37 - final monthsWithAverage = report.monthsWithAverage;  
38 - var minimumMonths = report.monthsAtExtreme(maximum: false);  
39 - var maximumMonths = report.monthsAtExtreme(maximum: true);  
40 - if (monthsWithAverage.length == 1) {  
41 - final month = monthsWithAverage.single;  
42 - final level = HrvStressLevel.fromAverageHrv(  
43 - report.averageForMonth(month)!,  
44 - );  
45 - final isStressed =  
46 - level == HrvStressLevel.attention || level == HrvStressLevel.overload;  
47 - minimumMonths = isStressed ? [month] : const [];  
48 - maximumMonths = isStressed ? const [] : [month];  
49 - } 37 + final extremeMonths = report.averageStressExtremeMonths();
50 return _Card( 38 return _Card(
51 padding: const EdgeInsets.fromLTRB(20, 17, 20, 18), 39 padding: const EdgeInsets.fromLTRB(20, 17, 20, 18),
52 child: Column( 40 child: Column(
@@ -67,13 +55,13 @@ class _YearTrendCard extends StatelessWidget { @@ -67,13 +55,13 @@ class _YearTrendCard extends StatelessWidget {
67 Expanded( 55 Expanded(
68 child: _MonthExtreme( 56 child: _MonthExtreme(
69 label: context.l10n.hrvMostStressed, 57 label: context.l10n.hrvMostStressed,
70 - months: minimumMonths, 58 + months: extremeMonths.mostStressed,
71 ), 59 ),
72 ), 60 ),
73 Expanded( 61 Expanded(
74 child: _MonthExtreme( 62 child: _MonthExtreme(
75 label: context.l10n.hrvLeastStressed, 63 label: context.l10n.hrvLeastStressed,
76 - months: maximumMonths, 64 + months: extremeMonths.leastStressed,
77 ), 65 ),
78 ), 66 ),
79 ], 67 ],
@@ -121,8 +109,7 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -121,8 +109,7 @@ class _YearBarChartState extends State<_YearBarChart> {
121 BarChartRodData( 109 BarChartRodData(
122 toY: widget.report.averageForMonth(month) ?? 0, 110 toY: widget.report.averageForMonth(month) ?? 0,
123 width: 8, 111 width: 8,
124 - color: _colorForValue(  
125 - widget.report.averageForMonth(month)), 112 + color: _colorForMonth(month),
126 borderRadius: const BorderRadius.vertical( 113 borderRadius: const BorderRadius.vertical(
127 top: Radius.circular(5), 114 top: Radius.circular(5),
128 ), 115 ),
@@ -201,8 +188,12 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -201,8 +188,12 @@ class _YearBarChartState extends State<_YearBarChart> {
201 if (widget.report.averageForMonth(month) == null) { 188 if (widget.report.averageForMonth(month) == null) {
202 return null; 189 return null;
203 } 190 }
204 - final stressedDays = _stressedDaysForMonth(month);  
205 - final relaxedDays = _relaxedDaysForMonth(month); 191 + final stressedDays = widget.report.stressedDaysForMonth(
  192 + month,
  193 + );
  194 + final relaxedDays = widget.report.relaxedDaysForMonth(
  195 + month,
  196 + );
206 return BarTooltipItem( 197 return BarTooltipItem(
207 '${reportMonth(month)}\n', 198 '${reportMonth(month)}\n',
208 const TextStyle( 199 const TextStyle(
@@ -275,25 +266,17 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -275,25 +266,17 @@ class _YearBarChartState extends State<_YearBarChart> {
275 ); 266 );
276 } 267 }
277 268
  269 + Color _colorForMonth(int month) {
  270 + final level = widget.report.levelForMonth(month);
  271 + if (level != null) return Color(level.colorValue);
  272 + return _colorForValue(widget.report.averageForMonth(month));
  273 + }
  274 +
278 Color _colorForValue(double? value) { 275 Color _colorForValue(double? value) {
279 if (value == null) return Colors.transparent; 276 if (value == null) return Colors.transparent;
280 return Color(HrvStressLevel.fromAverageHrv(value).colorValue); 277 return Color(HrvStressLevel.fromAverageHrv(value).colorValue);
281 } 278 }
282 279
283 - int _relaxedDaysForMonth(int month) {  
284 - return widget.report.daysForMonth(month).where((day) {  
285 - return day.level == HrvStressLevel.excellent ||  
286 - day.level == HrvStressLevel.normal;  
287 - }).length;  
288 - }  
289 -  
290 - int _stressedDaysForMonth(int month) {  
291 - return widget.report.daysForMonth(month).where((day) {  
292 - return day.level == HrvStressLevel.attention ||  
293 - day.level == HrvStressLevel.overload;  
294 - }).length;  
295 - }  
296 -  
297 double? _lineXForMonth(double width) { 280 double? _lineXForMonth(double width) {
298 final month = _touchedMonth; 281 final month = _touchedMonth;
299 if (month == null) return null; 282 if (month == null) return null;
@@ -13,4 +13,9 @@ String reportWeekdayLabel(int weekday) => switch (weekday) { @@ -13,4 +13,9 @@ String reportWeekdayLabel(int weekday) => switch (weekday) {
13 String reportMonthDay(DateTime date) => 13 String reportMonthDay(DateTime date) =>
14 l10n.reportDateMonthDay(date.month, date.day); 14 l10n.reportDateMonthDay(date.month, date.day);
15 15
  16 +String reportMonthDayWithWeekday(DateTime date) => l10n.reportDateWithWeekday(
  17 + reportMonthDay(date),
  18 + reportWeekdayLabel(date.weekday),
  19 + );
  20 +
16 String reportMonth(int month) => l10n.reportDateMonth(month); 21 String reportMonth(int month) => l10n.reportDateMonth(month);
1 import 'package:doublefeel_flutter/r.dart'; 1 import 'package:doublefeel_flutter/r.dart';
2 import 'package:fl_chart/fl_chart.dart'; 2 import 'package:fl_chart/fl_chart.dart';
  3 +import 'package:flutter/foundation.dart';
3 import 'package:flutter/material.dart'; 4 import 'package:flutter/material.dart';
4 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
5 6
@@ -26,6 +27,39 @@ class SleepWeekReportView extends StatelessWidget { @@ -26,6 +27,39 @@ class SleepWeekReportView extends StatelessWidget {
26 static const h3 = Color(0xFFB0B0B6); 27 static const h3 = Color(0xFFB0B0B6);
27 static const grid = Color(0xFFF3F3F3); 28 static const grid = Color(0xFFF3F3F3);
28 29
  30 + static bool get _usesAppleTextRenderer =>
  31 + defaultTargetPlatform == TargetPlatform.iOS ||
  32 + defaultTargetPlatform == TargetPlatform.macOS;
  33 +
  34 + static TextStyle get titleTextStyle {
  35 + const baseStyle = TextStyle(
  36 + color: h1,
  37 + fontSize: 16,
  38 + fontWeight: FontWeight.w600,
  39 + height: 1.2,
  40 + );
  41 + if (!_usesAppleTextRenderer) return baseStyle;
  42 + return baseStyle.copyWith(
  43 + fontFamily: 'PingFang SC',
  44 + fontFamilyFallback: const ['Heiti SC', 'Arial Unicode MS'],
  45 + );
  46 + }
  47 +
  48 + static StrutStyle get titleStrutStyle {
  49 + if (!_usesAppleTextRenderer) {
  50 + return const StrutStyle(
  51 + fontSize: 16,
  52 + height: 1.2,
  53 + );
  54 + }
  55 + return const StrutStyle(
  56 + fontSize: 16,
  57 + fontFamily: 'PingFang SC',
  58 + fontFamilyFallback: ['Heiti SC', 'Arial Unicode MS'],
  59 + height: 1.2,
  60 + );
  61 + }
  62 +
29 static int minutesFromEvening(DateTime time) { 63 static int minutesFromEvening(DateTime time) {
30 final minutes = time.hour * 60 + time.minute; 64 final minutes = time.hour * 60 + time.minute;
31 return minutes < 12 * 60 ? minutes + 24 * 60 : minutes; 65 return minutes < 12 * 60 ? minutes + 24 * 60 : minutes;
@@ -411,12 +445,8 @@ class _TrendCard extends StatelessWidget { @@ -411,12 +445,8 @@ class _TrendCard extends StatelessWidget {
411 children: [ 445 children: [
412 Text( 446 Text(
413 title, 447 title,
414 - style: const TextStyle(  
415 - color: SleepWeekReportView.h1,  
416 - fontSize: 16,  
417 - fontWeight: FontWeight.w600,  
418 - height: 1.2,  
419 - ), 448 + strutStyle: SleepWeekReportView.titleStrutStyle,
  449 + style: SleepWeekReportView.titleTextStyle,
420 ), 450 ),
421 const SizedBox(height: 14), 451 const SizedBox(height: 14),
422 Expanded(child: chart), 452 Expanded(child: chart),
@@ -914,12 +944,7 @@ BarTouchData _barTouchData({ @@ -914,12 +944,7 @@ BarTouchData _barTouchData({
914 BarTooltipItem _barTooltipItem(_ChartSummaryData data) { 944 BarTooltipItem _barTooltipItem(_ChartSummaryData data) {
915 return BarTooltipItem( 945 return BarTooltipItem(
916 data.title, 946 data.title,
917 - TextStyle(  
918 - color: data.titleColor,  
919 - fontSize: 16,  
920 - fontWeight: FontWeight.w600,  
921 - height: 1.2,  
922 - ), 947 + SleepWeekReportView.titleTextStyle.copyWith(color: data.titleColor),
923 textAlign: TextAlign.start, 948 textAlign: TextAlign.start,
924 children: [ 949 children: [
925 TextSpan( 950 TextSpan(
@@ -962,12 +987,7 @@ LineTouchTooltipData _lineTooltipData({ @@ -962,12 +987,7 @@ LineTouchTooltipData _lineTooltipData({
962 LineTooltipItem _lineTooltipItem(_ChartSummaryData data) { 987 LineTooltipItem _lineTooltipItem(_ChartSummaryData data) {
963 return LineTooltipItem( 988 return LineTooltipItem(
964 data.title, 989 data.title,
965 - TextStyle(  
966 - color: data.titleColor,  
967 - fontSize: 16,  
968 - fontWeight: FontWeight.w600,  
969 - height: 1.2,  
970 - ), 990 + SleepWeekReportView.titleTextStyle.copyWith(color: data.titleColor),
971 textAlign: TextAlign.start, 991 textAlign: TextAlign.start,
972 children: [ 992 children: [
973 TextSpan( 993 TextSpan(
@@ -186,23 +186,27 @@ class HrvTrendList { @@ -186,23 +186,27 @@ class HrvTrendList {
186 this.timeKey, 186 this.timeKey,
187 this.hrvAverage, 187 this.hrvAverage,
188 this.hrAverage, 188 this.hrAverage,
  189 + this.state,
189 }); 190 });
190 191
191 HrvTrendList.fromJson(dynamic json) { 192 HrvTrendList.fromJson(dynamic json) {
192 timeKey = json['time_key']; 193 timeKey = json['time_key'];
193 hrvAverage = _parseDouble(json['hrv_average']); 194 hrvAverage = _parseDouble(json['hrv_average']);
194 hrAverage = _parseDouble(json['hr_average']); 195 hrAverage = _parseDouble(json['hr_average']);
  196 + state = _parseInt(json['state']);
195 } 197 }
196 198
197 Object? timeKey; 199 Object? timeKey;
198 double? hrvAverage; 200 double? hrvAverage;
199 double? hrAverage; 201 double? hrAverage;
  202 + int? state;
200 203
201 Map<String, dynamic> toJson() { 204 Map<String, dynamic> toJson() {
202 final map = <String, dynamic>{}; 205 final map = <String, dynamic>{};
203 map['time_key'] = timeKey; 206 map['time_key'] = timeKey;
204 map['hrv_average'] = hrvAverage; 207 map['hrv_average'] = hrvAverage;
205 map['hr_average'] = hrAverage; 208 map['hr_average'] = hrAverage;
  209 + map['state'] = state;
206 return map; 210 return map;
207 } 211 }
208 } 212 }
@@ -213,3 +217,10 @@ double? _parseDouble(Object? value) { @@ -213,3 +217,10 @@ double? _parseDouble(Object? value) {
213 if (value is String) return double.tryParse(value); 217 if (value is String) return double.tryParse(value);
214 return null; 218 return null;
215 } 219 }
  220 +
  221 +int? _parseInt(Object? value) {
  222 + if (value == null) return null;
  223 + if (value is num) return value.toInt();
  224 + if (value is String) return int.tryParse(value);
  225 + return null;
  226 +}
@@ -376,7 +376,7 @@ @@ -376,7 +376,7 @@
376 "sleepQualityNormalRange": "60–85 pts", 376 "sleepQualityNormalRange": "60–85 pts",
377 "sleepQualityExcellentRange": ">85 pts", 377 "sleepQualityExcellentRange": ">85 pts",
378 "friendsAddCloseContactDescription": "Add a close contact so someone else can look out for your health", 378 "friendsAddCloseContactDescription": "Add a close contact so someone else can look out for your health",
379 - "friendsLimitReached": "Friend limit reached", 379 + "friendsLimitReached": "You can add up to 10 friends",
380 "friendsAddCloseContact": "Add a close contact", 380 "friendsAddCloseContact": "Add a close contact",
381 "friendsAddCloseContactWithCount": "Add a close contact ({count}/{max})", 381 "friendsAddCloseContactWithCount": "Add a close contact ({count}/{max})",
382 "friendsMe": "Me", 382 "friendsMe": "Me",
@@ -599,7 +599,7 @@ @@ -599,7 +599,7 @@
599 "sleepQualityNormalRange": "60~85分", 599 "sleepQualityNormalRange": "60~85分",
600 "sleepQualityExcellentRange": ">85分", 600 "sleepQualityExcellentRange": ">85分",
601 "friendsAddCloseContactDescription": "添加亲密联系人,多一个人关注你的健康", 601 "friendsAddCloseContactDescription": "添加亲密联系人,多一个人关注你的健康",
602 - "friendsLimitReached": "好友数量已达上限", 602 + "friendsLimitReached": "最多只能添加10个好友哦",
603 "friendsAddCloseContact": "添加亲密联系人", 603 "friendsAddCloseContact": "添加亲密联系人",
604 "friendsAddCloseContactWithCount": "添加亲密联系人({count}/{max})", 604 "friendsAddCloseContactWithCount": "添加亲密联系人({count}/{max})",
605 "@friendsAddCloseContactWithCount": { 605 "@friendsAddCloseContactWithCount": {
@@ -2358,7 +2358,7 @@ abstract class AppLocalizations { @@ -2358,7 +2358,7 @@ abstract class AppLocalizations {
2358 /// No description provided for @friendsLimitReached. 2358 /// No description provided for @friendsLimitReached.
2359 /// 2359 ///
2360 /// In zh, this message translates to: 2360 /// In zh, this message translates to:
2361 - /// **'好友数量已达上限'** 2361 + /// **'最多只能添加10个好友哦'**
2362 String get friendsLimitReached; 2362 String get friendsLimitReached;
2363 2363
2364 /// No description provided for @friendsAddCloseContact. 2364 /// No description provided for @friendsAddCloseContact.
@@ -1294,7 +1294,7 @@ class AppLocalizationsEn extends AppLocalizations { @@ -1294,7 +1294,7 @@ class AppLocalizationsEn extends AppLocalizations {
1294 'Add a close contact so someone else can look out for your health'; 1294 'Add a close contact so someone else can look out for your health';
1295 1295
1296 @override 1296 @override
1297 - String get friendsLimitReached => 'Friend limit reached'; 1297 + String get friendsLimitReached => 'You can add up to 10 friends';
1298 1298
1299 @override 1299 @override
1300 String get friendsAddCloseContact => 'Add a close contact'; 1300 String get friendsAddCloseContact => 'Add a close contact';
@@ -1231,7 +1231,7 @@ class AppLocalizationsZh extends AppLocalizations { @@ -1231,7 +1231,7 @@ class AppLocalizationsZh extends AppLocalizations {
1231 String get friendsAddCloseContactDescription => '添加亲密联系人,多一个人关注你的健康'; 1231 String get friendsAddCloseContactDescription => '添加亲密联系人,多一个人关注你的健康';
1232 1232
1233 @override 1233 @override
1234 - String get friendsLimitReached => '好友数量已达上限'; 1234 + String get friendsLimitReached => '最多只能添加10个好友哦';
1235 1235
1236 @override 1236 @override
1237 String get friendsAddCloseContact => '添加亲密联系人'; 1237 String get friendsAddCloseContact => '添加亲密联系人';