Commit 747d84d745315341439545e1da0b685ad30c9c0b

Authored by 刘宏哲
1 parent 243ed246

feat(app): bug fixed

@@ -63,12 +63,12 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -63,12 +63,12 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
63 weekStart: start, 63 weekStart: start,
64 weekEnd: start.add(const Duration(days: 6)), 64 weekEnd: start.add(const Duration(days: 6)),
65 days: _periodReports(start, 7, data), 65 days: _periodReports(start, 7, data),
66 - totalActiveEnergyOverride: data.totalMove?.round(), 66 + totalActiveEnergyOverride: data.totalMove?.toInt(),
67 totalExerciseMinutesOverride: _durationMinutes(data.totalExercise), 67 totalExerciseMinutesOverride: _durationMinutes(data.totalExercise),
68 totalStandHoursOverride: _hours(data.totalStand), 68 totalStandHoursOverride: _hours(data.totalStand),
69 - averageDailyActiveEnergyOverride: data.avgMove?.round(),  
70 - activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),  
71 - previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.round(), 69 + averageDailyActiveEnergyOverride: data.avgMove?.toInt(),
  70 + activeEnergyGoalOverride: data.activityTargetInfo?.move?.toInt(),
  71 + previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.toInt(),
72 ), 72 ),
73 AppFailure() => WeeklyActivityBurnReport.empty(start), 73 AppFailure() => WeeklyActivityBurnReport.empty(start),
74 }; 74 };
@@ -91,12 +91,12 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -91,12 +91,12 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
91 monthStart: start, 91 monthStart: start,
92 monthEnd: end, 92 monthEnd: end,
93 days: _periodReports(start, end.day, data), 93 days: _periodReports(start, end.day, data),
94 - totalActiveEnergyOverride: data.totalMove?.round(), 94 + totalActiveEnergyOverride: data.totalMove?.toInt(),
95 totalExerciseMinutesOverride: _durationMinutes(data.totalExercise), 95 totalExerciseMinutesOverride: _durationMinutes(data.totalExercise),
96 totalStandHoursOverride: _hours(data.totalStand), 96 totalStandHoursOverride: _hours(data.totalStand),
97 - averageDailyActiveEnergyOverride: data.avgMove?.round(),  
98 - activeEnergyGoalOverride: data.activityTargetInfo?.move?.round(),  
99 - previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.round(), 97 + averageDailyActiveEnergyOverride: data.avgMove?.toInt(),
  98 + activeEnergyGoalOverride: data.activityTargetInfo?.move?.toInt(),
  99 + previousAverageDailyActiveEnergyOverride: data.qoqAvgMove?.toInt(),
100 ), 100 ),
101 AppFailure() => MonthlyActivityBurnReport.empty(start), 101 AppFailure() => MonthlyActivityBurnReport.empty(start),
102 }; 102 };
@@ -247,7 +247,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -247,7 +247,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
247 247
248 ActivityBurnMetric? _metric(num? value, num? goal) { 248 ActivityBurnMetric? _metric(num? value, num? goal) {
249 if (value == null) return null; 249 if (value == null) return null;
250 - return ActivityBurnMetric(value: value.round(), goal: goal?.round() ?? 0); 250 + return ActivityBurnMetric(value: value.toInt(), goal: goal?.toInt() ?? 0);
251 } 251 }
252 252
253 ActivityBurnMetric? _exerciseDurationMetric(num? seconds, num? target) { 253 ActivityBurnMetric? _exerciseDurationMetric(num? seconds, num? target) {
@@ -269,7 +269,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -269,7 +269,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
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(); 272 + int? _hours(num? hours) => hours?.toInt();
273 273
274 int _secondsToWholeMinutes(num? seconds) { 274 int _secondsToWholeMinutes(num? seconds) {
275 if (seconds == null || seconds < 60) return 0; 275 if (seconds == null || seconds < 60) return 0;
@@ -278,7 +278,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -278,7 +278,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
278 278
279 int _exerciseTargetMinutes(num? target) { 279 int _exerciseTargetMinutes(num? target) {
280 if (target == null || target <= 0) return 0; 280 if (target == null || target <= 0) return 0;
281 - return target >= 60 ? _secondsToWholeMinutes(target) : target.round(); 281 + return target >= 60 ? _secondsToWholeMinutes(target) : target.toInt();
282 } 282 }
283 283
284 DateTime? _parseDateTime(Object? value, {DateTime? fallbackDate}) { 284 DateTime? _parseDateTime(Object? value, {DateTime? fallbackDate}) {
@@ -289,7 +289,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource { @@ -289,7 +289,7 @@ class ApiActivityBurnReportDataSource implements ActivityBurnReportDataSource {
289 return DateTime.fromMillisecondsSinceEpoch(milliseconds); 289 return DateTime.fromMillisecondsSinceEpoch(milliseconds);
290 } 290 }
291 if (fallbackDate != null && value >= 0 && value < 86400) { 291 if (fallbackDate != null && value >= 0 && value < 86400) {
292 - return fallbackDate.add(Duration(seconds: value.round())); 292 + return fallbackDate.add(Duration(seconds: value.toInt()));
293 } 293 }
294 } 294 }
295 final raw = value?.toString(); 295 final raw = value?.toString();
@@ -126,7 +126,7 @@ class WeeklyActivityBurnReport { @@ -126,7 +126,7 @@ class WeeklyActivityBurnReport {
126 int get averageDailyActiveEnergy { 126 int get averageDailyActiveEnergy {
127 if (averageDailyActiveEnergyOverride case final value?) return value; 127 if (averageDailyActiveEnergyOverride case final value?) return value;
128 if (daysWithData.isEmpty) return 0; 128 if (daysWithData.isEmpty) return 0;
129 - return (totalActiveEnergy / daysWithData.length).round(); 129 + return (totalActiveEnergy / daysWithData.length).toInt();
130 } 130 }
131 131
132 int get activeEnergyGoal => 132 int get activeEnergyGoal =>
@@ -141,7 +141,7 @@ class WeeklyActivityBurnReport { @@ -141,7 +141,7 @@ class WeeklyActivityBurnReport {
141 int? get activeEnergyComparisonPercent { 141 int? get activeEnergyComparisonPercent {
142 final previous = previousAverageDailyActiveEnergy; 142 final previous = previousAverageDailyActiveEnergy;
143 if (previous == null || previous <= 0) return null; 143 if (previous == null || previous <= 0) return null;
144 - return (((averageDailyActiveEnergy - previous) / previous) * 100).round(); 144 + return (((averageDailyActiveEnergy - previous) / previous) * 100).toInt();
145 } 145 }
146 146
147 int get perfectRingDays => daysWithData 147 int get perfectRingDays => daysWithData
@@ -232,7 +232,7 @@ class MonthlyActivityBurnReport { @@ -232,7 +232,7 @@ class MonthlyActivityBurnReport {
232 int get averageDailyActiveEnergy { 232 int get averageDailyActiveEnergy {
233 if (averageDailyActiveEnergyOverride case final value?) return value; 233 if (averageDailyActiveEnergyOverride case final value?) return value;
234 if (daysWithData.isEmpty) return 0; 234 if (daysWithData.isEmpty) return 0;
235 - return (totalActiveEnergy / daysWithData.length).round(); 235 + return (totalActiveEnergy / daysWithData.length).toInt();
236 } 236 }
237 237
238 int get activeEnergyGoal => 238 int get activeEnergyGoal =>
@@ -247,7 +247,7 @@ class MonthlyActivityBurnReport { @@ -247,7 +247,7 @@ class MonthlyActivityBurnReport {
247 int? get activeEnergyComparisonPercent { 247 int? get activeEnergyComparisonPercent {
248 final previous = previousAverageDailyActiveEnergy; 248 final previous = previousAverageDailyActiveEnergy;
249 if (previous == null || previous <= 0) return null; 249 if (previous == null || previous <= 0) return null;
250 - return (((averageDailyActiveEnergy - previous) / previous) * 100).round(); 250 + return (((averageDailyActiveEnergy - previous) / previous) * 100).toInt();
251 } 251 }
252 252
253 int get perfectRingDays => daysWithData 253 int get perfectRingDays => daysWithData
@@ -400,13 +400,13 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget { @@ -400,13 +400,13 @@ class ActivityBurnHeartRateZoneCard extends StatelessWidget {
400 } 400 }
401 401
402 String? _bottomLabel(double value, DateTime start, double maxX) { 402 String? _bottomLabel(double value, DateTime start, double maxX) {
403 - final rounded = value.round();  
404 - if (rounded % 360 != 0 || rounded < 0 || rounded > maxX.round()) { 403 + final minutes = value.toInt();
  404 + if (minutes % 360 != 0 || minutes < 0 || minutes > maxX.toInt()) {
405 return null; 405 return null;
406 } 406 }
407 - if (rounded == 1440) return '24:00'; 407 + if (minutes == 1440) return '24:00';
408 408
409 - final time = start.add(Duration(minutes: rounded)); 409 + final time = start.add(Duration(minutes: minutes));
410 return DateFormat('HH:mm').format(time); 410 return DateFormat('HH:mm').format(time);
411 } 411 }
412 } 412 }
@@ -484,7 +484,7 @@ class _HeartRateAxis { @@ -484,7 +484,7 @@ class _HeartRateAxis {
484 } 484 }
485 485
486 String formatYLabel(double value) { 486 String formatYLabel(double value) {
487 - if (value == value.roundToDouble()) return value.round().toString(); 487 + if (value == value.truncateToDouble()) return value.toInt().toString();
488 return value.toStringAsFixed(1); 488 return value.toStringAsFixed(1);
489 } 489 }
490 490
@@ -683,7 +683,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> { @@ -683,7 +683,7 @@ class _MonthEnergyTrendCardState extends State<_MonthEnergyTrendCard> {
683 reservedSize: 36, 683 reservedSize: 36,
684 interval: 100, 684 interval: 100,
685 getTitlesWidget: (value, meta) { 685 getTitlesWidget: (value, meta) {
686 - final isHundredTick = value.round() % 100 == 0; 686 + final isHundredTick = value.toInt() % 100 == 0;
687 final isMax = (value - maxY).abs() < 0.001; 687 final isMax = (value - maxY).abs() < 0.001;
688 if (!isHundredTick && !isMax) return const SizedBox.shrink(); 688 if (!isHundredTick && !isMax) return const SizedBox.shrink();
689 return Transform.translate( 689 return Transform.translate(
@@ -139,10 +139,12 @@ class _ActivityBurnRingPainter extends CustomPainter { @@ -139,10 +139,12 @@ class _ActivityBurnRingPainter extends CustomPainter {
139 static const _track = Color(0xFF35101D); 139 static const _track = Color(0xFF35101D);
140 static const _innerTrack = Color(0xFF13251F); 140 static const _innerTrack = Color(0xFF13251F);
141 static const _center = Color(0xFF0F0F11); 141 static const _center = Color(0xFF0F0F11);
142 - static const _outerRadius = 88.0;  
143 - static const _middleRadius = 60.0;  
144 - static const _innerRadius = 32.0;  
145 - static const _ringWidth = 24.0; 142 + static const _ringWidth = 20.0;
  143 + static const _ringGap = 4.0;
  144 + static const _outerEdgeRadius = 100.0;
  145 + static const _outerRadius = _outerEdgeRadius - _ringWidth / 2;
  146 + static const _middleRadius = _outerRadius - _ringWidth - _ringGap;
  147 + static const _innerRadius = _middleRadius - _ringWidth - _ringGap;
146 148
147 @override 149 @override
148 void paint(Canvas canvas, Size size) { 150 void paint(Canvas canvas, Size size) {
@@ -661,7 +661,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> { @@ -661,7 +661,7 @@ class _EnergyTrendCardState extends State<_EnergyTrendCard> {
661 reservedSize: 36, 661 reservedSize: 36,
662 interval: 100, 662 interval: 100,
663 getTitlesWidget: (value, meta) { 663 getTitlesWidget: (value, meta) {
664 - final isHundredTick = value.round() % 100 == 0; 664 + final isHundredTick = value.toInt() % 100 == 0;
665 final isMax = (value - maxY).abs() < 0.001; 665 final isMax = (value - maxY).abs() < 0.001;
666 if (!isHundredTick && !isMax) return const SizedBox.shrink(); 666 if (!isHundredTick && !isMax) return const SizedBox.shrink();
667 return Transform.translate( 667 return Transform.translate(
1 -import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart';  
2 import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; 1 import 'package:doublefeel_flutter/core/network/api/friend_api.dart';
3 import 'package:doublefeel_flutter/core/result/app_result.dart'; 2 import 'package:doublefeel_flutter/core/result/app_result.dart';
4 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart' 3 import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
@@ -7,6 +6,7 @@ import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; @@ -7,6 +6,7 @@ import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
7 import 'package:intl/intl.dart'; 6 import 'package:intl/intl.dart';
8 7
9 import '../models/friend_health_data.dart'; 8 import '../models/friend_health_data.dart';
  9 +import '../models/friend_stress_state.dart';
10 10
11 abstract class FriendsRepository { 11 abstract class FriendsRepository {
12 Future<List<FriendHealthData>> getFriends({bool withHealthData = true}); 12 Future<List<FriendHealthData>> getFriends({bool withHealthData = true});
@@ -64,7 +64,6 @@ class FriendsRepositoryImpl implements FriendsRepository { @@ -64,7 +64,6 @@ class FriendsRepositoryImpl implements FriendsRepository {
64 64
65 FriendHealthData _mapFriend(api_models.FriendItem friend) { 65 FriendHealthData _mapFriend(api_models.FriendItem friend) {
66 final healthData = friend.healthData; 66 final healthData = friend.healthData;
67 - final stressValue = healthData?.latestHrv;  
68 final nickname = friend.friendNickname?.trim(); 67 final nickname = friend.friendNickname?.trim();
69 final remark = friend.remarkName?.trim(); 68 final remark = friend.remarkName?.trim();
70 69
@@ -81,10 +80,7 @@ class FriendsRepositoryImpl implements FriendsRepository { @@ -81,10 +80,7 @@ class FriendsRepositoryImpl implements FriendsRepository {
81 steps: healthData?.totalSteps == null 80 steps: healthData?.totalSteps == null
82 ? null 81 ? null
83 : l10n.friendsStepCount(healthData!.totalSteps!), 82 : l10n.friendsStepCount(healthData!.totalSteps!),
84 - statusText: stressValue == null  
85 - ? l10n.friendsWaitingForData  
86 - : HrvStressLevel.fromRealtimeStress(stressValue).label,  
87 - stressValue: stressValue, 83 + stressState: FriendStressState.fromValue(healthData?.hrvState),
88 isOnWatchFace: friend.isShowInDial, 84 isOnWatchFace: friend.isShowInDial,
89 ); 85 );
90 } 86 }
@@ -2,6 +2,8 @@ import 'package:doublefeel_flutter/data/models/friend/friend_models.dart' @@ -2,6 +2,8 @@ import 'package:doublefeel_flutter/data/models/friend/friend_models.dart'
2 as api_models; 2 as api_models;
3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 3 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
4 4
  5 +import 'friend_stress_state.dart';
  6 +
5 class FriendHealthData { 7 class FriendHealthData {
6 const FriendHealthData({ 8 const FriendHealthData({
7 required this.friendItem, 9 required this.friendItem,
@@ -12,8 +14,7 @@ class FriendHealthData { @@ -12,8 +14,7 @@ class FriendHealthData {
12 required this.updatedAt, 14 required this.updatedAt,
13 required this.sleepQualityScore, 15 required this.sleepQualityScore,
14 required this.steps, 16 required this.steps,
15 - required this.statusText,  
16 - required this.stressValue, 17 + required this.stressState,
17 this.isOnWatchFace = false, 18 this.isOnWatchFace = false,
18 }); 19 });
19 20
@@ -25,8 +26,7 @@ class FriendHealthData { @@ -25,8 +26,7 @@ class FriendHealthData {
25 final String updatedAt; 26 final String updatedAt;
26 final int? sleepQualityScore; 27 final int? sleepQualityScore;
27 final String? steps; 28 final String? steps;
28 - final String statusText;  
29 - final double? stressValue; 29 + final FriendStressState stressState;
30 final bool isOnWatchFace; 30 final bool isOnWatchFace;
31 31
32 String get displayName { 32 String get displayName {
@@ -44,8 +44,7 @@ class FriendHealthData { @@ -44,8 +44,7 @@ class FriendHealthData {
44 String? updatedAt, 44 String? updatedAt,
45 int? sleepQualityScore, 45 int? sleepQualityScore,
46 String? steps, 46 String? steps,
47 - String? statusText,  
48 - double? stressValue, 47 + FriendStressState? stressState,
49 bool? isOnWatchFace, 48 bool? isOnWatchFace,
50 }) { 49 }) {
51 return FriendHealthData( 50 return FriendHealthData(
@@ -57,8 +56,7 @@ class FriendHealthData { @@ -57,8 +56,7 @@ class FriendHealthData {
57 updatedAt: updatedAt ?? this.updatedAt, 56 updatedAt: updatedAt ?? this.updatedAt,
58 sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore, 57 sleepQualityScore: sleepQualityScore ?? this.sleepQualityScore,
59 steps: steps ?? this.steps, 58 steps: steps ?? this.steps,
60 - statusText: statusText ?? this.statusText,  
61 - stressValue: stressValue ?? this.stressValue, 59 + stressState: stressState ?? this.stressState,
62 isOnWatchFace: isOnWatchFace ?? this.isOnWatchFace, 60 isOnWatchFace: isOnWatchFace ?? this.isOnWatchFace,
63 ); 61 );
64 } 62 }
  1 +import 'package:doublefeel_flutter/r.dart';
  2 +import 'package:flutter/material.dart';
  3 +
  4 +enum FriendStressState {
  5 + wait(0, '等待数据'),
  6 + stressful(1, '压力过载'),
  7 + slightStress(2, '注意压力'),
  8 + normal(3, '状态正常'),
  9 + energetic(4, '状态优秀');
  10 +
  11 + const FriendStressState(this.value, this.label);
  12 +
  13 + final int value;
  14 + final String label;
  15 +
  16 + static FriendStressState fromValue(int? value) {
  17 + return switch (value) {
  18 + 1 => FriendStressState.stressful,
  19 + 2 => FriendStressState.slightStress,
  20 + 3 => FriendStressState.normal,
  21 + 4 => FriendStressState.energetic,
  22 + _ => FriendStressState.wait,
  23 + };
  24 + }
  25 +
  26 + bool get hasData => this != FriendStressState.wait;
  27 +
  28 + int? get segmentIndex {
  29 + return switch (this) {
  30 + FriendStressState.stressful => 0,
  31 + FriendStressState.slightStress => 1,
  32 + FriendStressState.normal => 2,
  33 + FriendStressState.energetic => 3,
  34 + FriendStressState.wait => null,
  35 + };
  36 + }
  37 +
  38 + Color get color {
  39 + return switch (this) {
  40 + FriendStressState.stressful => const Color(0xFFFF5279),
  41 + FriendStressState.slightStress => const Color(0xFFFF9A6E),
  42 + FriendStressState.normal => const Color(0xFF7B9BFB),
  43 + FriendStressState.energetic => const Color(0xFF3BD49D),
  44 + FriendStressState.wait => const Color(0xFFF3F3F3),
  45 + };
  46 + }
  47 +
  48 + String get iconPath {
  49 + return switch (this) {
  50 + FriendStressState.stressful => R.assetsImagesRealtimeStressOverloadIcon,
  51 + FriendStressState.slightStress =>
  52 + R.assetsImagesRealtimeStressAttentionIcon,
  53 + FriendStressState.normal => R.assetsImagesRealtimeStressNormalIcon,
  54 + FriendStressState.energetic => R.assetsImagesRealtimeStressExcellentIcon,
  55 + FriendStressState.wait => R.assetsImagesRealtimeStressNoDataIcon,
  56 + };
  57 + }
  58 +}
1 import 'package:doublefeel_flutter/app/modules/home/widgets/df_tab_bar.dart'; 1 import 'package:doublefeel_flutter/app/modules/home/widgets/df_tab_bar.dart';
2 -import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart';  
3 import 'package:doublefeel_flutter/app/routes/app_pages.dart'; 2 import 'package:doublefeel_flutter/app/routes/app_pages.dart';
4 import 'package:doublefeel_flutter/core/constants/intent_keys.dart'; 3 import 'package:doublefeel_flutter/core/constants/intent_keys.dart';
5 import 'package:doublefeel_flutter/core/util/app_toast.dart'; 4 import 'package:doublefeel_flutter/core/util/app_toast.dart';
@@ -12,12 +11,13 @@ import 'package:intl/intl.dart'; @@ -12,12 +11,13 @@ import 'package:intl/intl.dart';
12 11
13 import '../controllers/friends_controller.dart'; 12 import '../controllers/friends_controller.dart';
14 import '../models/friend_health_data.dart'; 13 import '../models/friend_health_data.dart';
  14 +import '../models/friend_stress_state.dart';
15 import '../widgets/friend_health_card.dart'; 15 import '../widgets/friend_health_card.dart';
16 16
17 class FriendsTab extends GetView<FriendsController> { 17 class FriendsTab extends GetView<FriendsController> {
18 const FriendsTab({super.key}); 18 const FriendsTab({super.key});
19 19
20 - static const double _floatingAddButtonGapAboveTabBar = 23; 20 + static const double _floatingAddButtonBottomGap = 24;
21 21
22 @override 22 @override
23 Widget build(BuildContext context) { 23 Widget build(BuildContext context) {
@@ -49,9 +49,9 @@ class FriendsTab extends GetView<FriendsController> { @@ -49,9 +49,9 @@ class FriendsTab extends GetView<FriendsController> {
49 stressScore == null; 49 stressScore == null;
50 final isFriendsInitialLoading = 50 final isFriendsInitialLoading =
51 controller.isLoading.value && !hasFriends; 51 controller.isLoading.value && !hasFriends;
52 - final stressValue = stressScore?.state == 0  
53 - ? null  
54 - : stressScore?.comprehensiveScore?.toDouble(); 52 + final selfStressState = FriendStressState.fromValue(
  53 + stressScore?.comprehensiveScore,
  54 + );
55 final selfHealthCard = _SelfHealthCard( 55 final selfHealthCard = _SelfHealthCard(
56 name: currentUser?.nickname?.trim().isNotEmpty == true 56 name: currentUser?.nickname?.trim().isNotEmpty == true
57 ? currentUser!.nickname!.trim() 57 ? currentUser!.nickname!.trim()
@@ -69,12 +69,7 @@ class FriendsTab extends GetView<FriendsController> { @@ -69,12 +69,7 @@ class FriendsTab extends GetView<FriendsController> {
69 : context.l10n.friendsStepCount( 69 : context.l10n.friendsStepCount(
70 healthData!.steps!, 70 healthData!.steps!,
71 ), 71 ),
72 - statusText: stressValue == null  
73 - ? context.l10n.friendsWaitingForData  
74 - : HrvStressLevel.fromRealtimeStress(  
75 - stressValue,  
76 - ).label,  
77 - stressValue: stressValue, 72 + stressState: selfStressState,
78 isLoading: isSelfInitialLoading, 73 isLoading: isSelfInitialLoading,
79 ); 74 );
80 75
@@ -185,8 +180,7 @@ class FriendsTab extends GetView<FriendsController> { @@ -185,8 +180,7 @@ class FriendsTab extends GetView<FriendsController> {
185 updatedAt: friend.updatedAt, 180 updatedAt: friend.updatedAt,
186 sleepQualityScore: friend.sleepQualityScore, 181 sleepQualityScore: friend.sleepQualityScore,
187 steps: friend.steps, 182 steps: friend.steps,
188 - statusText: friend.statusText,  
189 - stressValue: friend.stressValue, 183 + stressState: friend.stressState,
190 isOnWatchFace: friend.isOnWatchFace, 184 isOnWatchFace: friend.isOnWatchFace,
191 onTap: () => _openFriendHome(friend), 185 onTap: () => _openFriendHome(friend),
192 onMoreSelected: (action) { 186 onMoreSelected: (action) {
@@ -225,7 +219,7 @@ class FriendsTab extends GetView<FriendsController> { @@ -225,7 +219,7 @@ class FriendsTab extends GetView<FriendsController> {
225 } 219 }
226 220
227 double _floatingAddButtonBottom(BuildContext context) { 221 double _floatingAddButtonBottom(BuildContext context) {
228 - return _tabBarAvoidanceBottom(context) + _floatingAddButtonGapAboveTabBar; 222 + return MediaQuery.paddingOf(context).bottom + _floatingAddButtonBottomGap;
229 } 223 }
230 224
231 double _tabBarAvoidanceBottom(BuildContext context) { 225 double _tabBarAvoidanceBottom(BuildContext context) {
@@ -286,8 +280,7 @@ class _SelfHealthCard extends StatelessWidget { @@ -286,8 +280,7 @@ class _SelfHealthCard extends StatelessWidget {
286 required this.updatedAt, 280 required this.updatedAt,
287 required this.sleepQualityScore, 281 required this.sleepQualityScore,
288 required this.steps, 282 required this.steps,
289 - required this.statusText,  
290 - required this.stressValue, 283 + required this.stressState,
291 required this.isLoading, 284 required this.isLoading,
292 }); 285 });
293 286
@@ -296,8 +289,7 @@ class _SelfHealthCard extends StatelessWidget { @@ -296,8 +289,7 @@ class _SelfHealthCard extends StatelessWidget {
296 final String updatedAt; 289 final String updatedAt;
297 final int? sleepQualityScore; 290 final int? sleepQualityScore;
298 final String? steps; 291 final String? steps;
299 - final String statusText;  
300 - final double? stressValue; 292 + final FriendStressState stressState;
301 final bool isLoading; 293 final bool isLoading;
302 294
303 @override 295 @override
@@ -311,8 +303,7 @@ class _SelfHealthCard extends StatelessWidget { @@ -311,8 +303,7 @@ class _SelfHealthCard extends StatelessWidget {
311 updatedAt: updatedAt, 303 updatedAt: updatedAt,
312 sleepQualityScore: sleepQualityScore, 304 sleepQualityScore: sleepQualityScore,
313 steps: steps, 305 steps: steps,
314 - statusText: statusText,  
315 - stressValue: stressValue, 306 + stressState: stressState,
316 isSelf: true, 307 isSelf: true,
317 borderRadius: borderRadius, 308 borderRadius: borderRadius,
318 contentPadding: const EdgeInsets.fromLTRB(36, 20, 35, 20), 309 contentPadding: const EdgeInsets.fromLTRB(36, 20, 35, 20),
@@ -67,8 +67,7 @@ class _SelectFriendViewState extends State<SelectFriendView> { @@ -67,8 +67,7 @@ class _SelectFriendViewState extends State<SelectFriendView> {
67 updatedAt: friend.updatedAt, 67 updatedAt: friend.updatedAt,
68 sleepQualityScore: friend.sleepQualityScore, 68 sleepQualityScore: friend.sleepQualityScore,
69 steps: friend.steps, 69 steps: friend.steps,
70 - statusText: friend.statusText,  
71 - stressValue: friend.stressValue, 70 + stressState: friend.stressState,
72 isOnWatchFace: friend.isOnWatchFace, 71 isOnWatchFace: friend.isOnWatchFace,
73 isSelected: isSelected, 72 isSelected: isSelected,
74 showCheckbox: true, 73 showCheckbox: true,
1 import 'dart:math' as math; 1 import 'dart:math' as math;
2 2
3 -import 'package:doublefeel_flutter/app/modules/hrv_report/models/hrv_report_models.dart'; 3 +import 'package:doublefeel_flutter/app/modules/friends/models/friend_stress_state.dart';
4 import 'package:doublefeel_flutter/app/modules/sleep_report/models/sleep_report_models.dart'; 4 import 'package:doublefeel_flutter/app/modules/sleep_report/models/sleep_report_models.dart';
5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; 5 import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
6 import 'package:doublefeel_flutter/r.dart'; 6 import 'package:doublefeel_flutter/r.dart';
@@ -21,8 +21,7 @@ class FriendHealthCard extends StatelessWidget { @@ -21,8 +21,7 @@ class FriendHealthCard extends StatelessWidget {
21 required this.updatedAt, 21 required this.updatedAt,
22 required this.sleepQualityScore, 22 required this.sleepQualityScore,
23 required this.steps, 23 required this.steps,
24 - required this.statusText,  
25 - required this.stressValue, 24 + required this.stressState,
26 this.isSelf = false, 25 this.isSelf = false,
27 this.isOnWatchFace = false, 26 this.isOnWatchFace = false,
28 this.isSelected = false, 27 this.isSelected = false,
@@ -39,8 +38,7 @@ class FriendHealthCard extends StatelessWidget { @@ -39,8 +38,7 @@ class FriendHealthCard extends StatelessWidget {
39 final String updatedAt; 38 final String updatedAt;
40 final int? sleepQualityScore; 39 final int? sleepQualityScore;
41 final String? steps; 40 final String? steps;
42 - final String statusText;  
43 - final double? stressValue; 41 + final FriendStressState stressState;
44 final bool isSelf; 42 final bool isSelf;
45 final bool isOnWatchFace; 43 final bool isOnWatchFace;
46 final bool isSelected; 44 final bool isSelected;
@@ -92,9 +90,7 @@ class FriendHealthCard extends StatelessWidget { @@ -92,9 +90,7 @@ class FriendHealthCard extends StatelessWidget {
92 children: [ 90 children: [
93 Expanded( 91 Expanded(
94 child: _StatusFigure( 92 child: _StatusFigure(
95 - statusText: statusText,  
96 - stressValue: stressValue,  
97 - waiting: stressValue == null, 93 + stressState: stressState,
98 ), 94 ),
99 ), 95 ),
100 const SizedBox(width: 18), 96 const SizedBox(width: 18),
@@ -138,7 +134,8 @@ class FriendHealthCard extends StatelessWidget { @@ -138,7 +134,8 @@ class FriendHealthCard extends StatelessWidget {
138 child: AnimatedContainer( 134 child: AnimatedContainer(
139 duration: const Duration(milliseconds: 160), 135 duration: const Duration(milliseconds: 160),
140 curve: Curves.easeOut, 136 curve: Curves.easeOut,
141 - transform: Matrix4.identity()..scale(isSelected ? 1.0 : 0.92), 137 + transform: Matrix4.identity()
  138 + ..scaleByDouble(isSelected ? 1.0 : 0.92, 1, 1, 1),
142 child: _FriendSelectMark(isSelected: isSelected), 139 child: _FriendSelectMark(isSelected: isSelected),
143 ), 140 ),
144 ), 141 ),
@@ -333,21 +330,13 @@ class _AvatarFallback extends StatelessWidget { @@ -333,21 +330,13 @@ class _AvatarFallback extends StatelessWidget {
333 330
334 class _StatusFigure extends StatelessWidget { 331 class _StatusFigure extends StatelessWidget {
335 const _StatusFigure({ 332 const _StatusFigure({
336 - required this.statusText,  
337 - required this.stressValue,  
338 - required this.waiting, 333 + required this.stressState,
339 }); 334 });
340 335
341 - final String statusText;  
342 - final double? stressValue;  
343 - final bool waiting; 336 + final FriendStressState stressState;
344 337
345 @override 338 @override
346 Widget build(BuildContext context) { 339 Widget build(BuildContext context) {
347 - final stressLevel = stressValue == null  
348 - ? null  
349 - : HrvStressLevel.fromRealtimeStress(stressValue!);  
350 -  
351 return SizedBox( 340 return SizedBox(
352 height: 144, 341 height: 144,
353 child: Column( 342 child: Column(
@@ -363,16 +352,14 @@ class _StatusFigure extends StatelessWidget { @@ -363,16 +352,14 @@ class _StatusFigure extends StatelessWidget {
363 CustomPaint( 352 CustomPaint(
364 size: const Size(124, 121), 353 size: const Size(124, 121),
365 painter: _StressStatusRingPainter( 354 painter: _StressStatusRingPainter(
366 - stressValue: stressValue,  
367 - muted: waiting, 355 + stressState: stressState,
368 ), 356 ),
369 ), 357 ),
370 Positioned( 358 Positioned(
371 left: 22, 359 left: 22,
372 top: 24, 360 top: 24,
373 child: Image.asset( 361 child: Image.asset(
374 - stressLevel?.realtimeStressIconPath ??  
375 - R.assetsImagesRealtimeStressNoDataIcon, 362 + stressState.iconPath,
376 width: 72, 363 width: 72,
377 height: 72, 364 height: 72,
378 fit: BoxFit.contain, 365 fit: BoxFit.contain,
@@ -383,7 +370,7 @@ class _StatusFigure extends StatelessWidget { @@ -383,7 +370,7 @@ class _StatusFigure extends StatelessWidget {
383 ), 370 ),
384 const SizedBox(height: 3), 371 const SizedBox(height: 3),
385 Text( 372 Text(
386 - statusText, 373 + stressState.label,
387 style: const TextStyle( 374 style: const TextStyle(
388 color: Color(0xFF0F0F11), 375 color: Color(0xFF0F0F11),
389 fontSize: 14, 376 fontSize: 14,
@@ -399,18 +386,16 @@ class _StatusFigure extends StatelessWidget { @@ -399,18 +386,16 @@ class _StatusFigure extends StatelessWidget {
399 386
400 class _StressStatusRingPainter extends CustomPainter { 387 class _StressStatusRingPainter extends CustomPainter {
401 const _StressStatusRingPainter({ 388 const _StressStatusRingPainter({
402 - required this.stressValue,  
403 - required this.muted, 389 + required this.stressState,
404 }); 390 });
405 391
406 - final double? stressValue;  
407 - final bool muted; 392 + final FriendStressState stressState;
408 393
409 - static const _segments = [  
410 - _StressStatusSegment(80, 100, Color(0xFFFF5279)),  
411 - _StressStatusSegment(50, 80, Color(0xFFFF9A6E)),  
412 - _StressStatusSegment(20, 50, Color(0xFF7B9BFB)),  
413 - _StressStatusSegment(0, 20, Color(0xFF3BD49D)), 394 + static const _segmentColors = [
  395 + Color(0xFFFF5279),
  396 + Color(0xFFFF9A6E),
  397 + Color(0xFF7B9BFB),
  398 + Color(0xFF3BD49D),
414 ]; 399 ];
415 400
416 static const _arcSpecs = [ 401 static const _arcSpecs = [
@@ -419,6 +404,7 @@ class _StressStatusRingPainter extends CustomPainter { @@ -419,6 +404,7 @@ class _StressStatusRingPainter extends CustomPainter {
419 _ArcSpec(270, 80), 404 _ArcSpec(270, 80),
420 _ArcSpec(-10, 55), 405 _ArcSpec(-10, 55),
421 ]; 406 ];
  407 + static const _arcGapDegrees = 5.0;
422 408
423 @override 409 @override
424 void paint(Canvas canvas, Size size) { 410 void paint(Canvas canvas, Size size) {
@@ -436,7 +422,7 @@ class _StressStatusRingPainter extends CustomPainter { @@ -436,7 +422,7 @@ class _StressStatusRingPainter extends CustomPainter {
436 ..strokeWidth = 12 422 ..strokeWidth = 12
437 ..strokeCap = StrokeCap.round; 423 ..strokeCap = StrokeCap.round;
438 424
439 - if (muted) { 425 + if (!stressState.hasData) {
440 const mutedSpec = _ArcSpec(135, 270); 426 const mutedSpec = _ArcSpec(135, 270);
441 final startAngle = _degreesToRadians(mutedSpec.startDegrees); 427 final startAngle = _degreesToRadians(mutedSpec.startDegrees);
442 final sweepAngle = _degreesToRadians(mutedSpec.sweepDegrees); 428 final sweepAngle = _degreesToRadians(mutedSpec.sweepDegrees);
@@ -453,8 +439,9 @@ class _StressStatusRingPainter extends CustomPainter { @@ -453,8 +439,9 @@ class _StressStatusRingPainter extends CustomPainter {
453 439
454 const paintOrder = [3, 2, 1, 0]; 440 const paintOrder = [3, 2, 1, 0];
455 for (final i in paintOrder) { 441 for (final i in paintOrder) {
456 - final startAngle = _degreesToRadians(_arcSpecs[i].startDegrees);  
457 - final sweepAngle = _degreesToRadians(_arcSpecs[i].sweepDegrees); 442 + final visualSpec = _arcSpecs[i].withGap(_arcGapDegrees);
  443 + final startAngle = _degreesToRadians(visualSpec.startDegrees);
  444 + final sweepAngle = _degreesToRadians(visualSpec.sweepDegrees);
458 canvas.drawArc( 445 canvas.drawArc(
459 rect, 446 rect,
460 startAngle, 447 startAngle,
@@ -463,7 +450,7 @@ class _StressStatusRingPainter extends CustomPainter { @@ -463,7 +450,7 @@ class _StressStatusRingPainter extends CustomPainter {
463 borderPaint, 450 borderPaint,
464 ); 451 );
465 452
466 - segmentPaint.color = _segments[i].color.withValues(alpha: 0.42); 453 + segmentPaint.color = _segmentColors[i].withValues(alpha: 0.42);
467 canvas.drawArc( 454 canvas.drawArc(
468 rect, 455 rect,
469 startAngle, 456 startAngle,
@@ -473,21 +460,15 @@ class _StressStatusRingPainter extends CustomPainter { @@ -473,21 +460,15 @@ class _StressStatusRingPainter extends CustomPainter {
473 ); 460 );
474 } 461 }
475 462
476 - final value = stressValue;  
477 - if (value == null) return;  
478 -  
479 - final segmentIndex =  
480 - _segments.indexWhere((segment) => segment.contains(value));  
481 - final safeSegmentIndex = segmentIndex < 0 ? 2 : segmentIndex;  
482 - final segment = _segments[safeSegmentIndex];  
483 - final spec = _arcSpecs[safeSegmentIndex];  
484 - final progress = segment.progressOf(value); 463 + final segmentIndex = stressState.segmentIndex;
  464 + if (segmentIndex == null) return;
  465 + final spec = _arcSpecs[segmentIndex].withGap(_arcGapDegrees);
485 final indicatorAngle = 466 final indicatorAngle =
486 - _degreesToRadians(spec.startDegrees + spec.sweepDegrees * progress); 467 + _degreesToRadians(spec.startDegrees + spec.sweepDegrees / 2);
487 final indicatorPoint = _pointOnOval(rect, indicatorAngle); 468 final indicatorPoint = _pointOnOval(rect, indicatorAngle);
488 469
489 final indicatorPaint = Paint() 470 final indicatorPaint = Paint()
490 - ..color = segment.color 471 + ..color = _segmentColors[segmentIndex]
491 ..style = PaintingStyle.fill; 472 ..style = PaintingStyle.fill;
492 canvas.drawCircle(indicatorPoint, 10, indicatorPaint); 473 canvas.drawCircle(indicatorPoint, 10, indicatorPaint);
493 474
@@ -508,24 +489,7 @@ class _StressStatusRingPainter extends CustomPainter { @@ -508,24 +489,7 @@ class _StressStatusRingPainter extends CustomPainter {
508 489
509 @override 490 @override
510 bool shouldRepaint(covariant _StressStatusRingPainter oldDelegate) { 491 bool shouldRepaint(covariant _StressStatusRingPainter oldDelegate) {
511 - return oldDelegate.stressValue != stressValue || oldDelegate.muted != muted;  
512 - }  
513 -}  
514 -  
515 -class _StressStatusSegment {  
516 - const _StressStatusSegment(this.min, this.max, this.color);  
517 -  
518 - final double min;  
519 - final double max;  
520 - final Color color;  
521 -  
522 - bool contains(double value) {  
523 - final lowerMatched = min == 0 ? value >= min : value > min;  
524 - return lowerMatched && value <= max;  
525 - }  
526 -  
527 - double progressOf(double value) {  
528 - return ((value.clamp(min, max) - min) / (max - min)).toDouble(); 492 + return oldDelegate.stressState != stressState;
529 } 493 }
530 } 494 }
531 495
@@ -534,6 +498,14 @@ class _ArcSpec { @@ -534,6 +498,14 @@ class _ArcSpec {
534 498
535 final double startDegrees; 499 final double startDegrees;
536 final double sweepDegrees; 500 final double sweepDegrees;
  501 +
  502 + _ArcSpec withGap(double gapDegrees) {
  503 + final clampedGap = gapDegrees.clamp(0, sweepDegrees / 2).toDouble();
  504 + return _ArcSpec(
  505 + startDegrees + clampedGap,
  506 + sweepDegrees - clampedGap * 2,
  507 + );
  508 + }
537 } 509 }
538 510
539 class _MetricTile extends StatelessWidget { 511 class _MetricTile extends StatelessWidget {
@@ -6,28 +6,6 @@ enum HrvStressLevel { @@ -6,28 +6,6 @@ enum HrvStressLevel {
6 normal, 6 normal,
7 attention, 7 attention,
8 overload; 8 overload;
9 -  
10 - static HrvStressLevel fromHrvState(int hrvState) {  
11 - if (hrvState == 1) return HrvStressLevel.overload;  
12 - if (hrvState == 2) return HrvStressLevel.attention;  
13 - if (hrvState == 3) return HrvStressLevel.normal;  
14 - return HrvStressLevel.excellent;  
15 - }  
16 -  
17 - static HrvStressLevel fromRealtimeStress(double value) {  
18 - final normalizedValue = value.clamp(0, 100);  
19 - if (normalizedValue > 80) return HrvStressLevel.overload;  
20 - if (normalizedValue > 50) return HrvStressLevel.attention;  
21 - if (normalizedValue > 20) return HrvStressLevel.normal;  
22 - return HrvStressLevel.excellent;  
23 - }  
24 -  
25 - static HrvStressLevel fromAverageHrv(double value) {  
26 - if (value >= 75) return HrvStressLevel.excellent;  
27 - if (value >= 55) return HrvStressLevel.normal;  
28 - if (value >= 40) return HrvStressLevel.attention;  
29 - return HrvStressLevel.overload;  
30 - }  
31 } 9 }
32 10
33 extension HrvStressLevelPresentation on HrvStressLevel { 11 extension HrvStressLevelPresentation on HrvStressLevel {
@@ -345,17 +323,15 @@ class YearlyHrvReport implements HrvPeriodReport { @@ -345,17 +323,15 @@ class YearlyHrvReport implements HrvPeriodReport {
345 return levels.first; 323 return levels.first;
346 } 324 }
347 325
348 - List<int> get monthsWithAverage => [ 326 + List<int> get monthsWithLevel => [
349 for (var month = 1; month <= 12; month++) 327 for (var month = 1; month <= 12; month++)
350 - if (averageForMonth(month) != null) month, 328 + if (levelForMonth(month) != null) month,
351 ]; 329 ];
352 330
353 - ({List<int> mostStressed, List<int> leastStressed})  
354 - averageStressExtremeMonths() { 331 + ({List<int> mostStressed, List<int> leastStressed}) stressExtremeMonths() {
355 final values = [ 332 final values = [
356 - for (final month in monthsWithAverage)  
357 - if (averageForMonth(month) case final average?)  
358 - MapEntry(month, average), 333 + for (final month in monthsWithLevel)
  334 + if (levelForMonth(month) case final level?) MapEntry(month, level),
359 ]; 335 ];
360 values.sort(_compareByStressDescending); 336 values.sort(_compareByStressDescending);
361 337
@@ -367,7 +343,7 @@ class YearlyHrvReport implements HrvPeriodReport { @@ -367,7 +343,7 @@ class YearlyHrvReport implements HrvPeriodReport {
367 343
368 if (values.length == 1) { 344 if (values.length == 1) {
369 final entry = values.single; 345 final entry = values.single;
370 - if (_isStressedAverage(entry.value)) { 346 + if (_isStressedLevel(entry.value)) {
371 mostStressed.add(entry.key); 347 mostStressed.add(entry.key);
372 } else { 348 } else {
373 leastStressed.add(entry.key); 349 leastStressed.add(entry.key);
@@ -380,7 +356,7 @@ class YearlyHrvReport implements HrvPeriodReport { @@ -380,7 +356,7 @@ class YearlyHrvReport implements HrvPeriodReport {
380 356
381 if (values.length == 3) { 357 if (values.length == 3) {
382 final middle = values[1]; 358 final middle = values[1];
383 - if (_isStressedAverage(middle.value)) { 359 + if (_isStressedLevel(middle.value)) {
384 mostStressed.add(middle.key); 360 mostStressed.add(middle.key);
385 } else { 361 } else {
386 leastStressed.add(middle.key); 362 leastStressed.add(middle.key);
@@ -396,19 +372,29 @@ class YearlyHrvReport implements HrvPeriodReport { @@ -396,19 +372,29 @@ class YearlyHrvReport implements HrvPeriodReport {
396 } 372 }
397 373
398 int _compareByStressDescending( 374 int _compareByStressDescending(
399 - MapEntry<int, double> a,  
400 - MapEntry<int, double> b, 375 + MapEntry<int, HrvStressLevel> a,
  376 + MapEntry<int, HrvStressLevel> b,
401 ) { 377 ) {
402 - final valueComparison = a.value.compareTo(b.value); 378 + final valueComparison = _stressRank(b.value).compareTo(
  379 + _stressRank(a.value),
  380 + );
403 return valueComparison == 0 ? a.key.compareTo(b.key) : valueComparison; 381 return valueComparison == 0 ? a.key.compareTo(b.key) : valueComparison;
404 } 382 }
405 383
406 - bool _isStressedAverage(double average) {  
407 - final level = HrvStressLevel.fromAverageHrv(average); 384 + bool _isStressedLevel(HrvStressLevel level) {
408 return level == HrvStressLevel.attention || 385 return level == HrvStressLevel.attention ||
409 level == HrvStressLevel.overload; 386 level == HrvStressLevel.overload;
410 } 387 }
411 388
  389 + int _stressRank(HrvStressLevel level) {
  390 + return switch (level) {
  391 + HrvStressLevel.overload => 3,
  392 + HrvStressLevel.attention => 2,
  393 + HrvStressLevel.normal => 1,
  394 + HrvStressLevel.excellent => 0,
  395 + };
  396 + }
  397 +
412 List<int> monthsAtExtreme({required bool maximum}) { 398 List<int> monthsAtExtreme({required bool maximum}) {
413 final values = [ 399 final values = [
414 for (var month = 1; month <= 12; month++) 400 for (var month = 1; month <= 12; month++)
@@ -34,7 +34,7 @@ class _YearTrendCard extends StatelessWidget { @@ -34,7 +34,7 @@ class _YearTrendCard extends StatelessWidget {
34 34
35 @override 35 @override
36 Widget build(BuildContext context) { 36 Widget build(BuildContext context) {
37 - final extremeMonths = report.averageStressExtremeMonths(); 37 + final extremeMonths = report.stressExtremeMonths();
38 return _Card( 38 return _Card(
39 padding: const EdgeInsets.fromLTRB(20, 17, 20, 18), 39 padding: const EdgeInsets.fromLTRB(20, 17, 20, 18),
40 child: Column( 40 child: Column(
@@ -269,12 +269,7 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -269,12 +269,7 @@ class _YearBarChartState extends State<_YearBarChart> {
269 Color _colorForMonth(int month) { 269 Color _colorForMonth(int month) {
270 final level = widget.report.levelForMonth(month); 270 final level = widget.report.levelForMonth(month);
271 if (level != null) return Color(level.colorValue); 271 if (level != null) return Color(level.colorValue);
272 - return _colorForValue(widget.report.averageForMonth(month));  
273 - }  
274 -  
275 - Color _colorForValue(double? value) {  
276 - if (value == null) return Colors.transparent;  
277 - return Color(HrvStressLevel.fromAverageHrv(value).colorValue); 272 + return Colors.transparent;
278 } 273 }
279 274
280 double? _lineXForMonth(double width) { 275 double? _lineXForMonth(double width) {
1 /// Global selectable range for report dates. 1 /// Global selectable range for report dates.
2 abstract final class ReportDateRangeConfig { 2 abstract final class ReportDateRangeConfig {
3 static const int yearsBeforeCurrent = 2; 3 static const int yearsBeforeCurrent = 2;
4 - static const int yearsAfterCurrent = 1;  
5 4
6 static int firstYear([DateTime? now]) => 5 static int firstYear([DateTime? now]) =>
7 (now ?? DateTime.now()).year - yearsBeforeCurrent; 6 (now ?? DateTime.now()).year - yearsBeforeCurrent;
8 7
9 - static int lastYear([DateTime? now]) =>  
10 - (now ?? DateTime.now()).year + yearsAfterCurrent; 8 + static int lastYear([DateTime? now]) => (now ?? DateTime.now()).year;
11 9
12 static DateTime firstDate([DateTime? now]) => DateTime(firstYear(now)); 10 static DateTime firstDate([DateTime? now]) => DateTime(firstYear(now));
13 11
14 - static DateTime lastDate([DateTime? now]) =>  
15 - DateTime(lastYear(now), DateTime.december, 31); 12 + static DateTime lastDate([DateTime? now]) {
  13 + final date = now ?? DateTime.now();
  14 + return DateTime(date.year, date.month, date.day);
  15 + }
16 16
17 static DateTime firstMonth([DateTime? now]) => DateTime(firstYear(now)); 17 static DateTime firstMonth([DateTime? now]) => DateTime(firstYear(now));
18 18
19 - static DateTime lastMonth([DateTime? now]) =>  
20 - DateTime(lastYear(now), DateTime.december); 19 + static DateTime lastMonth([DateTime? now]) {
  20 + final date = now ?? DateTime.now();
  21 + return DateTime(date.year, date.month);
  22 + }
21 23
22 static DateTime firstWeekStart([DateTime? now]) { 24 static DateTime firstWeekStart([DateTime? now]) {
23 final first = firstDate(now); 25 final first = firstDate(now);
@@ -181,8 +181,8 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -181,8 +181,8 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
181 date: date, 181 date: date,
182 duration: durationSeconds == null 182 duration: durationSeconds == null
183 ? report.duration 183 ? report.duration
184 - : SleepDuration(minutes: (durationSeconds / 60).round()),  
185 - qualityScore: score?.round() ?? report.qualityScore, 184 + : SleepDuration(minutes: durationSeconds ~/ 60),
  185 + qualityScore: score?.toInt() ?? report.qualityScore,
186 heartRate: report.heartRate, 186 heartRate: report.heartRate,
187 ); 187 );
188 } 188 }
@@ -276,9 +276,9 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -276,9 +276,9 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
276 dailyTrend, 276 dailyTrend,
277 asleepTime, 277 asleepTime,
278 points, 278 points,
279 - averageBpm: data.avgHr?.round(),  
280 - maxBpm: data.maxHr?.round(),  
281 - minBpm: data.minHr?.round(), 279 + averageBpm: data.avgHr?.toInt(),
  280 + maxBpm: data.maxHr?.toInt(),
  281 + minBpm: data.minHr?.toInt(),
282 ); 282 );
283 } 283 }
284 284
@@ -317,9 +317,9 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -317,9 +317,9 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
317 trends[date], 317 trends[date],
318 asleepTimes[date], 318 asleepTimes[date],
319 _sleepPoints(date, asleepTimes[date], pointsByDate), 319 _sleepPoints(date, asleepTimes[date], pointsByDate),
320 - averageBpm: isSingleDayResponse ? data.avgHr?.round() : null,  
321 - maxBpm: isSingleDayResponse ? data.maxHr?.round() : null,  
322 - minBpm: isSingleDayResponse ? data.minHr?.round() : null, 320 + averageBpm: isSingleDayResponse ? data.avgHr?.toInt() : null,
  321 + maxBpm: isSingleDayResponse ? data.maxHr?.toInt() : null,
  322 + minBpm: isSingleDayResponse ? data.minHr?.toInt() : null,
323 ), 323 ),
324 }; 324 };
325 } 325 }
@@ -351,13 +351,13 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -351,13 +351,13 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
351 final durationSeconds = trend?.totalTime; 351 final durationSeconds = trend?.totalTime;
352 final sleepEnd = asleepTime == null || durationSeconds == null 352 final sleepEnd = asleepTime == null || durationSeconds == null
353 ? null 353 ? null
354 - : asleepTime.add(Duration(seconds: durationSeconds.round())); 354 + : asleepTime.add(Duration(seconds: durationSeconds.toInt()));
355 return SleepReport( 355 return SleepReport(
356 date: date, 356 date: date,
357 duration: durationSeconds == null 357 duration: durationSeconds == null
358 ? null 358 ? null
359 - : SleepDuration(minutes: (durationSeconds / 60).round()),  
360 - qualityScore: trend?.score?.round(), 359 + : SleepDuration(minutes: durationSeconds ~/ 60),
  360 + qualityScore: trend?.score?.toInt(),
361 heartRate: _heartRateSummary( 361 heartRate: _heartRateSummary(
362 points, 362 points,
363 asleepTime, 363 asleepTime,
@@ -395,10 +395,10 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -395,10 +395,10 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
395 if (point.bpm < minPoint.bpm) minPoint = point; 395 if (point.bpm < minPoint.bpm) minPoint = point;
396 } 396 }
397 return SleepHeartRateSummary( 397 return SleepHeartRateSummary(
398 - averageBpm: averageBpm ?? (total / points.length).round(),  
399 - maxBpm: maxBpm ?? maxPoint.bpm.round(), 398 + averageBpm: averageBpm ?? (total / points.length).toInt(),
  399 + maxBpm: maxBpm ?? maxPoint.bpm.toInt(),
400 maxAt: maxPoint.time, 400 maxAt: maxPoint.time,
401 - minBpm: minBpm ?? minPoint.bpm.round(), 401 + minBpm: minBpm ?? minPoint.bpm.toInt(),
402 minAt: minPoint.time, 402 minAt: minPoint.time,
403 sleepStart: sleepStart ?? points.first.time, 403 sleepStart: sleepStart ?? points.first.time,
404 sleepEnd: sleepEnd ?? points.last.time, 404 sleepEnd: sleepEnd ?? points.last.time,
@@ -419,7 +419,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { @@ -419,7 +419,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource {
419 return DateTime.fromMillisecondsSinceEpoch(milliseconds); 419 return DateTime.fromMillisecondsSinceEpoch(milliseconds);
420 } 420 }
421 if (fallbackDate != null && value >= 0 && value < 86400) { 421 if (fallbackDate != null && value >= 0 && value < 86400) {
422 - return fallbackDate.add(Duration(seconds: value.round())); 422 + return fallbackDate.add(Duration(seconds: value.toInt()));
423 } 423 }
424 } 424 }
425 final raw = value?.toString(); 425 final raw = value?.toString();
@@ -658,10 +658,10 @@ SleepHeartRateSummary _buildHeartRateSummary({ @@ -658,10 +658,10 @@ SleepHeartRateSummary _buildHeartRateSummary({
658 } 658 }
659 659
660 return SleepHeartRateSummary( 660 return SleepHeartRateSummary(
661 - averageBpm: average.round(),  
662 - maxBpm: maxPoint.bpm.round(), 661 + averageBpm: average.toInt(),
  662 + maxBpm: maxPoint.bpm.toInt(),
663 maxAt: maxPoint.time, 663 maxAt: maxPoint.time,
664 - minBpm: minPoint.bpm.round(), 664 + minBpm: minPoint.bpm.toInt(),
665 minAt: minPoint.time, 665 minAt: minPoint.time,
666 sleepStart: sleepStart, 666 sleepStart: sleepStart,
667 sleepEnd: sleepEnd, 667 sleepEnd: sleepEnd,
@@ -166,11 +166,17 @@ class SleepReport { @@ -166,11 +166,17 @@ class SleepReport {
166 this.heartRate = const SleepHeartRateSummary(), 166 this.heartRate = const SleepHeartRateSummary(),
167 }); 167 });
168 168
169 - bool get hasData =>  
170 - duration != null || qualityScore != null || heartRate.hasData; 169 + bool get hasData => hasSleepData;
  170 +
  171 + bool get hasSleepData => (duration?.minutes ?? 0) > 0;
  172 +
  173 + int? get validQualityScore => hasSleepData ? qualityScore : null;
  174 +
  175 + SleepHeartRateSummary get validHeartRate =>
  176 + hasSleepData ? heartRate : const SleepHeartRateSummary();
171 177
172 SleepQualityLevel get qualityLevel => 178 SleepQualityLevel get qualityLevel =>
173 - sleepQualityLevelFromScore(qualityScore); 179 + sleepQualityLevelFromScore(validQualityScore);
174 180
175 factory SleepReport.empty(DateTime date) { 181 factory SleepReport.empty(DateTime date) {
176 return SleepReport(date: date); 182 return SleepReport(date: date);
@@ -232,14 +238,12 @@ class WeeklySleepReport { @@ -232,14 +238,12 @@ class WeeklySleepReport {
232 days.where((report) => report.hasData).toList(); 238 days.where((report) => report.hasData).toList();
233 239
234 bool get hasData => 240 bool get hasData =>
235 - daysWithData.isNotEmpty ||  
236 - averageDurationSeconds != null ||  
237 - averageScore != null; 241 + daysWithData.isNotEmpty || _hasPositiveSeconds(averageDurationSeconds);
238 242
239 int? get averageDurationMinutes { 243 int? get averageDurationMinutes {
240 final averageSeconds = _computedAverageDurationSeconds; 244 final averageSeconds = _computedAverageDurationSeconds;
241 if (averageSeconds == null) return null; 245 if (averageSeconds == null) return null;
242 - return (averageSeconds / 60).round(); 246 + return averageSeconds ~/ 60;
243 } 247 }
244 248
245 num? get _computedAverageDurationSeconds { 249 num? get _computedAverageDurationSeconds {
@@ -249,22 +253,26 @@ class WeeklySleepReport { @@ -249,22 +253,26 @@ class WeeklySleepReport {
249 .where((minutes) => minutes > 0) 253 .where((minutes) => minutes > 0)
250 .toList(); 254 .toList();
251 if (values.isEmpty) { 255 if (values.isEmpty) {
252 - return averageDurationSeconds; 256 + return _hasPositiveSeconds(averageDurationSeconds)
  257 + ? averageDurationSeconds
  258 + : null;
253 } 259 }
254 return values.reduce((sum, minutes) => sum + minutes) * 60 / values.length; 260 return values.reduce((sum, minutes) => sum + minutes) * 60 / values.length;
255 } 261 }
256 262
257 int? get averageQualityScore { 263 int? get averageQualityScore {
258 final average = _computedAverageScore; 264 final average = _computedAverageScore;
259 - return average?.round(); 265 + return average?.toInt();
260 } 266 }
261 267
262 num? get _computedAverageScore { 268 num? get _computedAverageScore {
263 final values = daysWithData 269 final values = daysWithData
264 - .map((report) => report.qualityScore) 270 + .map((report) => report.validQualityScore)
265 .whereType<int>() 271 .whereType<int>()
266 .toList(); 272 .toList();
267 - if (values.isEmpty) return averageScore; 273 + if (values.isEmpty) {
  274 + return _hasPositiveSeconds(averageDurationSeconds) ? averageScore : null;
  275 + }
268 return values.reduce((sum, score) => sum + score) / values.length; 276 return values.reduce((sum, score) => sum + score) / values.length;
269 } 277 }
270 278
@@ -281,7 +289,7 @@ class WeeklySleepReport { @@ -281,7 +289,7 @@ class WeeklySleepReport {
281 289
282 SleepHeartRateSummary get heartRate { 290 SleepHeartRateSummary get heartRate {
283 final dailySummaries = daysWithData 291 final dailySummaries = daysWithData
284 - .map((report) => report.heartRate) 292 + .map((report) => report.validHeartRate)
285 .where((summary) => summary.hasData) 293 .where((summary) => summary.hasData)
286 .toList(); 294 .toList();
287 if (dailySummaries.isEmpty) return const SleepHeartRateSummary(); 295 if (dailySummaries.isEmpty) return const SleepHeartRateSummary();
@@ -294,7 +302,7 @@ class WeeklySleepReport { @@ -294,7 +302,7 @@ class WeeklySleepReport {
294 final average = averageValues.isEmpty 302 final average = averageValues.isEmpty
295 ? null 303 ? null
296 : (averageValues.reduce((sum, bpm) => sum + bpm) / averageValues.length) 304 : (averageValues.reduce((sum, bpm) => sum + bpm) / averageValues.length)
297 - .round(); 305 + .toInt();
298 306
299 SleepHeartRatePoint? maxPoint; 307 SleepHeartRatePoint? maxPoint;
300 SleepHeartRatePoint? minPoint; 308 SleepHeartRatePoint? minPoint;
@@ -305,9 +313,9 @@ class WeeklySleepReport { @@ -305,9 +313,9 @@ class WeeklySleepReport {
305 313
306 return SleepHeartRateSummary( 314 return SleepHeartRateSummary(
307 averageBpm: average, 315 averageBpm: average,
308 - maxBpm: maxPoint?.bpm.round(), 316 + maxBpm: maxPoint?.bpm.toInt(),
309 maxAt: maxPoint?.time, 317 maxAt: maxPoint?.time,
310 - minBpm: minPoint?.bpm.round(), 318 + minBpm: minPoint?.bpm.toInt(),
311 minAt: minPoint?.time, 319 minAt: minPoint?.time,
312 sleepStart: points.isEmpty ? null : points.first.time, 320 sleepStart: points.isEmpty ? null : points.first.time,
313 sleepEnd: points.isEmpty ? null : points.last.time, 321 sleepEnd: points.isEmpty ? null : points.last.time,
@@ -360,14 +368,12 @@ class MonthlySleepReport { @@ -360,14 +368,12 @@ class MonthlySleepReport {
360 days.where((report) => report.hasData).toList(); 368 days.where((report) => report.hasData).toList();
361 369
362 bool get hasData => 370 bool get hasData =>
363 - daysWithData.isNotEmpty ||  
364 - averageDurationSeconds != null ||  
365 - averageScore != null; 371 + daysWithData.isNotEmpty || _hasPositiveSeconds(averageDurationSeconds);
366 372
367 int? get averageDurationMinutes { 373 int? get averageDurationMinutes {
368 final averageSeconds = _computedAverageDurationSeconds; 374 final averageSeconds = _computedAverageDurationSeconds;
369 if (averageSeconds == null) return null; 375 if (averageSeconds == null) return null;
370 - return (averageSeconds / 60).round(); 376 + return averageSeconds ~/ 60;
371 } 377 }
372 378
373 num? get _computedAverageDurationSeconds { 379 num? get _computedAverageDurationSeconds {
@@ -377,22 +383,26 @@ class MonthlySleepReport { @@ -377,22 +383,26 @@ class MonthlySleepReport {
377 .where((minutes) => minutes > 0) 383 .where((minutes) => minutes > 0)
378 .toList(); 384 .toList();
379 if (values.isEmpty) { 385 if (values.isEmpty) {
380 - return averageDurationSeconds; 386 + return _hasPositiveSeconds(averageDurationSeconds)
  387 + ? averageDurationSeconds
  388 + : null;
381 } 389 }
382 return values.reduce((sum, minutes) => sum + minutes) * 60 / values.length; 390 return values.reduce((sum, minutes) => sum + minutes) * 60 / values.length;
383 } 391 }
384 392
385 int? get averageQualityScore { 393 int? get averageQualityScore {
386 final average = _computedAverageScore; 394 final average = _computedAverageScore;
387 - return average?.round(); 395 + return average?.toInt();
388 } 396 }
389 397
390 num? get _computedAverageScore { 398 num? get _computedAverageScore {
391 final values = daysWithData 399 final values = daysWithData
392 - .map((report) => report.qualityScore) 400 + .map((report) => report.validQualityScore)
393 .whereType<int>() 401 .whereType<int>()
394 .toList(); 402 .toList();
395 - if (values.isEmpty) return averageScore; 403 + if (values.isEmpty) {
  404 + return _hasPositiveSeconds(averageDurationSeconds) ? averageScore : null;
  405 + }
396 return values.reduce((sum, score) => sum + score) / values.length; 406 return values.reduce((sum, score) => sum + score) / values.length;
397 } 407 }
398 408
@@ -425,3 +435,5 @@ double? _comparisonPercent(num? current, num? previous) { @@ -425,3 +435,5 @@ double? _comparisonPercent(num? current, num? previous) {
425 if (current == null || previous == null || previous == 0) return null; 435 if (current == null || previous == null || previous == 0) return null;
426 return (current - previous) / previous * 100; 436 return (current - previous) / previous * 100;
427 } 437 }
  438 +
  439 +bool _hasPositiveSeconds(num? seconds) => seconds != null && seconds > 0;
@@ -134,6 +134,7 @@ class _DailyReportContent extends StatelessWidget { @@ -134,6 +134,7 @@ class _DailyReportContent extends StatelessWidget {
134 SleepHeartRateCard( 134 SleepHeartRateCard(
135 date: report?.date, 135 date: report?.date,
136 summary: report?.heartRate ?? const SleepHeartRateSummary(), 136 summary: report?.heartRate ?? const SleepHeartRateSummary(),
  137 + hasSleepData: (report?.duration?.minutes ?? 0) > 0,
137 ), 138 ),
138 ], 139 ],
139 ); 140 );
@@ -11,10 +11,12 @@ class SleepHeartRateCard extends StatelessWidget { @@ -11,10 +11,12 @@ class SleepHeartRateCard extends StatelessWidget {
11 super.key, 11 super.key,
12 this.date, 12 this.date,
13 required this.summary, 13 required this.summary,
  14 + this.hasSleepData = true,
14 }); 15 });
15 16
16 final DateTime? date; 17 final DateTime? date;
17 final SleepHeartRateSummary summary; 18 final SleepHeartRateSummary summary;
  19 + final bool hasSleepData;
18 20
19 static const _h1 = Color(0xFF0F0F11); 21 static const _h1 = Color(0xFF0F0F11);
20 static const _h3 = Color(0xFFB0B0B6); 22 static const _h3 = Color(0xFFB0B0B6);
@@ -46,14 +48,16 @@ class SleepHeartRateCard extends StatelessWidget { @@ -46,14 +48,16 @@ class SleepHeartRateCard extends StatelessWidget {
46 ), 48 ),
47 ), 49 ),
48 const SizedBox(height: 24), 50 const SizedBox(height: 24),
49 - _MetricRow(summary: summary), 51 + _MetricRow(
  52 + summary: hasSleepData ? summary : const SleepHeartRateSummary(),
  53 + ),
50 const SizedBox(height: 18), 54 const SizedBox(height: 18),
51 Expanded( 55 Expanded(
52 child: Builder( 56 child: Builder(
53 builder: (context) { 57 builder: (context) {
54 final xAxis = _heartRateXAxis; 58 final xAxis = _heartRateXAxis;
55 final chartPoints = _chartPoints(xAxis); 59 final chartPoints = _chartPoints(xAxis);
56 - final hasChartData = chartPoints.isNotEmpty; 60 + final hasChartData = hasSleepData && chartPoints.isNotEmpty;
57 61
58 return Stack( 62 return Stack(
59 alignment: Alignment.center, 63 alignment: Alignment.center,
@@ -22,7 +22,7 @@ class _SleepQualityRingState extends State<SleepQualityRing> @@ -22,7 +22,7 @@ class _SleepQualityRingState extends State<SleepQualityRing>
22 late Animation<double> _progress; 22 late Animation<double> _progress;
23 23
24 double get _targetProgress => 24 double get _targetProgress =>
25 - ((widget.report?.qualityScore ?? 0).clamp(0, 100) / 100).toDouble(); 25 + ((widget.report?.validQualityScore ?? 0).clamp(0, 100) / 100).toDouble();
26 26
27 @override 27 @override
28 void initState() { 28 void initState() {
@@ -38,7 +38,8 @@ class _SleepQualityRingState extends State<SleepQualityRing> @@ -38,7 +38,8 @@ class _SleepQualityRingState extends State<SleepQualityRing>
38 void didUpdateWidget(covariant SleepQualityRing oldWidget) { 38 void didUpdateWidget(covariant SleepQualityRing oldWidget) {
39 super.didUpdateWidget(oldWidget); 39 super.didUpdateWidget(oldWidget);
40 final oldProgress = 40 final oldProgress =
41 - ((oldWidget.report?.qualityScore ?? 0).clamp(0, 100) / 100).toDouble(); 41 + ((oldWidget.report?.validQualityScore ?? 0).clamp(0, 100) / 100)
  42 + .toDouble();
42 if (oldProgress != _targetProgress) { 43 if (oldProgress != _targetProgress) {
43 _animateTo(_targetProgress, from: _progress.value); 44 _animateTo(_targetProgress, from: _progress.value);
44 } 45 }
@@ -67,7 +68,7 @@ class _SleepQualityRingState extends State<SleepQualityRing> @@ -67,7 +68,7 @@ class _SleepQualityRingState extends State<SleepQualityRing>
67 builder: (context, child) => CustomPaint( 68 builder: (context, child) => CustomPaint(
68 painter: _SleepQualityRingPainter( 69 painter: _SleepQualityRingPainter(
69 progress: _progress.value, 70 progress: _progress.value,
70 - hasData: widget.report?.qualityScore != null, 71 + hasData: widget.report?.validQualityScore != null,
71 ), 72 ),
72 ), 73 ),
73 ), 74 ),
@@ -20,8 +20,8 @@ class SleepSummaryCard extends StatelessWidget { @@ -20,8 +20,8 @@ class SleepSummaryCard extends StatelessWidget {
20 20
21 @override 21 @override
22 Widget build(BuildContext context) { 22 Widget build(BuildContext context) {
23 - final duration = report?.duration;  
24 - final score = report?.qualityScore; 23 + final duration = report?.hasSleepData == true ? report?.duration : null;
  24 + final score = report?.validQualityScore;
25 final quality = report?.qualityLevel ?? SleepQualityLevel.unknown; 25 final quality = report?.qualityLevel ?? SleepQualityLevel.unknown;
26 26
27 return Container( 27 return Container(
@@ -147,7 +147,7 @@ class _DurationValue extends StatelessWidget { @@ -147,7 +147,7 @@ class _DurationValue extends StatelessWidget {
147 147
148 @override 148 @override
149 Widget build(BuildContext context) { 149 Widget build(BuildContext context) {
150 - final hasData = duration != null; 150 + final hasData = (duration?.minutes ?? 0) > 0;
151 151
152 return Row( 152 return Row(
153 crossAxisAlignment: CrossAxisAlignment.end, 153 crossAxisAlignment: CrossAxisAlignment.end,
@@ -29,7 +29,8 @@ class SleepWeekReportView extends StatelessWidget { @@ -29,7 +29,8 @@ class SleepWeekReportView extends StatelessWidget {
29 29
30 static TextStyle get titleTextStyle => SleepReportTextStyles.titleTextStyle; 30 static TextStyle get titleTextStyle => SleepReportTextStyles.titleTextStyle;
31 31
32 - static StrutStyle get titleStrutStyle => SleepReportTextStyles.titleStrutStyle; 32 + static StrutStyle get titleStrutStyle =>
  33 + SleepReportTextStyles.titleStrutStyle;
33 34
34 static int minutesFromEvening(DateTime time) { 35 static int minutesFromEvening(DateTime time) {
35 final minutes = time.hour * 60 + time.minute; 36 final minutes = time.hour * 60 + time.minute;
@@ -202,7 +203,7 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -202,7 +203,7 @@ class _SleepPeriodTrendContent extends StatelessWidget {
202 List<SleepReport> days, { 203 List<SleepReport> days, {
203 required bool longest, 204 required bool longest,
204 }) { 205 }) {
205 - final values = days.where((day) => day.duration != null); 206 + final values = days.where((day) => day.hasSleepData);
206 if (values.isEmpty) return null; 207 if (values.isEmpty) return null;
207 return values.reduce((selected, day) { 208 return values.reduce((selected, day) {
208 final selectedValue = selected.duration!.minutes; 209 final selectedValue = selected.duration!.minutes;
@@ -216,11 +217,11 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -216,11 +217,11 @@ class _SleepPeriodTrendContent extends StatelessWidget {
216 List<SleepReport> days, { 217 List<SleepReport> days, {
217 required bool best, 218 required bool best,
218 }) { 219 }) {
219 - final values = days.where((day) => day.qualityScore != null); 220 + final values = days.where((day) => day.validQualityScore != null);
220 if (values.isEmpty) return null; 221 if (values.isEmpty) return null;
221 return values.reduce((selected, day) { 222 return values.reduce((selected, day) {
222 - final selectedValue = selected.qualityScore!;  
223 - final dayValue = day.qualityScore!; 223 + final selectedValue = selected.validQualityScore!;
  224 + final dayValue = day.validQualityScore!;
224 if (best) return dayValue > selectedValue ? day : selected; 225 if (best) return dayValue > selectedValue ? day : selected;
225 return dayValue < selectedValue ? day : selected; 226 return dayValue < selectedValue ? day : selected;
226 }); 227 });
@@ -230,7 +231,9 @@ class _SleepPeriodTrendContent extends StatelessWidget { @@ -230,7 +231,9 @@ class _SleepPeriodTrendContent extends StatelessWidget {
230 List<SleepReport> days, { 231 List<SleepReport> days, {
231 required bool earliest, 232 required bool earliest,
232 }) { 233 }) {
233 - final values = days.where((day) => day.heartRate.sleepStart != null); 234 + final values = days.where(
  235 + (day) => day.hasSleepData && day.heartRate.sleepStart != null,
  236 + );
234 if (values.isEmpty) return null; 237 if (values.isEmpty) return null;
235 return values.reduce((selected, day) { 238 return values.reduce((selected, day) {
236 final selectedValue = SleepWeekReportView.minutesFromEvening( 239 final selectedValue = SleepWeekReportView.minutesFromEvening(
@@ -325,7 +328,7 @@ class _TopSummary extends StatelessWidget { @@ -325,7 +328,7 @@ class _TopSummary extends StatelessWidget {
325 328
326 String _formatComparison(double? value) { 329 String _formatComparison(double? value) {
327 if (!hasData || value == null) return '-'; 330 if (!hasData || value == null) return '-';
328 - return value.abs().round().toString(); 331 + return value.abs().toInt().toString();
329 } 332 }
330 } 333 }
331 334
@@ -466,7 +469,7 @@ class _DurationChartState extends State<_DurationChart> { @@ -466,7 +469,7 @@ class _DurationChartState extends State<_DurationChart> {
466 final values = <double>[ 469 final values = <double>[
467 8, 470 8,
468 for (final day in widget.days) 471 for (final day in widget.days)
469 - if (day.duration != null) day.duration!.minutes / 60, 472 + if (day.hasSleepData) day.duration!.minutes / 60,
470 if (widget.averageDurationMinutes != null) 473 if (widget.averageDurationMinutes != null)
471 widget.averageDurationMinutes! / 60, 474 widget.averageDurationMinutes! / 60,
472 if (_targetDurationHours case final target?) target, 475 if (_targetDurationHours case final target?) target,
@@ -477,7 +480,7 @@ class _DurationChartState extends State<_DurationChart> { @@ -477,7 +480,7 @@ class _DurationChartState extends State<_DurationChart> {
477 480
478 @override 481 @override
479 Widget build(BuildContext context) { 482 Widget build(BuildContext context) {
480 - final hasData = widget.days.any((day) => day.duration != null); 483 + final hasData = widget.days.any((day) => day.hasSleepData);
481 return Column( 484 return Column(
482 children: [ 485 children: [
483 _ChartLegend( 486 _ChartLegend(
@@ -540,7 +543,7 @@ class _DurationChartState extends State<_DurationChart> { @@ -540,7 +543,7 @@ class _DurationChartState extends State<_DurationChart> {
540 barTouchData: _barTouchData( 543 barTouchData: _barTouchData(
541 days: widget.days, 544 days: widget.days,
542 dataBuilder: _ChartSummaryData.duration, 545 dataBuilder: _ChartSummaryData.duration,
543 - hasData: (report) => report.duration != null, 546 + hasData: (report) => report.hasSleepData,
544 onSelected: (_, offset) => setState(() => _selectedOffset = offset), 547 onSelected: (_, offset) => setState(() => _selectedOffset = offset),
545 ), 548 ),
546 gridData: const FlGridData(show: false), 549 gridData: const FlGridData(show: false),
@@ -590,7 +593,7 @@ class _QualityChartState extends State<_QualityChart> { @@ -590,7 +593,7 @@ class _QualityChartState extends State<_QualityChart> {
590 593
591 @override 594 @override
592 Widget build(BuildContext context) { 595 Widget build(BuildContext context) {
593 - final hasData = widget.days.any((day) => day.qualityScore != null); 596 + final hasData = widget.days.any((day) => day.validQualityScore != null);
594 return Column( 597 return Column(
595 children: [ 598 children: [
596 const _ChartLegend( 599 const _ChartLegend(
@@ -646,7 +649,7 @@ class _QualityChartState extends State<_QualityChart> { @@ -646,7 +649,7 @@ class _QualityChartState extends State<_QualityChart> {
646 barTouchData: _barTouchData( 649 barTouchData: _barTouchData(
647 days: widget.days, 650 days: widget.days,
648 dataBuilder: _ChartSummaryData.quality, 651 dataBuilder: _ChartSummaryData.quality,
649 - hasData: (report) => report.qualityScore != null, 652 + hasData: (report) => report.validQualityScore != null,
650 onSelected: (_, offset) => setState(() => _selectedOffset = offset), 653 onSelected: (_, offset) => setState(() => _selectedOffset = offset),
651 ), 654 ),
652 gridData: const FlGridData(show: false), 655 gridData: const FlGridData(show: false),
@@ -661,7 +664,7 @@ class _QualityChartState extends State<_QualityChart> { @@ -661,7 +664,7 @@ class _QualityChartState extends State<_QualityChart> {
661 x: i, 664 x: i,
662 barRods: [ 665 barRods: [
663 BarChartRodData( 666 BarChartRodData(
664 - toY: (widget.days[i].qualityScore ?? 0).toDouble(), 667 + toY: (widget.days[i].validQualityScore ?? 0).toDouble(),
665 width: _barWidth(widget.compact), 668 width: _barWidth(widget.compact),
666 color: SleepWeekReportView.blue, 669 color: SleepWeekReportView.blue,
667 borderRadius: const BorderRadius.vertical( 670 borderRadius: const BorderRadius.vertical(
@@ -686,7 +689,9 @@ class _BedtimeChart extends StatelessWidget { @@ -686,7 +689,9 @@ class _BedtimeChart extends StatelessWidget {
686 689
687 @override 690 @override
688 Widget build(BuildContext context) { 691 Widget build(BuildContext context) {
689 - final hasData = days.any((day) => day.heartRate.sleepStart != null); 692 + final hasData = days.any(
  693 + (day) => day.hasSleepData && day.heartRate.sleepStart != null,
  694 + );
690 final axis = _BedtimeAxis.fromReports(days); 695 final axis = _BedtimeAxis.fromReports(days);
691 return Stack( 696 return Stack(
692 alignment: Alignment.center, 697 alignment: Alignment.center,
@@ -706,7 +711,7 @@ class _BedtimeChart extends StatelessWidget { @@ -706,7 +711,7 @@ class _BedtimeChart extends StatelessWidget {
706 LineChartData _chartData(_BedtimeAxis axis) { 711 LineChartData _chartData(_BedtimeAxis axis) {
707 final points = <FlSpot>[ 712 final points = <FlSpot>[
708 for (var i = 0; i < days.length; i++) 713 for (var i = 0; i < days.length; i++)
709 - if (days[i].heartRate.sleepStart != null) 714 + if (days[i].hasSleepData && days[i].heartRate.sleepStart != null)
710 FlSpot( 715 FlSpot(
711 i.toDouble(), 716 i.toDouble(),
712 _bedtimeY(days[i].heartRate.sleepStart!), 717 _bedtimeY(days[i].heartRate.sleepStart!),
@@ -758,7 +763,7 @@ class _BedtimeChart extends StatelessWidget { @@ -758,7 +763,7 @@ class _BedtimeChart extends StatelessWidget {
758 } 763 }
759 764
760 String _timeAxisLabel(double value) { 765 String _timeAxisLabel(double value) {
761 - final hour = value.round() % 24; 766 + final hour = value.toInt() % 24;
762 return '${hour.toString().padLeft(2, '0')}:00'; 767 return '${hour.toString().padLeft(2, '0')}:00';
763 } 768 }
764 } 769 }
@@ -783,6 +788,7 @@ class _BedtimeAxis { @@ -783,6 +788,7 @@ class _BedtimeAxis {
783 788
784 factory _BedtimeAxis.fromReports(List<SleepReport> days) { 789 factory _BedtimeAxis.fromReports(List<SleepReport> days) {
785 final values = days 790 final values = days
  791 + .where((day) => day.hasSleepData)
786 .map((day) => day.heartRate.sleepStart) 792 .map((day) => day.heartRate.sleepStart)
787 .whereType<DateTime>() 793 .whereType<DateTime>()
788 .map(SleepWeekReportView.minutesFromEvening) 794 .map(SleepWeekReportView.minutesFromEvening)
@@ -945,10 +951,12 @@ LineTouchTooltipData _lineTooltipData({ @@ -945,10 +951,12 @@ LineTouchTooltipData _lineTooltipData({
945 fitInsideVertically: false, 951 fitInsideVertically: false,
946 getTooltipItems: (spots) { 952 getTooltipItems: (spots) {
947 return spots.map((spot) { 953 return spots.map((spot) {
948 - final index = spot.x.round(); 954 + final index = spot.x.toInt();
949 if (index < 0 || index >= days.length) return null; 955 if (index < 0 || index >= days.length) return null;
950 final report = days[index]; 956 final report = days[index];
951 - if (report.heartRate.sleepStart == null) return null; 957 + if (!report.hasSleepData || report.heartRate.sleepStart == null) {
  958 + return null;
  959 + }
952 return _lineTooltipItem(dataBuilder(report)); 960 return _lineTooltipItem(dataBuilder(report));
953 }).toList(); 961 }).toList();
954 }, 962 },
@@ -1016,7 +1024,7 @@ class _ChartSummaryData { @@ -1016,7 +1024,7 @@ class _ChartSummaryData {
1016 } 1024 }
1017 1025
1018 factory _ChartSummaryData.quality(SleepReport report) { 1026 factory _ChartSummaryData.quality(SleepReport report) {
1019 - final score = report.qualityScore!; 1027 + final score = report.validQualityScore!;
1020 return _ChartSummaryData( 1028 return _ChartSummaryData(
1021 title: l10n.sleepQualityScore( 1029 title: l10n.sleepQualityScore(
1022 report.qualityLevel.shortText, 1030 report.qualityLevel.shortText,
@@ -1244,7 +1252,7 @@ class _HorizontalDashedGridPainter extends CustomPainter { @@ -1244,7 +1252,7 @@ class _HorizontalDashedGridPainter extends CustomPainter {
1244 final paint = Paint() 1252 final paint = Paint()
1245 ..color = SleepWeekReportView.grid 1253 ..color = SleepWeekReportView.grid
1246 ..strokeWidth = 1; 1254 ..strokeWidth = 1;
1247 - final steps = ((maxY - minY) / interval).round(); 1255 + final steps = ((maxY - minY) / interval).ceil();
1248 for (var index = 0; index <= steps; index++) { 1256 for (var index = 0; index <= steps; index++) {
1249 final y = size.height * (1 - index / steps); 1257 final y = size.height * (1 - index / steps);
1250 for (var x = 0.0; x < size.width; x += 4) { 1258 for (var x = 0.0; x < size.width; x += 4) {
@@ -1323,7 +1331,7 @@ class _RightAxisLabels extends StatelessWidget { @@ -1323,7 +1331,7 @@ class _RightAxisLabels extends StatelessWidget {
1323 return IgnorePointer( 1331 return IgnorePointer(
1324 child: LayoutBuilder( 1332 child: LayoutBuilder(
1325 builder: (context, constraints) { 1333 builder: (context, constraints) {
1326 - final steps = ((maxY - minY) / interval).round(); 1334 + final steps = ((maxY - minY) / interval).ceil();
1327 return Stack( 1335 return Stack(
1328 clipBehavior: Clip.none, 1336 clipBehavior: Clip.none,
1329 children: [ 1337 children: [
@@ -1454,10 +1462,11 @@ class _ExtremeMetric extends StatelessWidget { @@ -1454,10 +1462,11 @@ class _ExtremeMetric extends StatelessWidget {
1454 required Color color, 1462 required Color color,
1455 required SleepReport? report, 1463 required SleepReport? report,
1456 }) { 1464 }) {
  1465 + final score = report?.validQualityScore;
1457 return _ExtremeMetric( 1466 return _ExtremeMetric(
1458 title: title, 1467 title: title,
1459 color: color, 1468 color: color,
1460 - value: report?.qualityScore == null ? '-' : '${report!.qualityScore}', 1469 + value: score == null ? '-' : '$score',
1461 unit: l10n.reportUnitScore, 1470 unit: l10n.reportUnitScore,
1462 date: report?.date, 1471 date: report?.date,
1463 ); 1472 );
@@ -1468,7 +1477,8 @@ class _ExtremeMetric extends StatelessWidget { @@ -1468,7 +1477,8 @@ class _ExtremeMetric extends StatelessWidget {
1468 required Color color, 1477 required Color color,
1469 required SleepReport? report, 1478 required SleepReport? report,
1470 }) { 1479 }) {
1471 - final start = report?.heartRate.sleepStart; 1480 + final start =
  1481 + report?.hasSleepData == true ? report?.heartRate.sleepStart : null;
1472 return _ExtremeMetric( 1482 return _ExtremeMetric(
1473 title: title, 1483 title: title,
1474 color: color, 1484 color: color,