Showing
14 changed files
with
753 additions
and
67 deletions
| @@ -12,6 +12,7 @@ | @@ -12,6 +12,7 @@ | ||
| 12 | .swiftpm/ | 12 | .swiftpm/ |
| 13 | migrate_working_dir/ | 13 | migrate_working_dir/ |
| 14 | SDK/ | 14 | SDK/ |
| 15 | +ohos/ | ||
| 15 | 16 | ||
| 16 | # IntelliJ related | 17 | # IntelliJ related |
| 17 | *.iml | 18 | *.iml |
| @@ -34,6 +35,7 @@ SDK/ | @@ -34,6 +35,7 @@ SDK/ | ||
| 34 | .pub/ | 35 | .pub/ |
| 35 | /build/ | 36 | /build/ |
| 36 | 37 | ||
| 38 | + | ||
| 37 | # Symbolication related | 39 | # Symbolication related |
| 38 | app.*.symbols | 40 | app.*.symbols |
| 39 | 41 |
| @@ -340,7 +340,7 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | @@ -340,7 +340,7 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | ||
| 340 | 340 | ||
| 341 | HealthKitRawDataPoint _sleepPointFromItem(OhosHealthRawDataItem item) { | 341 | HealthKitRawDataPoint _sleepPointFromItem(OhosHealthRawDataItem item) { |
| 342 | return HealthKitRawDataPoint( | 342 | return HealthKitRawDataPoint( |
| 343 | - dataType: _appleSleepType(item.dataType), | 343 | + dataType: item.dataType, |
| 344 | startTime: _intPayload(item.payload, 'from_time') ?? item.dataTime, | 344 | startTime: _intPayload(item.payload, 'from_time') ?? item.dataTime, |
| 345 | endTime: _intPayload(item.payload, 'to_time') ?? item.dataTime, | 345 | endTime: _intPayload(item.payload, 'to_time') ?? item.dataTime, |
| 346 | value: null, | 346 | value: null, |
| @@ -376,19 +376,6 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | @@ -376,19 +376,6 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | ||
| 376 | return point; | 376 | return point; |
| 377 | } | 377 | } |
| 378 | 378 | ||
| 379 | - int _appleSleepType(int ohosSleepType) { | ||
| 380 | - return switch (ohosSleepType) { | ||
| 381 | - 1 => 4, // deep | ||
| 382 | - 2 => 3, // light/core | ||
| 383 | - 3 => 5, // REM | ||
| 384 | - 4 => 2, // awake | ||
| 385 | - 5 => 1, // nap/asleep | ||
| 386 | - 6 => 0, // in bed | ||
| 387 | - 7 => 1, // manual sleep | ||
| 388 | - _ => 1, | ||
| 389 | - }; | ||
| 390 | - } | ||
| 391 | - | ||
| 392 | int? _intPayload(Map<String, Object?> payload, String key) { | 379 | int? _intPayload(Map<String, Object?> payload, String key) { |
| 393 | final value = payload[key]; | 380 | final value = payload[key]; |
| 394 | return value is num ? value.toInt() : null; | 381 | return value is num ? value.toInt() : null; |
| @@ -19,7 +19,9 @@ class HealthSleepCalculator { | @@ -19,7 +19,9 @@ class HealthSleepCalculator { | ||
| 19 | required DateTime day, | 19 | required DateTime day, |
| 20 | required List<HealthKitRawDataPoint> sleepIntervals, | 20 | required List<HealthKitRawDataPoint> sleepIntervals, |
| 21 | }) { | 21 | }) { |
| 22 | - final intervals = completeSleepIntervalsForDay(day, sleepIntervals); | 22 | + final intervals = mergeSameTypeSleepIntervals( |
| 23 | + completeSleepIntervalsForDay(day, sleepIntervals), | ||
| 24 | + ); | ||
| 23 | final mergedInterval = mergeContinuousSleepIntervals(intervals); | 25 | final mergedInterval = mergeContinuousSleepIntervals(intervals); |
| 24 | final windowStart = mergedInterval?.startTime ?? unixSeconds(day); | 26 | final windowStart = mergedInterval?.startTime ?? unixSeconds(day); |
| 25 | final windowEnd = mergedInterval?.endTime ?? | 27 | final windowEnd = mergedInterval?.endTime ?? |
| @@ -109,6 +111,59 @@ class HealthSleepCalculator { | @@ -109,6 +111,59 @@ class HealthSleepCalculator { | ||
| 109 | ); | 111 | ); |
| 110 | } | 112 | } |
| 111 | 113 | ||
| 114 | + static List<HealthKitRawDataPoint> mergeSameTypeSleepIntervals( | ||
| 115 | + List<HealthKitRawDataPoint> sleepIntervals, | ||
| 116 | + ) { | ||
| 117 | + if (sleepIntervals.length < 2) return sleepIntervals; | ||
| 118 | + final byType = <int, List<HealthKitRawDataPoint>>{}; | ||
| 119 | + for (final interval in sleepIntervals) { | ||
| 120 | + byType.putIfAbsent(interval.dataType, () => []).add(interval); | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + final merged = <HealthKitRawDataPoint>[]; | ||
| 124 | + for (final entry in byType.entries) { | ||
| 125 | + final intervals = entry.value | ||
| 126 | + ..sort((a, b) { | ||
| 127 | + final startCompare = a.startTime.compareTo(b.startTime); | ||
| 128 | + if (startCompare != 0) return startCompare; | ||
| 129 | + return a.endTime.compareTo(b.endTime); | ||
| 130 | + }); | ||
| 131 | + var start = intervals.first.startTime; | ||
| 132 | + var end = intervals.first.endTime; | ||
| 133 | + for (final interval in intervals.skip(1)) { | ||
| 134 | + if (interval.startTime <= end + _sleepContinuityToleranceSeconds) { | ||
| 135 | + end = math.max(end, interval.endTime); | ||
| 136 | + continue; | ||
| 137 | + } | ||
| 138 | + merged.add( | ||
| 139 | + HealthKitRawDataPoint( | ||
| 140 | + dataType: entry.key, | ||
| 141 | + startTime: start, | ||
| 142 | + endTime: end, | ||
| 143 | + ), | ||
| 144 | + ); | ||
| 145 | + start = interval.startTime; | ||
| 146 | + end = interval.endTime; | ||
| 147 | + } | ||
| 148 | + merged.add( | ||
| 149 | + HealthKitRawDataPoint( | ||
| 150 | + dataType: entry.key, | ||
| 151 | + startTime: start, | ||
| 152 | + endTime: end, | ||
| 153 | + ), | ||
| 154 | + ); | ||
| 155 | + } | ||
| 156 | + | ||
| 157 | + return merged | ||
| 158 | + ..sort((a, b) { | ||
| 159 | + final startCompare = a.startTime.compareTo(b.startTime); | ||
| 160 | + if (startCompare != 0) return startCompare; | ||
| 161 | + final endCompare = a.endTime.compareTo(b.endTime); | ||
| 162 | + if (endCompare != 0) return endCompare; | ||
| 163 | + return a.dataType.compareTo(b.dataType); | ||
| 164 | + }); | ||
| 165 | + } | ||
| 166 | + | ||
| 112 | static HealthSleepSummary sleepSummary( | 167 | static HealthSleepSummary sleepSummary( |
| 113 | List<HealthKitRawDataPoint> intervals, | 168 | List<HealthKitRawDataPoint> intervals, |
| 114 | int windowStart, | 169 | int windowStart, |
| @@ -241,12 +241,14 @@ class OHOSHealthRawDataCoreService { | @@ -241,12 +241,14 @@ class OHOSHealthRawDataCoreService { | ||
| 241 | final hasAuthorization = await _hasHealthReadAuthorizationSafely(); | 241 | final hasAuthorization = await _hasHealthReadAuthorizationSafely(); |
| 242 | final syncResults = await _syncCalculationRawDataSafely( | 242 | final syncResults = await _syncCalculationRawDataSafely( |
| 243 | hasAuthorization: hasAuthorization, | 243 | hasAuthorization: hasAuthorization, |
| 244 | - startTime: requestedStartTime, | 244 | + startTime: forceStartTime, |
| 245 | endTime: effectiveEndTime, | 245 | endTime: effectiveEndTime, |
| 246 | ); | 246 | ); |
| 247 | + final syncedRawStartTime = _earliestStoredTime(syncResults); | ||
| 247 | _logInfo( | 248 | _logInfo( |
| 248 | 'calculate_sync_finish startTime=$requestedStartTime ' | 249 | 'calculate_sync_finish startTime=$requestedStartTime ' |
| 249 | - 'endTime=$effectiveEndTime syncResults=$syncResults', | 250 | + 'endTime=$effectiveEndTime syncedRawStartTime=$syncedRawStartTime ' |
| 251 | + 'syncResults=$syncResults', | ||
| 250 | ); | 252 | ); |
| 251 | 253 | ||
| 252 | final storedResult = await _calculateAndStoreSafely( | 254 | final storedResult = await _calculateAndStoreSafely( |
| @@ -254,6 +256,7 @@ class OHOSHealthRawDataCoreService { | @@ -254,6 +256,7 @@ class OHOSHealthRawDataCoreService { | ||
| 254 | requestedStartTime: requestedStartTime, | 256 | requestedStartTime: requestedStartTime, |
| 255 | effectiveEndTime: effectiveEndTime, | 257 | effectiveEndTime: effectiveEndTime, |
| 256 | earliestStartTime: earliestStartTime, | 258 | earliestStartTime: earliestStartTime, |
| 259 | + syncedRawStartTime: syncedRawStartTime, | ||
| 257 | readChunkDays: readChunkDays, | 260 | readChunkDays: readChunkDays, |
| 258 | ); | 261 | ); |
| 259 | _scheduleResultUpload(); | 262 | _scheduleResultUpload(); |
| @@ -270,6 +273,7 @@ class OHOSHealthRawDataCoreService { | @@ -270,6 +273,7 @@ class OHOSHealthRawDataCoreService { | ||
| 270 | required int requestedStartTime, | 273 | required int requestedStartTime, |
| 271 | required int effectiveEndTime, | 274 | required int effectiveEndTime, |
| 272 | required int earliestStartTime, | 275 | required int earliestStartTime, |
| 276 | + required int? syncedRawStartTime, | ||
| 273 | required int readChunkDays, | 277 | required int readChunkDays, |
| 274 | }) async { | 278 | }) async { |
| 275 | var hasExistingHrv = false; | 279 | var hasExistingHrv = false; |
| @@ -288,27 +292,36 @@ class OHOSHealthRawDataCoreService { | @@ -288,27 +292,36 @@ class OHOSHealthRawDataCoreService { | ||
| 288 | hasExistingHrv = latestHrvRawEndTime != null; | 292 | hasExistingHrv = latestHrvRawEndTime != null; |
| 289 | hasExistingSleep = latestSleepResultTime != null; | 293 | hasExistingSleep = latestSleepResultTime != null; |
| 290 | 294 | ||
| 295 | + final calculationStartTime = math.max( | ||
| 296 | + _minNullable(requestedStartTime, syncedRawStartTime) ?? | ||
| 297 | + requestedStartTime, | ||
| 298 | + earliestStartTime, | ||
| 299 | + ); | ||
| 291 | final hrvStartTime = math.max( | 300 | final hrvStartTime = math.max( |
| 292 | - hrvContextStart ?? requestedStartTime, | 301 | + _minNullable(hrvContextStart, calculationStartTime) ?? |
| 302 | + calculationStartTime, | ||
| 293 | earliestStartTime, | 303 | earliestStartTime, |
| 294 | ); | 304 | ); |
| 295 | final realtimeStartTime = math.max( | 305 | final realtimeStartTime = math.max( |
| 296 | - realtimeContextStart ?? requestedStartTime, | 306 | + _minNullable(realtimeContextStart, calculationStartTime) ?? |
| 307 | + calculationStartTime, | ||
| 297 | earliestStartTime, | 308 | earliestStartTime, |
| 298 | ); | 309 | ); |
| 299 | final heartRateStartTime = math.max( | 310 | final heartRateStartTime = math.max( |
| 300 | - _minNullable(hrvStartTime, realtimeStartTime) ?? requestedStartTime, | 311 | + _minNullable(hrvStartTime, realtimeStartTime) ?? calculationStartTime, |
| 301 | earliestStartTime, | 312 | earliestStartTime, |
| 302 | ); | 313 | ); |
| 314 | + final sleepAnchorTime = latestSleepResultTime == null | ||
| 315 | + ? calculationStartTime | ||
| 316 | + : latestSleepResultTime - Duration.secondsPerDay; | ||
| 303 | final sleepStartTime = math.max( | 317 | final sleepStartTime = math.max( |
| 304 | - latestSleepResultTime == null | ||
| 305 | - ? requestedStartTime | ||
| 306 | - : latestSleepResultTime - Duration.secondsPerDay, | 318 | + _minNullable(sleepAnchorTime, syncedRawStartTime) ?? sleepAnchorTime, |
| 307 | earliestStartTime, | 319 | earliestStartTime, |
| 308 | ); | 320 | ); |
| 309 | _logInfo( | 321 | _logInfo( |
| 310 | 'calculate_context userId=$userId ' | 322 | 'calculate_context userId=$userId ' |
| 311 | 'requestedStartTime=$requestedStartTime endTime=$effectiveEndTime ' | 323 | 'requestedStartTime=$requestedStartTime endTime=$effectiveEndTime ' |
| 324 | + 'syncedRawStartTime=$syncedRawStartTime ' | ||
| 312 | 'hrvContextStart=$hrvContextStart ' | 325 | 'hrvContextStart=$hrvContextStart ' |
| 313 | 'realtimeContextStart=$realtimeContextStart ' | 326 | 'realtimeContextStart=$realtimeContextStart ' |
| 314 | 'latestHrvRawEndTime=$latestHrvRawEndTime ' | 327 | 'latestHrvRawEndTime=$latestHrvRawEndTime ' |
| @@ -385,10 +398,12 @@ class OHOSHealthRawDataCoreService { | @@ -385,10 +398,12 @@ class OHOSHealthRawDataCoreService { | ||
| 385 | hrvStressPoints: _filterNewHrvStressPoints( | 398 | hrvStressPoints: _filterNewHrvStressPoints( |
| 386 | result.hrvStressPoints, | 399 | result.hrvStressPoints, |
| 387 | latestHrvRawEndTime, | 400 | latestHrvRawEndTime, |
| 401 | + recomputeStartTime: syncedRawStartTime, | ||
| 388 | ), | 402 | ), |
| 389 | realtimeStressPoints: _filterNewRealtimeStressPoints( | 403 | realtimeStressPoints: _filterNewRealtimeStressPoints( |
| 390 | result.realtimeStressPoints, | 404 | result.realtimeStressPoints, |
| 391 | latestRealtimeRawEndTime, | 405 | latestRealtimeRawEndTime, |
| 406 | + recomputeStartTime: syncedRawStartTime, | ||
| 392 | ), | 407 | ), |
| 393 | ); | 408 | ); |
| 394 | _logInfo( | 409 | _logInfo( |
| @@ -884,7 +899,7 @@ class OHOSHealthRawDataCoreService { | @@ -884,7 +899,7 @@ class OHOSHealthRawDataCoreService { | ||
| 884 | } | 899 | } |
| 885 | 900 | ||
| 886 | Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataIfNeeded({ | 901 | Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataIfNeeded({ |
| 887 | - required int startTime, | 902 | + required int? startTime, |
| 888 | required int endTime, | 903 | required int endTime, |
| 889 | }) async { | 904 | }) async { |
| 890 | final rawDataSource = _rawDataSource; | 905 | final rawDataSource = _rawDataSource; |
| @@ -899,7 +914,7 @@ class OHOSHealthRawDataCoreService { | @@ -899,7 +914,7 @@ class OHOSHealthRawDataCoreService { | ||
| 899 | 914 | ||
| 900 | Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataSafely({ | 915 | Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataSafely({ |
| 901 | required bool hasAuthorization, | 916 | required bool hasAuthorization, |
| 902 | - required int startTime, | 917 | + required int? startTime, |
| 903 | required int endTime, | 918 | required int endTime, |
| 904 | }) async { | 919 | }) async { |
| 905 | if (!hasAuthorization) { | 920 | if (!hasAuthorization) { |
| @@ -1002,9 +1017,8 @@ class OHOSHealthRawDataCoreService { | @@ -1002,9 +1017,8 @@ class OHOSHealthRawDataCoreService { | ||
| 1002 | for (final date in affectedDates) { | 1017 | for (final date in affectedDates) { |
| 1003 | if (date != todayDate && existingDates.contains(date)) { | 1018 | if (date != todayDate && existingDates.contains(date)) { |
| 1004 | _logInfo( | 1019 | _logInfo( |
| 1005 | - '$_dailyStressLogMarker skip_existing userId=$userId date=$date', | 1020 | + '$_dailyStressLogMarker refresh_existing userId=$userId date=$date', |
| 1006 | ); | 1021 | ); |
| 1007 | - continue; | ||
| 1008 | } | 1022 | } |
| 1009 | final (startTime, endTime) = _dayRangeFromDateKey(date); | 1023 | final (startTime, endTime) = _dayRangeFromDateKey(date); |
| 1010 | final dayRealtimePoints = await _localStore.queryRealtimeStressPoints( | 1024 | final dayRealtimePoints = await _localStore.queryRealtimeStressPoints( |
| @@ -1139,24 +1153,48 @@ class OHOSHealthRawDataCoreService { | @@ -1139,24 +1153,48 @@ class OHOSHealthRawDataCoreService { | ||
| 1139 | 1153 | ||
| 1140 | List<HealthRawHrvStressPoint> _filterNewHrvStressPoints( | 1154 | List<HealthRawHrvStressPoint> _filterNewHrvStressPoints( |
| 1141 | List<HealthRawHrvStressPoint> points, | 1155 | List<HealthRawHrvStressPoint> points, |
| 1142 | - int? latestRawEndTime, | ||
| 1143 | - ) { | ||
| 1144 | - if (latestRawEndTime == null) return points; | 1156 | + int? latestRawEndTime, { |
| 1157 | + int? recomputeStartTime, | ||
| 1158 | + }) { | ||
| 1159 | + if (latestRawEndTime == null && recomputeStartTime == null) return points; | ||
| 1145 | return points | 1160 | return points |
| 1146 | - .where((point) => point.rawEndTime > latestRawEndTime) | 1161 | + .where( |
| 1162 | + (point) => | ||
| 1163 | + (latestRawEndTime == null || | ||
| 1164 | + point.rawEndTime > latestRawEndTime) || | ||
| 1165 | + (recomputeStartTime != null && | ||
| 1166 | + point.rawEndTime >= recomputeStartTime), | ||
| 1167 | + ) | ||
| 1147 | .toList(); | 1168 | .toList(); |
| 1148 | } | 1169 | } |
| 1149 | 1170 | ||
| 1150 | List<HealthRawRealtimeStressPoint> _filterNewRealtimeStressPoints( | 1171 | List<HealthRawRealtimeStressPoint> _filterNewRealtimeStressPoints( |
| 1151 | List<HealthRawRealtimeStressPoint> points, | 1172 | List<HealthRawRealtimeStressPoint> points, |
| 1152 | - int? latestRawEndTime, | ||
| 1153 | - ) { | ||
| 1154 | - if (latestRawEndTime == null) return points; | 1173 | + int? latestRawEndTime, { |
| 1174 | + int? recomputeStartTime, | ||
| 1175 | + }) { | ||
| 1176 | + if (latestRawEndTime == null && recomputeStartTime == null) return points; | ||
| 1155 | return points | 1177 | return points |
| 1156 | - .where((point) => point.rawEndTime > latestRawEndTime) | 1178 | + .where( |
| 1179 | + (point) => | ||
| 1180 | + (latestRawEndTime == null || | ||
| 1181 | + point.rawEndTime > latestRawEndTime) || | ||
| 1182 | + (recomputeStartTime != null && | ||
| 1183 | + point.rawEndTime >= recomputeStartTime), | ||
| 1184 | + ) | ||
| 1157 | .toList(); | 1185 | .toList(); |
| 1158 | } | 1186 | } |
| 1159 | 1187 | ||
| 1188 | + int? _earliestStoredTime(List<OhosHealthRawDataSyncResult> syncResults) { | ||
| 1189 | + int? earliest; | ||
| 1190 | + for (final result in syncResults) { | ||
| 1191 | + final time = result.earliestStoredTime; | ||
| 1192 | + if (time == null) continue; | ||
| 1193 | + earliest = earliest == null ? time : math.min(earliest, time); | ||
| 1194 | + } | ||
| 1195 | + return earliest; | ||
| 1196 | + } | ||
| 1197 | + | ||
| 1160 | Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks( | 1198 | Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks( |
| 1161 | int dataType, | 1199 | int dataType, |
| 1162 | int startTime, | 1200 | int startTime, |
| @@ -476,7 +476,7 @@ CREATE TABLE IF NOT EXISTS $table ( | @@ -476,7 +476,7 @@ CREATE TABLE IF NOT EXISTS $table ( | ||
| 476 | if (_isDailyDataType(dataType)) { | 476 | if (_isDailyDataType(dataType)) { |
| 477 | return keyTime >= _startOfLocalDay(latestTime) - Duration.secondsPerDay; | 477 | return keyTime >= _startOfLocalDay(latestTime) - Duration.secondsPerDay; |
| 478 | } | 478 | } |
| 479 | - return keyTime > latestTime; | 479 | + return true; |
| 480 | } | 480 | } |
| 481 | 481 | ||
| 482 | int _dedupeKeyTime({ | 482 | int _dedupeKeyTime({ |
| 1 | +import 'dart:math' as math; | ||
| 2 | + | ||
| 1 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; | 3 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; |
| 2 | import 'package:doublefeel_flutter/core/result/app_result.dart'; | 4 | import 'package:doublefeel_flutter/core/result/app_result.dart'; |
| 3 | import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; | 5 | import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; |
| @@ -30,6 +32,7 @@ class OhosHealthRawDataSyncService { | @@ -30,6 +32,7 @@ class OhosHealthRawDataSyncService { | ||
| 30 | _logSink = logSink; | 32 | _logSink = logSink; |
| 31 | 33 | ||
| 32 | static const int defaultLookbackDays = 183; | 34 | static const int defaultLookbackDays = 183; |
| 35 | + static const int incrementalBackfillDays = 7; | ||
| 33 | static const int heartRateFetchChunkDays = 10; | 36 | static const int heartRateFetchChunkDays = 10; |
| 34 | static const int defaultFetchChunkDays = 30; | 37 | static const int defaultFetchChunkDays = 30; |
| 35 | static const String logMarker = '[OHOS_HEALTH_RAW_SYNC]'; | 38 | static const String logMarker = '[OHOS_HEALTH_RAW_SYNC]'; |
| @@ -355,6 +358,7 @@ class OhosHealthRawDataSyncService { | @@ -355,6 +358,7 @@ class OhosHealthRawDataSyncService { | ||
| 355 | segmentCount: fetched.segmentCount, | 358 | segmentCount: fetched.segmentCount, |
| 356 | pageCount: pageCount, | 359 | pageCount: pageCount, |
| 357 | storedCount: storedCount, | 360 | storedCount: storedCount, |
| 361 | + earliestStoredTime: storedCount > 0 ? fetched.earliestItemTime : null, | ||
| 358 | ); | 362 | ); |
| 359 | } | 363 | } |
| 360 | 364 | ||
| @@ -364,11 +368,21 @@ class OhosHealthRawDataSyncService { | @@ -364,11 +368,21 @@ class OhosHealthRawDataSyncService { | ||
| 364 | required int endTime, | 368 | required int endTime, |
| 365 | }) async { | 369 | }) async { |
| 366 | final latestDataTime = await _localStore.latestDataTime(dataType: dataType); | 370 | final latestDataTime = await _localStore.latestDataTime(dataType: dataType); |
| 367 | - final baseStartTime = latestDataTime ?? | ||
| 368 | - requestedStartTime ?? | ||
| 369 | - _unixSeconds( | ||
| 370 | - _nowProvider().subtract(const Duration(days: defaultLookbackDays)), | ||
| 371 | - ); | 371 | + final int baseStartTime; |
| 372 | + if (latestDataTime != null) { | ||
| 373 | + final backfillStart = math.max( | ||
| 374 | + 0, | ||
| 375 | + latestDataTime - incrementalBackfillDays * Duration.secondsPerDay, | ||
| 376 | + ); | ||
| 377 | + baseStartTime = requestedStartTime == null | ||
| 378 | + ? backfillStart | ||
| 379 | + : math.min(requestedStartTime, backfillStart); | ||
| 380 | + } else { | ||
| 381 | + baseStartTime = requestedStartTime ?? | ||
| 382 | + _unixSeconds( | ||
| 383 | + _nowProvider().subtract(const Duration(days: defaultLookbackDays)), | ||
| 384 | + ); | ||
| 385 | + } | ||
| 372 | final resolvedStartTime = _startOfLocalDay(baseStartTime); | 386 | final resolvedStartTime = _startOfLocalDay(baseStartTime); |
| 373 | return resolvedStartTime > endTime ? endTime : resolvedStartTime; | 387 | return resolvedStartTime > endTime ? endTime : resolvedStartTime; |
| 374 | } | 388 | } |
| @@ -735,6 +749,17 @@ class _FetchedRawDataSync { | @@ -735,6 +749,17 @@ class _FetchedRawDataSync { | ||
| 735 | final List<_FetchedRawDataSegment> segments; | 749 | final List<_FetchedRawDataSegment> segments; |
| 736 | 750 | ||
| 737 | int get segmentCount => segments.length; | 751 | int get segmentCount => segments.length; |
| 752 | + | ||
| 753 | + int? get earliestItemTime { | ||
| 754 | + int? earliest; | ||
| 755 | + for (final segment in segments) { | ||
| 756 | + for (final item in segment.page.items) { | ||
| 757 | + final keyTime = _dedupeKeyTime(dataType: dataType, item: item); | ||
| 758 | + earliest = earliest == null ? keyTime : math.min(earliest, keyTime); | ||
| 759 | + } | ||
| 760 | + } | ||
| 761 | + return earliest; | ||
| 762 | + } | ||
| 738 | } | 763 | } |
| 739 | 764 | ||
| 740 | class _FetchedRawDataSegment { | 765 | class _FetchedRawDataSegment { |
| @@ -757,6 +782,7 @@ class OhosHealthRawDataSyncResult { | @@ -757,6 +782,7 @@ class OhosHealthRawDataSyncResult { | ||
| 757 | this.segmentCount = 1, | 782 | this.segmentCount = 1, |
| 758 | required this.pageCount, | 783 | required this.pageCount, |
| 759 | required this.storedCount, | 784 | required this.storedCount, |
| 785 | + this.earliestStoredTime, | ||
| 760 | }); | 786 | }); |
| 761 | 787 | ||
| 762 | final int dataType; | 788 | final int dataType; |
| @@ -765,6 +791,7 @@ class OhosHealthRawDataSyncResult { | @@ -765,6 +791,7 @@ class OhosHealthRawDataSyncResult { | ||
| 765 | final int segmentCount; | 791 | final int segmentCount; |
| 766 | final int pageCount; | 792 | final int pageCount; |
| 767 | final int storedCount; | 793 | final int storedCount; |
| 794 | + final int? earliestStoredTime; | ||
| 768 | 795 | ||
| 769 | @override | 796 | @override |
| 770 | String toString() { | 797 | String toString() { |
| @@ -774,11 +801,30 @@ class OhosHealthRawDataSyncResult { | @@ -774,11 +801,30 @@ class OhosHealthRawDataSyncResult { | ||
| 774 | 'endTime=$endTime, ' | 801 | 'endTime=$endTime, ' |
| 775 | 'segmentCount=$segmentCount, ' | 802 | 'segmentCount=$segmentCount, ' |
| 776 | 'pageCount=$pageCount, ' | 803 | 'pageCount=$pageCount, ' |
| 777 | - 'storedCount=$storedCount' | 804 | + 'storedCount=$storedCount, ' |
| 805 | + 'earliestStoredTime=$earliestStoredTime' | ||
| 778 | ')'; | 806 | ')'; |
| 779 | } | 807 | } |
| 780 | } | 808 | } |
| 781 | 809 | ||
| 810 | +int _dedupeKeyTime({ | ||
| 811 | + required int dataType, | ||
| 812 | + required OhosHealthRawDataItem item, | ||
| 813 | +}) { | ||
| 814 | + if (dataType == OhosHealthRawDataType.sleepAnalysis || | ||
| 815 | + dataType == OhosHealthRawDataType.workout) { | ||
| 816 | + return _numPayload(item.payload, 'from_time') ?? | ||
| 817 | + _numPayload(item.payload, 'start_time') ?? | ||
| 818 | + item.dataTime; | ||
| 819 | + } | ||
| 820 | + return _numPayload(item.payload, 'time') ?? item.dataTime; | ||
| 821 | +} | ||
| 822 | + | ||
| 823 | +int? _numPayload(Map<String, Object?> payload, String key) { | ||
| 824 | + final value = payload[key]; | ||
| 825 | + return value is num ? value.toInt() : null; | ||
| 826 | +} | ||
| 827 | + | ||
| 782 | int _dateKeyFromUnixSeconds(int seconds) { | 828 | int _dateKeyFromUnixSeconds(int seconds) { |
| 783 | final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); | 829 | final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); |
| 784 | return _dateKeyFromDateTime(dateTime); | 830 | return _dateKeyFromDateTime(dateTime); |
| @@ -141,7 +141,7 @@ class LocalHealthDataConvert { | @@ -141,7 +141,7 @@ class LocalHealthDataConvert { | ||
| 141 | avgHr: shouldIncludeHeartRate | 141 | avgHr: shouldIncludeHeartRate |
| 142 | ? _averageOrNull( | 142 | ? _averageOrNull( |
| 143 | allSleepHr.map((e) => e.value).whereType<num>().toList(), | 143 | allSleepHr.map((e) => e.value).whereType<num>().toList(), |
| 144 | - ) | 144 | + )?.floor() |
| 145 | : null, | 145 | : null, |
| 146 | maxHr: shouldIncludeHeartRate | 146 | maxHr: shouldIncludeHeartRate |
| 147 | ? _maxOrNull( | 147 | ? _maxOrNull( |
| @@ -234,7 +234,7 @@ class LocalHealthDataConvert { | @@ -234,7 +234,7 @@ class LocalHealthDataConvert { | ||
| 234 | avgHr: shouldIncludeHeartRate | 234 | avgHr: shouldIncludeHeartRate |
| 235 | ? _averageOrNull( | 235 | ? _averageOrNull( |
| 236 | allSleepHr.map((e) => e.value).whereType<num>().toList(), | 236 | allSleepHr.map((e) => e.value).whereType<num>().toList(), |
| 237 | - ) | 237 | + )?.floor() |
| 238 | : null, | 238 | : null, |
| 239 | maxHr: shouldIncludeHeartRate | 239 | maxHr: shouldIncludeHeartRate |
| 240 | ? _maxOrNull( | 240 | ? _maxOrNull( |
| @@ -38,14 +38,14 @@ class HmSleepDataItem { | @@ -38,14 +38,14 @@ class HmSleepDataItem { | ||
| 38 | 38 | ||
| 39 | num? fromTime; | 39 | num? fromTime; |
| 40 | num? toTime; | 40 | num? toTime; |
| 41 | - /// 0:未知 | ||
| 42 | - /// 1:深睡 | ||
| 43 | - /// 2:浅睡 | ||
| 44 | - /// 3:REM | ||
| 45 | - /// 4:清醒 | ||
| 46 | - /// 5:午睡(零星小睡) | ||
| 47 | - /// 6:卧床 | ||
| 48 | - /// 7:睡眠(手工) | 41 | + |
| 42 | + /// Apple Health/iOS-compatible sleep stage. | ||
| 43 | + /// 0:卧床 | ||
| 44 | + /// 1:睡眠(未指定) | ||
| 45 | + /// 2:清醒 | ||
| 46 | + /// 3:核心睡眠 | ||
| 47 | + /// 4:深睡 | ||
| 48 | + /// 5:REM | ||
| 49 | num? dataType; | 49 | num? dataType; |
| 50 | 50 | ||
| 51 | Map<String, dynamic> toJson() { | 51 | Map<String, dynamic> toJson() { |
| @@ -5,12 +5,12 @@ | @@ -5,12 +5,12 @@ | ||
| 5 | "name": "auto", | 5 | "name": "auto", |
| 6 | "type": "HarmonyOS", | 6 | "type": "HarmonyOS", |
| 7 | "material": { | 7 | "material": { |
| 8 | - "certpath": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer", | 8 | + "certpath": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer", |
| 9 | "keyAlias": "debugKey", | 9 | "keyAlias": "debugKey", |
| 10 | "keyPassword": "00000019084446C3F235D890CFE9AC6015D14270D13D370ACF44FEDD89097E1ABBBDE3810C8FEBFFFD", | 10 | "keyPassword": "00000019084446C3F235D890CFE9AC6015D14270D13D370ACF44FEDD89097E1ABBBDE3810C8FEBFFFD", |
| 11 | - "profile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b", | 11 | + "profile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b", |
| 12 | "signAlg": "SHA256withECDSA", | 12 | "signAlg": "SHA256withECDSA", |
| 13 | - "storeFile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12", | 13 | + "storeFile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12", |
| 14 | "storePassword": "00000019D85309732E0C3572D434400BA13D429AE5A182B54C1EA6F2C9D7DB5AE81BD9A64F12ACC681" | 14 | "storePassword": "00000019D85309732E0C3572D434400BA13D429AE5A182B54C1EA6F2C9D7DB5AE81BD9A64F12ACC681" |
| 15 | } | 15 | } |
| 16 | }, | 16 | }, |
| @@ -18,33 +18,33 @@ | @@ -18,33 +18,33 @@ | ||
| 18 | "name": "release", | 18 | "name": "release", |
| 19 | "type": "HarmonyOS", | 19 | "type": "HarmonyOS", |
| 20 | "material": { | 20 | "material": { |
| 21 | - "storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12", | 21 | + "storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12", |
| 22 | "storePassword": "0000001E8F02C32C2D5431F31FC368F020D891F18E6DC45A5913C60CACC1074B11282B3527F3E7D420F00B21B13A", | 22 | "storePassword": "0000001E8F02C32C2D5431F31FC368F020D891F18E6DC45A5913C60CACC1074B11282B3527F3E7D420F00B21B13A", |
| 23 | "keyAlias": "DoubleFeel", | 23 | "keyAlias": "DoubleFeel", |
| 24 | "keyPassword": "0000001ECC963DD8029FC0957D403D334E6A864BA98BD1ABF0F96E41AEB1002C3CBAFE12680A33902C1D5C61E7C9", | 24 | "keyPassword": "0000001ECC963DD8029FC0957D403D334E6A864BA98BD1ABF0F96E41AEB1002C3CBAFE12680A33902C1D5C61E7C9", |
| 25 | "signAlg": "SHA256withECDSA", | 25 | "signAlg": "SHA256withECDSA", |
| 26 | - "profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b", | ||
| 27 | - "certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer" | 26 | + "profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b", |
| 27 | + "certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer" | ||
| 28 | } | 28 | } |
| 29 | }, | 29 | }, |
| 30 | { | 30 | { |
| 31 | "name": "debug", | 31 | "name": "debug", |
| 32 | "type": "HarmonyOS", | 32 | "type": "HarmonyOS", |
| 33 | "material": { | 33 | "material": { |
| 34 | - "storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12", | 34 | + "storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12", |
| 35 | "storePassword": "0000001E1BE8542E4E9F7969905217A5421D89506648518A33A93BE1045A511094FF10805EC4686172CC000ED9FA", | 35 | "storePassword": "0000001E1BE8542E4E9F7969905217A5421D89506648518A33A93BE1045A511094FF10805EC4686172CC000ED9FA", |
| 36 | "keyAlias": "DoubleFeel", | 36 | "keyAlias": "DoubleFeel", |
| 37 | "keyPassword": "0000001EFBAD9B2C408962C5BF2044ED8A57E4E192D171CAC3EBFF8D648684E52A690DFD6B8BDD7DC28AF8D6174C", | 37 | "keyPassword": "0000001EFBAD9B2C408962C5BF2044ED8A57E4E192D171CAC3EBFF8D648684E52A690DFD6B8BDD7DC28AF8D6174C", |
| 38 | "signAlg": "SHA256withECDSA", | 38 | "signAlg": "SHA256withECDSA", |
| 39 | - "profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b", | ||
| 40 | - "certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer" | 39 | + "profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b", |
| 40 | + "certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer" | ||
| 41 | } | 41 | } |
| 42 | } | 42 | } |
| 43 | ], | 43 | ], |
| 44 | "products": [ | 44 | "products": [ |
| 45 | { | 45 | { |
| 46 | "name": "default", | 46 | "name": "default", |
| 47 | - "signingConfig": "auto", | 47 | + "signingConfig": "debug", |
| 48 | "targetSdkVersion": "6.0.0(20)", | 48 | "targetSdkVersion": "6.0.0(20)", |
| 49 | "compatibleSdkVersion": "6.0.0(20)", | 49 | "compatibleSdkVersion": "6.0.0(20)", |
| 50 | "runtimeOS": "HarmonyOS", | 50 | "runtimeOS": "HarmonyOS", |
| @@ -48,4 +48,82 @@ void main() { | @@ -48,4 +48,82 @@ void main() { | ||
| 48 | expect(calculation.score, isNotNull); | 48 | expect(calculation.score, isNotNull); |
| 49 | expect(calculation.state, isNotNull); | 49 | expect(calculation.state, isNotNull); |
| 50 | }); | 50 | }); |
| 51 | + | ||
| 52 | + test('deduplicates overlapping intervals with the same sleep type', () { | ||
| 53 | + final day = DateTime(2026, 8, 31); | ||
| 54 | + final start = HealthSleepCalculator.unixSeconds(day); | ||
| 55 | + final firstEnd = start + const Duration(hours: 1).inSeconds; | ||
| 56 | + final overlapStart = start + const Duration(minutes: 30).inSeconds; | ||
| 57 | + final secondEnd = start + const Duration(minutes: 90).inSeconds; | ||
| 58 | + final deepEnd = start + const Duration(hours: 2).inSeconds; | ||
| 59 | + | ||
| 60 | + final calculation = HealthSleepCalculator.calculateDay( | ||
| 61 | + day: day, | ||
| 62 | + sleepIntervals: [ | ||
| 63 | + HealthKitRawDataPoint( | ||
| 64 | + dataType: 3, | ||
| 65 | + startTime: start, | ||
| 66 | + endTime: firstEnd, | ||
| 67 | + ), | ||
| 68 | + HealthKitRawDataPoint( | ||
| 69 | + dataType: 3, | ||
| 70 | + startTime: overlapStart, | ||
| 71 | + endTime: secondEnd, | ||
| 72 | + ), | ||
| 73 | + HealthKitRawDataPoint( | ||
| 74 | + dataType: 4, | ||
| 75 | + startTime: secondEnd, | ||
| 76 | + endTime: deepEnd, | ||
| 77 | + ), | ||
| 78 | + ], | ||
| 79 | + ); | ||
| 80 | + | ||
| 81 | + expect(calculation.summary.coreMinutes, 90); | ||
| 82 | + expect(calculation.summary.deepMinutes, 30); | ||
| 83 | + expect(calculation.summary.sleepMinutes, 120); | ||
| 84 | + expect(calculation.durationSeconds, const Duration(hours: 2).inSeconds); | ||
| 85 | + }); | ||
| 86 | + | ||
| 87 | + test('excludes awake intervals from provided OHOS sleep sample', () { | ||
| 88 | + final day = DateTime.fromMillisecondsSinceEpoch(1788399300 * 1000); | ||
| 89 | + | ||
| 90 | + final calculation = HealthSleepCalculator.calculateDay( | ||
| 91 | + day: DateTime(day.year, day.month, day.day), | ||
| 92 | + sleepIntervals: [ | ||
| 93 | + _sleepPoint(1, 1788398340, 1788399300), | ||
| 94 | + _sleepPoint(2, 1788375120, 1788375480), | ||
| 95 | + _sleepPoint(1, 1788375480, 1788378420), | ||
| 96 | + _sleepPoint(4, 1788378420, 1788379200), | ||
| 97 | + _sleepPoint(2, 1788379200, 1788379380), | ||
| 98 | + _sleepPoint(1, 1788379380, 1788381300), | ||
| 99 | + _sleepPoint(4, 1788381300, 1788383100), | ||
| 100 | + _sleepPoint(1, 1788383100, 1788384660), | ||
| 101 | + _sleepPoint(4, 1788384660, 1788385020), | ||
| 102 | + _sleepPoint(1, 1788385020, 1788385740), | ||
| 103 | + _sleepPoint(4, 1788385740, 1788386460), | ||
| 104 | + _sleepPoint(1, 1788386460, 1788387480), | ||
| 105 | + _sleepPoint(5, 1788387480, 1788389640), | ||
| 106 | + _sleepPoint(1, 1788389640, 1788391620), | ||
| 107 | + _sleepPoint(4, 1788391620, 1788393660), | ||
| 108 | + _sleepPoint(5, 1788393660, 1788394320), | ||
| 109 | + _sleepPoint(1, 1788394320, 1788395760), | ||
| 110 | + _sleepPoint(5, 1788395760, 1788396300), | ||
| 111 | + _sleepPoint(1, 1788396300, 1788397680), | ||
| 112 | + _sleepPoint(2, 1788397680, 1788398340), | ||
| 113 | + ], | ||
| 114 | + ); | ||
| 115 | + | ||
| 116 | + expect(calculation.summary.awakeMinutes, 20); | ||
| 117 | + expect(calculation.summary.sleepMinutes, 6 * 60 + 23); | ||
| 118 | + expect(calculation.durationSeconds, | ||
| 119 | + const Duration(hours: 6, minutes: 23).inSeconds); | ||
| 120 | + }); | ||
| 121 | +} | ||
| 122 | + | ||
| 123 | +HealthKitRawDataPoint _sleepPoint(int dataType, int startTime, int endTime) { | ||
| 124 | + return HealthKitRawDataPoint( | ||
| 125 | + dataType: dataType, | ||
| 126 | + startTime: startTime, | ||
| 127 | + endTime: endTime, | ||
| 128 | + ); | ||
| 51 | } | 129 | } |
| @@ -27,8 +27,13 @@ void main() { | @@ -27,8 +27,13 @@ void main() { | ||
| 27 | endTime: base - 4 * 60 * 60, | 27 | endTime: base - 4 * 60 * 60, |
| 28 | ), | 28 | ), |
| 29 | HealthKitRawDataPoint( | 29 | HealthKitRawDataPoint( |
| 30 | - dataType: 3, | 30 | + dataType: 2, |
| 31 | startTime: base - 4 * 60 * 60, | 31 | startTime: base - 4 * 60 * 60, |
| 32 | + endTime: base - 3 * 60 * 60, | ||
| 33 | + ), | ||
| 34 | + HealthKitRawDataPoint( | ||
| 35 | + dataType: 3, | ||
| 36 | + startTime: base - 3 * 60 * 60, | ||
| 32 | endTime: base - 1 * 60 * 60, | 37 | endTime: base - 1 * 60 * 60, |
| 33 | ), | 38 | ), |
| 34 | ], | 39 | ], |
| @@ -54,6 +59,193 @@ void main() { | @@ -54,6 +59,193 @@ void main() { | ||
| 54 | expect(store.realtimeStressPoints, hasLength(1)); | 59 | expect(store.realtimeStressPoints, hasLength(1)); |
| 55 | expect(store.dailyStressPoints, hasLength(1)); | 60 | expect(store.dailyStressPoints, hasLength(1)); |
| 56 | expect(store.sleepResults, hasLength(1)); | 61 | expect(store.sleepResults, hasLength(1)); |
| 62 | + expect(store.sleepResults.single.awakMinutes, 60); | ||
| 63 | + expect(store.sleepResults.single.sleepMinutes, 6 * 60); | ||
| 64 | + }); | ||
| 65 | + | ||
| 66 | + test('OHOS core excludes awake sleep stages from stored sleep duration', | ||
| 67 | + () async { | ||
| 68 | + final store = _FakeHealthRawStressLocalStore(); | ||
| 69 | + final service = OHOSHealthRawDataCoreService( | ||
| 70 | + rawDataSource: _FakeHealthRawDataSource( | ||
| 71 | + pointsByDataType: const {}, | ||
| 72 | + sleepPoints: [ | ||
| 73 | + HealthKitRawDataPoint( | ||
| 74 | + dataType: 1, | ||
| 75 | + startTime: 1788398340, | ||
| 76 | + endTime: 1788399300, | ||
| 77 | + ), | ||
| 78 | + HealthKitRawDataPoint( | ||
| 79 | + dataType: 2, | ||
| 80 | + startTime: 1788375120, | ||
| 81 | + endTime: 1788375480, | ||
| 82 | + ), | ||
| 83 | + HealthKitRawDataPoint( | ||
| 84 | + dataType: 1, | ||
| 85 | + startTime: 1788375480, | ||
| 86 | + endTime: 1788378420, | ||
| 87 | + ), | ||
| 88 | + HealthKitRawDataPoint( | ||
| 89 | + dataType: 4, | ||
| 90 | + startTime: 1788378420, | ||
| 91 | + endTime: 1788379200, | ||
| 92 | + ), | ||
| 93 | + HealthKitRawDataPoint( | ||
| 94 | + dataType: 2, | ||
| 95 | + startTime: 1788379200, | ||
| 96 | + endTime: 1788379380, | ||
| 97 | + ), | ||
| 98 | + HealthKitRawDataPoint( | ||
| 99 | + dataType: 1, | ||
| 100 | + startTime: 1788379380, | ||
| 101 | + endTime: 1788381300, | ||
| 102 | + ), | ||
| 103 | + HealthKitRawDataPoint( | ||
| 104 | + dataType: 4, | ||
| 105 | + startTime: 1788381300, | ||
| 106 | + endTime: 1788383100, | ||
| 107 | + ), | ||
| 108 | + HealthKitRawDataPoint( | ||
| 109 | + dataType: 1, | ||
| 110 | + startTime: 1788383100, | ||
| 111 | + endTime: 1788384660, | ||
| 112 | + ), | ||
| 113 | + HealthKitRawDataPoint( | ||
| 114 | + dataType: 4, | ||
| 115 | + startTime: 1788384660, | ||
| 116 | + endTime: 1788385020, | ||
| 117 | + ), | ||
| 118 | + HealthKitRawDataPoint( | ||
| 119 | + dataType: 1, | ||
| 120 | + startTime: 1788385020, | ||
| 121 | + endTime: 1788385740, | ||
| 122 | + ), | ||
| 123 | + HealthKitRawDataPoint( | ||
| 124 | + dataType: 4, | ||
| 125 | + startTime: 1788385740, | ||
| 126 | + endTime: 1788386460, | ||
| 127 | + ), | ||
| 128 | + HealthKitRawDataPoint( | ||
| 129 | + dataType: 1, | ||
| 130 | + startTime: 1788386460, | ||
| 131 | + endTime: 1788387480, | ||
| 132 | + ), | ||
| 133 | + HealthKitRawDataPoint( | ||
| 134 | + dataType: 5, | ||
| 135 | + startTime: 1788387480, | ||
| 136 | + endTime: 1788389640, | ||
| 137 | + ), | ||
| 138 | + HealthKitRawDataPoint( | ||
| 139 | + dataType: 1, | ||
| 140 | + startTime: 1788389640, | ||
| 141 | + endTime: 1788391620, | ||
| 142 | + ), | ||
| 143 | + HealthKitRawDataPoint( | ||
| 144 | + dataType: 4, | ||
| 145 | + startTime: 1788391620, | ||
| 146 | + endTime: 1788393660, | ||
| 147 | + ), | ||
| 148 | + HealthKitRawDataPoint( | ||
| 149 | + dataType: 5, | ||
| 150 | + startTime: 1788393660, | ||
| 151 | + endTime: 1788394320, | ||
| 152 | + ), | ||
| 153 | + HealthKitRawDataPoint( | ||
| 154 | + dataType: 1, | ||
| 155 | + startTime: 1788394320, | ||
| 156 | + endTime: 1788395760, | ||
| 157 | + ), | ||
| 158 | + HealthKitRawDataPoint( | ||
| 159 | + dataType: 5, | ||
| 160 | + startTime: 1788395760, | ||
| 161 | + endTime: 1788396300, | ||
| 162 | + ), | ||
| 163 | + HealthKitRawDataPoint( | ||
| 164 | + dataType: 1, | ||
| 165 | + startTime: 1788396300, | ||
| 166 | + endTime: 1788397680, | ||
| 167 | + ), | ||
| 168 | + HealthKitRawDataPoint( | ||
| 169 | + dataType: 2, | ||
| 170 | + startTime: 1788397680, | ||
| 171 | + endTime: 1788398340, | ||
| 172 | + ), | ||
| 173 | + ], | ||
| 174 | + ), | ||
| 175 | + localStore: store, | ||
| 176 | + userIdProvider: () => 42, | ||
| 177 | + uploadResultsAfterCalculation: false, | ||
| 178 | + healthReadAuthorizationChecker: () async => false, | ||
| 179 | + ); | ||
| 180 | + | ||
| 181 | + final result = await service.syncAndStore( | ||
| 182 | + startTime: 1788375120, | ||
| 183 | + endTime: 1788399300, | ||
| 184 | + readChunkDays: 1, | ||
| 185 | + ); | ||
| 186 | + | ||
| 187 | + expect(result.sleepResults, hasLength(1)); | ||
| 188 | + expect(store.sleepResults.single.awakMinutes, 20); | ||
| 189 | + expect(store.sleepResults.single.sleepMinutes, 6 * 60 + 23); | ||
| 190 | + }); | ||
| 191 | + | ||
| 192 | + test('OHOS core recalculates daily stress for synced historical backfill', | ||
| 193 | + () async { | ||
| 194 | + final backfillDay = DateTime(2026, 8, 30); | ||
| 195 | + final backfillBase = | ||
| 196 | + backfillDay.add(const Duration(hours: 10)).millisecondsSinceEpoch ~/ | ||
| 197 | + 1000; | ||
| 198 | + final latestBase = DateTime(2026, 9, 3, 10).millisecondsSinceEpoch ~/ 1000; | ||
| 199 | + final syncLocalStore = | ||
| 200 | + _FakeOhosHealthRawDataLocalStore(latestDataTime: latestBase); | ||
| 201 | + final remote = _HistoricalBackfillRemoteDataSource( | ||
| 202 | + hrvItems: [_rawItem(1, backfillBase, 45)], | ||
| 203 | + heartRateItems: [ | ||
| 204 | + for (var index = 0; index < 10; index++) | ||
| 205 | + _rawItem(2, backfillBase + index * 6 * 60, 100), | ||
| 206 | + ], | ||
| 207 | + ); | ||
| 208 | + final store = _FakeHealthRawStressLocalStore() | ||
| 209 | + ..hrvStressPoints.add(_existingHrvResult(latestBase)) | ||
| 210 | + ..realtimeStressPoints.add(_existingRealtimeResult(latestBase)); | ||
| 211 | + final service = OHOSHealthRawDataCoreService( | ||
| 212 | + rawDataSource: OhosHealthRawDataSource( | ||
| 213 | + syncService: OhosHealthRawDataSyncService( | ||
| 214 | + remoteDataSource: remote, | ||
| 215 | + localStore: syncLocalStore, | ||
| 216 | + ), | ||
| 217 | + ), | ||
| 218 | + localStore: store, | ||
| 219 | + userIdProvider: () => 42, | ||
| 220 | + uploadResultsAfterCalculation: false, | ||
| 221 | + healthReadAuthorizationChecker: () async => true, | ||
| 222 | + ); | ||
| 223 | + | ||
| 224 | + final result = await service.startCoreCaculate( | ||
| 225 | + endTime: latestBase + Duration.secondsPerHour, | ||
| 226 | + readChunkDays: 1, | ||
| 227 | + ); | ||
| 228 | + | ||
| 229 | + expect( | ||
| 230 | + remote.calls | ||
| 231 | + .where((call) => call.dataType == 1 || call.dataType == 2) | ||
| 232 | + .map((call) => _dateKey(call.startTime)), | ||
| 233 | + everyElement(lessThanOrEqualTo(20260830)), | ||
| 234 | + ); | ||
| 235 | + expect( | ||
| 236 | + store.hrvStressPoints.any((point) => point.rawEndTime == backfillBase), | ||
| 237 | + isTrue, | ||
| 238 | + ); | ||
| 239 | + expect( | ||
| 240 | + store.realtimeStressPoints.any( | ||
| 241 | + (point) => _dateKey(point.rawEndTime) == 20260830, | ||
| 242 | + ), | ||
| 243 | + isTrue, | ||
| 244 | + ); | ||
| 245 | + expect(result.dailyStressPoints.map((point) => point.date), | ||
| 246 | + contains(20260830)); | ||
| 247 | + expect( | ||
| 248 | + store.dailyStressPoints.map((point) => point.date), contains(20260830)); | ||
| 57 | }); | 249 | }); |
| 58 | 250 | ||
| 59 | test('OHOS core aborts calculation and shows debug toast when sync fails', | 251 | test('OHOS core aborts calculation and shows debug toast when sync fails', |
| @@ -112,10 +304,63 @@ class _FailingOhosHealthRawDataRemoteDataSource | @@ -112,10 +304,63 @@ class _FailingOhosHealthRawDataRemoteDataSource | ||
| 112 | } | 304 | } |
| 113 | } | 305 | } |
| 114 | 306 | ||
| 307 | +class _HistoricalBackfillRemoteDataSource | ||
| 308 | + implements OhosHealthRawDataRemoteDataSource { | ||
| 309 | + _HistoricalBackfillRemoteDataSource({ | ||
| 310 | + required this.hrvItems, | ||
| 311 | + required this.heartRateItems, | ||
| 312 | + }); | ||
| 313 | + | ||
| 314 | + final List<OhosHealthRawDataItem> hrvItems; | ||
| 315 | + final List<OhosHealthRawDataItem> heartRateItems; | ||
| 316 | + final calls = <_RemoteRawCall>[]; | ||
| 317 | + | ||
| 318 | + @override | ||
| 319 | + Future<OhosHealthRawDataPage> fetchRawDataPage({ | ||
| 320 | + required int dataType, | ||
| 321 | + required int startTime, | ||
| 322 | + required int endTime, | ||
| 323 | + }) async { | ||
| 324 | + calls.add( | ||
| 325 | + _RemoteRawCall( | ||
| 326 | + dataType: dataType, | ||
| 327 | + startTime: startTime, | ||
| 328 | + endTime: endTime, | ||
| 329 | + ), | ||
| 330 | + ); | ||
| 331 | + if (dataType == 1) return OhosHealthRawDataPage(items: hrvItems); | ||
| 332 | + if (dataType == 2) return OhosHealthRawDataPage(items: heartRateItems); | ||
| 333 | + return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]); | ||
| 334 | + } | ||
| 335 | + | ||
| 336 | + @override | ||
| 337 | + Future<V2ActivityTarget?> fetchActivityGoal() async { | ||
| 338 | + return null; | ||
| 339 | + } | ||
| 340 | +} | ||
| 341 | + | ||
| 342 | +class _RemoteRawCall { | ||
| 343 | + const _RemoteRawCall({ | ||
| 344 | + required this.dataType, | ||
| 345 | + required this.startTime, | ||
| 346 | + required this.endTime, | ||
| 347 | + }); | ||
| 348 | + | ||
| 349 | + final int dataType; | ||
| 350 | + final int startTime; | ||
| 351 | + final int endTime; | ||
| 352 | +} | ||
| 353 | + | ||
| 115 | class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | 354 | class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { |
| 355 | + _FakeOhosHealthRawDataLocalStore({int? latestDataTime}) | ||
| 356 | + : _latestDataTime = latestDataTime; | ||
| 357 | + | ||
| 358 | + final int? _latestDataTime; | ||
| 359 | + final storedBatches = <List<OhosHealthRawDataItem>>[]; | ||
| 360 | + | ||
| 116 | @override | 361 | @override |
| 117 | Future<int?> latestDataTime({required int dataType}) async { | 362 | Future<int?> latestDataTime({required int dataType}) async { |
| 118 | - return null; | 363 | + return _latestDataTime; |
| 119 | } | 364 | } |
| 120 | 365 | ||
| 121 | @override | 366 | @override |
| @@ -124,7 +369,15 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -124,7 +369,15 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 124 | required int startTime, | 369 | required int startTime, |
| 125 | required int endTime, | 370 | required int endTime, |
| 126 | }) async { | 371 | }) async { |
| 127 | - return const <OhosHealthRawDataItem>[]; | 372 | + return storedBatches |
| 373 | + .expand((batch) => batch) | ||
| 374 | + .where( | ||
| 375 | + (item) => | ||
| 376 | + item.dataType == dataType && | ||
| 377 | + item.dataTime >= startTime && | ||
| 378 | + item.dataTime <= endTime, | ||
| 379 | + ) | ||
| 380 | + .toList(growable: false); | ||
| 128 | } | 381 | } |
| 129 | 382 | ||
| 130 | @override | 383 | @override |
| @@ -140,6 +393,7 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -140,6 +393,7 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 140 | required int dataType, | 393 | required int dataType, |
| 141 | required List<OhosHealthRawDataItem> items, | 394 | required List<OhosHealthRawDataItem> items, |
| 142 | }) async { | 395 | }) async { |
| 396 | + storedBatches.add(items); | ||
| 143 | return items.length; | 397 | return items.length; |
| 144 | } | 398 | } |
| 145 | } | 399 | } |
| @@ -301,3 +555,47 @@ HealthKitRawDataPoint _point(int dataType, int time, double value) { | @@ -301,3 +555,47 @@ HealthKitRawDataPoint _point(int dataType, int time, double value) { | ||
| 301 | value: value, | 555 | value: value, |
| 302 | ); | 556 | ); |
| 303 | } | 557 | } |
| 558 | + | ||
| 559 | +OhosHealthRawDataItem _rawItem(int dataType, int time, double value) { | ||
| 560 | + return OhosHealthRawDataItem( | ||
| 561 | + dataType: dataType, | ||
| 562 | + dataTime: time, | ||
| 563 | + payload: <String, Object?>{ | ||
| 564 | + 'time': time, | ||
| 565 | + 'value': value, | ||
| 566 | + 'is_asleep': 0, | ||
| 567 | + }, | ||
| 568 | + ); | ||
| 569 | +} | ||
| 570 | + | ||
| 571 | +HealthRawHrvStressPoint _existingHrvResult(int time) { | ||
| 572 | + return HealthRawHrvStressPoint( | ||
| 573 | + userId: 42, | ||
| 574 | + rawEndTime: time, | ||
| 575 | + rawHrv: 50, | ||
| 576 | + result: 50, | ||
| 577 | + sourceStartTime: time, | ||
| 578 | + sourceEndTime: time, | ||
| 579 | + state: HealthRawStressState.normal, | ||
| 580 | + baselineHrv: 50, | ||
| 581 | + baselineAwakeHrv: 50, | ||
| 582 | + baselineSleepHrv: null, | ||
| 583 | + baselineRestingHr: 65, | ||
| 584 | + ); | ||
| 585 | +} | ||
| 586 | + | ||
| 587 | +HealthRawRealtimeStressPoint _existingRealtimeResult(int time) { | ||
| 588 | + return HealthRawRealtimeStressPoint( | ||
| 589 | + userId: 42, | ||
| 590 | + rawEndTime: time, | ||
| 591 | + rawHr: 80, | ||
| 592 | + result: 50, | ||
| 593 | + sourceStartTime: time, | ||
| 594 | + sourceEndTime: time, | ||
| 595 | + ); | ||
| 596 | +} | ||
| 597 | + | ||
| 598 | +int _dateKey(int seconds) { | ||
| 599 | + final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); | ||
| 600 | + return date.year * 10000 + date.month * 100 + date.day; | ||
| 601 | +} |
| @@ -45,6 +45,58 @@ void main() { | @@ -45,6 +45,58 @@ void main() { | ||
| 45 | expect(points.single.appleExerciseTime, 1800); | 45 | expect(points.single.appleExerciseTime, 1800); |
| 46 | expect(points.single.appleStandHours, 2); | 46 | expect(points.single.appleStandHours, 2); |
| 47 | }); | 47 | }); |
| 48 | + | ||
| 49 | + test('OHOS sleep data keeps server iOS-compatible sleep stages', () async { | ||
| 50 | + const start = 1800000000; | ||
| 51 | + const deepEnd = start + 3600; | ||
| 52 | + const awakeEnd = deepEnd + 600; | ||
| 53 | + const remEnd = awakeEnd + 1800; | ||
| 54 | + final local = _FakeOhosHealthRawDataLocalStore( | ||
| 55 | + itemsByDataType: { | ||
| 56 | + OhosHealthRawDataType.sleepAnalysis: const [ | ||
| 57 | + OhosHealthRawDataItem( | ||
| 58 | + dataType: 4, | ||
| 59 | + dataTime: deepEnd, | ||
| 60 | + payload: { | ||
| 61 | + 'data_type': 4, | ||
| 62 | + 'from_time': start, | ||
| 63 | + 'to_time': deepEnd, | ||
| 64 | + }, | ||
| 65 | + ), | ||
| 66 | + OhosHealthRawDataItem( | ||
| 67 | + dataType: 2, | ||
| 68 | + dataTime: awakeEnd, | ||
| 69 | + payload: { | ||
| 70 | + 'data_type': 2, | ||
| 71 | + 'from_time': deepEnd, | ||
| 72 | + 'to_time': awakeEnd, | ||
| 73 | + }, | ||
| 74 | + ), | ||
| 75 | + OhosHealthRawDataItem( | ||
| 76 | + dataType: 5, | ||
| 77 | + dataTime: remEnd, | ||
| 78 | + payload: { | ||
| 79 | + 'data_type': 5, | ||
| 80 | + 'from_time': awakeEnd, | ||
| 81 | + 'to_time': remEnd, | ||
| 82 | + }, | ||
| 83 | + ), | ||
| 84 | + ], | ||
| 85 | + }, | ||
| 86 | + ); | ||
| 87 | + final syncService = OhosHealthRawDataSyncService( | ||
| 88 | + remoteDataSource: const _FakeOhosHealthRawDataRemoteDataSource(), | ||
| 89 | + localStore: local, | ||
| 90 | + ); | ||
| 91 | + final dataSource = OhosHealthRawDataSource(syncService: syncService); | ||
| 92 | + | ||
| 93 | + final groups = await dataSource.getRawSleepData(start, remEnd); | ||
| 94 | + final points = groups.single.sleepDataPoints; | ||
| 95 | + | ||
| 96 | + expect(points.map((point) => point.dataType), [4, 2, 5]); | ||
| 97 | + expect(points.first.startTime, start); | ||
| 98 | + expect(points.first.endTime, deepEnd); | ||
| 99 | + }); | ||
| 48 | } | 100 | } |
| 49 | 101 | ||
| 50 | class _FakeOhosHealthRawDataRemoteDataSource | 102 | class _FakeOhosHealthRawDataRemoteDataSource |
| @@ -47,6 +47,36 @@ void main() { | @@ -47,6 +47,36 @@ void main() { | ||
| 47 | expect(local.storedBatches.map((e) => e.length), [2]); | 47 | expect(local.storedBatches.map((e) => e.length), [2]); |
| 48 | }); | 48 | }); |
| 49 | 49 | ||
| 50 | + test('syncRawData backfills recent days when local data already exists', | ||
| 51 | + () async { | ||
| 52 | + final latest = _unixSeconds(DateTime(2026, 9, 3, 10)); | ||
| 53 | + final remote = _FakeOhosHealthRawDataRemoteDataSource([ | ||
| 54 | + const OhosHealthRawDataPage( | ||
| 55 | + items: [ | ||
| 56 | + OhosHealthRawDataItem( | ||
| 57 | + dataType: 1, | ||
| 58 | + dataTime: 1788019232, | ||
| 59 | + payload: {'time': 1788019232, 'value': 33}, | ||
| 60 | + ), | ||
| 61 | + ], | ||
| 62 | + ), | ||
| 63 | + ]); | ||
| 64 | + final local = _FakeOhosHealthRawDataLocalStore(latestDataTime: latest); | ||
| 65 | + final service = OhosHealthRawDataSyncService( | ||
| 66 | + remoteDataSource: remote, | ||
| 67 | + localStore: local, | ||
| 68 | + ); | ||
| 69 | + | ||
| 70 | + final result = await service.syncRawData( | ||
| 71 | + dataType: HuaweiHealthDataType.hrv.dataType, | ||
| 72 | + endTime: _unixSeconds(DateTime(2026, 9, 3, 11)), | ||
| 73 | + ); | ||
| 74 | + | ||
| 75 | + expect(_dateKey(remote.calls.single.startTime), 20260827); | ||
| 76 | + expect(result.storedCount, 1); | ||
| 77 | + expect(result.earliestStoredTime, 1788019232); | ||
| 78 | + }); | ||
| 79 | + | ||
| 50 | test('syncRawData falls back to half-year lookback when local data is empty', | 80 | test('syncRawData falls back to half-year lookback when local data is empty', |
| 51 | () async { | 81 | () async { |
| 52 | final now = DateTime(2026, 8, 13, 12); | 82 | final now = DateTime(2026, 8, 13, 12); |
| @@ -483,7 +513,7 @@ void main() { | @@ -483,7 +513,7 @@ void main() { | ||
| 483 | HmSleepDataItem( | 513 | HmSleepDataItem( |
| 484 | fromTime: 1787850000, | 514 | fromTime: 1787850000, |
| 485 | toTime: 1787870000, | 515 | toTime: 1787870000, |
| 486 | - dataType: 100, | 516 | + dataType: 4, |
| 487 | ), | 517 | ), |
| 488 | HmSleepDataItem( | 518 | HmSleepDataItem( |
| 489 | fromTime: 1787880000, | 519 | fromTime: 1787880000, |
| @@ -502,9 +532,10 @@ void main() { | @@ -502,9 +532,10 @@ void main() { | ||
| 502 | expect(client.sleepCalls.single.startDate, 20260801); | 532 | expect(client.sleepCalls.single.startDate, 20260801); |
| 503 | expect(client.sleepCalls.single.endDate, 20260802); | 533 | expect(client.sleepCalls.single.endDate, 20260802); |
| 504 | expect(page.items, hasLength(1)); | 534 | expect(page.items, hasLength(1)); |
| 505 | - expect(page.items.single.dataType, 100); | 535 | + expect(page.items.single.dataType, 4); |
| 506 | expect(page.items.single.dataTime, 1787870000); | 536 | expect(page.items.single.dataTime, 1787870000); |
| 507 | expect(page.items.single.payload['from_time'], 1787850000); | 537 | expect(page.items.single.payload['from_time'], 1787850000); |
| 538 | + expect(page.items.single.payload['data_type'], 4); | ||
| 508 | }); | 539 | }); |
| 509 | 540 | ||
| 510 | test('Harmony remote data source calls dedicated workout API', () async { | 541 | test('Harmony remote data source calls dedicated workout API', () async { |
| @@ -161,6 +161,53 @@ void main() { | @@ -161,6 +161,53 @@ void main() { | ||
| 161 | ); | 161 | ); |
| 162 | }); | 162 | }); |
| 163 | 163 | ||
| 164 | + test('floors average sleep heart rate from result statistics', () { | ||
| 165 | + final day = DateTime(2026, 7, 16); | ||
| 166 | + final sleepStart = DateTime(2026, 7, 15, 23); | ||
| 167 | + final sleepEnd = DateTime(2026, 7, 16, 7); | ||
| 168 | + final result = HealthRawSleepResult( | ||
| 169 | + userId: 1, | ||
| 170 | + date: LocalHealthDataConvert.unixSeconds(sleepEnd), | ||
| 171 | + startDate: LocalHealthDataConvert.unixSeconds(sleepStart), | ||
| 172 | + sleepScore: 85, | ||
| 173 | + sleepState: 1, | ||
| 174 | + inBedMinutes: 480, | ||
| 175 | + awakMinutes: 80, | ||
| 176 | + sleepMinutes: 400, | ||
| 177 | + ); | ||
| 178 | + | ||
| 179 | + final statistics = LocalHealthDataConvert.sleepStatisticsFromResults( | ||
| 180 | + dateRangeType: 3, | ||
| 181 | + days: [day], | ||
| 182 | + previousDays: const [], | ||
| 183 | + sleepResults: [result], | ||
| 184 | + heartRate: [ | ||
| 185 | + HealthKitRawDataPoint( | ||
| 186 | + dataType: 1, | ||
| 187 | + startTime: LocalHealthDataConvert.unixSeconds(sleepStart), | ||
| 188 | + endTime: LocalHealthDataConvert.unixSeconds( | ||
| 189 | + sleepStart.add(const Duration(hours: 1)), | ||
| 190 | + ), | ||
| 191 | + value: 60, | ||
| 192 | + ), | ||
| 193 | + HealthKitRawDataPoint( | ||
| 194 | + dataType: 1, | ||
| 195 | + startTime: LocalHealthDataConvert.unixSeconds( | ||
| 196 | + sleepStart.add(const Duration(hours: 1)), | ||
| 197 | + ), | ||
| 198 | + endTime: LocalHealthDataConvert.unixSeconds( | ||
| 199 | + sleepStart.add(const Duration(hours: 2)), | ||
| 200 | + ), | ||
| 201 | + value: 61, | ||
| 202 | + ), | ||
| 203 | + ], | ||
| 204 | + ); | ||
| 205 | + | ||
| 206 | + expect(statistics.avgHr, 60); | ||
| 207 | + expect(statistics.maxHr, 61); | ||
| 208 | + expect(statistics.minHr, 60); | ||
| 209 | + }); | ||
| 210 | + | ||
| 164 | test('v2 health sleep values prefer sleep statistics trend result', () { | 211 | test('v2 health sleep values prefer sleep statistics trend result', () { |
| 165 | final day = DateTime(2026, 7, 16); | 212 | final day = DateTime(2026, 7, 16); |
| 166 | final result = HealthRawSleepResult( | 213 | final result = HealthRawSleepResult( |
| @@ -503,6 +550,58 @@ void main() { | @@ -503,6 +550,58 @@ void main() { | ||
| 503 | const Duration(minutes: 40).inSeconds, | 550 | const Duration(minutes: 40).inSeconds, |
| 504 | ); | 551 | ); |
| 505 | }); | 552 | }); |
| 553 | + | ||
| 554 | + test('sleep statistics and v2 health data deduplicate same sleep type', () { | ||
| 555 | + final day = DateTime(2026, 7, 16); | ||
| 556 | + final start = DateTime(2026, 7, 16); | ||
| 557 | + final firstEnd = start.add(const Duration(hours: 1)); | ||
| 558 | + final overlapStart = start.add(const Duration(minutes: 30)); | ||
| 559 | + final secondEnd = start.add(const Duration(minutes: 90)); | ||
| 560 | + final deepEnd = start.add(const Duration(hours: 2)); | ||
| 561 | + final intervals = [ | ||
| 562 | + HealthKitRawDataPoint( | ||
| 563 | + dataType: 3, | ||
| 564 | + startTime: LocalHealthDataConvert.unixSeconds(start), | ||
| 565 | + endTime: LocalHealthDataConvert.unixSeconds(firstEnd), | ||
| 566 | + ), | ||
| 567 | + HealthKitRawDataPoint( | ||
| 568 | + dataType: 3, | ||
| 569 | + startTime: LocalHealthDataConvert.unixSeconds(overlapStart), | ||
| 570 | + endTime: LocalHealthDataConvert.unixSeconds(secondEnd), | ||
| 571 | + ), | ||
| 572 | + HealthKitRawDataPoint( | ||
| 573 | + dataType: 4, | ||
| 574 | + startTime: LocalHealthDataConvert.unixSeconds(secondEnd), | ||
| 575 | + endTime: LocalHealthDataConvert.unixSeconds(deepEnd), | ||
| 576 | + ), | ||
| 577 | + ]; | ||
| 578 | + | ||
| 579 | + final statistics = LocalHealthDataConvert.sleepStatistics( | ||
| 580 | + dateRangeType: 3, | ||
| 581 | + days: [day], | ||
| 582 | + previousDays: const [], | ||
| 583 | + sleepIntervals: intervals, | ||
| 584 | + heartRate: const [], | ||
| 585 | + ); | ||
| 586 | + final v2HealthData = LocalHealthDataConvert.v2HealthData( | ||
| 587 | + day: day, | ||
| 588 | + hrvPoints: const [], | ||
| 589 | + activity: const [], | ||
| 590 | + sleepingHeartRate: const [], | ||
| 591 | + restingHeartRate: const [], | ||
| 592 | + sleepIntervals: intervals, | ||
| 593 | + ); | ||
| 594 | + | ||
| 595 | + expect( | ||
| 596 | + statistics.sleepTrendList?.single.totalTime, | ||
| 597 | + const Duration(hours: 2).inSeconds, | ||
| 598 | + ); | ||
| 599 | + expect( | ||
| 600 | + statistics.avgSleepDuration, | ||
| 601 | + const Duration(hours: 2).inSeconds, | ||
| 602 | + ); | ||
| 603 | + expect(v2HealthData.sleepDuration, const Duration(hours: 2).inSeconds); | ||
| 604 | + }); | ||
| 506 | }); | 605 | }); |
| 507 | 606 | ||
| 508 | group('HealthRawDailyStressCalculator', () { | 607 | group('HealthRawDailyStressCalculator', () { |
-
Please register or login to post a comment