Showing
10 changed files
with
657 additions
and
272 deletions
| @@ -139,7 +139,7 @@ final class HealthKitQueryReader { | @@ -139,7 +139,7 @@ final class HealthKitQueryReader { | ||
| 139 | return try await fetchRawQuantitySamples( | 139 | return try await fetchRawQuantitySamples( |
| 140 | identifier: .appleStandTime, | 140 | identifier: .appleStandTime, |
| 141 | dataType: .stand, | 141 | dataType: .stand, |
| 142 | - unit: .second(), | 142 | + unit: .hour(), |
| 143 | startDate: startDate, | 143 | startDate: startDate, |
| 144 | endDate: endDate | 144 | endDate: endDate |
| 145 | ) | 145 | ) |
| @@ -216,8 +216,8 @@ final class HealthKitQueryReader { | @@ -216,8 +216,8 @@ final class HealthKitQueryReader { | ||
| 216 | appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary), | 216 | appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary), |
| 217 | appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()), | 217 | appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()), |
| 218 | exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary), | 218 | exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary), |
| 219 | - appleStandHours: Self.standSeconds(from: summary.appleStandHours), | ||
| 220 | - standHoursGoal: Self.standHoursGoalValue(from: summary) | 219 | + appleStandHours: summary.appleStandHours.doubleValue(for: .count()), |
| 220 | + standHoursGoal: summary.standHoursGoal?.doubleValue(for: .count()), | ||
| 221 | ) | 221 | ) |
| 222 | } | 222 | } |
| 223 | 223 | ||
| @@ -249,17 +249,6 @@ final class HealthKitQueryReader { | @@ -249,17 +249,6 @@ final class HealthKitQueryReader { | ||
| 249 | return nil | 249 | return nil |
| 250 | } | 250 | } |
| 251 | 251 | ||
| 252 | - private static func standHoursGoalValue(from summary: HKActivitySummary) -> Double? { | ||
| 253 | - if #available(iOS 16.0, *) { | ||
| 254 | - return summary.standHoursGoal.map { Self.standSeconds(from: $0) } | ||
| 255 | - } | ||
| 256 | - return nil | ||
| 257 | - } | ||
| 258 | - | ||
| 259 | - private static func standSeconds(from quantity: HKQuantity) -> Double { | ||
| 260 | - quantity.doubleValue(for: .count()) * 60 * 60 | ||
| 261 | - } | ||
| 262 | - | ||
| 263 | private func fetchHeartRateFamily(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] { | 252 | private func fetchHeartRateFamily(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] { |
| 264 | async let heartRate = fetchQuantitySamples( | 253 | async let heartRate = fetchQuantitySamples( |
| 265 | identifier: .heartRate, | 254 | identifier: .heartRate, |
| @@ -139,7 +139,7 @@ struct NativeActivityTarget { | @@ -139,7 +139,7 @@ struct NativeActivityTarget { | ||
| 139 | // 公里 | 139 | // 公里 |
| 140 | let appleMoveTime: Double? | 140 | let appleMoveTime: Double? |
| 141 | let appleMoveTimeGoal: Double? | 141 | let appleMoveTimeGoal: Double? |
| 142 | - // 分钟 | 142 | + // 秒 |
| 143 | let appleExerciseTime: Double? | 143 | let appleExerciseTime: Double? |
| 144 | let exerciseTimeGoal: Double? | 144 | let exerciseTimeGoal: Double? |
| 145 | //小时 | 145 | //小时 |
| @@ -262,7 +262,7 @@ class TodayController extends GetMaterialController { | @@ -262,7 +262,7 @@ class TodayController extends GetMaterialController { | ||
| 262 | if (success) { | 262 | if (success) { |
| 263 | // await LoadingService.instance.run(() async { | 263 | // await LoadingService.instance.run(() async { |
| 264 | _rawDataApi.performHealthDataUpload(); | 264 | _rawDataApi.performHealthDataUpload(); |
| 265 | - _startCoreCaculate(); | 265 | + _startCoreCaculate(refreshAfterComplete: true); |
| 266 | // }); | 266 | // }); |
| 267 | // result.status = 1; | 267 | // result.status = 1; |
| 268 | // AppToast.show('刷新完成'); | 268 | // AppToast.show('刷新完成'); |
| @@ -278,7 +278,7 @@ class TodayController extends GetMaterialController { | @@ -278,7 +278,7 @@ class TodayController extends GetMaterialController { | ||
| 278 | } else if (result.status == 1) { | 278 | } else if (result.status == 1) { |
| 279 | // await LoadingService.instance.run(() async { | 279 | // await LoadingService.instance.run(() async { |
| 280 | _rawDataApi.performHealthDataUpload(); | 280 | _rawDataApi.performHealthDataUpload(); |
| 281 | - _startCoreCaculate(); | 281 | + _startCoreCaculate(refreshAfterComplete: true); |
| 282 | // }); | 282 | // }); |
| 283 | if (isFromNoPermissionPage) { | 283 | if (isFromNoPermissionPage) { |
| 284 | Get.back(); | 284 | Get.back(); |
| @@ -376,11 +376,14 @@ class TodayController extends GetMaterialController { | @@ -376,11 +376,14 @@ class TodayController extends GetMaterialController { | ||
| 376 | ); | 376 | ); |
| 377 | } | 377 | } |
| 378 | 378 | ||
| 379 | - void _startCoreCaculate() { | 379 | + void _startCoreCaculate({bool refreshAfterComplete = false}) { |
| 380 | unawaited( | 380 | unawaited( |
| 381 | (() async { | 381 | (() async { |
| 382 | try { | 382 | try { |
| 383 | await _healthRawDataCoreService.startCoreCaculate(); | 383 | await _healthRawDataCoreService.startCoreCaculate(); |
| 384 | + if (refreshAfterComplete) { | ||
| 385 | + await loadDataForDate(selectedDate.value); | ||
| 386 | + } | ||
| 384 | } catch (error, stackTrace) { | 387 | } catch (error, stackTrace) { |
| 385 | AppLogger.e('Apple Health calculate failed', error, stackTrace); | 388 | AppLogger.e('Apple Health calculate failed', error, stackTrace); |
| 386 | } | 389 | } |
| @@ -450,13 +453,11 @@ class TodayController extends GetMaterialController { | @@ -450,13 +453,11 @@ class TodayController extends GetMaterialController { | ||
| 450 | case AppFailure(): | 453 | case AppFailure(): |
| 451 | stressChartData.clear(); | 454 | stressChartData.clear(); |
| 452 | } | 455 | } |
| 453 | - if (v2ActivityTarget.value == null) { | ||
| 454 | - switch (await _healthApi.getV2ActivityTarget(friendUserId)) { | ||
| 455 | - case AppSuccess(:final data): | ||
| 456 | - v2ActivityTarget.value = data; | ||
| 457 | - case AppFailure(): | ||
| 458 | - v2ActivityTarget.value = null; | ||
| 459 | - } | 456 | + switch (await _healthApi.getV2ActivityTarget(friendUserId)) { |
| 457 | + case AppSuccess(:final data): | ||
| 458 | + v2ActivityTarget.value = data; | ||
| 459 | + case AppFailure(): | ||
| 460 | + v2ActivityTarget.value = null; | ||
| 460 | } | 461 | } |
| 461 | } | 462 | } |
| 462 | 463 |
| @@ -225,7 +225,7 @@ class TodayActivityCard extends StatelessWidget { | @@ -225,7 +225,7 @@ class TodayActivityCard extends StatelessWidget { | ||
| 225 | ); | 225 | ); |
| 226 | } | 226 | } |
| 227 | 227 | ||
| 228 | - // stand 单位为秒,转换为小时+分钟 | 228 | + // stand 单位为小时 |
| 229 | Widget standWidget; | 229 | Widget standWidget; |
| 230 | if (stand > 0 && (activityTarget?.stand ?? 0) > 0) { | 230 | if (stand > 0 && (activityTarget?.stand ?? 0) > 0) { |
| 231 | standWidget = Text.rich( | 231 | standWidget = Text.rich( |
| @@ -180,11 +180,14 @@ class HealthRawDataCoreService { | @@ -180,11 +180,14 @@ class HealthRawDataCoreService { | ||
| 180 | userId: userId, | 180 | userId: userId, |
| 181 | hrvStressPoints: const <HealthRawHrvStressPoint>[], | 181 | hrvStressPoints: const <HealthRawHrvStressPoint>[], |
| 182 | realtimeStressPoints: const <HealthRawRealtimeStressPoint>[], | 182 | realtimeStressPoints: const <HealthRawRealtimeStressPoint>[], |
| 183 | + dailyStressPoints: const <HealthRawDailyStressPoint>[], | ||
| 183 | ); | 184 | ); |
| 184 | } | 185 | } |
| 185 | - final totalWatch = Stopwatch()..start(); | 186 | + final calculateStartedAt = DateTime.now(); |
| 187 | + _logInfo( | ||
| 188 | + 'calculateStartAt=${_formatDateTimeMilliseconds(calculateStartedAt)}', | ||
| 189 | + ); | ||
| 186 | await _localStore.ensureReadable(userId); | 190 | await _localStore.ensureReadable(userId); |
| 187 | - final readWatch = Stopwatch()..start(); | ||
| 188 | final effectiveEndTime = | 191 | final effectiveEndTime = |
| 189 | endTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; | 192 | endTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; |
| 190 | final earliestStartTime = DateTime.now() | 193 | final earliestStartTime = DateTime.now() |
| @@ -215,8 +218,6 @@ class HealthRawDataCoreService { | @@ -215,8 +218,6 @@ class HealthRawDataCoreService { | ||
| 215 | earliestStartTime, | 218 | earliestStartTime, |
| 216 | ); | 219 | ); |
| 217 | final restingHeartRateStartTime = heartRateStartTime; | 220 | final restingHeartRateStartTime = heartRateStartTime; |
| 218 | - final readStartTime = | ||
| 219 | - math.min(math.min(hrvStartTime, heartRateStartTime), effectiveEndTime); | ||
| 220 | 221 | ||
| 221 | final hrvPoints = await _fetchRawDataInChunks( | 222 | final hrvPoints = await _fetchRawDataInChunks( |
| 222 | HealthDataUploadType.hrv.type, | 223 | HealthDataUploadType.hrv.type, |
| @@ -246,11 +247,7 @@ class HealthRawDataCoreService { | @@ -246,11 +247,7 @@ class HealthRawDataCoreService { | ||
| 246 | effectiveEndTime, | 247 | effectiveEndTime, |
| 247 | readChunkDays: readChunkDays, | 248 | readChunkDays: readChunkDays, |
| 248 | ); | 249 | ); |
| 249 | - readWatch.stop(); | ||
| 250 | 250 | ||
| 251 | - final calculateAndStoreWatch = Stopwatch()..start(); | ||
| 252 | - final calculateStartedAt = DateTime.now(); | ||
| 253 | - final calculateWatch = Stopwatch()..start(); | ||
| 254 | final result = calculate( | 251 | final result = calculate( |
| 255 | hrvPoints: hrvPoints, | 252 | hrvPoints: hrvPoints, |
| 256 | heartRatePoints: heartRatePoints, | 253 | heartRatePoints: heartRatePoints, |
| @@ -260,38 +257,21 @@ class HealthRawDataCoreService { | @@ -260,38 +257,21 @@ class HealthRawDataCoreService { | ||
| 260 | startTime: math.min(hrvStartTime, heartRateStartTime), | 257 | startTime: math.min(hrvStartTime, heartRateStartTime), |
| 261 | endTime: effectiveEndTime, | 258 | endTime: effectiveEndTime, |
| 262 | ); | 259 | ); |
| 263 | - calculateWatch.stop(); | ||
| 264 | - final calculateFinishedAt = DateTime.now(); | ||
| 265 | await _localStore.upsertResult(result); | 260 | await _localStore.upsertResult(result); |
| 266 | - calculateAndStoreWatch.stop(); | ||
| 267 | - totalWatch.stop(); | 261 | + final dailyStressPoints = await _calculateAndStoreDailyStressPoints( |
| 262 | + userId: userId, | ||
| 263 | + realtimePoints: result.realtimeStressPoints, | ||
| 264 | + nowSeconds: effectiveEndTime, | ||
| 265 | + ); | ||
| 266 | + final storedResult = result.copyWith(dailyStressPoints: dailyStressPoints); | ||
| 268 | if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) { | 267 | if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) { |
| 269 | - AppToast.show( | ||
| 270 | - '首次计算完成, 总耗时${_formatDurationMinutesSeconds(totalWatch.elapsed)}', | ||
| 271 | - ); | 268 | + AppToast.show('首次计算完成'); |
| 272 | } | 269 | } |
| 273 | - | 270 | + final calculateFinishedAt = DateTime.now(); |
| 274 | _logInfo( | 271 | _logInfo( |
| 275 | - 'timing userId=$userId ' | ||
| 276 | - 'rangeStart=$readStartTime rangeStartAt=${_formatUnixSeconds(readStartTime)} ' | ||
| 277 | - 'rangeEnd=$effectiveEndTime rangeEndAt=${_formatUnixSeconds(effectiveEndTime)} ' | ||
| 278 | - 'cursorHrvSourceStart=$hrvContextStart ' | ||
| 279 | - 'cursorRealtimeSourceStart=$realtimeContextStart ' | ||
| 280 | - 'hrvStart=$hrvStartTime realtimeStart=$realtimeStartTime ' | ||
| 281 | - 'hrStart=$heartRateStartTime restingHrStart=$restingHeartRateStartTime ' | ||
| 282 | - 'calculateStartAt=${calculateStartedAt.toIso8601String()} ' | ||
| 283 | - 'calculateEndAt=${calculateFinishedAt.toIso8601String()} ' | ||
| 284 | - 'raw(hrv=${hrvPoints.length}, hr=${heartRatePoints.length}, ' | ||
| 285 | - 'restingHr=${restingHeartRatePoints.length}, ' | ||
| 286 | - 'sleep=${sleepIntervals.length}, workout=${workoutIntervals.length}) ' | ||
| 287 | - 'result(hrv=${result.hrvStressPoints.length}, ' | ||
| 288 | - 'realtime=${result.realtimeStressPoints.length}) ' | ||
| 289 | - 'readMs=${readWatch.elapsedMilliseconds} ' | ||
| 290 | - 'calculateMs=${calculateWatch.elapsedMilliseconds} ' | ||
| 291 | - 'calculateAndStoreMs=${calculateAndStoreWatch.elapsedMilliseconds} ' | ||
| 292 | - 'totalMs=${totalWatch.elapsedMilliseconds}', | 272 | + 'calculateEndAt=${_formatDateTimeMilliseconds(calculateFinishedAt)}', |
| 293 | ); | 273 | ); |
| 294 | - return result; | 274 | + return storedResult; |
| 295 | } | 275 | } |
| 296 | 276 | ||
| 297 | Future<bool> _hasHealthReadAuthorization() async { | 277 | Future<bool> _hasHealthReadAuthorization() async { |
| @@ -392,6 +372,17 @@ class HealthRawDataCoreService { | @@ -392,6 +372,17 @@ class HealthRawDataCoreService { | ||
| 392 | ); | 372 | ); |
| 393 | } | 373 | } |
| 394 | 374 | ||
| 375 | + Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({ | ||
| 376 | + required int startDate, | ||
| 377 | + required int endDate, | ||
| 378 | + }) { | ||
| 379 | + return _localStore.queryDailyStressPoints( | ||
| 380 | + userId: _userId, | ||
| 381 | + startDate: startDate, | ||
| 382 | + endDate: endDate, | ||
| 383 | + ); | ||
| 384 | + } | ||
| 385 | + | ||
| 395 | Future<List<HealthKitRawDataPoint>> queryRawDataPoints({ | 386 | Future<List<HealthKitRawDataPoint>> queryRawDataPoints({ |
| 396 | required int dataType, | 387 | required int dataType, |
| 397 | required int startTime, | 388 | required int startTime, |
| @@ -481,6 +472,57 @@ class HealthRawDataCoreService { | @@ -481,6 +472,57 @@ class HealthRawDataCoreService { | ||
| 481 | ); | 472 | ); |
| 482 | } | 473 | } |
| 483 | 474 | ||
| 475 | + Future<List<HealthRawDailyStressPoint>> _calculateAndStoreDailyStressPoints({ | ||
| 476 | + required int userId, | ||
| 477 | + required List<HealthRawRealtimeStressPoint> realtimePoints, | ||
| 478 | + required int nowSeconds, | ||
| 479 | + }) async { | ||
| 480 | + final todayDate = _dateKeyFromUnixSeconds(nowSeconds); | ||
| 481 | + final affectedDates = <int>{ | ||
| 482 | + todayDate, | ||
| 483 | + for (final point in realtimePoints) | ||
| 484 | + _dateKeyFromUnixSeconds(point.rawEndTime), | ||
| 485 | + }.toList() | ||
| 486 | + ..sort(); | ||
| 487 | + final existingDates = await _localStore.existingDailyStressDates( | ||
| 488 | + userId: userId, | ||
| 489 | + dates: affectedDates, | ||
| 490 | + ); | ||
| 491 | + final dailyStressPoints = <HealthRawDailyStressPoint>[]; | ||
| 492 | + final emptyDates = <int>[]; | ||
| 493 | + for (final date in affectedDates) { | ||
| 494 | + if (date != todayDate && existingDates.contains(date)) { | ||
| 495 | + continue; | ||
| 496 | + } | ||
| 497 | + final (startTime, endTime) = _dayRangeFromDateKey(date); | ||
| 498 | + final dayRealtimePoints = await _localStore.queryRealtimeStressPoints( | ||
| 499 | + userId: userId, | ||
| 500 | + startTime: startTime, | ||
| 501 | + endTime: endTime, | ||
| 502 | + ); | ||
| 503 | + final point = HealthRawDailyStressCalculator.calculate( | ||
| 504 | + userId: userId, | ||
| 505 | + date: date, | ||
| 506 | + realtimePoints: dayRealtimePoints, | ||
| 507 | + dataTime: date == todayDate ? nowSeconds : startTime, | ||
| 508 | + ); | ||
| 509 | + if (point == null) { | ||
| 510 | + emptyDates.add(date); | ||
| 511 | + continue; | ||
| 512 | + } | ||
| 513 | + dailyStressPoints.add(point); | ||
| 514 | + } | ||
| 515 | + await _localStore.upsertDailyStressPoints( | ||
| 516 | + userId: userId, | ||
| 517 | + points: dailyStressPoints, | ||
| 518 | + ); | ||
| 519 | + await _localStore.deleteDailyStressDates( | ||
| 520 | + userId: userId, | ||
| 521 | + dates: emptyDates, | ||
| 522 | + ); | ||
| 523 | + return dailyStressPoints; | ||
| 524 | + } | ||
| 525 | + | ||
| 484 | Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks( | 526 | Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks( |
| 485 | int dataType, | 527 | int dataType, |
| 486 | int startTime, | 528 | int startTime, |
| @@ -540,26 +582,37 @@ class HealthRawDataCoreService { | @@ -540,26 +582,37 @@ class HealthRawDataCoreService { | ||
| 540 | return math.min(a, b); | 582 | return math.min(a, b); |
| 541 | } | 583 | } |
| 542 | 584 | ||
| 543 | - static String _formatUnixSeconds(int seconds) { | ||
| 544 | - return DateTime.fromMillisecondsSinceEpoch(seconds * 1000) | ||
| 545 | - .toIso8601String(); | 585 | + static int _dateKeyFromUnixSeconds(int seconds) { |
| 586 | + final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); | ||
| 587 | + return date.year * 10000 + date.month * 100 + date.day; | ||
| 546 | } | 588 | } |
| 547 | 589 | ||
| 548 | - static String _formatDurationMinutesSeconds(Duration duration) { | ||
| 549 | - final minutes = duration.inMinutes; | ||
| 550 | - final seconds = duration.inSeconds.remainder(60); | ||
| 551 | - return '$minutes分钟$seconds秒'; | 590 | + static (int startTime, int endTime) _dayRangeFromDateKey(int dateKey) { |
| 591 | + final year = dateKey ~/ 10000; | ||
| 592 | + final month = (dateKey ~/ 100) % 100; | ||
| 593 | + final day = dateKey % 100; | ||
| 594 | + final start = DateTime(year, month, day); | ||
| 595 | + return ( | ||
| 596 | + start.millisecondsSinceEpoch ~/ 1000, | ||
| 597 | + start.add(const Duration(days: 1)).millisecondsSinceEpoch ~/ 1000 - 1, | ||
| 598 | + ); | ||
| 599 | + } | ||
| 600 | + | ||
| 601 | + static String _formatDateTimeMilliseconds(DateTime dateTime) { | ||
| 602 | + String two(int value) => value.toString().padLeft(2, '0'); | ||
| 603 | + String three(int value) => value.toString().padLeft(3, '0'); | ||
| 604 | + return '${dateTime.year.toString().padLeft(4, '0')}-' | ||
| 605 | + '${two(dateTime.month)}-' | ||
| 606 | + '${two(dateTime.day)}T' | ||
| 607 | + '${two(dateTime.hour)}:' | ||
| 608 | + '${two(dateTime.minute)}:' | ||
| 609 | + '${two(dateTime.second)}.' | ||
| 610 | + '${three(dateTime.millisecond)}'; | ||
| 552 | } | 611 | } |
| 553 | 612 | ||
| 554 | static void _logInfo(String message) { | 613 | static void _logInfo(String message) { |
| 555 | final tagged = 'HealthRawDataCoreService $message'; | 614 | final tagged = 'HealthRawDataCoreService $message'; |
| 556 | - developer.log(tagged, name: 'HealthRawDataCoreService'); | ||
| 557 | debugPrint(tagged); | 615 | debugPrint(tagged); |
| 558 | - try { | ||
| 559 | - AppLogger.i(tagged); | ||
| 560 | - } catch (_) { | ||
| 561 | - // Logger may be unavailable in isolated unit tests. | ||
| 562 | - } | ||
| 563 | } | 616 | } |
| 564 | 617 | ||
| 565 | static void _logError( | 618 | static void _logError( |
| @@ -588,11 +641,26 @@ class HealthRawStressCalculationResult { | @@ -588,11 +641,26 @@ class HealthRawStressCalculationResult { | ||
| 588 | required this.userId, | 641 | required this.userId, |
| 589 | required this.hrvStressPoints, | 642 | required this.hrvStressPoints, |
| 590 | required this.realtimeStressPoints, | 643 | required this.realtimeStressPoints, |
| 644 | + required this.dailyStressPoints, | ||
| 591 | }); | 645 | }); |
| 592 | 646 | ||
| 593 | final int userId; | 647 | final int userId; |
| 594 | final List<HealthRawHrvStressPoint> hrvStressPoints; | 648 | final List<HealthRawHrvStressPoint> hrvStressPoints; |
| 595 | final List<HealthRawRealtimeStressPoint> realtimeStressPoints; | 649 | final List<HealthRawRealtimeStressPoint> realtimeStressPoints; |
| 650 | + final List<HealthRawDailyStressPoint> dailyStressPoints; | ||
| 651 | + | ||
| 652 | + HealthRawStressCalculationResult copyWith({ | ||
| 653 | + List<HealthRawHrvStressPoint>? hrvStressPoints, | ||
| 654 | + List<HealthRawRealtimeStressPoint>? realtimeStressPoints, | ||
| 655 | + List<HealthRawDailyStressPoint>? dailyStressPoints, | ||
| 656 | + }) { | ||
| 657 | + return HealthRawStressCalculationResult( | ||
| 658 | + userId: userId, | ||
| 659 | + hrvStressPoints: hrvStressPoints ?? this.hrvStressPoints, | ||
| 660 | + realtimeStressPoints: realtimeStressPoints ?? this.realtimeStressPoints, | ||
| 661 | + dailyStressPoints: dailyStressPoints ?? this.dailyStressPoints, | ||
| 662 | + ); | ||
| 663 | + } | ||
| 596 | } | 664 | } |
| 597 | 665 | ||
| 598 | class HealthRawDataUpdatedEvent { | 666 | class HealthRawDataUpdatedEvent { |
| @@ -799,6 +867,140 @@ class HealthRawRealtimeStressPoint { | @@ -799,6 +867,140 @@ class HealthRawRealtimeStressPoint { | ||
| 799 | } | 867 | } |
| 800 | } | 868 | } |
| 801 | 869 | ||
| 870 | +class HealthRawDailyStressPoint { | ||
| 871 | + const HealthRawDailyStressPoint({ | ||
| 872 | + required this.userId, | ||
| 873 | + required this.date, | ||
| 874 | + required this.stressValue, | ||
| 875 | + required this.stressScore, | ||
| 876 | + required this.state, | ||
| 877 | + required this.dataTime, | ||
| 878 | + this.uploaded = false, | ||
| 879 | + }); | ||
| 880 | + | ||
| 881 | + final int userId; | ||
| 882 | + final int date; | ||
| 883 | + final double stressValue; | ||
| 884 | + final int stressScore; | ||
| 885 | + final HealthRawStressState state; | ||
| 886 | + final int dataTime; | ||
| 887 | + final bool uploaded; | ||
| 888 | + | ||
| 889 | + factory HealthRawDailyStressPoint.fromDb(Map<String, Object?> row) { | ||
| 890 | + final stressScore = row['stress_score'] as int; | ||
| 891 | + return HealthRawDailyStressPoint( | ||
| 892 | + userId: row['user_id'] as int, | ||
| 893 | + date: row['date'] as int, | ||
| 894 | + stressValue: (row['stress_value'] as num).toDouble(), | ||
| 895 | + stressScore: stressScore, | ||
| 896 | + state: _stressStateFromDb(row['state']) ?? | ||
| 897 | + healthRawRealtimeStressState(stressScore), | ||
| 898 | + dataTime: row['data_time'] as int, | ||
| 899 | + uploaded: (row['uploaded'] as int) == 1, | ||
| 900 | + ); | ||
| 901 | + } | ||
| 902 | +} | ||
| 903 | + | ||
| 904 | +class HealthRawDailyStressCalculator { | ||
| 905 | + const HealthRawDailyStressCalculator._(); | ||
| 906 | + | ||
| 907 | + static const int _confirmedActivityBufferSeconds = 12 * 60; | ||
| 908 | + | ||
| 909 | + static HealthRawDailyStressPoint? calculate({ | ||
| 910 | + required int userId, | ||
| 911 | + required int date, | ||
| 912 | + required List<HealthRawRealtimeStressPoint> realtimePoints, | ||
| 913 | + required int dataTime, | ||
| 914 | + }) { | ||
| 915 | + final validValuePoints = | ||
| 916 | + realtimePoints.where((e) => _isValidStressValue(e.result)).toList(); | ||
| 917 | + final activityBuffers = _confirmedActivityBuffers(realtimePoints); | ||
| 918 | + final values = validValuePoints | ||
| 919 | + .where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers)) | ||
| 920 | + .map((e) => e.result) | ||
| 921 | + .toList() | ||
| 922 | + ..sort(); | ||
| 923 | + if (values.isEmpty) return null; | ||
| 924 | + final stressValue = _average(values); | ||
| 925 | + final stressScore = _dailyStressScore(values).round().clamp(1, 100).toInt(); | ||
| 926 | + return HealthRawDailyStressPoint( | ||
| 927 | + userId: userId, | ||
| 928 | + date: date, | ||
| 929 | + stressValue: stressValue, | ||
| 930 | + stressScore: stressScore, | ||
| 931 | + state: healthRawRealtimeStressState(stressScore), | ||
| 932 | + dataTime: dataTime, | ||
| 933 | + ); | ||
| 934 | + } | ||
| 935 | + | ||
| 936 | + static double _dailyStressScore(List<double> sortedValues) { | ||
| 937 | + final median = _median(sortedValues); | ||
| 938 | + final p75 = _percentile75(sortedValues); | ||
| 939 | + return _round(_clamp(median * 0.60 + p75 * 0.40, 1, 100)); | ||
| 940 | + } | ||
| 941 | + | ||
| 942 | + static List<({int start, int end})> _confirmedActivityBuffers( | ||
| 943 | + List<HealthRawRealtimeStressPoint> realtimePoints, | ||
| 944 | + ) { | ||
| 945 | + final ranges = realtimePoints | ||
| 946 | + .where((e) => | ||
| 947 | + _isValidStressValue(e.result) && | ||
| 948 | + (e.isWorkout || e.isWorkoutRecovery)) | ||
| 949 | + .map((e) => ( | ||
| 950 | + start: e.rawEndTime - _confirmedActivityBufferSeconds, | ||
| 951 | + end: e.rawEndTime + _confirmedActivityBufferSeconds, | ||
| 952 | + )) | ||
| 953 | + .toList() | ||
| 954 | + ..sort((a, b) => a.start.compareTo(b.start)); | ||
| 955 | + if (ranges.isEmpty) return const <({int start, int end})>[]; | ||
| 956 | + | ||
| 957 | + final merged = <({int start, int end})>[]; | ||
| 958 | + var current = ranges.first; | ||
| 959 | + for (final range in ranges.skip(1)) { | ||
| 960 | + if (range.start <= current.end) { | ||
| 961 | + current = ( | ||
| 962 | + start: current.start, | ||
| 963 | + end: math.max(current.end, range.end), | ||
| 964 | + ); | ||
| 965 | + } else { | ||
| 966 | + merged.add(current); | ||
| 967 | + current = range; | ||
| 968 | + } | ||
| 969 | + } | ||
| 970 | + merged.add(current); | ||
| 971 | + return merged; | ||
| 972 | + } | ||
| 973 | + | ||
| 974 | + static bool _isInTimeRanges(int time, List<({int start, int end})> ranges) { | ||
| 975 | + return ranges.any((e) => time >= e.start && time <= e.end); | ||
| 976 | + } | ||
| 977 | + | ||
| 978 | + static bool _isValidStressValue(num value) => value >= 1 && value <= 100; | ||
| 979 | + | ||
| 980 | + static double _average(List<double> values) => | ||
| 981 | + values.reduce((a, b) => a + b) / values.length; | ||
| 982 | + | ||
| 983 | + static double _median(List<double> sortedValues) { | ||
| 984 | + final length = sortedValues.length; | ||
| 985 | + final middle = length ~/ 2; | ||
| 986 | + if (length.isOdd) return sortedValues[middle]; | ||
| 987 | + return (sortedValues[middle - 1] + sortedValues[middle]) / 2; | ||
| 988 | + } | ||
| 989 | + | ||
| 990 | + static double _percentile75(List<double> sortedValues) { | ||
| 991 | + final rank = (sortedValues.length * 0.75).ceil().clamp( | ||
| 992 | + 1, | ||
| 993 | + sortedValues.length, | ||
| 994 | + ); | ||
| 995 | + return sortedValues[rank - 1]; | ||
| 996 | + } | ||
| 997 | + | ||
| 998 | + static double _clamp(num value, num min, num max) => | ||
| 999 | + math.max(min.toDouble(), math.min(max.toDouble(), value.toDouble())); | ||
| 1000 | + | ||
| 1001 | + static double _round(double value) => (value * 10).round() / 10; | ||
| 1002 | +} | ||
| 1003 | + | ||
| 802 | HealthRawStressState healthRawRealtimeStressState(num value) { | 1004 | HealthRawStressState healthRawRealtimeStressState(num value) { |
| 803 | if (value >= 81) return HealthRawStressState.overload; | 1005 | if (value >= 81) return HealthRawStressState.overload; |
| 804 | if (value >= 61) return HealthRawStressState.attention; | 1006 | if (value >= 61) return HealthRawStressState.attention; |
| @@ -834,10 +1036,12 @@ class HealthRawStressDbRows { | @@ -834,10 +1036,12 @@ class HealthRawStressDbRows { | ||
| 834 | const HealthRawStressDbRows({ | 1036 | const HealthRawStressDbRows({ |
| 835 | required this.hrvRows, | 1037 | required this.hrvRows, |
| 836 | required this.realtimeRows, | 1038 | required this.realtimeRows, |
| 1039 | + required this.dailyStressRows, | ||
| 837 | }); | 1040 | }); |
| 838 | 1041 | ||
| 839 | final List<Map<String, Object?>> hrvRows; | 1042 | final List<Map<String, Object?>> hrvRows; |
| 840 | final List<Map<String, Object?>> realtimeRows; | 1043 | final List<Map<String, Object?>> realtimeRows; |
| 1044 | + final List<Map<String, Object?>> dailyStressRows; | ||
| 841 | } | 1045 | } |
| 842 | 1046 | ||
| 843 | class HealthRawStressLocalStore { | 1047 | class HealthRawStressLocalStore { |
| @@ -849,6 +1053,7 @@ class HealthRawStressLocalStore { | @@ -849,6 +1053,7 @@ class HealthRawStressLocalStore { | ||
| 849 | 1053 | ||
| 850 | static const hrvResultsTable = 'hrv_results'; | 1054 | static const hrvResultsTable = 'hrv_results'; |
| 851 | static const realtimeStressResultsTable = 'realtime_stress_results'; | 1055 | static const realtimeStressResultsTable = 'realtime_stress_results'; |
| 1056 | + static const dailyStressResultsTable = 'daily_stress_results'; | ||
| 852 | 1057 | ||
| 853 | final Directory? _rootDirectory; | 1058 | final Directory? _rootDirectory; |
| 854 | final DatabaseFactory? _databaseFactory; | 1059 | final DatabaseFactory? _databaseFactory; |
| @@ -874,6 +1079,48 @@ class HealthRawStressLocalStore { | @@ -874,6 +1079,48 @@ class HealthRawStressLocalStore { | ||
| 874 | }); | 1079 | }); |
| 875 | } | 1080 | } |
| 876 | 1081 | ||
| 1082 | + Future<void> upsertDailyStressPoints({ | ||
| 1083 | + required int userId, | ||
| 1084 | + required Iterable<HealthRawDailyStressPoint> points, | ||
| 1085 | + }) async { | ||
| 1086 | + final db = await _database(userId); | ||
| 1087 | + await db.transaction((txn) async { | ||
| 1088 | + for (final point in points) { | ||
| 1089 | + await _upsertDailyStressResettingUploaded(txn, _dailyStressRow(point)); | ||
| 1090 | + } | ||
| 1091 | + }); | ||
| 1092 | + } | ||
| 1093 | + | ||
| 1094 | + Future<Set<int>> existingDailyStressDates({ | ||
| 1095 | + required int userId, | ||
| 1096 | + required Iterable<int> dates, | ||
| 1097 | + }) async { | ||
| 1098 | + final dateList = dates.toSet().toList(); | ||
| 1099 | + if (dateList.isEmpty) return const <int>{}; | ||
| 1100 | + final db = await _database(userId); | ||
| 1101 | + final rows = await db.query( | ||
| 1102 | + dailyStressResultsTable, | ||
| 1103 | + columns: ['date'], | ||
| 1104 | + where: 'date IN (${List.filled(dateList.length, '?').join(',')})', | ||
| 1105 | + whereArgs: dateList, | ||
| 1106 | + ); | ||
| 1107 | + return rows.map((row) => row['date'] as int).toSet(); | ||
| 1108 | + } | ||
| 1109 | + | ||
| 1110 | + Future<void> deleteDailyStressDates({ | ||
| 1111 | + required int userId, | ||
| 1112 | + required Iterable<int> dates, | ||
| 1113 | + }) async { | ||
| 1114 | + final dateList = dates.toSet().toList(); | ||
| 1115 | + if (dateList.isEmpty) return; | ||
| 1116 | + final db = await _database(userId); | ||
| 1117 | + await db.delete( | ||
| 1118 | + dailyStressResultsTable, | ||
| 1119 | + where: 'date IN (${List.filled(dateList.length, '?').join(',')})', | ||
| 1120 | + whereArgs: dateList, | ||
| 1121 | + ); | ||
| 1122 | + } | ||
| 1123 | + | ||
| 877 | Future<void> ensureReadable(int userId) async { | 1124 | Future<void> ensureReadable(int userId) async { |
| 878 | final db = await _database(userId); | 1125 | final db = await _database(userId); |
| 879 | await db.query( | 1126 | await db.query( |
| @@ -929,6 +1176,21 @@ class HealthRawStressLocalStore { | @@ -929,6 +1176,21 @@ class HealthRawStressLocalStore { | ||
| 929 | return rows.map(HealthRawRealtimeStressPoint.fromDb).toList(); | 1176 | return rows.map(HealthRawRealtimeStressPoint.fromDb).toList(); |
| 930 | } | 1177 | } |
| 931 | 1178 | ||
| 1179 | + Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({ | ||
| 1180 | + required int userId, | ||
| 1181 | + required int startDate, | ||
| 1182 | + required int endDate, | ||
| 1183 | + }) async { | ||
| 1184 | + final db = await _database(userId); | ||
| 1185 | + final rows = await db.query( | ||
| 1186 | + dailyStressResultsTable, | ||
| 1187 | + where: 'date >= ? AND date <= ?', | ||
| 1188 | + whereArgs: [startDate, endDate], | ||
| 1189 | + orderBy: 'date ASC', | ||
| 1190 | + ); | ||
| 1191 | + return rows.map(HealthRawDailyStressPoint.fromDb).toList(); | ||
| 1192 | + } | ||
| 1193 | + | ||
| 932 | Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { | 1194 | Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { |
| 933 | final db = await _database(userId); | 1195 | final db = await _database(userId); |
| 934 | final hrvRows = await db.query( | 1196 | final hrvRows = await db.query( |
| @@ -939,9 +1201,14 @@ class HealthRawStressLocalStore { | @@ -939,9 +1201,14 @@ class HealthRawStressLocalStore { | ||
| 939 | realtimeStressResultsTable, | 1201 | realtimeStressResultsTable, |
| 940 | orderBy: 'raw_end_time ASC', | 1202 | orderBy: 'raw_end_time ASC', |
| 941 | ); | 1203 | ); |
| 1204 | + final dailyStressRows = await db.query( | ||
| 1205 | + dailyStressResultsTable, | ||
| 1206 | + orderBy: 'date ASC', | ||
| 1207 | + ); | ||
| 942 | return HealthRawStressDbRows( | 1208 | return HealthRawStressDbRows( |
| 943 | hrvRows: hrvRows, | 1209 | hrvRows: hrvRows, |
| 944 | realtimeRows: realtimeRows, | 1210 | realtimeRows: realtimeRows, |
| 1211 | + dailyStressRows: dailyStressRows, | ||
| 945 | ); | 1212 | ); |
| 946 | } | 1213 | } |
| 947 | 1214 | ||
| @@ -1015,7 +1282,7 @@ class HealthRawStressLocalStore { | @@ -1015,7 +1282,7 @@ class HealthRawStressLocalStore { | ||
| 1015 | final db = await factory.openDatabase( | 1282 | final db = await factory.openDatabase( |
| 1016 | path, | 1283 | path, |
| 1017 | options: OpenDatabaseOptions( | 1284 | options: OpenDatabaseOptions( |
| 1018 | - version: 4, | 1285 | + version: 5, |
| 1019 | onCreate: (db, version) async { | 1286 | onCreate: (db, version) async { |
| 1020 | await _createTables(db); | 1287 | await _createTables(db); |
| 1021 | }, | 1288 | }, |
| @@ -1030,6 +1297,9 @@ class HealthRawStressLocalStore { | @@ -1030,6 +1297,9 @@ class HealthRawStressLocalStore { | ||
| 1030 | if (oldVersion < 4) { | 1297 | if (oldVersion < 4) { |
| 1031 | await _addHrvRawAndStateColumns(db); | 1298 | await _addHrvRawAndStateColumns(db); |
| 1032 | } | 1299 | } |
| 1300 | + if (oldVersion < 5) { | ||
| 1301 | + await _createDailyStressTable(db); | ||
| 1302 | + } | ||
| 1033 | }, | 1303 | }, |
| 1034 | ), | 1304 | ), |
| 1035 | ); | 1305 | ); |
| @@ -1072,6 +1342,21 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | @@ -1072,6 +1342,21 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | ||
| 1072 | uploaded INTEGER NOT NULL DEFAULT 0 | 1342 | uploaded INTEGER NOT NULL DEFAULT 0 |
| 1073 | ) | 1343 | ) |
| 1074 | '''); | 1344 | '''); |
| 1345 | + await _createDailyStressTable(db); | ||
| 1346 | + } | ||
| 1347 | + | ||
| 1348 | + Future<void> _createDailyStressTable(DatabaseExecutor db) async { | ||
| 1349 | + await db.execute(''' | ||
| 1350 | +CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( | ||
| 1351 | + date INTEGER PRIMARY KEY, | ||
| 1352 | + user_id INTEGER NOT NULL, | ||
| 1353 | + stress_value REAL NOT NULL, | ||
| 1354 | + stress_score INTEGER NOT NULL, | ||
| 1355 | + state INTEGER NOT NULL, | ||
| 1356 | + data_time INTEGER NOT NULL, | ||
| 1357 | + uploaded INTEGER NOT NULL DEFAULT 0 | ||
| 1358 | +) | ||
| 1359 | +'''); | ||
| 1075 | } | 1360 | } |
| 1076 | 1361 | ||
| 1077 | Future<void> _addFlagColumns(DatabaseExecutor db, String table) async { | 1362 | Future<void> _addFlagColumns(DatabaseExecutor db, String table) async { |
| @@ -1181,6 +1466,18 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | @@ -1181,6 +1466,18 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | ||
| 1181 | }; | 1466 | }; |
| 1182 | } | 1467 | } |
| 1183 | 1468 | ||
| 1469 | + Map<String, Object?> _dailyStressRow(HealthRawDailyStressPoint point) { | ||
| 1470 | + return <String, Object?>{ | ||
| 1471 | + 'date': point.date, | ||
| 1472 | + 'user_id': point.userId, | ||
| 1473 | + 'stress_value': point.stressValue, | ||
| 1474 | + 'stress_score': point.stressScore, | ||
| 1475 | + 'state': point.state.value, | ||
| 1476 | + 'data_time': point.dataTime, | ||
| 1477 | + 'uploaded': point.uploaded ? 1 : 0, | ||
| 1478 | + }; | ||
| 1479 | + } | ||
| 1480 | + | ||
| 1184 | Map<String, Object?> _flagsRow(HealthRawPointFlags flags) { | 1481 | Map<String, Object?> _flagsRow(HealthRawPointFlags flags) { |
| 1185 | return <String, Object?>{ | 1482 | return <String, Object?>{ |
| 1186 | 'is_workout': flags.isWorkout ? 1 : 0, | 1483 | 'is_workout': flags.isWorkout ? 1 : 0, |
| @@ -1227,4 +1524,28 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | @@ -1227,4 +1524,28 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | ||
| 1227 | whereArgs: [row['raw_end_time']], | 1524 | whereArgs: [row['raw_end_time']], |
| 1228 | ); | 1525 | ); |
| 1229 | } | 1526 | } |
| 1527 | + | ||
| 1528 | + Future<void> _upsertDailyStressResettingUploaded( | ||
| 1529 | + DatabaseExecutor db, | ||
| 1530 | + Map<String, Object?> row, | ||
| 1531 | + ) async { | ||
| 1532 | + await db.insert( | ||
| 1533 | + dailyStressResultsTable, | ||
| 1534 | + row, | ||
| 1535 | + conflictAlgorithm: ConflictAlgorithm.ignore, | ||
| 1536 | + ); | ||
| 1537 | + await db.update( | ||
| 1538 | + dailyStressResultsTable, | ||
| 1539 | + <String, Object?>{ | ||
| 1540 | + 'user_id': row['user_id'], | ||
| 1541 | + 'stress_value': row['stress_value'], | ||
| 1542 | + 'stress_score': row['stress_score'], | ||
| 1543 | + 'state': row['state'], | ||
| 1544 | + 'data_time': row['data_time'], | ||
| 1545 | + 'uploaded': 0, | ||
| 1546 | + }, | ||
| 1547 | + where: 'date = ?', | ||
| 1548 | + whereArgs: [row['date']], | ||
| 1549 | + ); | ||
| 1550 | + } | ||
| 1230 | } | 1551 | } |
| @@ -59,6 +59,7 @@ class HealthRawStressCalculator { | @@ -59,6 +59,7 @@ class HealthRawStressCalculator { | ||
| 59 | userId: userId, | 59 | userId: userId, |
| 60 | hrvStressPoints: hrvStressPoints, | 60 | hrvStressPoints: hrvStressPoints, |
| 61 | realtimeStressPoints: realtimeStressPoints, | 61 | realtimeStressPoints: realtimeStressPoints, |
| 62 | + dailyStressPoints: const <HealthRawDailyStressPoint>[], | ||
| 62 | ); | 63 | ); |
| 63 | } | 64 | } |
| 64 | 65 |
| @@ -16,7 +16,6 @@ const _sleepTypeAsleepDeep = 4; | @@ -16,7 +16,6 @@ const _sleepTypeAsleepDeep = 4; | ||
| 16 | const _sleepTypeAsleepRem = 5; | 16 | const _sleepTypeAsleepRem = 5; |
| 17 | const _sleepGoalMinutes = 8 * 60.0; | 17 | const _sleepGoalMinutes = 8 * 60.0; |
| 18 | const _sleepContinuityToleranceSeconds = 1; | 18 | const _sleepContinuityToleranceSeconds = 1; |
| 19 | -const _stressConfirmedActivityBufferSeconds = 12 * 60; | ||
| 20 | 19 | ||
| 21 | class LocalHealthDataConvert { | 20 | class LocalHealthDataConvert { |
| 22 | const LocalHealthDataConvert._(); | 21 | const LocalHealthDataConvert._(); |
| @@ -230,7 +229,7 @@ class LocalHealthDataConvert { | @@ -230,7 +229,7 @@ class LocalHealthDataConvert { | ||
| 230 | final data = ActivityBurnStatisticsDataV2( | 229 | final data = ActivityBurnStatisticsDataV2( |
| 231 | totalMove: _sum(daily.map((e) => e.move).toList()), | 230 | totalMove: _sum(daily.map((e) => e.move).toList()), |
| 232 | totalSteps: _sum(daily.map((e) => e.steps).toList()), | 231 | totalSteps: _sum(daily.map((e) => e.steps).toList()), |
| 233 | - totalStand: _sum(daily.map((e) => e.standSeconds).toList()), | 232 | + totalStand: _sum(daily.map((e) => e.standHours).toList()), |
| 234 | totalExercise: _sum(daily.map((e) => e.exerciseSeconds).toList()), | 233 | totalExercise: _sum(daily.map((e) => e.exerciseSeconds).toList()), |
| 235 | avgMove: _averageOrNull(daily.map((e) => e.move).toList()), | 234 | avgMove: _averageOrNull(daily.map((e) => e.move).toList()), |
| 236 | qoqAvgMove: _averageOrNull(previousDaily.map((e) => e.move).toList()), | 235 | qoqAvgMove: _averageOrNull(previousDaily.map((e) => e.move).toList()), |
| @@ -248,7 +247,7 @@ class LocalHealthDataConvert { | @@ -248,7 +247,7 @@ class LocalHealthDataConvert { | ||
| 248 | timeKey: dateKey(item.day), | 247 | timeKey: dateKey(item.day), |
| 249 | value: Value( | 248 | value: Value( |
| 250 | move: item.move, | 249 | move: item.move, |
| 251 | - stand: item.standSeconds, | 250 | + stand: item.standHours, |
| 252 | exercise: item.exerciseSeconds, | 251 | exercise: item.exerciseSeconds, |
| 253 | ), | 252 | ), |
| 254 | ), | 253 | ), |
| @@ -281,17 +280,17 @@ class LocalHealthDataConvert { | @@ -281,17 +280,17 @@ class LocalHealthDataConvert { | ||
| 281 | required List<DateTime> days, | 280 | required List<DateTime> days, |
| 282 | required List<DateTime> previousDays, | 281 | required List<DateTime> previousDays, |
| 283 | required List<HealthRawHrvStressPoint> hrvPoints, | 282 | required List<HealthRawHrvStressPoint> hrvPoints, |
| 284 | - required List<HealthRawRealtimeStressPoint> realtimePoints, | 283 | + required List<HealthRawDailyStressPoint> dailyStressPoints, |
| 285 | }) { | 284 | }) { |
| 286 | final daily = <_HrvDaySummary>[]; | 285 | final daily = <_HrvDaySummary>[]; |
| 287 | for (final day in days) { | 286 | for (final day in days) { |
| 288 | - final summary = _hrvDaySummary(day, hrvPoints, realtimePoints); | 287 | + final summary = _hrvDaySummary(day, hrvPoints, dailyStressPoints); |
| 289 | if (summary.hasData) daily.add(summary); | 288 | if (summary.hasData) daily.add(summary); |
| 290 | } | 289 | } |
| 291 | final validDaily = daily.where((e) => e.hrvAverage != null).toList(); | 290 | final validDaily = daily.where((e) => e.hrvAverage != null).toList(); |
| 292 | final previousDaily = <_HrvDaySummary>[]; | 291 | final previousDaily = <_HrvDaySummary>[]; |
| 293 | for (final day in previousDays) { | 292 | for (final day in previousDays) { |
| 294 | - final summary = _hrvDaySummary(day, hrvPoints, realtimePoints); | 293 | + final summary = _hrvDaySummary(day, hrvPoints, dailyStressPoints); |
| 295 | if (summary.hasData) previousDaily.add(summary); | 294 | if (summary.hasData) previousDaily.add(summary); |
| 296 | } | 295 | } |
| 297 | final trendList = dateRangeType == 2 | 296 | final trendList = dateRangeType == 2 |
| @@ -414,7 +413,7 @@ class LocalHealthDataConvert { | @@ -414,7 +413,7 @@ class LocalHealthDataConvert { | ||
| 414 | )?.round(), | 413 | )?.round(), |
| 415 | move: activitySummary.move.round(), | 414 | move: activitySummary.move.round(), |
| 416 | exercise: activitySummary.exerciseSeconds.round(), | 415 | exercise: activitySummary.exerciseSeconds.round(), |
| 417 | - stand: activitySummary.standSeconds.round(), | 416 | + stand: activitySummary.standHours.round(), |
| 418 | steps: activitySummary.steps.round(), | 417 | steps: activitySummary.steps.round(), |
| 419 | sleepDuration: sleepSummary.durationSeconds.round(), | 418 | sleepDuration: sleepSummary.durationSeconds.round(), |
| 420 | sleepScore: sleepSummary.score?.toDouble(), | 419 | sleepScore: sleepSummary.score?.toDouble(), |
| @@ -471,83 +470,14 @@ class LocalHealthDataConvert { | @@ -471,83 +470,14 @@ class LocalHealthDataConvert { | ||
| 471 | ); | 470 | ); |
| 472 | } | 471 | } |
| 473 | 472 | ||
| 474 | - static V2StressScore v2StressScore( | ||
| 475 | - List<HealthRawRealtimeStressPoint> realtimePoints, { | ||
| 476 | - required int startTime, | ||
| 477 | - required int endTime, | ||
| 478 | - }) { | ||
| 479 | - final dayPoints = realtimePoints | ||
| 480 | - .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime) | ||
| 481 | - .toList(); | ||
| 482 | - final validValuePoints = | ||
| 483 | - dayPoints.where((e) => _isValidStressValue(e.result)).toList(); | ||
| 484 | - final activityBuffers = _confirmedActivityBuffers(realtimePoints); | ||
| 485 | - final validStressValues = validValuePoints | ||
| 486 | - .where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers)) | ||
| 487 | - .map((e) => e.result) | ||
| 488 | - .toList() | ||
| 489 | - ..sort(); | ||
| 490 | - final dailyStress = _dailyStress(validStressValues); | ||
| 491 | - final score = dailyStress?.round(); | 473 | + static V2StressScore v2StressScore(HealthRawDailyStressPoint? dailyStress) { |
| 474 | + final score = dailyStress?.stressScore; | ||
| 492 | return V2StressScore( | 475 | return V2StressScore( |
| 493 | - state: score == null ? 0 : healthRawRealtimeStressState(score).value, | 476 | + state: dailyStress?.state.value ?? 0, |
| 494 | comprehensiveScore: score, | 477 | comprehensiveScore: score, |
| 495 | ); | 478 | ); |
| 496 | } | 479 | } |
| 497 | 480 | ||
| 498 | - static double? _dailyStress(List<double> sortedValues) { | ||
| 499 | - if (sortedValues.isEmpty) return null; | ||
| 500 | - final median = _median(sortedValues)!; | ||
| 501 | - final p75 = _percentile75(sortedValues); | ||
| 502 | - return _round(_clamp(median * 0.60 + p75 * 0.40, 1, 100), 1); | ||
| 503 | - } | ||
| 504 | - | ||
| 505 | - static List<({int start, int end})> _confirmedActivityBuffers( | ||
| 506 | - List<HealthRawRealtimeStressPoint> realtimePoints, | ||
| 507 | - ) { | ||
| 508 | - final ranges = realtimePoints | ||
| 509 | - .where((e) => | ||
| 510 | - _isValidStressValue(e.result) && | ||
| 511 | - (e.isWorkout || e.isWorkoutRecovery)) | ||
| 512 | - .map((e) => ( | ||
| 513 | - start: e.rawEndTime - _stressConfirmedActivityBufferSeconds, | ||
| 514 | - end: e.rawEndTime + _stressConfirmedActivityBufferSeconds, | ||
| 515 | - )) | ||
| 516 | - .toList() | ||
| 517 | - ..sort((a, b) => a.start.compareTo(b.start)); | ||
| 518 | - if (ranges.isEmpty) return const <({int start, int end})>[]; | ||
| 519 | - | ||
| 520 | - final merged = <({int start, int end})>[]; | ||
| 521 | - var current = ranges.first; | ||
| 522 | - for (final range in ranges.skip(1)) { | ||
| 523 | - if (range.start <= current.end) { | ||
| 524 | - current = ( | ||
| 525 | - start: current.start, | ||
| 526 | - end: math.max(current.end, range.end), | ||
| 527 | - ); | ||
| 528 | - } else { | ||
| 529 | - merged.add(current); | ||
| 530 | - current = range; | ||
| 531 | - } | ||
| 532 | - } | ||
| 533 | - merged.add(current); | ||
| 534 | - return merged; | ||
| 535 | - } | ||
| 536 | - | ||
| 537 | - static bool _isInTimeRanges(int time, List<({int start, int end})> ranges) { | ||
| 538 | - return ranges.any((e) => time >= e.start && time <= e.end); | ||
| 539 | - } | ||
| 540 | - | ||
| 541 | - static bool _isValidStressValue(num value) => value >= 1 && value <= 100; | ||
| 542 | - | ||
| 543 | - static double _percentile75(List<double> sortedValues) { | ||
| 544 | - final rank = (sortedValues.length * 0.75).ceil().clamp( | ||
| 545 | - 1, | ||
| 546 | - sortedValues.length, | ||
| 547 | - ); | ||
| 548 | - return sortedValues[rank - 1]; | ||
| 549 | - } | ||
| 550 | - | ||
| 551 | static _SleepDaySummary _sleepDaySummary( | 481 | static _SleepDaySummary _sleepDaySummary( |
| 552 | DateTime day, | 482 | DateTime day, |
| 553 | List<HealthKitRawDataPoint> sleepIntervals, | 483 | List<HealthKitRawDataPoint> sleepIntervals, |
| @@ -598,7 +528,7 @@ class LocalHealthDataConvert { | @@ -598,7 +528,7 @@ class LocalHealthDataConvert { | ||
| 598 | day: day, | 528 | day: day, |
| 599 | move: latest?.activeEnergyBurned ?? 0, | 529 | move: latest?.activeEnergyBurned ?? 0, |
| 600 | steps: latest?.appleMoveTime ?? 0, | 530 | steps: latest?.appleMoveTime ?? 0, |
| 601 | - standSeconds: latest?.appleStandHours ?? 0, | 531 | + standHours: latest?.appleStandHours ?? 0, |
| 602 | exerciseSeconds: latest?.appleExerciseTime ?? 0, | 532 | exerciseSeconds: latest?.appleExerciseTime ?? 0, |
| 603 | moveGoal: latest?.activeEnergyBurnedGoal, | 533 | moveGoal: latest?.activeEnergyBurnedGoal, |
| 604 | stepGoal: latest?.appleMoveTimeGoal, | 534 | stepGoal: latest?.appleMoveTimeGoal, |
| @@ -648,30 +578,33 @@ class LocalHealthDataConvert { | @@ -648,30 +578,33 @@ class LocalHealthDataConvert { | ||
| 648 | static _HrvDaySummary _hrvDaySummary( | 578 | static _HrvDaySummary _hrvDaySummary( |
| 649 | DateTime day, | 579 | DateTime day, |
| 650 | List<HealthRawHrvStressPoint> hrv, | 580 | List<HealthRawHrvStressPoint> hrv, |
| 651 | - List<HealthRawRealtimeStressPoint> realtime, | 581 | + List<HealthRawDailyStressPoint> dailyStress, |
| 652 | ) { | 582 | ) { |
| 653 | final start = unixSeconds(day); | 583 | final start = unixSeconds(day); |
| 654 | final end = unixSeconds(day.add(const Duration(days: 1))); | 584 | final end = unixSeconds(day.add(const Duration(days: 1))); |
| 655 | final dayHrv = | 585 | final dayHrv = |
| 656 | hrv.where((e) => e.rawEndTime >= start && e.rawEndTime < end).toList(); | 586 | hrv.where((e) => e.rawEndTime >= start && e.rawEndTime < end).toList(); |
| 657 | - final dayRealtime = realtime | ||
| 658 | - .where((e) => e.rawEndTime >= start && e.rawEndTime < end) | ||
| 659 | - .toList(); | 587 | + final stress = _dailyStressPointForDay(day, dailyStress); |
| 660 | final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList()); | 588 | final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList()); |
| 661 | - final stressState = _dailyStressState( | ||
| 662 | - realtime, | ||
| 663 | - startTime: start, | ||
| 664 | - endTime: end - 1, | ||
| 665 | - ); | ||
| 666 | return _HrvDaySummary( | 589 | return _HrvDaySummary( |
| 667 | day: day, | 590 | day: day, |
| 668 | hrvAverage: hrvAverage?.toDouble(), | 591 | hrvAverage: hrvAverage?.toDouble(), |
| 669 | - hrAverage: | ||
| 670 | - _averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(), | ||
| 671 | - state: stressState, | 592 | + hrAverage: stress?.stressScore.toDouble(), |
| 593 | + state: stress?.state.value, | ||
| 672 | ); | 594 | ); |
| 673 | } | 595 | } |
| 674 | 596 | ||
| 597 | + static HealthRawDailyStressPoint? _dailyStressPointForDay( | ||
| 598 | + DateTime day, | ||
| 599 | + List<HealthRawDailyStressPoint> dailyStress, | ||
| 600 | + ) { | ||
| 601 | + final key = dateKey(day); | ||
| 602 | + for (final item in dailyStress) { | ||
| 603 | + if (item.date == key) return item; | ||
| 604 | + } | ||
| 605 | + return null; | ||
| 606 | + } | ||
| 607 | + | ||
| 675 | static List<HrvTrendList> _monthlyHrvTrend(List<_HrvDaySummary> daily) { | 608 | static List<HrvTrendList> _monthlyHrvTrend(List<_HrvDaySummary> daily) { |
| 676 | final byMonth = <int, List<_HrvDaySummary>>{}; | 609 | final byMonth = <int, List<_HrvDaySummary>>{}; |
| 677 | for (final item in daily) { | 610 | for (final item in daily) { |
| @@ -702,28 +635,6 @@ class LocalHealthDataConvert { | @@ -702,28 +635,6 @@ class LocalHealthDataConvert { | ||
| 702 | return result; | 635 | return result; |
| 703 | } | 636 | } |
| 704 | 637 | ||
| 705 | - static int? _dailyStressState( | ||
| 706 | - List<HealthRawRealtimeStressPoint> realtimePoints, { | ||
| 707 | - required int startTime, | ||
| 708 | - required int endTime, | ||
| 709 | - }) { | ||
| 710 | - final dayPoints = realtimePoints | ||
| 711 | - .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime) | ||
| 712 | - .toList(); | ||
| 713 | - final validValuePoints = | ||
| 714 | - dayPoints.where((e) => _isValidStressValue(e.result)).toList(); | ||
| 715 | - final activityBuffers = _confirmedActivityBuffers(realtimePoints); | ||
| 716 | - final validStressValues = validValuePoints | ||
| 717 | - .where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers)) | ||
| 718 | - .map((e) => e.result) | ||
| 719 | - .toList() | ||
| 720 | - ..sort(); | ||
| 721 | - final dailyStress = _dailyStress(validStressValues); | ||
| 722 | - return dailyStress == null | ||
| 723 | - ? null | ||
| 724 | - : healthRawRealtimeStressState(dailyStress).value; | ||
| 725 | - } | ||
| 726 | - | ||
| 727 | static _HrvDaySummary? _extremeHrv( | 638 | static _HrvDaySummary? _extremeHrv( |
| 728 | List<_HrvDaySummary> daily, { | 639 | List<_HrvDaySummary> daily, { |
| 729 | required bool min, | 640 | required bool min, |
| @@ -943,16 +854,6 @@ class LocalHealthDataConvert { | @@ -943,16 +854,6 @@ class LocalHealthDataConvert { | ||
| 943 | return _sum(values) / values.length; | 854 | return _sum(values) / values.length; |
| 944 | } | 855 | } |
| 945 | 856 | ||
| 946 | - static double? _median(List<double> values) { | ||
| 947 | - if (values.isEmpty) return null; | ||
| 948 | - final sorted = [...values]..sort(); | ||
| 949 | - final middle = sorted.length ~/ 2; | ||
| 950 | - if (sorted.length.isEven) { | ||
| 951 | - return (sorted[middle - 1] + sorted[middle]) / 2; | ||
| 952 | - } | ||
| 953 | - return sorted[middle]; | ||
| 954 | - } | ||
| 955 | - | ||
| 956 | static num? _maxOrNull(List<num> values) { | 857 | static num? _maxOrNull(List<num> values) { |
| 957 | if (values.isEmpty) return null; | 858 | if (values.isEmpty) return null; |
| 958 | return values.reduce(math.max); | 859 | return values.reduce(math.max); |
| @@ -962,15 +863,6 @@ class LocalHealthDataConvert { | @@ -962,15 +863,6 @@ class LocalHealthDataConvert { | ||
| 962 | if (values.isEmpty) return null; | 863 | if (values.isEmpty) return null; |
| 963 | return values.reduce(math.min); | 864 | return values.reduce(math.min); |
| 964 | } | 865 | } |
| 965 | - | ||
| 966 | - static double _clamp(double value, double lower, double upper) { | ||
| 967 | - return math.min(math.max(value, lower), upper).toDouble(); | ||
| 968 | - } | ||
| 969 | - | ||
| 970 | - static double _round(double value, int places) { | ||
| 971 | - final scale = math.pow(10, places).toDouble(); | ||
| 972 | - return (value * scale).round() / scale; | ||
| 973 | - } | ||
| 974 | } | 866 | } |
| 975 | 867 | ||
| 976 | class _SleepDaySummary { | 868 | class _SleepDaySummary { |
| @@ -1022,7 +914,7 @@ class _ActivityDaySummary { | @@ -1022,7 +914,7 @@ class _ActivityDaySummary { | ||
| 1022 | required this.day, | 914 | required this.day, |
| 1023 | required this.move, | 915 | required this.move, |
| 1024 | required this.steps, | 916 | required this.steps, |
| 1025 | - required this.standSeconds, | 917 | + required this.standHours, |
| 1026 | required this.exerciseSeconds, | 918 | required this.exerciseSeconds, |
| 1027 | required this.moveGoal, | 919 | required this.moveGoal, |
| 1028 | required this.stepGoal, | 920 | required this.stepGoal, |
| @@ -1033,7 +925,7 @@ class _ActivityDaySummary { | @@ -1033,7 +925,7 @@ class _ActivityDaySummary { | ||
| 1033 | final DateTime day; | 925 | final DateTime day; |
| 1034 | final num move; | 926 | final num move; |
| 1035 | final num steps; | 927 | final num steps; |
| 1036 | - final num standSeconds; | 928 | + final num standHours; |
| 1037 | final num exerciseSeconds; | 929 | final num exerciseSeconds; |
| 1038 | final num? moveGoal; | 930 | final num? moveGoal; |
| 1039 | final num? stepGoal; | 931 | final num? stepGoal; |
| @@ -117,22 +117,16 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -117,22 +117,16 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 117 | dateRangeType, | 117 | dateRangeType, |
| 118 | startDate, | 118 | startDate, |
| 119 | ); | 119 | ); |
| 120 | - final allDays = [...previousDays, ...days]; | ||
| 121 | - final queryStart = | ||
| 122 | - LocalHealthDataConvert.unixSeconds(allDays.first) - 12 * 60; | ||
| 123 | - final queryEnd = LocalHealthDataConvert.unixSeconds( | ||
| 124 | - days.last.add(const Duration(days: 1)), | ||
| 125 | - ) + | ||
| 126 | - 12 * 60; | ||
| 127 | final hrvPoints = await coreService.queryHrvStressPoints( | 120 | final hrvPoints = await coreService.queryHrvStressPoints( |
| 128 | startTime: LocalHealthDataConvert.unixSeconds(days.first), | 121 | startTime: LocalHealthDataConvert.unixSeconds(days.first), |
| 129 | endTime: LocalHealthDataConvert.unixSeconds( | 122 | endTime: LocalHealthDataConvert.unixSeconds( |
| 130 | days.last.add(const Duration(days: 1)), | 123 | days.last.add(const Duration(days: 1)), |
| 131 | ), | 124 | ), |
| 132 | ); | 125 | ); |
| 133 | - final realtimePoints = await coreService.queryRealtimeStressPoints( | ||
| 134 | - startTime: queryStart, | ||
| 135 | - endTime: queryEnd, | 126 | + final dailyStressPoints = await coreService.queryDailyStressPoints( |
| 127 | + startDate: LocalHealthDataConvert.dateKey( | ||
| 128 | + previousDays.isEmpty ? days.first : previousDays.first), | ||
| 129 | + endDate: LocalHealthDataConvert.dateKey(days.last), | ||
| 136 | ); | 130 | ); |
| 137 | 131 | ||
| 138 | return AppSuccess( | 132 | return AppSuccess( |
| @@ -141,7 +135,7 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -141,7 +135,7 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 141 | days: days, | 135 | days: days, |
| 142 | previousDays: previousDays, | 136 | previousDays: previousDays, |
| 143 | hrvPoints: hrvPoints, | 137 | hrvPoints: hrvPoints, |
| 144 | - realtimePoints: realtimePoints, | 138 | + dailyStressPoints: dailyStressPoints, |
| 145 | ), | 139 | ), |
| 146 | ); | 140 | ); |
| 147 | } catch (error) { | 141 | } catch (error) { |
| @@ -288,16 +282,13 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -288,16 +282,13 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 288 | Future<AppResult<V2StressScore>> getV2StressScore( | 282 | Future<AppResult<V2StressScore>> getV2StressScore( |
| 289 | int? queryUserId, int intDate) async { | 283 | int? queryUserId, int intDate) async { |
| 290 | try { | 284 | try { |
| 291 | - final (startTime, endTime) = _dayRange(intDate); | ||
| 292 | - final realtimePoints = await coreService.queryRealtimeStressPoints( | ||
| 293 | - startTime: startTime - 12 * 60, | ||
| 294 | - endTime: endTime + 12 * 60, | 285 | + final dailyStressPoints = await coreService.queryDailyStressPoints( |
| 286 | + startDate: intDate, | ||
| 287 | + endDate: intDate, | ||
| 295 | ); | 288 | ); |
| 296 | return AppSuccess( | 289 | return AppSuccess( |
| 297 | LocalHealthDataConvert.v2StressScore( | 290 | LocalHealthDataConvert.v2StressScore( |
| 298 | - realtimePoints, | ||
| 299 | - startTime: startTime, | ||
| 300 | - endTime: endTime, | 291 | + dailyStressPoints.isEmpty ? null : dailyStressPoints.last, |
| 301 | ), | 292 | ), |
| 302 | ); | 293 | ); |
| 303 | } catch (error) { | 294 | } catch (error) { |
| @@ -124,6 +124,110 @@ void main() { | @@ -124,6 +124,110 @@ void main() { | ||
| 124 | expect(rows.hrvRows.first['baseline_hrv'], greaterThan(0)); | 124 | expect(rows.hrvRows.first['baseline_hrv'], greaterThan(0)); |
| 125 | expect(rows.hrvRows.first['baseline_awake_hrv'], greaterThan(0)); | 125 | expect(rows.hrvRows.first['baseline_awake_hrv'], greaterThan(0)); |
| 126 | expect(rows.hrvRows.first['baseline_resting_hr'], greaterThan(0)); | 126 | expect(rows.hrvRows.first['baseline_resting_hr'], greaterThan(0)); |
| 127 | + expect( | ||
| 128 | + store.operationLog.indexOf('upsertResult'), | ||
| 129 | + lessThan(store.operationLog.indexOf('queryRealtimeStressPoints')), | ||
| 130 | + ); | ||
| 131 | + expect( | ||
| 132 | + store.operationLog.indexOf('queryRealtimeStressPoints'), | ||
| 133 | + lessThan(store.operationLog.indexOf('upsertDailyStressPoints')), | ||
| 134 | + ); | ||
| 135 | + }); | ||
| 136 | + | ||
| 137 | + test('daily stress keeps existing historical rows and does not recalculate', | ||
| 138 | + () async { | ||
| 139 | + final oldDay = DateTime.now().subtract(const Duration(days: 2)); | ||
| 140 | + final oldStart = DateTime(oldDay.year, oldDay.month, oldDay.day) | ||
| 141 | + .millisecondsSinceEpoch ~/ | ||
| 142 | + 1000; | ||
| 143 | + final oldDate = oldDay.year * 10000 + oldDay.month * 100 + oldDay.day; | ||
| 144 | + final api = _FakeHealthKitRawDataHostApi(); | ||
| 145 | + api.setPoints(HealthDataUploadType.heartRate.type, [ | ||
| 146 | + _point(oldStart + 60, 68), | ||
| 147 | + _point(oldStart + 120, 70), | ||
| 148 | + _point(oldStart + 180, 72), | ||
| 149 | + ]); | ||
| 150 | + api.setPoints(HealthDataUploadType.restingHeartRate.type, [ | ||
| 151 | + _point(oldStart + 30, 60), | ||
| 152 | + _point(oldStart + 90, 61), | ||
| 153 | + ]); | ||
| 154 | + | ||
| 155 | + final store = _MemoryHealthRawStressLocalStore(); | ||
| 156 | + store.insertDailyStress( | ||
| 157 | + HealthRawDailyStressPoint( | ||
| 158 | + userId: 42, | ||
| 159 | + date: oldDate, | ||
| 160 | + stressValue: 11, | ||
| 161 | + stressScore: 11, | ||
| 162 | + state: HealthRawStressState.excellent, | ||
| 163 | + dataTime: oldStart, | ||
| 164 | + uploaded: true, | ||
| 165 | + ), | ||
| 166 | + ); | ||
| 167 | + final service = HealthRawDataCoreService( | ||
| 168 | + healthApi: _FakeHealthKitHostApi(), | ||
| 169 | + rawDataApi: api, | ||
| 170 | + localStore: store, | ||
| 171 | + userIdProvider: () => 42, | ||
| 172 | + ); | ||
| 173 | + | ||
| 174 | + final result = await service.syncAndStore( | ||
| 175 | + startTime: oldStart, | ||
| 176 | + endTime: oldStart + Duration.secondsPerDay + 300, | ||
| 177 | + readChunkDays: 1, | ||
| 178 | + ); | ||
| 179 | + | ||
| 180 | + expect(result.realtimeStressPoints, isNotEmpty); | ||
| 181 | + expect(result.dailyStressPoints, isEmpty); | ||
| 182 | + final daily = await service.queryDailyStressPoints( | ||
| 183 | + startDate: oldDate, | ||
| 184 | + endDate: oldDate, | ||
| 185 | + ); | ||
| 186 | + expect(daily, hasLength(1)); | ||
| 187 | + expect(daily.single.stressScore, 11); | ||
| 188 | + expect(daily.single.uploaded, isTrue); | ||
| 189 | + }); | ||
| 190 | + | ||
| 191 | + test('daily stress table upsert overwrites by date and resets uploaded', | ||
| 192 | + () async { | ||
| 193 | + final store = _MemoryHealthRawStressLocalStore(); | ||
| 194 | + store.insertDailyStress( | ||
| 195 | + const HealthRawDailyStressPoint( | ||
| 196 | + userId: 42, | ||
| 197 | + date: 20260720, | ||
| 198 | + stressValue: 10, | ||
| 199 | + stressScore: 10, | ||
| 200 | + state: HealthRawStressState.excellent, | ||
| 201 | + dataTime: 1000, | ||
| 202 | + uploaded: true, | ||
| 203 | + ), | ||
| 204 | + ); | ||
| 205 | + | ||
| 206 | + await store.upsertDailyStressPoints( | ||
| 207 | + userId: 42, | ||
| 208 | + points: const [ | ||
| 209 | + HealthRawDailyStressPoint( | ||
| 210 | + userId: 42, | ||
| 211 | + date: 20260720, | ||
| 212 | + stressValue: 66, | ||
| 213 | + stressScore: 70, | ||
| 214 | + state: HealthRawStressState.attention, | ||
| 215 | + dataTime: 2000, | ||
| 216 | + ), | ||
| 217 | + ], | ||
| 218 | + ); | ||
| 219 | + | ||
| 220 | + final daily = await store.queryDailyStressPoints( | ||
| 221 | + userId: 42, | ||
| 222 | + startDate: 20260720, | ||
| 223 | + endDate: 20260720, | ||
| 224 | + ); | ||
| 225 | + expect(daily, hasLength(1)); | ||
| 226 | + expect(daily.single.stressValue, 66); | ||
| 227 | + expect(daily.single.stressScore, 70); | ||
| 228 | + expect(daily.single.state, HealthRawStressState.attention); | ||
| 229 | + expect(daily.single.dataTime, 2000); | ||
| 230 | + expect(daily.single.uploaded, isFalse); | ||
| 127 | }); | 231 | }); |
| 128 | 232 | ||
| 129 | test('startCoreCaculate skips raw reads when health auth is missing', | 233 | test('startCoreCaculate skips raw reads when health auth is missing', |
| @@ -266,6 +370,18 @@ class _ReadCall { | @@ -266,6 +370,18 @@ class _ReadCall { | ||
| 266 | class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | 370 | class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { |
| 267 | final Map<int, List<HealthRawHrvStressPoint>> _hrv = {}; | 371 | final Map<int, List<HealthRawHrvStressPoint>> _hrv = {}; |
| 268 | final Map<int, List<HealthRawRealtimeStressPoint>> _realtime = {}; | 372 | final Map<int, List<HealthRawRealtimeStressPoint>> _realtime = {}; |
| 373 | + final Map<int, List<HealthRawDailyStressPoint>> _daily = {}; | ||
| 374 | + final List<String> operationLog = []; | ||
| 375 | + | ||
| 376 | + void insertDailyStress(HealthRawDailyStressPoint point) { | ||
| 377 | + final byDate = <int, HealthRawDailyStressPoint>{ | ||
| 378 | + for (final item in _daily[point.userId] ?? <HealthRawDailyStressPoint>[]) | ||
| 379 | + item.date: item, | ||
| 380 | + }; | ||
| 381 | + byDate[point.date] = point; | ||
| 382 | + _daily[point.userId] = byDate.values.toList() | ||
| 383 | + ..sort((a, b) => a.date.compareTo(b.date)); | ||
| 384 | + } | ||
| 269 | 385 | ||
| 270 | @override | 386 | @override |
| 271 | Future<void> ensureReadable(int userId) async {} | 387 | Future<void> ensureReadable(int userId) async {} |
| @@ -275,6 +391,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -275,6 +391,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 275 | 391 | ||
| 276 | @override | 392 | @override |
| 277 | Future<void> upsertResult(HealthRawStressCalculationResult result) async { | 393 | Future<void> upsertResult(HealthRawStressCalculationResult result) async { |
| 394 | + operationLog.add('upsertResult'); | ||
| 278 | final hrvByTime = <int, HealthRawHrvStressPoint>{ | 395 | final hrvByTime = <int, HealthRawHrvStressPoint>{ |
| 279 | for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[]) | 396 | for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[]) |
| 280 | point.rawEndTime: point, | 397 | point.rawEndTime: point, |
| @@ -318,6 +435,53 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -318,6 +435,53 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 318 | } | 435 | } |
| 319 | 436 | ||
| 320 | @override | 437 | @override |
| 438 | + Future<void> upsertDailyStressPoints({ | ||
| 439 | + required int userId, | ||
| 440 | + required Iterable<HealthRawDailyStressPoint> points, | ||
| 441 | + }) async { | ||
| 442 | + operationLog.add('upsertDailyStressPoints'); | ||
| 443 | + final byDate = <int, HealthRawDailyStressPoint>{ | ||
| 444 | + for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[]) | ||
| 445 | + point.date: point, | ||
| 446 | + }; | ||
| 447 | + for (final point in points) { | ||
| 448 | + byDate[point.date] = HealthRawDailyStressPoint( | ||
| 449 | + userId: point.userId, | ||
| 450 | + date: point.date, | ||
| 451 | + stressValue: point.stressValue, | ||
| 452 | + stressScore: point.stressScore, | ||
| 453 | + state: point.state, | ||
| 454 | + dataTime: point.dataTime, | ||
| 455 | + ); | ||
| 456 | + } | ||
| 457 | + _daily[userId] = byDate.values.toList() | ||
| 458 | + ..sort((a, b) => a.date.compareTo(b.date)); | ||
| 459 | + } | ||
| 460 | + | ||
| 461 | + @override | ||
| 462 | + Future<Set<int>> existingDailyStressDates({ | ||
| 463 | + required int userId, | ||
| 464 | + required Iterable<int> dates, | ||
| 465 | + }) async { | ||
| 466 | + final dateSet = dates.toSet(); | ||
| 467 | + return (_daily[userId] ?? <HealthRawDailyStressPoint>[]) | ||
| 468 | + .where((e) => dateSet.contains(e.date)) | ||
| 469 | + .map((e) => e.date) | ||
| 470 | + .toSet(); | ||
| 471 | + } | ||
| 472 | + | ||
| 473 | + @override | ||
| 474 | + Future<void> deleteDailyStressDates({ | ||
| 475 | + required int userId, | ||
| 476 | + required Iterable<int> dates, | ||
| 477 | + }) async { | ||
| 478 | + final dateSet = dates.toSet(); | ||
| 479 | + _daily[userId] = (_daily[userId] ?? <HealthRawDailyStressPoint>[]) | ||
| 480 | + .where((e) => !dateSet.contains(e.date)) | ||
| 481 | + .toList(); | ||
| 482 | + } | ||
| 483 | + | ||
| 484 | + @override | ||
| 321 | Future<List<HealthRawHrvStressPoint>> queryHrvStressPoints({ | 485 | Future<List<HealthRawHrvStressPoint>> queryHrvStressPoints({ |
| 322 | required int userId, | 486 | required int userId, |
| 323 | required int startTime, | 487 | required int startTime, |
| @@ -334,12 +498,24 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -334,12 +498,24 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 334 | required int startTime, | 498 | required int startTime, |
| 335 | required int endTime, | 499 | required int endTime, |
| 336 | }) async { | 500 | }) async { |
| 501 | + operationLog.add('queryRealtimeStressPoints'); | ||
| 337 | return (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) | 502 | return (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) |
| 338 | .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime) | 503 | .where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime) |
| 339 | .toList(); | 504 | .toList(); |
| 340 | } | 505 | } |
| 341 | 506 | ||
| 342 | @override | 507 | @override |
| 508 | + Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({ | ||
| 509 | + required int userId, | ||
| 510 | + required int startDate, | ||
| 511 | + required int endDate, | ||
| 512 | + }) async { | ||
| 513 | + return (_daily[userId] ?? <HealthRawDailyStressPoint>[]) | ||
| 514 | + .where((e) => e.date >= startDate && e.date <= endDate) | ||
| 515 | + .toList(); | ||
| 516 | + } | ||
| 517 | + | ||
| 518 | + @override | ||
| 343 | Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { | 519 | Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { |
| 344 | return HealthRawStressDbRows( | 520 | return HealthRawStressDbRows( |
| 345 | hrvRows: | 521 | hrvRows: |
| @@ -347,6 +523,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -347,6 +523,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 347 | realtimeRows: (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) | 523 | realtimeRows: (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[]) |
| 348 | .map(_realtimeRow) | 524 | .map(_realtimeRow) |
| 349 | .toList(), | 525 | .toList(), |
| 526 | + dailyStressRows: (_daily[userId] ?? <HealthRawDailyStressPoint>[]) | ||
| 527 | + .map(_dailyStressRow) | ||
| 528 | + .toList(), | ||
| 350 | ); | 529 | ); |
| 351 | } | 530 | } |
| 352 | 531 | ||
| @@ -446,4 +625,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | @@ -446,4 +625,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { | ||
| 446 | 'uploaded': point.uploaded ? 1 : 0, | 625 | 'uploaded': point.uploaded ? 1 : 0, |
| 447 | }; | 626 | }; |
| 448 | } | 627 | } |
| 628 | + | ||
| 629 | + Map<String, Object?> _dailyStressRow(HealthRawDailyStressPoint point) { | ||
| 630 | + return <String, Object?>{ | ||
| 631 | + 'date': point.date, | ||
| 632 | + 'user_id': point.userId, | ||
| 633 | + 'stress_value': point.stressValue, | ||
| 634 | + 'stress_score': point.stressScore, | ||
| 635 | + 'state': point.state.value, | ||
| 636 | + 'data_time': point.dataTime, | ||
| 637 | + 'uploaded': point.uploaded ? 1 : 0, | ||
| 638 | + }; | ||
| 639 | + } | ||
| 449 | } | 640 | } |
| @@ -83,11 +83,10 @@ void main() { | @@ -83,11 +83,10 @@ void main() { | ||
| 83 | }); | 83 | }); |
| 84 | }); | 84 | }); |
| 85 | 85 | ||
| 86 | - group('LocalHealthDataConvert v2StressScore', () { | 86 | + group('HealthRawDailyStressCalculator', () { |
| 87 | test('uses median and p75 after excluding confirmed activity buffer', () { | 87 | test('uses median and p75 after excluding confirmed activity buffer', () { |
| 88 | const userId = 1; | 88 | const userId = 1; |
| 89 | const dayStart = 100000; | 89 | const dayStart = 100000; |
| 90 | - const dayEnd = dayStart + Duration.secondsPerDay - 1; | ||
| 91 | const flagsNone = HealthRawPointFlags.none(); | 90 | const flagsNone = HealthRawPointFlags.none(); |
| 92 | const workoutFlags = HealthRawPointFlags( | 91 | const workoutFlags = HealthRawPointFlags( |
| 93 | isWorkout: true, | 92 | isWorkout: true, |
| @@ -117,8 +116,11 @@ void main() { | @@ -117,8 +116,11 @@ void main() { | ||
| 117 | ); | 116 | ); |
| 118 | } | 117 | } |
| 119 | 118 | ||
| 120 | - final score = LocalHealthDataConvert.v2StressScore( | ||
| 121 | - [ | 119 | + final dailyStress = HealthRawDailyStressCalculator.calculate( |
| 120 | + userId: userId, | ||
| 121 | + date: 20260716, | ||
| 122 | + dataTime: dayStart, | ||
| 123 | + realtimePoints: [ | ||
| 122 | point(600, 10), | 124 | point(600, 10), |
| 123 | point(1200, 20, flags: suspectedFlags), | 125 | point(1200, 20, flags: suspectedFlags), |
| 124 | point(1800, 30), | 126 | point(1800, 30), |
| @@ -128,18 +130,17 @@ void main() { | @@ -128,18 +130,17 @@ void main() { | ||
| 128 | point(4200, 80), | 130 | point(4200, 80), |
| 129 | point(4800, 110), | 131 | point(4800, 110), |
| 130 | ], | 132 | ], |
| 131 | - startTime: dayStart, | ||
| 132 | - endTime: dayEnd, | ||
| 133 | ); | 133 | ); |
| 134 | + final score = LocalHealthDataConvert.v2StressScore(dailyStress); | ||
| 134 | 135 | ||
| 135 | expect(score.comprehensiveScore, 34); | 136 | expect(score.comprehensiveScore, 34); |
| 136 | expect(score.state, HealthRawStressState.normal.value); | 137 | expect(score.state, HealthRawStressState.normal.value); |
| 138 | + expect(dailyStress?.stressValue, 36); | ||
| 137 | }); | 139 | }); |
| 138 | 140 | ||
| 139 | test('uses activity buffer from adjacent day', () { | 141 | test('uses activity buffer from adjacent day', () { |
| 140 | const userId = 1; | 142 | const userId = 1; |
| 141 | const dayStart = 100000; | 143 | const dayStart = 100000; |
| 142 | - const dayEnd = dayStart + Duration.secondsPerDay - 1; | ||
| 143 | const workoutFlags = HealthRawPointFlags( | 144 | const workoutFlags = HealthRawPointFlags( |
| 144 | isWorkout: true, | 145 | isWorkout: true, |
| 145 | isWorkoutRecovery: false, | 146 | isWorkoutRecovery: false, |
| @@ -147,8 +148,11 @@ void main() { | @@ -147,8 +148,11 @@ void main() { | ||
| 147 | isSuspectedActivity: false, | 148 | isSuspectedActivity: false, |
| 148 | ); | 149 | ); |
| 149 | 150 | ||
| 150 | - final score = LocalHealthDataConvert.v2StressScore( | ||
| 151 | - [ | 151 | + final dailyStress = HealthRawDailyStressCalculator.calculate( |
| 152 | + userId: userId, | ||
| 153 | + date: 20260716, | ||
| 154 | + dataTime: dayStart, | ||
| 155 | + realtimePoints: [ | ||
| 152 | HealthRawRealtimeStressPoint( | 156 | HealthRawRealtimeStressPoint( |
| 153 | userId: userId, | 157 | userId: userId, |
| 154 | rawEndTime: dayStart - 300, | 158 | rawEndTime: dayStart - 300, |
| @@ -172,9 +176,8 @@ void main() { | @@ -172,9 +176,8 @@ void main() { | ||
| 172 | sourceEndTime: dayStart + 1000, | 176 | sourceEndTime: dayStart + 1000, |
| 173 | ), | 177 | ), |
| 174 | ], | 178 | ], |
| 175 | - startTime: dayStart, | ||
| 176 | - endTime: dayEnd, | ||
| 177 | ); | 179 | ); |
| 180 | + final score = LocalHealthDataConvert.v2StressScore(dailyStress); | ||
| 178 | 181 | ||
| 179 | expect(score.comprehensiveScore, 20); | 182 | expect(score.comprehensiveScore, 20); |
| 180 | expect(score.state, HealthRawStressState.excellent.value); | 183 | expect(score.state, HealthRawStressState.excellent.value); |
| @@ -186,7 +189,6 @@ void main() { | @@ -186,7 +189,6 @@ void main() { | ||
| 186 | final currentDay = DateTime(2026, 7, 16); | 189 | final currentDay = DateTime(2026, 7, 16); |
| 187 | final previousDay = DateTime(2026, 7, 9); | 190 | final previousDay = DateTime(2026, 7, 9); |
| 188 | final currentStart = LocalHealthDataConvert.unixSeconds(currentDay); | 191 | final currentStart = LocalHealthDataConvert.unixSeconds(currentDay); |
| 189 | - final previousStart = LocalHealthDataConvert.unixSeconds(previousDay); | ||
| 190 | 192 | ||
| 191 | HealthRawHrvStressPoint hrvPoint(int time, double value) { | 193 | HealthRawHrvStressPoint hrvPoint(int time, double value) { |
| 192 | return HealthRawHrvStressPoint( | 194 | return HealthRawHrvStressPoint( |
| @@ -204,13 +206,17 @@ void main() { | @@ -204,13 +206,17 @@ void main() { | ||
| 204 | ); | 206 | ); |
| 205 | } | 207 | } |
| 206 | 208 | ||
| 207 | - HealthRawRealtimeStressPoint stressPoint(int time, double value) { | ||
| 208 | - return HealthRawRealtimeStressPoint( | 209 | + HealthRawDailyStressPoint dailyStressPoint( |
| 210 | + DateTime day, | ||
| 211 | + int score, | ||
| 212 | + ) { | ||
| 213 | + return HealthRawDailyStressPoint( | ||
| 209 | userId: 1, | 214 | userId: 1, |
| 210 | - rawEndTime: time, | ||
| 211 | - result: value, | ||
| 212 | - sourceStartTime: time, | ||
| 213 | - sourceEndTime: time, | 215 | + date: LocalHealthDataConvert.dateKey(day), |
| 216 | + stressValue: score.toDouble(), | ||
| 217 | + stressScore: score, | ||
| 218 | + state: healthRawRealtimeStressState(score), | ||
| 219 | + dataTime: LocalHealthDataConvert.unixSeconds(day), | ||
| 214 | ); | 220 | ); |
| 215 | } | 221 | } |
| 216 | 222 | ||
| @@ -221,10 +227,9 @@ void main() { | @@ -221,10 +227,9 @@ void main() { | ||
| 221 | hrvPoints: [ | 227 | hrvPoints: [ |
| 222 | hrvPoint(currentStart + 600, 80), | 228 | hrvPoint(currentStart + 600, 80), |
| 223 | ], | 229 | ], |
| 224 | - realtimePoints: [ | ||
| 225 | - stressPoint(currentStart + 600, 10), | ||
| 226 | - stressPoint(currentStart + 1200, 80), | ||
| 227 | - stressPoint(previousStart + 600, 90), | 230 | + dailyStressPoints: [ |
| 231 | + dailyStressPoint(currentDay, 34), | ||
| 232 | + dailyStressPoint(previousDay, 90), | ||
| 228 | ], | 233 | ], |
| 229 | ); | 234 | ); |
| 230 | 235 | ||
| @@ -244,27 +249,20 @@ void main() { | @@ -244,27 +249,20 @@ void main() { | ||
| 244 | 249 | ||
| 245 | test('keeps yearly months that only have comprehensive stress data', () { | 250 | test('keeps yearly months that only have comprehensive stress data', () { |
| 246 | final mayDay = DateTime(2026, 5, 12); | 251 | final mayDay = DateTime(2026, 5, 12); |
| 247 | - final mayStart = LocalHealthDataConvert.unixSeconds(mayDay); | ||
| 248 | 252 | ||
| 249 | final statistics = LocalHealthDataConvert.hrvStatistics( | 253 | final statistics = LocalHealthDataConvert.hrvStatistics( |
| 250 | dateRangeType: 2, | 254 | dateRangeType: 2, |
| 251 | days: LocalHealthDataConvert.rangeDays(2, 20260101), | 255 | days: LocalHealthDataConvert.rangeDays(2, 20260101), |
| 252 | previousDays: const [], | 256 | previousDays: const [], |
| 253 | hrvPoints: const [], | 257 | hrvPoints: const [], |
| 254 | - realtimePoints: [ | ||
| 255 | - HealthRawRealtimeStressPoint( | 258 | + dailyStressPoints: [ |
| 259 | + HealthRawDailyStressPoint( | ||
| 256 | userId: 1, | 260 | userId: 1, |
| 257 | - rawEndTime: mayStart + 600, | ||
| 258 | - result: 70, | ||
| 259 | - sourceStartTime: mayStart + 600, | ||
| 260 | - sourceEndTime: mayStart + 600, | ||
| 261 | - ), | ||
| 262 | - HealthRawRealtimeStressPoint( | ||
| 263 | - userId: 1, | ||
| 264 | - rawEndTime: mayStart + 1200, | ||
| 265 | - result: 80, | ||
| 266 | - sourceStartTime: mayStart + 1200, | ||
| 267 | - sourceEndTime: mayStart + 1200, | 261 | + date: 20260512, |
| 262 | + stressValue: 75, | ||
| 263 | + stressScore: 77, | ||
| 264 | + state: HealthRawStressState.attention, | ||
| 265 | + dataTime: LocalHealthDataConvert.unixSeconds(mayDay), | ||
| 268 | ), | 266 | ), |
| 269 | ], | 267 | ], |
| 270 | ); | 268 | ); |
| @@ -272,7 +270,7 @@ void main() { | @@ -272,7 +270,7 @@ void main() { | ||
| 272 | expect(statistics.hrvTrendList, hasLength(1)); | 270 | expect(statistics.hrvTrendList, hasLength(1)); |
| 273 | expect(statistics.hrvTrendList?.single.timeKey, 5); | 271 | expect(statistics.hrvTrendList?.single.timeKey, 5); |
| 274 | expect(statistics.hrvTrendList?.single.hrvAverage, isNull); | 272 | expect(statistics.hrvTrendList?.single.hrvAverage, isNull); |
| 275 | - expect(statistics.hrvTrendList?.single.hrAverage, 75); | 273 | + expect(statistics.hrvTrendList?.single.hrAverage, 77); |
| 276 | expect( | 274 | expect( |
| 277 | statistics.hrvTrendList?.single.state, | 275 | statistics.hrvTrendList?.single.state, |
| 278 | HealthRawStressState.attention.value, | 276 | HealthRawStressState.attention.value, |
| @@ -289,7 +287,8 @@ void main() { | @@ -289,7 +287,8 @@ void main() { | ||
| 289 | }); | 287 | }); |
| 290 | 288 | ||
| 291 | group('LocalHealthDataConvert activity unit conversion', () { | 289 | group('LocalHealthDataConvert activity unit conversion', () { |
| 292 | - test('passes through exercise and stand seconds from raw activity data', | 290 | + test( |
| 291 | + 'passes through exercise seconds and stand hours from raw activity data', | ||
| 293 | () { | 292 | () { |
| 294 | final day = DateTime(2026, 7, 16); | 293 | final day = DateTime(2026, 7, 16); |
| 295 | final endTime = LocalHealthDataConvert.unixSeconds( | 294 | final endTime = LocalHealthDataConvert.unixSeconds( |
| @@ -304,8 +303,8 @@ void main() { | @@ -304,8 +303,8 @@ void main() { | ||
| 304 | appleMoveTimeGoal: 10000, | 303 | appleMoveTimeGoal: 10000, |
| 305 | appleExerciseTime: 1800, | 304 | appleExerciseTime: 1800, |
| 306 | exerciseTimeGoal: 3600, | 305 | exerciseTimeGoal: 3600, |
| 307 | - appleStandHours: 21600, | ||
| 308 | - standHoursGoal: 43200, | 306 | + appleStandHours: 6, |
| 307 | + standHoursGoal: 12, | ||
| 309 | ), | 308 | ), |
| 310 | ]; | 309 | ]; |
| 311 | 310 | ||
| @@ -330,13 +329,13 @@ void main() { | @@ -330,13 +329,13 @@ void main() { | ||
| 330 | ); | 329 | ); |
| 331 | 330 | ||
| 332 | expect(healthData.exercise, 1800); | 331 | expect(healthData.exercise, 1800); |
| 333 | - expect(healthData.stand, 21600); | 332 | + expect(healthData.stand, 6); |
| 334 | expect(target.exercise, 3600); | 333 | expect(target.exercise, 3600); |
| 335 | - expect(target.stand, 43200); | 334 | + expect(target.stand, 12); |
| 336 | expect(statistics.totalExercise, 1800); | 335 | expect(statistics.totalExercise, 1800); |
| 337 | - expect(statistics.totalStand, 21600); | 336 | + expect(statistics.totalStand, 6); |
| 338 | expect(statistics.activityTargetInfo?.exercise, 3600); | 337 | expect(statistics.activityTargetInfo?.exercise, 3600); |
| 339 | - expect(statistics.activityTargetInfo?.stand, 43200); | 338 | + expect(statistics.activityTargetInfo?.stand, 12); |
| 340 | }); | 339 | }); |
| 341 | }); | 340 | }); |
| 342 | } | 341 | } |
-
Please register or login to post a comment