Commit 21c67bdd2ef5f595b488918ffc3b7b8a4dda7e60

Authored by 刘宏哲
1 parent 9724003b

feat(app): bug fixed

@@ -13,6 +13,9 @@ import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; @@ -13,6 +13,9 @@ import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
13 class LocalHealthDataConvert { 13 class LocalHealthDataConvert {
14 const LocalHealthDataConvert._(); 14 const LocalHealthDataConvert._();
15 15
  16 + static const _sleepDayCutoffMinutes = 12 * 60;
  17 + static const _minutesPerDay = 24 * 60;
  18 +
16 static List<DateTime> rangeDays(int dateRangeType, int startDate) { 19 static List<DateTime> rangeDays(int dateRangeType, int startDate) {
17 final start = dateFromKey(startDate); 20 final start = dateFromKey(startDate);
18 final count = switch (dateRangeType) { 21 final count = switch (dateRangeType) {
@@ -120,8 +123,14 @@ class LocalHealthDataConvert { @@ -120,8 +123,14 @@ class LocalHealthDataConvert {
120 ], 123 ],
121 bestSleepInfo: _bestSleepInfo(validSleep), 124 bestSleepInfo: _bestSleepInfo(validSleep),
122 worstSleepInfo: _worstSleepInfo(validSleep), 125 worstSleepInfo: _worstSleepInfo(validSleep),
123 - earliestSleepInfo: _earliestSleepInfo(validSleep),  
124 - latestSleepInfo: _latestSleepInfo(validSleep), 126 + earliestSleepInfo: _earliestSleepInfo(
  127 + validSleep,
  128 + allowedSleepDays: days,
  129 + ),
  130 + latestSleepInfo: _latestSleepInfo(
  131 + validSleep,
  132 + allowedSleepDays: days,
  133 + ),
125 hrList: shouldIncludeHeartRate 134 hrList: shouldIncludeHeartRate
126 ? [ 135 ? [
127 // SleepHrItem(time: 1784091398, value: 63), 136 // SleepHrItem(time: 1784091398, value: 63),
@@ -208,8 +217,14 @@ class LocalHealthDataConvert { @@ -208,8 +217,14 @@ class LocalHealthDataConvert {
208 ], 217 ],
209 bestSleepInfo: _bestSleepResultInfo(validSleep, best: true), 218 bestSleepInfo: _bestSleepResultInfo(validSleep, best: true),
210 worstSleepInfo: _worstSleepResultInfo(validSleep), 219 worstSleepInfo: _worstSleepResultInfo(validSleep),
211 - earliestSleepInfo: _earliestSleepResultInfo(validSleep),  
212 - latestSleepInfo: _latestSleepResultInfo(validSleep), 220 + earliestSleepInfo: _earliestSleepResultInfo(
  221 + sleepResults,
  222 + allowedSleepDays: days,
  223 + ),
  224 + latestSleepInfo: _latestSleepResultInfo(
  225 + sleepResults,
  226 + allowedSleepDays: days,
  227 + ),
213 hrList: shouldIncludeHeartRate 228 hrList: shouldIncludeHeartRate
214 ? [ 229 ? [
215 for (final point in allSleepHr) 230 for (final point in allSleepHr)
@@ -827,16 +842,23 @@ class LocalHealthDataConvert { @@ -827,16 +842,23 @@ class LocalHealthDataConvert {
827 } 842 }
828 843
829 static EarliestSleepInfo? _earliestSleepInfo( 844 static EarliestSleepInfo? _earliestSleepInfo(
830 - List<HealthSleepDayCalculation> daily,  
831 - ) {  
832 - final sorted = daily.where((e) => e.asleepTime != null).toList()  
833 - ..sort((a, b) => a.asleepTime!.compareTo(b.asleepTime!));  
834 - final item = sorted.isEmpty ? null : sorted.first;  
835 - return item == null 845 + List<HealthSleepDayCalculation> daily, {
  846 + required Iterable<DateTime> allowedSleepDays,
  847 + }) {
  848 + final bedtime = _extremeBedtime(
  849 + _bedtimesBySleepDay(
  850 + daily,
  851 + (item) => item.asleepTime,
  852 + earliest: true,
  853 + allowedSleepDays: allowedSleepDays,
  854 + ),
  855 + earliest: true,
  856 + );
  857 + return bedtime == null
836 ? null 858 ? null
837 : EarliestSleepInfo( 859 : EarliestSleepInfo(
838 - timeKey: dateKey(item.day),  
839 - asleepTime: item.asleepTime, 860 + timeKey: dateKey(bedtime.sleepDay),
  861 + asleepTime: bedtime.timestamp,
840 ); 862 );
841 } 863 }
842 864
@@ -878,45 +900,130 @@ class LocalHealthDataConvert { @@ -878,45 +900,130 @@ class LocalHealthDataConvert {
878 } 900 }
879 901
880 static EarliestSleepInfo? _earliestSleepResultInfo( 902 static EarliestSleepInfo? _earliestSleepResultInfo(
881 - List<HealthRawSleepResult> sleepResults,  
882 - ) {  
883 - final sorted = [...sleepResults]  
884 - ..sort((a, b) => a.startDate.compareTo(b.startDate));  
885 - final item = sorted.isEmpty ? null : sorted.first;  
886 - if (item == null) return null; 903 + List<HealthRawSleepResult> sleepResults, {
  904 + required Iterable<DateTime> allowedSleepDays,
  905 + }) {
  906 + final bedtime = _extremeBedtime(
  907 + _bedtimesBySleepDay(
  908 + sleepResults,
  909 + (item) => item.startDate,
  910 + earliest: true,
  911 + allowedSleepDays: allowedSleepDays,
  912 + ),
  913 + earliest: true,
  914 + );
  915 + if (bedtime == null) return null;
887 return EarliestSleepInfo( 916 return EarliestSleepInfo(
888 - timeKey: dateKey(dayOf(item.date)),  
889 - asleepTime: item.startDate, 917 + timeKey: dateKey(bedtime.sleepDay),
  918 + asleepTime: bedtime.timestamp,
890 ); 919 );
891 } 920 }
892 921
893 static LatestSleepInfo? _latestSleepResultInfo( 922 static LatestSleepInfo? _latestSleepResultInfo(
894 - List<HealthRawSleepResult> sleepResults,  
895 - ) {  
896 - final sorted = [...sleepResults]  
897 - ..sort((a, b) => b.startDate.compareTo(a.startDate));  
898 - final item = sorted.isEmpty ? null : sorted.first;  
899 - if (item == null) return null; 923 + List<HealthRawSleepResult> sleepResults, {
  924 + required Iterable<DateTime> allowedSleepDays,
  925 + }) {
  926 + final bedtime = _extremeBedtime(
  927 + _bedtimesBySleepDay(
  928 + sleepResults,
  929 + (item) => item.startDate,
  930 + earliest: false,
  931 + allowedSleepDays: allowedSleepDays,
  932 + ),
  933 + earliest: false,
  934 + );
  935 + if (bedtime == null) return null;
900 return LatestSleepInfo( 936 return LatestSleepInfo(
901 - timeKey: dateKey(dayOf(item.date)),  
902 - asleepTime: item.startDate, 937 + timeKey: dateKey(bedtime.sleepDay),
  938 + asleepTime: bedtime.timestamp,
903 ); 939 );
904 } 940 }
905 941
906 static LatestSleepInfo? _latestSleepInfo( 942 static LatestSleepInfo? _latestSleepInfo(
907 - List<HealthSleepDayCalculation> daily,  
908 - ) {  
909 - final sorted = daily.where((e) => e.asleepTime != null).toList()  
910 - ..sort((a, b) => b.asleepTime!.compareTo(a.asleepTime!));  
911 - final item = sorted.isEmpty ? null : sorted.first;  
912 - return item == null 943 + List<HealthSleepDayCalculation> daily, {
  944 + required Iterable<DateTime> allowedSleepDays,
  945 + }) {
  946 + final bedtime = _extremeBedtime(
  947 + _bedtimesBySleepDay(
  948 + daily,
  949 + (item) => item.asleepTime,
  950 + earliest: false,
  951 + allowedSleepDays: allowedSleepDays,
  952 + ),
  953 + earliest: false,
  954 + );
  955 + return bedtime == null
913 ? null 956 ? null
914 : LatestSleepInfo( 957 : LatestSleepInfo(
915 - timeKey: dateKey(item.day),  
916 - asleepTime: item.asleepTime, 958 + timeKey: dateKey(bedtime.sleepDay),
  959 + asleepTime: bedtime.timestamp,
917 ); 960 );
918 } 961 }
919 962
  963 + static List<_SleepBedtime> _bedtimesBySleepDay<T>(
  964 + Iterable<T> records,
  965 + int? Function(T record) timestampOf, {
  966 + required bool earliest,
  967 + required Iterable<DateTime> allowedSleepDays,
  968 + }) {
  969 + final allowedSleepDayKeys = {
  970 + for (final day in allowedSleepDays) dateKey(day),
  971 + };
  972 + final bedtimesBySleepDay = <int, _SleepBedtime>{};
  973 + for (final record in records) {
  974 + final timestamp = timestampOf(record);
  975 + if (timestamp == null) continue;
  976 + final bedtime = _SleepBedtime(
  977 + timestamp: timestamp,
  978 + sleepDay: _sleepDayOf(timestamp),
  979 + normalizedMinutes: _normalizedBedtimeMinutes(timestamp),
  980 + );
  981 + final key = dateKey(bedtime.sleepDay);
  982 + if (!allowedSleepDayKeys.contains(key)) continue;
  983 + final existing = bedtimesBySleepDay[key];
  984 + if (existing == null ||
  985 + (earliest
  986 + ? bedtime.timestamp < existing.timestamp
  987 + : bedtime.timestamp > existing.timestamp)) {
  988 + bedtimesBySleepDay[key] = bedtime;
  989 + }
  990 + }
  991 + return bedtimesBySleepDay.values.toList();
  992 + }
  993 +
  994 + static _SleepBedtime? _extremeBedtime(
  995 + Iterable<_SleepBedtime> bedtimes, {
  996 + required bool earliest,
  997 + }) {
  998 + _SleepBedtime? selected;
  999 + for (final bedtime in bedtimes) {
  1000 + if (selected == null ||
  1001 + (earliest
  1002 + ? bedtime.normalizedMinutes < selected.normalizedMinutes
  1003 + : bedtime.normalizedMinutes > selected.normalizedMinutes)) {
  1004 + selected = bedtime;
  1005 + }
  1006 + }
  1007 + return selected;
  1008 + }
  1009 +
  1010 + static DateTime _sleepDayOf(int timestamp) {
  1011 + final time = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
  1012 + final day = DateTime(time.year, time.month, time.day);
  1013 + return time.hour * 60 + time.minute < _sleepDayCutoffMinutes
  1014 + ? day.subtract(const Duration(days: 1))
  1015 + : day;
  1016 + }
  1017 +
  1018 + /// Maps 12:00–23:59 to 0–719 and 00:00–11:59 to 720–1439.
  1019 + static int _normalizedBedtimeMinutes(int timestamp) {
  1020 + final time = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000);
  1021 + final minutes = time.hour * 60 + time.minute;
  1022 + return minutes < _sleepDayCutoffMinutes
  1023 + ? minutes + _minutesPerDay - _sleepDayCutoffMinutes
  1024 + : minutes - _sleepDayCutoffMinutes;
  1025 + }
  1026 +
920 static int? _modeState(Iterable<int> states) { 1027 static int? _modeState(Iterable<int> states) {
921 final counts = <int, int>{}; 1028 final counts = <int, int>{};
922 for (final state in states) { 1029 for (final state in states) {
@@ -1057,3 +1164,15 @@ class _HrvDaySummary { @@ -1057,3 +1164,15 @@ class _HrvDaySummary {
1057 1164
1058 bool get hasData => hrvAverage != null || hrAverage != null || state != null; 1165 bool get hasData => hrvAverage != null || hrAverage != null || state != null;
1059 } 1166 }
  1167 +
  1168 +class _SleepBedtime {
  1169 + const _SleepBedtime({
  1170 + required this.timestamp,
  1171 + required this.sleepDay,
  1172 + required this.normalizedMinutes,
  1173 + });
  1174 +
  1175 + final int timestamp;
  1176 + final DateTime sleepDay;
  1177 + final int normalizedMinutes;
  1178 +}
@@ -119,6 +119,165 @@ void main() { @@ -119,6 +119,165 @@ void main() {
119 expect(statistics.avgSleepEvaluate, 2); 119 expect(statistics.avgSleepEvaluate, 2);
120 }); 120 });
121 121
  122 + test('compares weekly bedtime extremes by sleep-day clock time', () {
  123 + final july27 = DateTime(2026, 7, 27);
  124 + final july28 = DateTime(2026, 7, 28);
  125 + final july29 = DateTime(2026, 7, 29);
  126 + final latestBedtime = DateTime(2026, 7, 28, 3, 59, 1);
  127 + final earliestBedtime = DateTime(2026, 7, 29, 2, 55, 28);
  128 +
  129 + HealthRawSleepResult result(DateTime day, DateTime bedtime) {
  130 + return HealthRawSleepResult(
  131 + userId: 1,
  132 + date: LocalHealthDataConvert.unixSeconds(
  133 + day.add(const Duration(hours: 8))),
  134 + startDate: LocalHealthDataConvert.unixSeconds(bedtime),
  135 + sleepScore: 80,
  136 + sleepState: 1,
  137 + inBedMinutes: 420,
  138 + awakMinutes: 20,
  139 + sleepMinutes: 400,
  140 + );
  141 + }
  142 +
  143 + final statistics = LocalHealthDataConvert.sleepStatisticsFromResults(
  144 + dateRangeType: 0,
  145 + days: [july27, july28, july29],
  146 + previousDays: const [],
  147 + sleepResults: [
  148 + result(july28, latestBedtime),
  149 + result(july29, earliestBedtime),
  150 + ],
  151 + heartRate: const [],
  152 + );
  153 +
  154 + expect(
  155 + statistics.earliestSleepInfo?.asleepTime,
  156 + LocalHealthDataConvert.unixSeconds(earliestBedtime),
  157 + );
  158 + expect(statistics.earliestSleepInfo?.timeKey, 20260728);
  159 + expect(
  160 + statistics.latestSleepInfo?.asleepTime,
  161 + LocalHealthDataConvert.unixSeconds(latestBedtime),
  162 + );
  163 + expect(statistics.latestSleepInfo?.timeKey, 20260727);
  164 + });
  165 +
  166 + test('uses the first and last records within a sleep day', () {
  167 + final july28 = DateTime(2026, 7, 28);
  168 + final july29 = DateTime(2026, 7, 29);
  169 + final bedtime = DateTime(2026, 7, 28, 22, 59, 59);
  170 + final continuedSleep = DateTime(2026, 7, 29, 3, 59, 59);
  171 +
  172 + HealthRawSleepResult result(DateTime start) {
  173 + return HealthRawSleepResult(
  174 + userId: 1,
  175 + date: LocalHealthDataConvert.unixSeconds(
  176 + july29.add(const Duration(hours: 8)),
  177 + ),
  178 + startDate: LocalHealthDataConvert.unixSeconds(start),
  179 + sleepScore: 80,
  180 + sleepState: 1,
  181 + inBedMinutes: 420,
  182 + awakMinutes: 20,
  183 + sleepMinutes: 400,
  184 + );
  185 + }
  186 +
  187 + final statistics = LocalHealthDataConvert.sleepStatisticsFromResults(
  188 + dateRangeType: 0,
  189 + days: [july28, july29],
  190 + previousDays: const [],
  191 + sleepResults: [
  192 + result(bedtime),
  193 + result(continuedSleep),
  194 + ],
  195 + heartRate: const [],
  196 + );
  197 +
  198 + final expectedBedtime = LocalHealthDataConvert.unixSeconds(bedtime);
  199 + expect(statistics.earliestSleepInfo?.asleepTime, expectedBedtime);
  200 + expect(
  201 + statistics.latestSleepInfo?.asleepTime,
  202 + LocalHealthDataConvert.unixSeconds(continuedSleep),
  203 + );
  204 + expect(statistics.earliestSleepInfo?.timeKey, 20260728);
  205 + expect(statistics.latestSleepInfo?.timeKey, 20260728);
  206 + });
  207 +
  208 + test('uses noon as the sleep day cutoff', () {
  209 + final july28 = DateTime(2026, 7, 28);
  210 + final july29 = DateTime(2026, 7, 29);
  211 + final july30 = DateTime(2026, 7, 30);
  212 + final beforeNoon = DateTime(2026, 7, 29, 11, 59, 59);
  213 + final atNoon = DateTime(2026, 7, 29, 12);
  214 +
  215 + HealthRawSleepResult result(DateTime day, DateTime bedtime) {
  216 + return HealthRawSleepResult(
  217 + userId: 1,
  218 + date: LocalHealthDataConvert.unixSeconds(
  219 + day.add(const Duration(hours: 8)),
  220 + ),
  221 + startDate: LocalHealthDataConvert.unixSeconds(bedtime),
  222 + sleepScore: 80,
  223 + sleepState: 1,
  224 + inBedMinutes: 420,
  225 + awakMinutes: 20,
  226 + sleepMinutes: 400,
  227 + );
  228 + }
  229 +
  230 + final statistics = LocalHealthDataConvert.sleepStatisticsFromResults(
  231 + dateRangeType: 0,
  232 + days: [july28, july29, july30],
  233 + previousDays: const [],
  234 + sleepResults: [
  235 + result(july29, beforeNoon),
  236 + result(july30, atNoon),
  237 + ],
  238 + heartRate: const [],
  239 + );
  240 +
  241 + expect(
  242 + statistics.earliestSleepInfo?.asleepTime,
  243 + LocalHealthDataConvert.unixSeconds(atNoon),
  244 + );
  245 + expect(statistics.earliestSleepInfo?.timeKey, 20260729);
  246 + expect(
  247 + statistics.latestSleepInfo?.asleepTime,
  248 + LocalHealthDataConvert.unixSeconds(beforeNoon),
  249 + );
  250 + expect(statistics.latestSleepInfo?.timeKey, 20260728);
  251 + });
  252 +
  253 + test('excludes records outside the report sleep-day range', () {
  254 + final july29 = DateTime(2026, 7, 29);
  255 + final overnightStart = DateTime(2026, 7, 29, 3);
  256 + final result = HealthRawSleepResult(
  257 + userId: 1,
  258 + date: LocalHealthDataConvert.unixSeconds(
  259 + july29.add(const Duration(hours: 8)),
  260 + ),
  261 + startDate: LocalHealthDataConvert.unixSeconds(overnightStart),
  262 + sleepScore: 80,
  263 + sleepState: 1,
  264 + inBedMinutes: 420,
  265 + awakMinutes: 20,
  266 + sleepMinutes: 400,
  267 + );
  268 +
  269 + final statistics = LocalHealthDataConvert.sleepStatisticsFromResults(
  270 + dateRangeType: 0,
  271 + days: [july29],
  272 + previousDays: const [],
  273 + sleepResults: [result],
  274 + heartRate: const [],
  275 + );
  276 +
  277 + expect(statistics.earliestSleepInfo, isNull);
  278 + expect(statistics.latestSleepInfo, isNull);
  279 + });
  280 +
122 test('sleep duration excludes awake intervals inside sleep range', () { 281 test('sleep duration excludes awake intervals inside sleep range', () {
123 final day = DateTime(2026, 7, 16); 282 final day = DateTime(2026, 7, 16);
124 final sleepStart = DateTime(2026, 7, 15, 23, 56); 283 final sleepStart = DateTime(2026, 7, 15, 23, 56);