Commit 81b8906787ed60d29cd749b2c114e03be9fa8707

Authored by 权海
1 parent dcd3e9d1

feat(ui):ohos 数据同步和算法完善

@@ -9,6 +9,7 @@ import 'package:get/get.dart'; @@ -9,6 +9,7 @@ import 'package:get/get.dart';
9 9
10 import '../../../../data/models/enums/app_enums.dart'; 10 import '../../../../data/models/enums/app_enums.dart';
11 import '../../../../data/models/health/health_v2_models.dart'; 11 import '../../../../data/models/health/health_v2_models.dart';
  12 +import '../../../../l10n/l10n_extensions.dart';
12 import '../../../../pigeon/health_kit_raw_data_api.g.dart'; 13 import '../../../../pigeon/health_kit_raw_data_api.g.dart';
13 import '../../../config/app_environment_config.dart'; 14 import '../../../config/app_environment_config.dart';
14 import '../../../logging/app_logger.dart'; 15 import '../../../logging/app_logger.dart';
@@ -19,6 +20,7 @@ import '../health_raw_data_source.dart'; @@ -19,6 +20,7 @@ import '../health_raw_data_source.dart';
19 import '../health_raw_models.dart'; 20 import '../health_raw_models.dart';
20 import '../health_sleep_calculator.dart'; 21 import '../health_sleep_calculator.dart';
21 import '../platform_ios/apple_health_raw_data_core_service.dart'; 22 import '../platform_ios/apple_health_raw_data_core_service.dart';
  23 +import '../platform_ios/apple_health_raw_local_notification.dart';
22 import 'huawei_health_raw_stress_calculator.dart'; 24 import 'huawei_health_raw_stress_calculator.dart';
23 import 'ohos_health_raw_data_sync_service.dart'; 25 import 'ohos_health_raw_data_sync_service.dart';
24 import 'ohos_health_raw_result_upload_service.dart'; 26 import 'ohos_health_raw_result_upload_service.dart';
@@ -26,6 +28,9 @@ import 'ohos_health_raw_result_upload_service.dart'; @@ -26,6 +28,9 @@ import 'ohos_health_raw_result_upload_service.dart';
26 class OHOSHealthRawDataCoreService { 28 class OHOSHealthRawDataCoreService {
27 static const int defaultLookbackDays = 183; 29 static const int defaultLookbackDays = 183;
28 static const int defaultReadChunkDays = 7; 30 static const int defaultReadChunkDays = 7;
  31 + static const String _calculateLogMarker = '[OHOS_HEALTH_CALCULATE]';
  32 + static const String _dailyStressLogMarker = '[OHOS_DAILY_STRESS_CALC]';
  33 + static const String _sleepCalcLogMarker = '[OHOS_SLEEP_CALC]';
29 34
30 OHOSHealthRawDataCoreService({ 35 OHOSHealthRawDataCoreService({
31 HealthRawDataSource? rawDataSource, 36 HealthRawDataSource? rawDataSource,
@@ -37,6 +42,7 @@ class OHOSHealthRawDataCoreService { @@ -37,6 +42,7 @@ class OHOSHealthRawDataCoreService {
37 HarmonyApi? harmonyApi, 42 HarmonyApi? harmonyApi,
38 Future<bool> Function()? healthReadAuthorizationChecker, 43 Future<bool> Function()? healthReadAuthorizationChecker,
39 OhosHealthRawResultUploadService? resultUploadService, 44 OhosHealthRawResultUploadService? resultUploadService,
  45 + HealthRawLocalNotificationDispatcher? localNotificationDispatcher,
40 }) : _rawDataSource = rawDataSource ?? 46 }) : _rawDataSource = rawDataSource ??
41 OhosHealthRawDataSource( 47 OhosHealthRawDataSource(
42 userIdProvider: userIdProvider, 48 userIdProvider: userIdProvider,
@@ -51,7 +57,9 @@ class OHOSHealthRawDataCoreService { @@ -51,7 +57,9 @@ class OHOSHealthRawDataCoreService {
51 _harmonyApi = harmonyApi ?? 57 _harmonyApi = harmonyApi ??
52 (Get.isRegistered<HarmonyApi>() ? Get.find<HarmonyApi>() : null), 58 (Get.isRegistered<HarmonyApi>() ? Get.find<HarmonyApi>() : null),
53 _healthReadAuthorizationChecker = healthReadAuthorizationChecker, 59 _healthReadAuthorizationChecker = healthReadAuthorizationChecker,
54 - _resultUploadService = resultUploadService; 60 + _resultUploadService = resultUploadService,
  61 + _localNotificationDispatcher = localNotificationDispatcher ??
  62 + HealthRawLocalNotificationDispatcher();
55 63
56 final HealthRawDataSource _rawDataSource; 64 final HealthRawDataSource _rawDataSource;
57 final HealthRawStressLocalStore _localStore; 65 final HealthRawStressLocalStore _localStore;
@@ -62,6 +70,7 @@ class OHOSHealthRawDataCoreService { @@ -62,6 +70,7 @@ class OHOSHealthRawDataCoreService {
62 final HarmonyApi? _harmonyApi; 70 final HarmonyApi? _harmonyApi;
63 final Future<bool> Function()? _healthReadAuthorizationChecker; 71 final Future<bool> Function()? _healthReadAuthorizationChecker;
64 OhosHealthRawResultUploadService? _resultUploadService; 72 OhosHealthRawResultUploadService? _resultUploadService;
  73 + final HealthRawLocalNotificationDispatcher _localNotificationDispatcher;
65 final StreamController<HealthRawDataUpdatedEvent> 74 final StreamController<HealthRawDataUpdatedEvent>
66 _healthDataUpdatedController = 75 _healthDataUpdatedController =
67 StreamController<HealthRawDataUpdatedEvent>.broadcast(); 76 StreamController<HealthRawDataUpdatedEvent>.broadcast();
@@ -222,159 +231,214 @@ class OHOSHealthRawDataCoreService { @@ -222,159 +231,214 @@ class OHOSHealthRawDataCoreService {
222 if (effectiveEndTime < requestedStartTime) { 231 if (effectiveEndTime < requestedStartTime) {
223 throw ArgumentError.value(endTime, 'endTime'); 232 throw ArgumentError.value(endTime, 'endTime');
224 } 233 }
225 - final hasAuthorization = await _hasHealthReadAuthorization();  
226 - final syncResults = hasAuthorization  
227 - ? await _syncCalculationRawDataIfNeeded(  
228 - startTime: requestedStartTime,  
229 - endTime: effectiveEndTime,  
230 - )  
231 - : const <OhosHealthRawDataSyncResult>[];  
232 - if (!hasAuthorization) {  
233 - _logInfo(  
234 - 'skip OHOS raw data sync without health privacy authorization; '  
235 - 'continue calculate and upload from local database',  
236 - );  
237 - } 234 + final hasAuthorization = await _hasHealthReadAuthorizationSafely();
  235 + final syncResults = await _syncCalculationRawDataSafely(
  236 + hasAuthorization: hasAuthorization,
  237 + startTime: requestedStartTime,
  238 + endTime: effectiveEndTime,
  239 + );
238 _logInfo( 240 _logInfo(
239 'calculate_sync_finish startTime=$requestedStartTime ' 241 'calculate_sync_finish startTime=$requestedStartTime '
240 'endTime=$effectiveEndTime syncResults=$syncResults', 242 'endTime=$effectiveEndTime syncResults=$syncResults',
241 ); 243 );
242 244
243 - final hrvContextStart = await _localStore.latestHrvSourceStartTime(userId);  
244 - final realtimeContextStart =  
245 - await _localStore.latestRealtimeSourceStartTime(userId);  
246 - final latestHrvRawEndTime = await _localStore.latestHrvRawEndTime(userId);  
247 - final latestRealtimeRawEndTime =  
248 - await _localStore.latestRealtimeRawEndTime(userId);  
249 - final latestSleepResultTime = await _localStore.latestSleepResultTime(  
250 - userId,  
251 - );  
252 - final hrvStartTime = math.max(  
253 - hrvContextStart ?? requestedStartTime,  
254 - earliestStartTime,  
255 - );  
256 - final realtimeStartTime = math.max(  
257 - realtimeContextStart ?? requestedStartTime,  
258 - earliestStartTime,  
259 - );  
260 - final heartRateStartTime = math.max(  
261 - _minNullable(hrvStartTime, realtimeStartTime) ?? requestedStartTime,  
262 - earliestStartTime,  
263 - );  
264 - final sleepStartTime = math.max(  
265 - latestSleepResultTime == null  
266 - ? requestedStartTime  
267 - : latestSleepResultTime - Duration.secondsPerDay,  
268 - earliestStartTime,  
269 - );  
270 - _logInfo(  
271 - 'calculate_context userId=$userId '  
272 - 'requestedStartTime=$requestedStartTime endTime=$effectiveEndTime '  
273 - 'hrvContextStart=$hrvContextStart '  
274 - 'realtimeContextStart=$realtimeContextStart '  
275 - 'latestHrvRawEndTime=$latestHrvRawEndTime '  
276 - 'latestRealtimeRawEndTime=$latestRealtimeRawEndTime '  
277 - 'latestSleepResultTime=$latestSleepResultTime '  
278 - 'hrvStartTime=$hrvStartTime heartRateStartTime=$heartRateStartTime '  
279 - 'sleepStartTime=$sleepStartTime',  
280 - );  
281 -  
282 - final hrvPoints = await _fetchRawDataInChunks(  
283 - HealthDataUploadType.hrv.type,  
284 - hrvStartTime,  
285 - effectiveEndTime,  
286 - readChunkDays: readChunkDays,  
287 - );  
288 - final heartRatePoints = await _fetchRawDataInChunks(  
289 - HealthDataUploadType.heartRate.type,  
290 - heartRateStartTime,  
291 - effectiveEndTime,  
292 - readChunkDays: readChunkDays,  
293 - );  
294 - final restingHeartRatePoints = await _fetchRawDataInChunks(  
295 - HealthDataUploadType.restingHeartRate.type,  
296 - heartRateStartTime,  
297 - effectiveEndTime,  
298 - readChunkDays: readChunkDays,  
299 - );  
300 - final sleepIntervals = await _fetchSleepIntervalsInChunks(  
301 - sleepStartTime,  
302 - effectiveEndTime,  
303 - readChunkDays: readChunkDays,  
304 - );  
305 - final workoutIntervals = await _fetchWorkoutIntervalsInChunks(  
306 - heartRateStartTime,  
307 - effectiveEndTime, 245 + final storedResult = await _calculateAndStoreSafely(
  246 + userId: userId,
  247 + requestedStartTime: requestedStartTime,
  248 + effectiveEndTime: effectiveEndTime,
  249 + earliestStartTime: earliestStartTime,
308 readChunkDays: readChunkDays, 250 readChunkDays: readChunkDays,
309 ); 251 );
310 - _logInfo(  
311 - 'calculate_raw_counts userId=$userId '  
312 - 'hrv=${hrvPoints.length} heartRate=${heartRatePoints.length} '  
313 - 'restingHeartRate=${restingHeartRatePoints.length} '  
314 - 'sleepIntervals=${sleepIntervals.length} '  
315 - 'workoutIntervals=${workoutIntervals.length}',  
316 - );  
317 -  
318 - final result = await Isolate.run(  
319 - () => HuaweiHealthRawStressCalculator(userId: userId).calculate(  
320 - hrvPoints: hrvPoints,  
321 - heartRatePoints: heartRatePoints,  
322 - restingHeartRatePoints: restingHeartRatePoints,  
323 - sleepIntervals: sleepIntervals,  
324 - workoutIntervals: workoutIntervals,  
325 - startTime: math.min(hrvStartTime, heartRateStartTime),  
326 - endTime: effectiveEndTime,  
327 - ),  
328 - debugName: 'OHOSHealthRawStressCalculator',  
329 - );  
330 - _logInfo(  
331 - 'calculate_result_counts userId=$userId '  
332 - 'hrv=${result.hrvStressPoints.length} '  
333 - 'realtime=${result.realtimeStressPoints.length}',  
334 - );  
335 - final newResult = result.copyWith(  
336 - hrvStressPoints: _filterNewHrvStressPoints(  
337 - result.hrvStressPoints,  
338 - latestHrvRawEndTime,  
339 - ),  
340 - realtimeStressPoints: _filterNewRealtimeStressPoints(  
341 - result.realtimeStressPoints,  
342 - latestRealtimeRawEndTime,  
343 - ),  
344 - );  
345 - _logInfo(  
346 - 'calculate_new_result_counts userId=$userId '  
347 - 'hrv=${newResult.hrvStressPoints.length} '  
348 - 'realtime=${newResult.realtimeStressPoints.length}',  
349 - );  
350 - await _localStore.upsertResult(newResult);  
351 - _logInfo(  
352 - 'calculate_result_stored userId=$userId '  
353 - 'hrv=${newResult.hrvStressPoints.length} '  
354 - 'realtime=${newResult.realtimeStressPoints.length}', 252 + _scheduleResultUpload();
  253 + await _sendLocalNotificationsAfterCalculationSafely(
  254 + result: storedResult.result,
  255 + hasExistingHrv: storedResult.hasExistingHrv,
  256 + hasExistingSleep: storedResult.hasExistingSleep,
355 ); 257 );
  258 + return storedResult.result;
  259 + }
356 260
357 - final dailyStressPoints = await _calculateAndStoreDailyStressPoints(  
358 - userId: userId,  
359 - realtimePoints: newResult.realtimeStressPoints,  
360 - nowSeconds: effectiveEndTime,  
361 - );  
362 - final sleepResults = await _calculateAndStoreSleepResults( 261 + Future<_OhosStoredCalculationResult> _calculateAndStoreSafely({
  262 + required int userId,
  263 + required int requestedStartTime,
  264 + required int effectiveEndTime,
  265 + required int earliestStartTime,
  266 + required int readChunkDays,
  267 + }) async {
  268 + var hasExistingHrv = false;
  269 + var hasExistingSleep = false;
  270 + final emptyResult = HealthRawStressCalculationResult(
363 userId: userId, 271 userId: userId,
364 - sleepIntervals: sleepIntervals,  
365 - latestSleepResultTime: latestSleepResultTime, 272 + hrvStressPoints: const <HealthRawHrvStressPoint>[],
  273 + realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
  274 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
  275 + sleepResults: const <HealthRawSleepResult>[],
366 ); 276 );
367 - _logInfo(  
368 - 'calculate_daily_sleep_stored userId=$userId '  
369 - 'daily=${dailyStressPoints.length} sleep=${sleepResults.length}',  
370 - );  
371 - if (_uploadResultsAfterCalculation) {  
372 - await _uploadResults(); 277 + try {
  278 + final hrvContextStart =
  279 + await _localStore.latestHrvSourceStartTime(userId);
  280 + final realtimeContextStart =
  281 + await _localStore.latestRealtimeSourceStartTime(userId);
  282 + final latestHrvRawEndTime = await _localStore.latestHrvRawEndTime(userId);
  283 + final latestRealtimeRawEndTime =
  284 + await _localStore.latestRealtimeRawEndTime(userId);
  285 + final latestSleepResultTime = await _localStore.latestSleepResultTime(
  286 + userId,
  287 + );
  288 + hasExistingHrv = latestHrvRawEndTime != null;
  289 + hasExistingSleep = latestSleepResultTime != null;
  290 +
  291 + final hrvStartTime = math.max(
  292 + hrvContextStart ?? requestedStartTime,
  293 + earliestStartTime,
  294 + );
  295 + final realtimeStartTime = math.max(
  296 + realtimeContextStart ?? requestedStartTime,
  297 + earliestStartTime,
  298 + );
  299 + final heartRateStartTime = math.max(
  300 + _minNullable(hrvStartTime, realtimeStartTime) ?? requestedStartTime,
  301 + earliestStartTime,
  302 + );
  303 + final sleepStartTime = math.max(
  304 + latestSleepResultTime == null
  305 + ? requestedStartTime
  306 + : latestSleepResultTime - Duration.secondsPerDay,
  307 + earliestStartTime,
  308 + );
  309 + _logInfo(
  310 + 'calculate_context userId=$userId '
  311 + 'requestedStartTime=$requestedStartTime endTime=$effectiveEndTime '
  312 + 'hrvContextStart=$hrvContextStart '
  313 + 'realtimeContextStart=$realtimeContextStart '
  314 + 'latestHrvRawEndTime=$latestHrvRawEndTime '
  315 + 'latestRealtimeRawEndTime=$latestRealtimeRawEndTime '
  316 + 'latestSleepResultTime=$latestSleepResultTime '
  317 + 'hrvStartTime=$hrvStartTime heartRateStartTime=$heartRateStartTime '
  318 + 'sleepStartTime=$sleepStartTime',
  319 + );
  320 +
  321 + final hrvPoints = await _fetchRawDataInChunks(
  322 + HealthDataUploadType.hrv.type,
  323 + hrvStartTime,
  324 + effectiveEndTime,
  325 + readChunkDays: readChunkDays,
  326 + );
  327 + final heartRatePoints = await _fetchRawDataInChunks(
  328 + HealthDataUploadType.heartRate.type,
  329 + heartRateStartTime,
  330 + effectiveEndTime,
  331 + readChunkDays: readChunkDays,
  332 + );
  333 + final restingHeartRatePoints = await _fetchRawDataInChunks(
  334 + HealthDataUploadType.restingHeartRate.type,
  335 + heartRateStartTime,
  336 + effectiveEndTime,
  337 + readChunkDays: readChunkDays,
  338 + );
  339 + final sleepIntervals = await _fetchSleepIntervalsInChunks(
  340 + sleepStartTime,
  341 + effectiveEndTime,
  342 + readChunkDays: readChunkDays,
  343 + );
  344 + final workoutIntervals = await _fetchWorkoutIntervalsInChunks(
  345 + heartRateStartTime,
  346 + effectiveEndTime,
  347 + readChunkDays: readChunkDays,
  348 + );
  349 + final latestRawHrvTime = _latestRawDataTime(hrvPoints);
  350 + final latestRawHrTime = _latestRawDataTime(heartRatePoints);
  351 + final latestRawSleepTime = _latestRawDataTime(sleepIntervals);
  352 + _logInfo(
  353 + '$_calculateLogMarker raw_counts userId=$userId '
  354 + 'hrv=${hrvPoints.length} heartRate=${heartRatePoints.length} '
  355 + 'restingHeartRate=${restingHeartRatePoints.length} '
  356 + 'sleepIntervals=${sleepIntervals.length} '
  357 + 'workoutIntervals=${workoutIntervals.length} '
  358 + 'latestRawHrvTime=$latestRawHrvTime '
  359 + 'latestRawHrTime=$latestRawHrTime '
  360 + 'latestRawSleepTime=$latestRawSleepTime '
  361 + 'needHrv=${_needsNewResult(latestRawHrvTime, latestHrvRawEndTime)} '
  362 + 'needRealtime=${_needsNewResult(latestRawHrTime, latestRealtimeRawEndTime)} '
  363 + 'needSleep=${_needsNewResult(latestRawSleepTime, latestSleepResultTime)}',
  364 + );
  365 +
  366 + final result = await Isolate.run(
  367 + () => HuaweiHealthRawStressCalculator(userId: userId).calculate(
  368 + hrvPoints: hrvPoints,
  369 + heartRatePoints: heartRatePoints,
  370 + restingHeartRatePoints: restingHeartRatePoints,
  371 + sleepIntervals: sleepIntervals,
  372 + workoutIntervals: workoutIntervals,
  373 + startTime: math.min(hrvStartTime, heartRateStartTime),
  374 + endTime: effectiveEndTime,
  375 + ),
  376 + debugName: 'OHOSHealthRawStressCalculator',
  377 + );
  378 + _logInfo(
  379 + '$_calculateLogMarker result_counts userId=$userId '
  380 + 'hrv=${result.hrvStressPoints.length} '
  381 + 'realtime=${result.realtimeStressPoints.length} '
  382 + 'daily=${result.dailyStressPoints.length}',
  383 + );
  384 + final newResult = result.copyWith(
  385 + hrvStressPoints: _filterNewHrvStressPoints(
  386 + result.hrvStressPoints,
  387 + latestHrvRawEndTime,
  388 + ),
  389 + realtimeStressPoints: _filterNewRealtimeStressPoints(
  390 + result.realtimeStressPoints,
  391 + latestRealtimeRawEndTime,
  392 + ),
  393 + );
  394 + _logInfo(
  395 + '$_calculateLogMarker new_result_counts userId=$userId '
  396 + 'latestHrvRawEndTime=$latestHrvRawEndTime '
  397 + 'latestRealtimeRawEndTime=$latestRealtimeRawEndTime '
  398 + 'hrv=${newResult.hrvStressPoints.length} '
  399 + 'realtime=${newResult.realtimeStressPoints.length}',
  400 + );
  401 + await _localStore.upsertResult(newResult);
  402 + _logInfo(
  403 + '$_calculateLogMarker result_stored userId=$userId '
  404 + 'hrv=${newResult.hrvStressPoints.length} '
  405 + 'realtime=${newResult.realtimeStressPoints.length}',
  406 + );
  407 +
  408 + final dailyStressPoints = await _calculateAndStoreDailyStressPoints(
  409 + userId: userId,
  410 + realtimePoints: newResult.realtimeStressPoints,
  411 + nowSeconds: effectiveEndTime,
  412 + );
  413 + final sleepResults = await _calculateAndStoreSleepResults(
  414 + userId: userId,
  415 + sleepIntervals: sleepIntervals,
  416 + latestSleepResultTime: latestSleepResultTime,
  417 + );
  418 + _logInfo(
  419 + 'calculate_daily_sleep_stored userId=$userId '
  420 + 'daily=${dailyStressPoints.length} sleep=${sleepResults.length}',
  421 + );
  422 + return _OhosStoredCalculationResult(
  423 + result: newResult.copyWith(
  424 + dailyStressPoints: dailyStressPoints,
  425 + sleepResults: sleepResults,
  426 + ),
  427 + hasExistingHrv: hasExistingHrv,
  428 + hasExistingSleep: hasExistingSleep,
  429 + );
  430 + } catch (error, stackTrace) {
  431 + _logError(
  432 + '$_calculateLogMarker calculate_failed userId=$userId',
  433 + error,
  434 + stackTrace,
  435 + );
  436 + return _OhosStoredCalculationResult(
  437 + result: emptyResult,
  438 + hasExistingHrv: hasExistingHrv,
  439 + hasExistingSleep: hasExistingSleep,
  440 + );
373 } 441 }
374 - return newResult.copyWith(  
375 - dailyStressPoints: dailyStressPoints,  
376 - sleepResults: sleepResults,  
377 - );  
378 } 442 }
379 443
380 Stream<HealthKitRawDataPoint> streamRawData({ 444 Stream<HealthKitRawDataPoint> streamRawData({
@@ -562,6 +626,267 @@ class OHOSHealthRawDataCoreService { @@ -562,6 +626,267 @@ class OHOSHealthRawDataCoreService {
562 } 626 }
563 } 627 }
564 628
  629 + void _scheduleResultUpload() {
  630 + if (!_uploadResultsAfterCalculation) return;
  631 + _logInfo('upload results scheduled');
  632 + unawaited(
  633 + _uploadResults().catchError((Object error, StackTrace stackTrace) {
  634 + _logError('upload results async failed', error, stackTrace);
  635 + return false;
  636 + }),
  637 + );
  638 + }
  639 +
  640 + Future<void> _sendLocalNotificationsAfterCalculationSafely({
  641 + required HealthRawStressCalculationResult result,
  642 + required bool hasExistingHrv,
  643 + required bool hasExistingSleep,
  644 + }) async {
  645 + try {
  646 + await _sendLocalNotificationsAfterCalculation(
  647 + result: result,
  648 + hasExistingHrv: hasExistingHrv,
  649 + hasExistingSleep: hasExistingSleep,
  650 + );
  651 + } catch (error, stackTrace) {
  652 + _logError('send local notifications failed', error, stackTrace);
  653 + }
  654 + }
  655 +
  656 + Future<void> _sendLocalNotificationsAfterCalculation({
  657 + required HealthRawStressCalculationResult result,
  658 + required bool hasExistingHrv,
  659 + required bool hasExistingSleep,
  660 + }) async {
  661 + _logInfo(
  662 + 'local_notification_start userId=${result.userId} '
  663 + 'hasExistingHrv=$hasExistingHrv hasExistingSleep=$hasExistingSleep '
  664 + 'hrv=${result.hrvStressPoints.length} '
  665 + 'realtime=${result.realtimeStressPoints.length} '
  666 + 'sleep=${result.sleepResults.length}',
  667 + );
  668 + if (result.hrvStressPoints.isEmpty &&
  669 + result.realtimeStressPoints.isEmpty &&
  670 + result.sleepResults.isEmpty) {
  671 + _logInfo('local_notification_skip reason=empty_calculation_result');
  672 + return;
  673 + }
  674 +
  675 + final latestRealtimeStressPoint = result.realtimeStressPoints.isEmpty
  676 + ? null
  677 + : result.realtimeStressPoints.reduce(
  678 + (a, b) => a.rawEndTime >= b.rawEndTime ? a : b,
  679 + );
  680 + var notificationResult = result;
  681 + var realtimeWindow = const <HealthRawRealtimeStressPoint>[];
  682 + var realtimeSleepIntervals = const <HealthRawSleepInterval>[];
  683 + if (latestRealtimeStressPoint != null) {
  684 + if (latestRealtimeStressPoint.isWorkout ||
  685 + latestRealtimeStressPoint.isWorkoutRecovery) {
  686 + _logInfo(
  687 + 'local_notification_realtime_suppressed_before_build '
  688 + 'reason=workout_or_recovery '
  689 + 'rawEndTime=${latestRealtimeStressPoint.rawEndTime}',
  690 + );
  691 + await _recordRealtimeStressTimeSafely(
  692 + userId: result.userId,
  693 + recordTime: latestRealtimeStressPoint.rawEndTime,
  694 + );
  695 + notificationResult = result.copyWith(
  696 + realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
  697 + );
  698 + } else {
  699 + realtimeWindow = await _queryRealtimeStressNotificationWindowSafely(
  700 + userId: result.userId,
  701 + latestRawEndTime: latestRealtimeStressPoint.rawEndTime,
  702 + );
  703 + realtimeSleepIntervals = await _queryRealtimeStressSleepIntervalsSafely(
  704 + userId: result.userId,
  705 + latestRawEndTime: latestRealtimeStressPoint.rawEndTime,
  706 + );
  707 + }
  708 + }
  709 +
  710 + final record = await _readLocalNotificationRecordSafely(result.userId);
  711 + final notifications = HealthRawLocalNotificationBuilder(l10n).build(
  712 + result: notificationResult,
  713 + hasExistingHrv: hasExistingHrv,
  714 + hasExistingSleep: hasExistingSleep,
  715 + realtimeWindow: realtimeWindow,
  716 + realtimeSleepIntervals: realtimeSleepIntervals,
  717 + lastRealtimeStressPushSendTime: record.lastRealtimeStressTime,
  718 + record: record,
  719 + );
  720 + _logInfo(
  721 + 'local_notification_built userId=${result.userId} '
  722 + 'count=${notifications.length} '
  723 + 'types=${notifications.map((e) => e.recordType.name).join(',')}',
  724 + );
  725 + if (notifications.isEmpty) return;
  726 +
  727 + final sentNotifications = <HealthRawLocalNotification>[];
  728 + for (final notification in notifications) {
  729 + final sent = await _sendLocalNotificationSafely(
  730 + userId: result.userId,
  731 + notification: notification,
  732 + );
  733 + _logInfo(
  734 + 'local_notification_send_finished userId=${result.userId} '
  735 + 'sent=$sent type=${notification.recordType.name} '
  736 + 'recordTime=${notification.recordTime}',
  737 + );
  738 + if (sent) {
  739 + sentNotifications.add(notification);
  740 + }
  741 + }
  742 + _scheduleRealtimeStressServerPush(sentNotifications);
  743 + }
  744 +
  745 + Future<HealthRawLocalNotificationRecord> _readLocalNotificationRecordSafely(
  746 + int userId,
  747 + ) async {
  748 + try {
  749 + return await _localNotificationDispatcher.readRecord(userId);
  750 + } catch (error, stackTrace) {
  751 + _logError('read local notification record failed', error, stackTrace);
  752 + return const HealthRawLocalNotificationRecord();
  753 + }
  754 + }
  755 +
  756 + Future<void> _recordRealtimeStressTimeSafely({
  757 + required int userId,
  758 + required int recordTime,
  759 + }) async {
  760 + try {
  761 + await _localNotificationDispatcher.recordRealtimeStressTime(
  762 + userId: userId,
  763 + recordTime: recordTime,
  764 + );
  765 + } catch (error, stackTrace) {
  766 + _logError('record realtime stress push time failed', error, stackTrace);
  767 + }
  768 + }
  769 +
  770 + Future<List<HealthRawRealtimeStressPoint>>
  771 + _queryRealtimeStressNotificationWindowSafely({
  772 + required int userId,
  773 + required int latestRawEndTime,
  774 + }) async {
  775 + try {
  776 + final points = await _localStore.queryRealtimeStressPoints(
  777 + userId: userId,
  778 + startTime: latestRawEndTime - Duration.secondsPerHour + 1,
  779 + endTime: latestRawEndTime,
  780 + );
  781 + _logInfo(
  782 + 'local_notification_realtime_window userId=$userId '
  783 + 'latestRawEndTime=$latestRawEndTime count=${points.length} '
  784 + 'valid=${points.where((e) => e.result >= 1 && e.result <= 100).length}',
  785 + );
  786 + return points;
  787 + } catch (error, stackTrace) {
  788 + _logError(
  789 + 'query realtime stress notification window failed',
  790 + error,
  791 + stackTrace,
  792 + );
  793 + return const <HealthRawRealtimeStressPoint>[];
  794 + }
  795 + }
  796 +
  797 + Future<List<HealthRawSleepInterval>>
  798 + _queryRealtimeStressSleepIntervalsSafely({
  799 + required int userId,
  800 + required int latestRawEndTime,
  801 + }) async {
  802 + try {
  803 + final (startTime, endTime) = _dayRangeFromDateKey(
  804 + _dateKeyFromUnixSeconds(latestRawEndTime),
  805 + );
  806 + final sleepResults = await _localStore.querySleepResults(
  807 + userId: userId,
  808 + startTime: startTime,
  809 + endTime: endTime,
  810 + );
  811 + final intervals = sleepResults
  812 + .where((result) => result.startDate <= result.date)
  813 + .map(
  814 + (result) => (
  815 + startTime: result.startDate,
  816 + endTime: result.date,
  817 + ),
  818 + )
  819 + .toList(growable: false);
  820 + _logInfo(
  821 + 'local_notification_realtime_sleep_intervals userId=$userId '
  822 + 'latestRawEndTime=$latestRawEndTime count=${intervals.length}',
  823 + );
  824 + return intervals;
  825 + } catch (error, stackTrace) {
  826 + _logError(
  827 + 'query realtime stress sleep intervals failed',
  828 + error,
  829 + stackTrace,
  830 + );
  831 + return const <HealthRawSleepInterval>[];
  832 + }
  833 + }
  834 +
  835 + Future<bool> _sendLocalNotificationSafely({
  836 + required int userId,
  837 + required HealthRawLocalNotification notification,
  838 + }) async {
  839 + try {
  840 + return await _localNotificationDispatcher.sendOne(
  841 + userId: userId,
  842 + notification: notification,
  843 + );
  844 + } catch (error, stackTrace) {
  845 + _logError('send local health notification failed', error, stackTrace);
  846 + return false;
  847 + }
  848 + }
  849 +
  850 + void _scheduleRealtimeStressServerPush(
  851 + Iterable<HealthRawLocalNotification> sentNotifications,
  852 + ) {
  853 + final realtimeNotifications = sentNotifications.where(
  854 + (notification) =>
  855 + notification.recordType ==
  856 + HealthRawLocalNotificationRecordType.realtimeStress &&
  857 + notification.currentState != null,
  858 + );
  859 + for (final notification in realtimeNotifications) {
  860 + unawaited(_pushRealtimeStressServerNotification(notification));
  861 + }
  862 + }
  863 +
  864 + Future<void> _pushRealtimeStressServerNotification(
  865 + HealthRawLocalNotification notification,
  866 + ) async {
  867 + final serverHealthApi = _serverHealthApi;
  868 + if (serverHealthApi == null) return;
  869 + try {
  870 + final result = await serverHealthApi.pushRealtimeStressNotification(
  871 + latestDataTime: notification.recordTime,
  872 + currentState: notification.currentState!,
  873 + );
  874 + if (result is AppFailure) {
  875 + _logError(
  876 + 'push realtime stress server notification failed',
  877 + result.error,
  878 + StackTrace.current,
  879 + );
  880 + }
  881 + } catch (error, stackTrace) {
  882 + _logError(
  883 + 'push realtime stress server notification failed',
  884 + error,
  885 + stackTrace,
  886 + );
  887 + }
  888 + }
  889 +
565 Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataIfNeeded({ 890 Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataIfNeeded({
566 required int startTime, 891 required int startTime,
567 required int endTime, 892 required int endTime,
@@ -577,6 +902,47 @@ class OHOSHealthRawDataCoreService { @@ -577,6 +902,47 @@ class OHOSHealthRawDataCoreService {
577 ); 902 );
578 } 903 }
579 904
  905 + Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataSafely({
  906 + required bool hasAuthorization,
  907 + required int startTime,
  908 + required int endTime,
  909 + }) async {
  910 + if (!hasAuthorization) {
  911 + _logInfo(
  912 + '$_calculateLogMarker sync_skipped reason=no_health_privacy_permission '
  913 + 'startTime=$startTime endTime=$endTime',
  914 + );
  915 + return const <OhosHealthRawDataSyncResult>[];
  916 + }
  917 + try {
  918 + return await _syncCalculationRawDataIfNeeded(
  919 + startTime: startTime,
  920 + endTime: endTime,
  921 + );
  922 + } catch (error, stackTrace) {
  923 + _logError(
  924 + '$_calculateLogMarker sync_failed_continue '
  925 + 'startTime=$startTime endTime=$endTime',
  926 + error,
  927 + stackTrace,
  928 + );
  929 + return const <OhosHealthRawDataSyncResult>[];
  930 + }
  931 + }
  932 +
  933 + Future<bool> _hasHealthReadAuthorizationSafely() async {
  934 + try {
  935 + return await _hasHealthReadAuthorization();
  936 + } catch (error, stackTrace) {
  937 + _logError(
  938 + '$_calculateLogMarker privacy_authorization_failed_continue',
  939 + error,
  940 + stackTrace,
  941 + );
  942 + return false;
  943 + }
  944 + }
  945 +
580 Future<bool> _hasHealthReadAuthorization() async { 946 Future<bool> _hasHealthReadAuthorization() async {
581 final checker = _healthReadAuthorizationChecker; 947 final checker = _healthReadAuthorizationChecker;
582 if (checker != null) { 948 if (checker != null) {
@@ -594,6 +960,19 @@ class OHOSHealthRawDataCoreService { @@ -594,6 +960,19 @@ class OHOSHealthRawDataCoreService {
594 }; 960 };
595 } 961 }
596 962
  963 + int? _latestRawDataTime(List<HealthKitRawDataPoint> points) {
  964 + if (points.isEmpty) return null;
  965 + return points
  966 + .map((point) => point.endTime)
  967 + .reduce((a, b) => a >= b ? a : b);
  968 + }
  969 +
  970 + bool _needsNewResult(int? latestRawTime, int? latestResultTime) {
  971 + if (latestRawTime == null) return false;
  972 + if (latestResultTime == null) return true;
  973 + return latestRawTime > latestResultTime;
  974 + }
  975 +
597 bool _logPrivacyAuthorizationFailure(Object error) { 976 bool _logPrivacyAuthorizationFailure(Object error) {
598 _logInfo('OHOS health privacy authorization check failed: $error'); 977 _logInfo('OHOS health privacy authorization check failed: $error');
599 return false; 978 return false;
@@ -615,16 +994,31 @@ class OHOSHealthRawDataCoreService { @@ -615,16 +994,31 @@ class OHOSHealthRawDataCoreService {
615 userId: userId, 994 userId: userId,
616 dates: affectedDates, 995 dates: affectedDates,
617 ); 996 );
  997 + _logInfo(
  998 + '$_dailyStressLogMarker start userId=$userId nowSeconds=$nowSeconds '
  999 + 'incomingRealtime=${realtimePoints.length} '
  1000 + 'affectedDates=$affectedDates existingDates=$existingDates',
  1001 + );
618 final dailyStressPoints = <HealthRawDailyStressPoint>[]; 1002 final dailyStressPoints = <HealthRawDailyStressPoint>[];
619 final emptyDates = <int>[]; 1003 final emptyDates = <int>[];
620 for (final date in affectedDates) { 1004 for (final date in affectedDates) {
621 - if (date != todayDate && existingDates.contains(date)) continue; 1005 + if (date != todayDate && existingDates.contains(date)) {
  1006 + _logInfo(
  1007 + '$_dailyStressLogMarker skip_existing userId=$userId date=$date',
  1008 + );
  1009 + continue;
  1010 + }
622 final (startTime, endTime) = _dayRangeFromDateKey(date); 1011 final (startTime, endTime) = _dayRangeFromDateKey(date);
623 final dayRealtimePoints = await _localStore.queryRealtimeStressPoints( 1012 final dayRealtimePoints = await _localStore.queryRealtimeStressPoints(
624 userId: userId, 1013 userId: userId,
625 startTime: startTime, 1014 startTime: startTime,
626 endTime: endTime, 1015 endTime: endTime,
627 ); 1016 );
  1017 + _logInfo(
  1018 + '$_dailyStressLogMarker day_query userId=$userId date=$date '
  1019 + 'startTime=$startTime endTime=$endTime '
  1020 + 'dayRealtime=${dayRealtimePoints.length}',
  1021 + );
628 final point = HuaweiHealthRawStressCalculator.calculateDailyStressPoints( 1022 final point = HuaweiHealthRawStressCalculator.calculateDailyStressPoints(
629 userId: userId, 1023 userId: userId,
630 realtimePoints: dayRealtimePoints, 1024 realtimePoints: dayRealtimePoints,
@@ -634,9 +1028,17 @@ class OHOSHealthRawDataCoreService { @@ -634,9 +1028,17 @@ class OHOSHealthRawDataCoreService {
634 ).firstOrNull; 1028 ).firstOrNull;
635 if (point == null) { 1029 if (point == null) {
636 emptyDates.add(date); 1030 emptyDates.add(date);
  1031 + _logInfo(
  1032 + '$_dailyStressLogMarker no_result userId=$userId date=$date',
  1033 + );
637 continue; 1034 continue;
638 } 1035 }
639 dailyStressPoints.add(point); 1036 dailyStressPoints.add(point);
  1037 + _logInfo(
  1038 + '$_dailyStressLogMarker result userId=$userId date=$date '
  1039 + 'stressValue=${point.stressValue} stressScore=${point.stressScore} '
  1040 + 'state=${point.state.value}',
  1041 + );
640 } 1042 }
641 await _localStore.upsertDailyStressPoints( 1043 await _localStore.upsertDailyStressPoints(
642 userId: userId, 1044 userId: userId,
@@ -646,6 +1048,10 @@ class OHOSHealthRawDataCoreService { @@ -646,6 +1048,10 @@ class OHOSHealthRawDataCoreService {
646 userId: userId, 1048 userId: userId,
647 dates: emptyDates, 1049 dates: emptyDates,
648 ); 1050 );
  1051 + _logInfo(
  1052 + '$_dailyStressLogMarker stored userId=$userId '
  1053 + 'stored=${dailyStressPoints.length} deletedEmptyDates=$emptyDates',
  1054 + );
649 return dailyStressPoints; 1055 return dailyStressPoints;
650 } 1056 }
651 1057
@@ -654,12 +1060,24 @@ class OHOSHealthRawDataCoreService { @@ -654,12 +1060,24 @@ class OHOSHealthRawDataCoreService {
654 required List<HealthKitRawDataPoint> sleepIntervals, 1060 required List<HealthKitRawDataPoint> sleepIntervals,
655 required int? latestSleepResultTime, 1061 required int? latestSleepResultTime,
656 }) async { 1062 }) async {
657 - if (sleepIntervals.isEmpty) return const <HealthRawSleepResult>[]; 1063 + _logInfo(
  1064 + '$_sleepCalcLogMarker start userId=$userId '
  1065 + 'sleepIntervals=${sleepIntervals.length} '
  1066 + 'latestSleepResultTime=$latestSleepResultTime',
  1067 + );
  1068 + if (sleepIntervals.isEmpty) {
  1069 + _logInfo('$_sleepCalcLogMarker no_raw_sleep userId=$userId');
  1070 + return const <HealthRawSleepResult>[];
  1071 + }
658 final days = { 1072 final days = {
659 for (final interval in sleepIntervals) 1073 for (final interval in sleepIntervals)
660 DateTime.fromMillisecondsSinceEpoch(interval.endTime * 1000) 1074 DateTime.fromMillisecondsSinceEpoch(interval.endTime * 1000)
661 }.map((date) => DateTime(date.year, date.month, date.day)).toList() 1075 }.map((date) => DateTime(date.year, date.month, date.day)).toList()
662 ..sort((a, b) => a.compareTo(b)); 1076 ..sort((a, b) => a.compareTo(b));
  1077 + _logInfo(
  1078 + '$_sleepCalcLogMarker days userId=$userId '
  1079 + 'days=${days.map(_dateKeyFromDateTime).toList()}',
  1080 + );
663 final results = <HealthRawSleepResult>[]; 1081 final results = <HealthRawSleepResult>[];
664 for (final day in days) { 1082 for (final day in days) {
665 final calculation = HealthSleepCalculator.calculateDay( 1083 final calculation = HealthSleepCalculator.calculateDay(
@@ -669,10 +1087,30 @@ class OHOSHealthRawDataCoreService { @@ -669,10 +1087,30 @@ class OHOSHealthRawDataCoreService {
669 final merged = calculation.mergeSleepTimeRange; 1087 final merged = calculation.mergeSleepTimeRange;
670 final score = calculation.score; 1088 final score = calculation.score;
671 final state = calculation.state; 1089 final state = calculation.state;
672 - if (merged == null || score == null || state == null) continue;  
673 - if (!calculation.hasValidSleep) continue; 1090 + if (merged == null || score == null || state == null) {
  1091 + _logInfo(
  1092 + '$_sleepCalcLogMarker skip_invalid userId=$userId '
  1093 + 'date=${_dateKeyFromDateTime(day)} '
  1094 + 'reason=missing_score_or_state '
  1095 + 'score=$score state=${state?.value}',
  1096 + );
  1097 + continue;
  1098 + }
  1099 + if (!calculation.hasValidSleep) {
  1100 + _logInfo(
  1101 + '$_sleepCalcLogMarker skip_invalid userId=$userId '
  1102 + 'date=${_dateKeyFromDateTime(day)} reason=no_valid_sleep '
  1103 + 'durationSeconds=${calculation.durationSeconds}',
  1104 + );
  1105 + continue;
  1106 + }
674 if (latestSleepResultTime != null && 1107 if (latestSleepResultTime != null &&
675 merged.endTime <= latestSleepResultTime) { 1108 merged.endTime <= latestSleepResultTime) {
  1109 + _logInfo(
  1110 + '$_sleepCalcLogMarker skip_existing userId=$userId '
  1111 + 'date=${_dateKeyFromDateTime(day)} mergedEnd=${merged.endTime} '
  1112 + 'latestSleepResultTime=$latestSleepResultTime',
  1113 + );
676 continue; 1114 continue;
677 } 1115 }
678 results.add( 1116 results.add(
@@ -688,8 +1126,17 @@ class OHOSHealthRawDataCoreService { @@ -688,8 +1126,17 @@ class OHOSHealthRawDataCoreService {
688 uploaded: false, 1126 uploaded: false,
689 ), 1127 ),
690 ); 1128 );
  1129 + _logInfo(
  1130 + '$_sleepCalcLogMarker result userId=$userId '
  1131 + 'date=${_dateKeyFromDateTime(day)} start=${merged.startTime} '
  1132 + 'end=${merged.endTime} score=$score state=${state.value} '
  1133 + 'sleepMinutes=${calculation.summary.sleepMinutes}',
  1134 + );
691 } 1135 }
692 await _localStore.upsertSleepResults(userId: userId, results: results); 1136 await _localStore.upsertSleepResults(userId: userId, results: results);
  1137 + _logInfo(
  1138 + '$_sleepCalcLogMarker stored userId=$userId stored=${results.length}',
  1139 + );
693 return results; 1140 return results;
694 } 1141 }
695 1142
@@ -774,6 +1221,10 @@ class OHOSHealthRawDataCoreService { @@ -774,6 +1221,10 @@ class OHOSHealthRawDataCoreService {
774 1221
775 static int _dateKeyFromUnixSeconds(int seconds) { 1222 static int _dateKeyFromUnixSeconds(int seconds) {
776 final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); 1223 final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
  1224 + return _dateKeyFromDateTime(date);
  1225 + }
  1226 +
  1227 + static int _dateKeyFromDateTime(DateTime date) {
777 return date.year * 10000 + date.month * 100 + date.day; 1228 return date.year * 10000 + date.month * 100 + date.day;
778 } 1229 }
779 1230
@@ -819,3 +1270,15 @@ class OHOSHealthRawDataCoreService { @@ -819,3 +1270,15 @@ class OHOSHealthRawDataCoreService {
819 } 1270 }
820 } 1271 }
821 } 1272 }
  1273 +
  1274 +class _OhosStoredCalculationResult {
  1275 + const _OhosStoredCalculationResult({
  1276 + required this.result,
  1277 + required this.hasExistingHrv,
  1278 + required this.hasExistingSleep,
  1279 + });
  1280 +
  1281 + final HealthRawStressCalculationResult result;
  1282 + final bool hasExistingHrv;
  1283 + final bool hasExistingSleep;
  1284 +}
@@ -281,10 +281,7 @@ class OhosHealthRawDataSyncService { @@ -281,10 +281,7 @@ class OhosHealthRawDataSyncService {
281 _unixSeconds( 281 _unixSeconds(
282 _nowProvider().subtract(const Duration(days: defaultLookbackDays)), 282 _nowProvider().subtract(const Duration(days: defaultLookbackDays)),
283 ); 283 );
284 - final dayStartTime = _startOfLocalDay(baseStartTime);  
285 - final resolvedStartTime = _isDailyDataType(dataType)  
286 - ? dayStartTime - Duration.secondsPerDay  
287 - : dayStartTime; 284 + final resolvedStartTime = _startOfLocalDay(baseStartTime);
288 return resolvedStartTime > endTime ? endTime : resolvedStartTime; 285 return resolvedStartTime > endTime ? endTime : resolvedStartTime;
289 } 286 }
290 287
@@ -297,14 +294,6 @@ class OhosHealthRawDataSyncService { @@ -297,14 +294,6 @@ class OhosHealthRawDataSyncService {
297 return _unixSeconds(DateTime(dateTime.year, dateTime.month, dateTime.day)); 294 return _unixSeconds(DateTime(dateTime.year, dateTime.month, dateTime.day));
298 } 295 }
299 296
300 - bool _isDailyDataType(int dataType) {  
301 - return dataType == HuaweiHealthDataType.activity.dataType ||  
302 - dataType == HuaweiHealthDataType.exerciseDuration.dataType ||  
303 - dataType == HuaweiHealthDataType.standingDuration.dataType ||  
304 - dataType == HuaweiHealthDataType.stepCount.dataType ||  
305 - dataType == OhosHealthRawDataType.activitySummary;  
306 - }  
307 -  
308 List<OhosHealthRawDataFetchRange> _splitIntoFetchRanges({ 297 List<OhosHealthRawDataFetchRange> _splitIntoFetchRanges({
309 required int dataType, 298 required int dataType,
310 required int startTime, 299 required int startTime,
1 import 'package:doublefeel_flutter/core/services/raw_data_service/health_sleep_calculator.dart'; 1 import 'package:doublefeel_flutter/core/services/raw_data_service/health_sleep_calculator.dart';
  2 +import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
2 import 'package:flutter_test/flutter_test.dart'; 3 import 'package:flutter_test/flutter_test.dart';
3 4
4 void main() { 5 void main() {
@@ -11,4 +12,40 @@ void main() { @@ -11,4 +12,40 @@ void main() {
11 expect(HealthSleepCalculator.sleepState(85), HealthSleepState.great); 12 expect(HealthSleepCalculator.sleepState(85), HealthSleepState.great);
12 }); 13 });
13 }); 14 });
  15 +
  16 + test('calculates overnight sleep with iOS-compatible sleep intervals', () {
  17 + final day = DateTime(2026, 8, 31);
  18 + final start = HealthSleepCalculator.unixSeconds(
  19 + day.subtract(const Duration(hours: 1)),
  20 + );
  21 + final deepEnd = HealthSleepCalculator.unixSeconds(
  22 + day.add(const Duration(hours: 2)),
  23 + );
  24 + final end = HealthSleepCalculator.unixSeconds(
  25 + day.add(const Duration(hours: 7)),
  26 + );
  27 +
  28 + final calculation = HealthSleepCalculator.calculateDay(
  29 + day: day,
  30 + sleepIntervals: [
  31 + HealthKitRawDataPoint(
  32 + dataType: 4,
  33 + startTime: start,
  34 + endTime: deepEnd,
  35 + ),
  36 + HealthKitRawDataPoint(
  37 + dataType: 3,
  38 + startTime: deepEnd,
  39 + endTime: end,
  40 + ),
  41 + ],
  42 + );
  43 +
  44 + expect(calculation.hasValidSleep, isTrue);
  45 + expect(calculation.mergeSleepTimeRange?.startTime, start);
  46 + expect(calculation.mergeSleepTimeRange?.endTime, end);
  47 + expect(calculation.summary.sleepMinutes, 8 * 60);
  48 + expect(calculation.score, isNotNull);
  49 + expect(calculation.state, isNotNull);
  50 + });
14 } 51 }
@@ -7,8 +7,7 @@ import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; @@ -7,8 +7,7 @@ import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
7 import 'package:flutter_test/flutter_test.dart'; 7 import 'package:flutter_test/flutter_test.dart';
8 8
9 void main() { 9 void main() {
10 - test('OHOS core calculates and stores Huawei hrv realtime and daily results',  
11 - () async { 10 + test('OHOS core calculates and stores all Huawei result tables', () async {
12 final day = DateTime.now().subtract(const Duration(days: 7)); 11 final day = DateTime.now().subtract(const Duration(days: 7));
13 final base = 12 final base =
14 DateTime(day.year, day.month, day.day, 10).millisecondsSinceEpoch ~/ 13 DateTime(day.year, day.month, day.day, 10).millisecondsSinceEpoch ~/
@@ -20,6 +19,18 @@ void main() { @@ -20,6 +19,18 @@ void main() {
20 1: [_point(1, base + 300, 60)], 19 1: [_point(1, base + 300, 60)],
21 2: [_point(2, base + 300, 80)], 20 2: [_point(2, base + 300, 80)],
22 }, 21 },
  22 + sleepPoints: [
  23 + HealthKitRawDataPoint(
  24 + dataType: 4,
  25 + startTime: base - 8 * 60 * 60,
  26 + endTime: base - 4 * 60 * 60,
  27 + ),
  28 + HealthKitRawDataPoint(
  29 + dataType: 3,
  30 + startTime: base - 4 * 60 * 60,
  31 + endTime: base - 1 * 60 * 60,
  32 + ),
  33 + ],
23 ), 34 ),
24 localStore: store, 35 localStore: store,
25 userIdProvider: () => 42, 36 userIdProvider: () => 42,
@@ -28,7 +39,7 @@ void main() { @@ -28,7 +39,7 @@ void main() {
28 ); 39 );
29 40
30 final result = await service.syncAndStore( 41 final result = await service.syncAndStore(
31 - startTime: base, 42 + startTime: base - 9 * 60 * 60,
32 endTime: base + 600, 43 endTime: base + 600,
33 readChunkDays: 1, 44 readChunkDays: 1,
34 ); 45 );
@@ -37,16 +48,22 @@ void main() { @@ -37,16 +48,22 @@ void main() {
37 expect(result.hrvStressPoints.single.result, 50); 48 expect(result.hrvStressPoints.single.result, 50);
38 expect(result.realtimeStressPoints, hasLength(1)); 49 expect(result.realtimeStressPoints, hasLength(1));
39 expect(result.dailyStressPoints, hasLength(1)); 50 expect(result.dailyStressPoints, hasLength(1));
  51 + expect(result.sleepResults, hasLength(1));
40 expect(store.hrvStressPoints.single.result, 50); 52 expect(store.hrvStressPoints.single.result, 50);
41 expect(store.realtimeStressPoints, hasLength(1)); 53 expect(store.realtimeStressPoints, hasLength(1));
42 expect(store.dailyStressPoints, hasLength(1)); 54 expect(store.dailyStressPoints, hasLength(1));
  55 + expect(store.sleepResults, hasLength(1));
43 }); 56 });
44 } 57 }
45 58
46 class _FakeHealthRawDataSource implements HealthRawDataSource { 59 class _FakeHealthRawDataSource implements HealthRawDataSource {
47 - _FakeHealthRawDataSource({required this.pointsByDataType}); 60 + _FakeHealthRawDataSource({
  61 + required this.pointsByDataType,
  62 + this.sleepPoints = const <HealthKitRawDataPoint>[],
  63 + });
48 64
49 final Map<int, List<HealthKitRawDataPoint>> pointsByDataType; 65 final Map<int, List<HealthKitRawDataPoint>> pointsByDataType;
  66 + final List<HealthKitRawDataPoint> sleepPoints;
50 67
51 @override 68 @override
52 Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) async { 69 Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) async {
@@ -78,7 +95,15 @@ class _FakeHealthRawDataSource implements HealthRawDataSource { @@ -78,7 +95,15 @@ class _FakeHealthRawDataSource implements HealthRawDataSource {
78 int startTime, 95 int startTime,
79 int endTime, 96 int endTime,
80 ) async { 97 ) async {
81 - return const <HealthKitRawSleepDataPoint>[]; 98 + return [
  99 + HealthKitRawSleepDataPoint(
  100 + dataType: 0,
  101 + sleepDataPoints: sleepPoints
  102 + .where((point) =>
  103 + point.endTime >= startTime && point.startTime <= endTime)
  104 + .toList(growable: false),
  105 + ),
  106 + ];
82 } 107 }
83 108
84 @override 109 @override
@@ -99,6 +124,7 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -99,6 +124,7 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore {
99 final hrvStressPoints = <HealthRawHrvStressPoint>[]; 124 final hrvStressPoints = <HealthRawHrvStressPoint>[];
100 final realtimeStressPoints = <HealthRawRealtimeStressPoint>[]; 125 final realtimeStressPoints = <HealthRawRealtimeStressPoint>[];
101 final dailyStressPoints = <HealthRawDailyStressPoint>[]; 126 final dailyStressPoints = <HealthRawDailyStressPoint>[];
  127 + final sleepResults = <HealthRawSleepResult>[];
102 128
103 @override 129 @override
104 Future<void> ensureReadable(int userId) async {} 130 Future<void> ensureReadable(int userId) async {}
@@ -174,7 +200,9 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -174,7 +200,9 @@ class _FakeHealthRawStressLocalStore extends HealthRawStressLocalStore {
174 Future<void> upsertSleepResults({ 200 Future<void> upsertSleepResults({
175 required int userId, 201 required int userId,
176 required Iterable<HealthRawSleepResult> results, 202 required Iterable<HealthRawSleepResult> results,
177 - }) async {} 203 + }) async {
  204 + sleepResults.addAll(results);
  205 + }
178 } 206 }
179 207
180 HealthKitRawDataPoint _point(int dataType, int time, double value) { 208 HealthKitRawDataPoint _point(int dataType, int time, double value) {
@@ -74,8 +74,7 @@ void main() { @@ -74,8 +74,7 @@ void main() {
74 ), 74 ),
75 ); 75 );
76 final expectedStart = 76 final expectedStart =
77 - _unixSeconds(DateTime(fallback.year, fallback.month, fallback.day)) -  
78 - Duration.secondsPerDay; 77 + _unixSeconds(DateTime(fallback.year, fallback.month, fallback.day));
79 final expectedEnd = now.millisecondsSinceEpoch ~/ 1000; 78 final expectedEnd = now.millisecondsSinceEpoch ~/ 1000;
80 79
81 expect(result.startTime, expectedStart); 80 expect(result.startTime, expectedStart);