Commit c2b507de4769c821296904fd29e5ed89f35ce746

Authored by 权海
1 parent 5b5ca491

feat(ui):修复睡眠时间错误

@@ -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', () {