Showing
11 changed files
with
1086 additions
and
634 deletions
| 1 | import Foundation | 1 | import Foundation |
| 2 | 2 | ||
| 3 | final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | 3 | final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { |
| 4 | - | ||
| 5 | private let service: HealthKitService | 4 | private let service: HealthKitService |
| 6 | 5 | ||
| 7 | init(service: HealthKitService = .shared) { | 6 | init(service: HealthKitService = .shared) { |
| @@ -14,6 +13,13 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | @@ -14,6 +13,13 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { | ||
| 14 | completion(.success(hasData)) | 13 | completion(.success(hasData)) |
| 15 | } | 14 | } |
| 16 | } | 15 | } |
| 16 | + | ||
| 17 | + func performHealthDataUpload(completion: @escaping (Result<Bool, any Error>) -> Void) { | ||
| 18 | + Task { | ||
| 19 | + let summary = await AnchoredHealthDataUploader.shared.uploadAll() | ||
| 20 | + completion(.success(true)) | ||
| 21 | + } | ||
| 22 | + } | ||
| 17 | 23 | ||
| 18 | func getHealthKitRawData( | 24 | func getHealthKitRawData( |
| 19 | dataType: Int64, | 25 | dataType: Int64, |
| @@ -10,7 +10,6 @@ import 'package:doublefeel_flutter/app/routes/app_pages.dart'; | @@ -10,7 +10,6 @@ import 'package:doublefeel_flutter/app/routes/app_pages.dart'; | ||
| 10 | import 'package:doublefeel_flutter/core/constants/intent_keys.dart'; | 10 | import 'package:doublefeel_flutter/core/constants/intent_keys.dart'; |
| 11 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; | 11 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; |
| 12 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; | 12 | import 'package:doublefeel_flutter/core/network/api/friend_api.dart'; |
| 13 | -import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | ||
| 14 | import 'package:doublefeel_flutter/core/network/api/pay_api.dart'; | 13 | import 'package:doublefeel_flutter/core/network/api/pay_api.dart'; |
| 15 | import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; | 14 | import 'package:doublefeel_flutter/core/network/api/vip_api.dart'; |
| 16 | import 'package:doublefeel_flutter/core/result/app_result.dart'; | 15 | import 'package:doublefeel_flutter/core/result/app_result.dart'; |
| @@ -27,6 +26,7 @@ import 'package:doublefeel_flutter/data/models/local/user_preferences.dart'; | @@ -27,6 +26,7 @@ import 'package:doublefeel_flutter/data/models/local/user_preferences.dart'; | ||
| 27 | import 'package:doublefeel_flutter/data/models/pay/pay_models.dart'; | 26 | import 'package:doublefeel_flutter/data/models/pay/pay_models.dart'; |
| 28 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 27 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 29 | import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart'; | 28 | import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart'; |
| 29 | +import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; | ||
| 30 | import 'package:doublefeel_flutter/pigeon/platform_api.g.dart'; | 30 | import 'package:doublefeel_flutter/pigeon/platform_api.g.dart'; |
| 31 | import 'package:doublefeel_flutter/pigeon/wear_engine_api.g.dart'; | 31 | import 'package:doublefeel_flutter/pigeon/wear_engine_api.g.dart'; |
| 32 | import 'package:flutter/material.dart'; | 32 | import 'package:flutter/material.dart'; |
| @@ -73,6 +73,7 @@ class TodayController extends GetMaterialController { | @@ -73,6 +73,7 @@ class TodayController extends GetMaterialController { | ||
| 73 | final FriendApi _friendApi; | 73 | final FriendApi _friendApi; |
| 74 | final UserStateService _userStateService; | 74 | final UserStateService _userStateService; |
| 75 | final HealthKitHostApi _hostApi = HealthKitHostApi(); | 75 | final HealthKitHostApi _hostApi = HealthKitHostApi(); |
| 76 | + final HealthKitRawDataHostApi _rawDataApi = HealthKitRawDataHostApi(); | ||
| 76 | final HealthRawDataCoreService _healthRawDataCoreService = | 77 | final HealthRawDataCoreService _healthRawDataCoreService = |
| 77 | Get.find<HealthRawDataCoreService>(); | 78 | Get.find<HealthRawDataCoreService>(); |
| 78 | 79 | ||
| @@ -142,9 +143,6 @@ class TodayController extends GetMaterialController { | @@ -142,9 +143,6 @@ class TodayController extends GetMaterialController { | ||
| 142 | void onInit() { | 143 | void onInit() { |
| 143 | super.onInit(); | 144 | super.onInit(); |
| 144 | 145 | ||
| 145 | - // 测试 | ||
| 146 | - unawaited(_startCoreCaculateForDebug()); | ||
| 147 | - | ||
| 148 | targetFriendInfo.value = _initialFriendInfo; | 146 | targetFriendInfo.value = _initialFriendInfo; |
| 149 | final today = DateUtils.dateOnly(DateTime.now()); | 147 | final today = DateUtils.dateOnly(DateTime.now()); |
| 150 | firstSelectableDay.value = DateUtils.dateOnly( | 148 | firstSelectableDay.value = DateUtils.dateOnly( |
| @@ -184,18 +182,6 @@ class TodayController extends GetMaterialController { | @@ -184,18 +182,6 @@ class TodayController extends GetMaterialController { | ||
| 184 | } | 182 | } |
| 185 | } | 183 | } |
| 186 | 184 | ||
| 187 | - Future<void> _startCoreCaculateForDebug() async { | ||
| 188 | - try { | ||
| 189 | - await _healthRawDataCoreService.startCoreCaculate(); | ||
| 190 | - } catch (error, stackTrace) { | ||
| 191 | - AppLogger.e( | ||
| 192 | - 'TodayController.startCoreCaculate failed', | ||
| 193 | - error, | ||
| 194 | - stackTrace, | ||
| 195 | - ); | ||
| 196 | - } | ||
| 197 | - } | ||
| 198 | - | ||
| 199 | void checkAddFriendVisible() { | 185 | void checkAddFriendVisible() { |
| 200 | if (isFriend) { | 186 | if (isFriend) { |
| 201 | showPartnerAdBanner.value = false; | 187 | showPartnerAdBanner.value = false; |
| @@ -275,6 +261,7 @@ class TodayController extends GetMaterialController { | @@ -275,6 +261,7 @@ class TodayController extends GetMaterialController { | ||
| 275 | AppLogger.d("requestHealthClientAuthorization : $success"); | 261 | AppLogger.d("requestHealthClientAuthorization : $success"); |
| 276 | if (success) { | 262 | if (success) { |
| 277 | // await LoadingService.instance.run(() async { | 263 | // await LoadingService.instance.run(() async { |
| 264 | + _rawDataApi.performHealthDataUpload(); | ||
| 278 | _startCoreCaculate(); | 265 | _startCoreCaculate(); |
| 279 | // }); | 266 | // }); |
| 280 | // result.status = 1; | 267 | // result.status = 1; |
| @@ -290,6 +277,7 @@ class TodayController extends GetMaterialController { | @@ -290,6 +277,7 @@ class TodayController extends GetMaterialController { | ||
| 290 | return; | 277 | return; |
| 291 | } else if (result.status == 1) { | 278 | } else if (result.status == 1) { |
| 292 | // await LoadingService.instance.run(() async { | 279 | // await LoadingService.instance.run(() async { |
| 280 | + _rawDataApi.performHealthDataUpload(); | ||
| 293 | _startCoreCaculate(); | 281 | _startCoreCaculate(); |
| 294 | // }); | 282 | // }); |
| 295 | if (isFromNoPermissionPage) { | 283 | if (isFromNoPermissionPage) { |
| @@ -197,7 +197,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { | @@ -197,7 +197,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { | ||
| 197 | ) { | 197 | ) { |
| 198 | final date = _parseDate(timeKey); | 198 | final date = _parseDate(timeKey); |
| 199 | if (date == null) return null; | 199 | if (date == null) return null; |
| 200 | - final asleepTime = _parseDateTime(asleepTimestamp, fallbackDate: date); | 200 | + final asleepTime = _parseDateTime(asleepTimestamp); |
| 201 | if (asleepTime == null) return null; | 201 | if (asleepTime == null) return null; |
| 202 | final report = reports[date] ?? SleepReport.empty(date); | 202 | final report = reports[date] ?? SleepReport.empty(date); |
| 203 | final heartRate = report.heartRate; | 203 | final heartRate = report.heartRate; |
| @@ -246,10 +246,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { | @@ -246,10 +246,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { | ||
| 246 | asleepTrend = asleepTrends.first; | 246 | asleepTrend = asleepTrends.first; |
| 247 | } | 247 | } |
| 248 | 248 | ||
| 249 | - final asleepTime = _parseDateTime( | ||
| 250 | - asleepTrend?.asleepTime, | ||
| 251 | - fallbackDate: date, | ||
| 252 | - ); | 249 | + final asleepTime = _parseDateTime(asleepTrend?.asleepTime); |
| 253 | final points = <SleepHeartRatePoint>[]; | 250 | final points = <SleepHeartRatePoint>[]; |
| 254 | for (final item in data.hrList ?? const <SleepHrItem>[]) { | 251 | for (final item in data.hrList ?? const <SleepHrItem>[]) { |
| 255 | final time = _parseDateTime(item.time); | 252 | final time = _parseDateTime(item.time); |
| @@ -301,8 +298,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { | @@ -301,8 +298,7 @@ class ApiSleepReportDataSource implements SleepReportDataSource { | ||
| 301 | for (final item | 298 | for (final item |
| 302 | in data.asleepTimeTrendList ?? const <AsleepTimeTrendList>[]) | 299 | in data.asleepTimeTrendList ?? const <AsleepTimeTrendList>[]) |
| 303 | if (_parseDate(item.timeKey) case final date?) | 300 | if (_parseDate(item.timeKey) case final date?) |
| 304 | - if (_parseDateTime(item.asleepTime, fallbackDate: date) | ||
| 305 | - case final asleepTime?) | 301 | + if (_parseDateTime(item.asleepTime) case final asleepTime?) |
| 306 | date: asleepTime, | 302 | date: asleepTime, |
| 307 | }; | 303 | }; |
| 308 | final pointsByDate = <DateTime, List<SleepHeartRatePoint>>{}; | 304 | final pointsByDate = <DateTime, List<SleepHeartRatePoint>>{}; |
| @@ -310,22 +310,7 @@ class SleepHeartRateCard extends StatelessWidget { | @@ -310,22 +310,7 @@ class SleepHeartRateCard extends StatelessWidget { | ||
| 310 | 310 | ||
| 311 | List<SleepHeartRatePoint> get _visibleDatePoints { | 311 | List<SleepHeartRatePoint> get _visibleDatePoints { |
| 312 | if (summary.points.isEmpty) return const <SleepHeartRatePoint>[]; | 312 | if (summary.points.isEmpty) return const <SleepHeartRatePoint>[]; |
| 313 | - | ||
| 314 | - final selectedDate = date; | ||
| 315 | - final points = selectedDate == null | ||
| 316 | - ? summary.points | ||
| 317 | - : summary.points.where((point) { | ||
| 318 | - final dayStart = DateTime( | ||
| 319 | - selectedDate.year, | ||
| 320 | - selectedDate.month, | ||
| 321 | - selectedDate.day, | ||
| 322 | - ); | ||
| 323 | - final nextDay = dayStart.add(const Duration(days: 1)); | ||
| 324 | - return !point.time.isBefore(dayStart) && | ||
| 325 | - point.time.isBefore(nextDay); | ||
| 326 | - }).toList(); | ||
| 327 | - final result = points.isEmpty ? summary.points : points; | ||
| 328 | - return [...result]..sort((a, b) => a.time.compareTo(b.time)); | 313 | + return [...summary.points]..sort((a, b) => a.time.compareTo(b.time)); |
| 329 | } | 314 | } |
| 330 | } | 315 | } |
| 331 | 316 |
| @@ -6,6 +6,7 @@ import 'package:doublefeel_flutter/core/services/user_state_service.dart'; | @@ -6,6 +6,7 @@ import 'package:doublefeel_flutter/core/services/user_state_service.dart'; | ||
| 6 | import 'package:doublefeel_flutter/data/local/user_account_storage.dart'; | 6 | import 'package:doublefeel_flutter/data/local/user_account_storage.dart'; |
| 7 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; | 7 | import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; |
| 8 | import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart'; | 8 | import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart'; |
| 9 | +import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; | ||
| 9 | import 'package:get/get.dart'; | 10 | import 'package:get/get.dart'; |
| 10 | import 'package:permission_handler/permission_handler.dart'; | 11 | import 'package:permission_handler/permission_handler.dart'; |
| 11 | 12 | ||
| @@ -219,6 +220,7 @@ class UserOnboardingController extends GetxController { | @@ -219,6 +220,7 @@ class UserOnboardingController extends GetxController { | ||
| 219 | /// 计算 Apple Health 新数据。 | 220 | /// 计算 Apple Health 新数据。 |
| 220 | Future<void> _performDataUpload() async { | 221 | Future<void> _performDataUpload() async { |
| 221 | try { | 222 | try { |
| 223 | + HealthKitRawDataHostApi().performHealthDataUpload(); | ||
| 222 | await Get.find<HealthRawDataCoreService>().startCoreCaculate(); | 224 | await Get.find<HealthRawDataCoreService>().startCoreCaculate(); |
| 223 | } catch (error, stackTrace) { | 225 | } catch (error, stackTrace) { |
| 224 | AppLogger.e('Apple Health calculate failed', error, stackTrace); | 226 | AppLogger.e('Apple Health calculate failed', error, stackTrace); |
| 1 | +import 'dart:io'; | ||
| 2 | + | ||
| 3 | +import 'package:path_provider/path_provider.dart'; | ||
| 4 | +import 'package:sqflite/sqflite.dart'; | ||
| 5 | + | ||
| 6 | +import '../../data/models/enums/app_enums.dart'; | ||
| 7 | +import '../../pigeon/health_kit_raw_data_api.g.dart'; | ||
| 8 | +import 'health_raw_data_core_service.dart'; | ||
| 9 | +import 'health_raw_stress_calculator.dart'; | ||
| 10 | + | ||
| 11 | +typedef FriendRawDataPageFetcher = Future<List<HealthKitRawDataPoint>> | ||
| 12 | + Function({ | ||
| 13 | + required int userId, | ||
| 14 | + required int dataType, | ||
| 15 | + required int? startTime, | ||
| 16 | + required int endTime, | ||
| 17 | + required int page, | ||
| 18 | + required int pageSize, | ||
| 19 | +}); | ||
| 20 | + | ||
| 21 | +class FriendHealthRawDataCoreService { | ||
| 22 | + FriendHealthRawDataCoreService({ | ||
| 23 | + Directory? rootDirectory, | ||
| 24 | + DatabaseFactory? databaseFactory, | ||
| 25 | + HealthRawStressLocalStore? stressLocalStore, | ||
| 26 | + FriendRawDataPageFetcher? remoteRawDataPageFetcher, | ||
| 27 | + }) : _rootDirectory = rootDirectory, | ||
| 28 | + _databaseFactory = databaseFactory, | ||
| 29 | + _stressLocalStore = stressLocalStore ?? HealthRawStressLocalStore(), | ||
| 30 | + _remoteRawDataPageFetcher = remoteRawDataPageFetcher; | ||
| 31 | + | ||
| 32 | + final Directory? _rootDirectory; | ||
| 33 | + final DatabaseFactory? _databaseFactory; | ||
| 34 | + final HealthRawStressLocalStore _stressLocalStore; | ||
| 35 | + final FriendRawDataPageFetcher? _remoteRawDataPageFetcher; | ||
| 36 | + final Map<int, Database> _opened = <int, Database>{}; | ||
| 37 | + | ||
| 38 | + Future<void> openDatabase(int userId) async { | ||
| 39 | + await _database(userId); | ||
| 40 | + } | ||
| 41 | + | ||
| 42 | + Future<void> closeDatabase({int? userId}) async { | ||
| 43 | + if (userId != null) { | ||
| 44 | + final db = _opened.remove(userId); | ||
| 45 | + if (db != null) { | ||
| 46 | + await db.close(); | ||
| 47 | + } | ||
| 48 | + return; | ||
| 49 | + } | ||
| 50 | + final databases = _opened.values.toList(); | ||
| 51 | + _opened.clear(); | ||
| 52 | + for (final db in databases) { | ||
| 53 | + await db.close(); | ||
| 54 | + } | ||
| 55 | + } | ||
| 56 | + | ||
| 57 | + Future<String> rawDatabaseFilePath(int userId) async { | ||
| 58 | + final dir = _rootDirectory ?? await getApplicationDocumentsDirectory(); | ||
| 59 | + return '${dir.path}/friend_health_raw_data_$userId.sqlite'; | ||
| 60 | + } | ||
| 61 | + | ||
| 62 | + Future<void> storeRawData({ | ||
| 63 | + required int userId, | ||
| 64 | + required int dataType, | ||
| 65 | + required Iterable<HealthKitRawDataPoint> points, | ||
| 66 | + }) async { | ||
| 67 | + _checkUserId(userId); | ||
| 68 | + final db = await _database(userId); | ||
| 69 | + final table = _rawDataTable(dataType); | ||
| 70 | + await _ensureRawDataTable(db, table); | ||
| 71 | + final rows = points.toList() | ||
| 72 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 73 | + if (rows.isEmpty) return; | ||
| 74 | + await db.transaction((txn) async { | ||
| 75 | + for (final point in rows) { | ||
| 76 | + await txn.insert( | ||
| 77 | + table, | ||
| 78 | + _rawDataRow(userId: userId, dataType: dataType, point: point), | ||
| 79 | + conflictAlgorithm: ConflictAlgorithm.replace, | ||
| 80 | + ); | ||
| 81 | + } | ||
| 82 | + }); | ||
| 83 | + } | ||
| 84 | + | ||
| 85 | + Future<void> storeRawDataByType({ | ||
| 86 | + required int userId, | ||
| 87 | + required Map<int, Iterable<HealthKitRawDataPoint>> pointsByType, | ||
| 88 | + }) async { | ||
| 89 | + for (final entry in pointsByType.entries) { | ||
| 90 | + await storeRawData( | ||
| 91 | + userId: userId, | ||
| 92 | + dataType: entry.key, | ||
| 93 | + points: entry.value, | ||
| 94 | + ); | ||
| 95 | + } | ||
| 96 | + } | ||
| 97 | + | ||
| 98 | + Future<List<HealthKitRawDataPoint>> queryRawData({ | ||
| 99 | + required int userId, | ||
| 100 | + required int dataType, | ||
| 101 | + int? startTime, | ||
| 102 | + required int endTime, | ||
| 103 | + }) async { | ||
| 104 | + _checkUserId(userId); | ||
| 105 | + final db = await _database(userId); | ||
| 106 | + final table = _rawDataTable(dataType); | ||
| 107 | + await _ensureRawDataTable(db, table); | ||
| 108 | + final where = startTime == null | ||
| 109 | + ? 'user_id = ? AND end_time <= ?' | ||
| 110 | + : 'user_id = ? AND end_time >= ? AND end_time <= ?'; | ||
| 111 | + final whereArgs = startTime == null | ||
| 112 | + ? <Object?>[userId, endTime] | ||
| 113 | + : <Object?>[userId, startTime, endTime]; | ||
| 114 | + final rows = await db.query( | ||
| 115 | + table, | ||
| 116 | + where: where, | ||
| 117 | + whereArgs: whereArgs, | ||
| 118 | + orderBy: 'end_time ASC', | ||
| 119 | + ); | ||
| 120 | + return rows.map(_rawDataPointFromRow).toList(); | ||
| 121 | + } | ||
| 122 | + | ||
| 123 | + Future<int?> latestRawDataEndTime({ | ||
| 124 | + required int userId, | ||
| 125 | + required int dataType, | ||
| 126 | + }) async { | ||
| 127 | + _checkUserId(userId); | ||
| 128 | + final db = await _database(userId); | ||
| 129 | + final table = _rawDataTable(dataType); | ||
| 130 | + await _ensureRawDataTable(db, table); | ||
| 131 | + final rows = await db.query( | ||
| 132 | + table, | ||
| 133 | + columns: ['end_time'], | ||
| 134 | + where: 'user_id = ?', | ||
| 135 | + whereArgs: [userId], | ||
| 136 | + orderBy: 'end_time DESC', | ||
| 137 | + limit: 1, | ||
| 138 | + ); | ||
| 139 | + if (rows.isEmpty) return null; | ||
| 140 | + return rows.first['end_time'] as int; | ||
| 141 | + } | ||
| 142 | + | ||
| 143 | + Future<void> pullRemoteRawData({ | ||
| 144 | + required int userId, | ||
| 145 | + required Iterable<int> readTypes, | ||
| 146 | + int? endTime, | ||
| 147 | + int pageSize = 500, | ||
| 148 | + }) async { | ||
| 149 | + _checkUserId(userId); | ||
| 150 | + final effectiveEndTime = | ||
| 151 | + endTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000; | ||
| 152 | + for (final dataType in readTypes) { | ||
| 153 | + final startTime = await latestRawDataEndTime( | ||
| 154 | + userId: userId, | ||
| 155 | + dataType: dataType, | ||
| 156 | + ); | ||
| 157 | + var page = 1; | ||
| 158 | + while (true) { | ||
| 159 | + final points = await _fetchRemoteRawDataPage( | ||
| 160 | + userId: userId, | ||
| 161 | + dataType: dataType, | ||
| 162 | + startTime: startTime, | ||
| 163 | + endTime: effectiveEndTime, | ||
| 164 | + page: page, | ||
| 165 | + pageSize: pageSize, | ||
| 166 | + ); | ||
| 167 | + if (points.isEmpty) break; | ||
| 168 | + await storeRawData( | ||
| 169 | + userId: userId, | ||
| 170 | + dataType: dataType, | ||
| 171 | + points: points, | ||
| 172 | + ); | ||
| 173 | + if (points.length < pageSize) break; | ||
| 174 | + page += 1; | ||
| 175 | + } | ||
| 176 | + } | ||
| 177 | + } | ||
| 178 | + | ||
| 179 | + Future<HealthRawStressCalculationResult> calculate({ | ||
| 180 | + required int userId, | ||
| 181 | + required List<HealthKitRawDataPoint> hrvPoints, | ||
| 182 | + required List<HealthKitRawDataPoint> heartRatePoints, | ||
| 183 | + required List<HealthKitRawDataPoint> restingHeartRatePoints, | ||
| 184 | + List<HealthKitRawDataPoint> sleepIntervals = | ||
| 185 | + const <HealthKitRawDataPoint>[], | ||
| 186 | + List<HealthKitRawWorkoutDataPoint> workoutIntervals = | ||
| 187 | + const <HealthKitRawWorkoutDataPoint>[], | ||
| 188 | + required int startTime, | ||
| 189 | + required int endTime, | ||
| 190 | + }) { | ||
| 191 | + _checkUserId(userId); | ||
| 192 | + return Future.value(HealthRawStressCalculator(userId: userId).calculate( | ||
| 193 | + hrvPoints: hrvPoints, | ||
| 194 | + heartRatePoints: heartRatePoints, | ||
| 195 | + restingHeartRatePoints: restingHeartRatePoints, | ||
| 196 | + sleepIntervals: sleepIntervals, | ||
| 197 | + workoutIntervals: workoutIntervals, | ||
| 198 | + startTime: startTime, | ||
| 199 | + endTime: endTime, | ||
| 200 | + )); | ||
| 201 | + } | ||
| 202 | + | ||
| 203 | + Future<HealthRawStressCalculationResult> calculateStoredRawData({ | ||
| 204 | + required int userId, | ||
| 205 | + required int startTime, | ||
| 206 | + required int endTime, | ||
| 207 | + List<HealthKitRawDataPoint> sleepIntervals = | ||
| 208 | + const <HealthKitRawDataPoint>[], | ||
| 209 | + List<HealthKitRawWorkoutDataPoint> workoutIntervals = | ||
| 210 | + const <HealthKitRawWorkoutDataPoint>[], | ||
| 211 | + }) async { | ||
| 212 | + final hrvPoints = await queryRawData( | ||
| 213 | + userId: userId, | ||
| 214 | + dataType: HealthDataUploadType.hrv.type, | ||
| 215 | + startTime: startTime, | ||
| 216 | + endTime: endTime, | ||
| 217 | + ); | ||
| 218 | + final heartRatePoints = await queryRawData( | ||
| 219 | + userId: userId, | ||
| 220 | + dataType: HealthDataUploadType.heartRate.type, | ||
| 221 | + startTime: startTime, | ||
| 222 | + endTime: endTime, | ||
| 223 | + ); | ||
| 224 | + final restingHeartRatePoints = await queryRawData( | ||
| 225 | + userId: userId, | ||
| 226 | + dataType: HealthDataUploadType.restingHeartRate.type, | ||
| 227 | + startTime: startTime, | ||
| 228 | + endTime: endTime, | ||
| 229 | + ); | ||
| 230 | + return calculate( | ||
| 231 | + userId: userId, | ||
| 232 | + hrvPoints: hrvPoints, | ||
| 233 | + heartRatePoints: heartRatePoints, | ||
| 234 | + restingHeartRatePoints: restingHeartRatePoints, | ||
| 235 | + sleepIntervals: sleepIntervals, | ||
| 236 | + workoutIntervals: workoutIntervals, | ||
| 237 | + startTime: startTime, | ||
| 238 | + endTime: endTime, | ||
| 239 | + ); | ||
| 240 | + } | ||
| 241 | + | ||
| 242 | + Future<HealthRawStressCalculationResult> calculateAndStoreStressResult({ | ||
| 243 | + required int userId, | ||
| 244 | + required List<HealthKitRawDataPoint> hrvPoints, | ||
| 245 | + required List<HealthKitRawDataPoint> heartRatePoints, | ||
| 246 | + required List<HealthKitRawDataPoint> restingHeartRatePoints, | ||
| 247 | + List<HealthKitRawDataPoint> sleepIntervals = | ||
| 248 | + const <HealthKitRawDataPoint>[], | ||
| 249 | + List<HealthKitRawWorkoutDataPoint> workoutIntervals = | ||
| 250 | + const <HealthKitRawWorkoutDataPoint>[], | ||
| 251 | + required int startTime, | ||
| 252 | + required int endTime, | ||
| 253 | + }) async { | ||
| 254 | + final result = await calculate( | ||
| 255 | + userId: userId, | ||
| 256 | + hrvPoints: hrvPoints, | ||
| 257 | + heartRatePoints: heartRatePoints, | ||
| 258 | + restingHeartRatePoints: restingHeartRatePoints, | ||
| 259 | + sleepIntervals: sleepIntervals, | ||
| 260 | + workoutIntervals: workoutIntervals, | ||
| 261 | + startTime: startTime, | ||
| 262 | + endTime: endTime, | ||
| 263 | + ); | ||
| 264 | + await _stressLocalStore.upsertResult(result); | ||
| 265 | + return result; | ||
| 266 | + } | ||
| 267 | + | ||
| 268 | + Future<HealthRawStressCalculationResult> | ||
| 269 | + calculateStoredRawDataAndStoreResult({ | ||
| 270 | + required int userId, | ||
| 271 | + required int startTime, | ||
| 272 | + required int endTime, | ||
| 273 | + List<HealthKitRawDataPoint> sleepIntervals = | ||
| 274 | + const <HealthKitRawDataPoint>[], | ||
| 275 | + List<HealthKitRawWorkoutDataPoint> workoutIntervals = | ||
| 276 | + const <HealthKitRawWorkoutDataPoint>[], | ||
| 277 | + }) async { | ||
| 278 | + final result = await calculateStoredRawData( | ||
| 279 | + userId: userId, | ||
| 280 | + startTime: startTime, | ||
| 281 | + endTime: endTime, | ||
| 282 | + sleepIntervals: sleepIntervals, | ||
| 283 | + workoutIntervals: workoutIntervals, | ||
| 284 | + ); | ||
| 285 | + await _stressLocalStore.upsertResult(result); | ||
| 286 | + return result; | ||
| 287 | + } | ||
| 288 | + | ||
| 289 | + Future<List<HealthKitRawDataPoint>> _fetchRemoteRawDataPage({ | ||
| 290 | + required int userId, | ||
| 291 | + required int dataType, | ||
| 292 | + required int? startTime, | ||
| 293 | + required int endTime, | ||
| 294 | + required int page, | ||
| 295 | + required int pageSize, | ||
| 296 | + }) { | ||
| 297 | + final fetcher = _remoteRawDataPageFetcher; | ||
| 298 | + if (fetcher == null) { | ||
| 299 | + return Future.value(const <HealthKitRawDataPoint>[]); | ||
| 300 | + } | ||
| 301 | + return fetcher( | ||
| 302 | + userId: userId, | ||
| 303 | + dataType: dataType, | ||
| 304 | + startTime: startTime, | ||
| 305 | + endTime: endTime, | ||
| 306 | + page: page, | ||
| 307 | + pageSize: pageSize, | ||
| 308 | + ); | ||
| 309 | + } | ||
| 310 | + | ||
| 311 | + Future<Database> _database(int userId) async { | ||
| 312 | + _checkUserId(userId); | ||
| 313 | + final existing = _opened[userId]; | ||
| 314 | + if (existing != null && existing.isOpen) return existing; | ||
| 315 | + final path = await rawDatabaseFilePath(userId); | ||
| 316 | + final parent = File(path).parent; | ||
| 317 | + if (!await parent.exists()) { | ||
| 318 | + await parent.create(recursive: true); | ||
| 319 | + } | ||
| 320 | + final factory = _databaseFactory ?? databaseFactory; | ||
| 321 | + final db = await factory.openDatabase( | ||
| 322 | + path, | ||
| 323 | + options: OpenDatabaseOptions(version: 1), | ||
| 324 | + ); | ||
| 325 | + _opened[userId] = db; | ||
| 326 | + return db; | ||
| 327 | + } | ||
| 328 | + | ||
| 329 | + Future<void> _ensureRawDataTable(DatabaseExecutor db, String table) { | ||
| 330 | + return db.execute(''' | ||
| 331 | +CREATE TABLE IF NOT EXISTS $table ( | ||
| 332 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| 333 | + user_id INTEGER NOT NULL, | ||
| 334 | + data_type INTEGER NOT NULL, | ||
| 335 | + start_time INTEGER NOT NULL, | ||
| 336 | + end_time INTEGER NOT NULL, | ||
| 337 | + value REAL, | ||
| 338 | + is_motion_like INTEGER, | ||
| 339 | + UNIQUE(user_id, data_type, start_time, end_time) | ||
| 340 | +) | ||
| 341 | +'''); | ||
| 342 | + } | ||
| 343 | + | ||
| 344 | + String _rawDataTable(int dataType) { | ||
| 345 | + if (dataType <= 0) { | ||
| 346 | + throw ArgumentError.value(dataType, 'dataType'); | ||
| 347 | + } | ||
| 348 | + return 'friend_raw_data_type_$dataType'; | ||
| 349 | + } | ||
| 350 | + | ||
| 351 | + Map<String, Object?> _rawDataRow({ | ||
| 352 | + required int userId, | ||
| 353 | + required int dataType, | ||
| 354 | + required HealthKitRawDataPoint point, | ||
| 355 | + }) { | ||
| 356 | + return <String, Object?>{ | ||
| 357 | + 'user_id': userId, | ||
| 358 | + 'data_type': dataType, | ||
| 359 | + 'start_time': point.startTime, | ||
| 360 | + 'end_time': point.endTime, | ||
| 361 | + 'value': point.value, | ||
| 362 | + 'is_motion_like': switch (point.isMotionLike) { | ||
| 363 | + true => 1, | ||
| 364 | + false => 0, | ||
| 365 | + null => null, | ||
| 366 | + }, | ||
| 367 | + }; | ||
| 368 | + } | ||
| 369 | + | ||
| 370 | + HealthKitRawDataPoint _rawDataPointFromRow(Map<String, Object?> row) { | ||
| 371 | + final isMotionLike = row['is_motion_like'] as int?; | ||
| 372 | + return HealthKitRawDataPoint( | ||
| 373 | + dataType: row['data_type'] as int, | ||
| 374 | + startTime: row['start_time'] as int, | ||
| 375 | + endTime: row['end_time'] as int, | ||
| 376 | + value: (row['value'] as num?)?.toDouble(), | ||
| 377 | + isMotionLike: isMotionLike == null ? null : isMotionLike == 1, | ||
| 378 | + ); | ||
| 379 | + } | ||
| 380 | + | ||
| 381 | + void _checkUserId(int userId) { | ||
| 382 | + if (userId <= 0) { | ||
| 383 | + throw ArgumentError.value(userId, 'userId'); | ||
| 384 | + } | ||
| 385 | + } | ||
| 386 | +} |
| @@ -13,6 +13,7 @@ import '../../pigeon/health_kit_raw_data_api.g.dart'; | @@ -13,6 +13,7 @@ import '../../pigeon/health_kit_raw_data_api.g.dart'; | ||
| 13 | import '../config/app_environment_config.dart'; | 13 | import '../config/app_environment_config.dart'; |
| 14 | import '../logging/app_logger.dart'; | 14 | import '../logging/app_logger.dart'; |
| 15 | import '../util/app_toast.dart'; | 15 | import '../util/app_toast.dart'; |
| 16 | +import 'health_raw_stress_calculator.dart'; | ||
| 16 | 17 | ||
| 17 | class HealthRawDataCoreService { | 18 | class HealthRawDataCoreService { |
| 18 | static const int defaultLookbackDays = 183; | 19 | static const int defaultLookbackDays = 183; |
| @@ -469,50 +470,15 @@ class HealthRawDataCoreService { | @@ -469,50 +470,15 @@ class HealthRawDataCoreService { | ||
| 469 | required int startTime, | 470 | required int startTime, |
| 470 | required int endTime, | 471 | required int endTime, |
| 471 | }) { | 472 | }) { |
| 472 | - final userId = _userId; | ||
| 473 | - final marked = _markRawData( | 473 | + return HealthRawStressCalculator(userId: _userId).calculate( |
| 474 | hrvPoints: hrvPoints, | 474 | hrvPoints: hrvPoints, |
| 475 | heartRatePoints: heartRatePoints, | 475 | heartRatePoints: heartRatePoints, |
| 476 | restingHeartRatePoints: restingHeartRatePoints, | 476 | restingHeartRatePoints: restingHeartRatePoints, |
| 477 | sleepIntervals: sleepIntervals, | 477 | sleepIntervals: sleepIntervals, |
| 478 | workoutIntervals: workoutIntervals, | 478 | workoutIntervals: workoutIntervals, |
| 479 | - ); | ||
| 480 | - final sortedHrv = marked.hrv.where((e) { | ||
| 481 | - final value = e.value; | ||
| 482 | - return value != null && _isValidHrv(value); | ||
| 483 | - }).toList() | ||
| 484 | - ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 485 | - final sortedHr = marked.heartRate.where((e) { | ||
| 486 | - final value = e.value; | ||
| 487 | - return value != null && _isValidHr(value); | ||
| 488 | - }).toList() | ||
| 489 | - ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 490 | - final sortedRestingHr = marked.restingHeartRate.where((e) { | ||
| 491 | - final value = e.value; | ||
| 492 | - return value != null && _isValidHr(value); | ||
| 493 | - }).toList() | ||
| 494 | - ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 495 | - | ||
| 496 | - final hrvStressPoints = _calculateHrvStressPoints( | ||
| 497 | - userId: userId, | ||
| 498 | - hrvPoints: sortedHrv, | ||
| 499 | - heartRatePoints: sortedHr, | ||
| 500 | - restingHeartRatePoints: sortedRestingHr, | ||
| 501 | - ); | ||
| 502 | - final realtimeStressPoints = _calculateRealtimeStressPoints( | ||
| 503 | - userId: userId, | ||
| 504 | - heartRatePoints: sortedHr, | ||
| 505 | - restingHeartRatePoints: sortedRestingHr, | ||
| 506 | - hrvStressPoints: hrvStressPoints, | ||
| 507 | startTime: startTime, | 479 | startTime: startTime, |
| 508 | endTime: endTime, | 480 | endTime: endTime, |
| 509 | ); | 481 | ); |
| 510 | - | ||
| 511 | - return HealthRawStressCalculationResult( | ||
| 512 | - userId: userId, | ||
| 513 | - hrvStressPoints: hrvStressPoints, | ||
| 514 | - realtimeStressPoints: realtimeStressPoints, | ||
| 515 | - ); | ||
| 516 | } | 482 | } |
| 517 | 483 | ||
| 518 | Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks( | 484 | Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks( |
| @@ -568,527 +534,6 @@ class HealthRawDataCoreService { | @@ -568,527 +534,6 @@ class HealthRawDataCoreService { | ||
| 568 | return points; | 534 | return points; |
| 569 | } | 535 | } |
| 570 | 536 | ||
| 571 | - HealthRawMarkedDataSet _markRawData({ | ||
| 572 | - required List<HealthKitRawDataPoint> hrvPoints, | ||
| 573 | - required List<HealthKitRawDataPoint> heartRatePoints, | ||
| 574 | - required List<HealthKitRawDataPoint> restingHeartRatePoints, | ||
| 575 | - required List<HealthKitRawDataPoint> sleepIntervals, | ||
| 576 | - required List<HealthKitRawWorkoutDataPoint> workoutIntervals, | ||
| 577 | - }) { | ||
| 578 | - final hrv = hrvPoints | ||
| 579 | - .map((point) => HealthRawMarkedDataPoint( | ||
| 580 | - raw: point, | ||
| 581 | - flags: _flagsForPoint( | ||
| 582 | - point, | ||
| 583 | - sleepIntervals: sleepIntervals, | ||
| 584 | - workoutIntervals: workoutIntervals, | ||
| 585 | - ), | ||
| 586 | - )) | ||
| 587 | - .toList() | ||
| 588 | - ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 589 | - final heartRate = heartRatePoints | ||
| 590 | - .map((point) => HealthRawMarkedDataPoint( | ||
| 591 | - raw: point, | ||
| 592 | - flags: _flagsForPoint( | ||
| 593 | - point, | ||
| 594 | - sleepIntervals: sleepIntervals, | ||
| 595 | - workoutIntervals: workoutIntervals, | ||
| 596 | - ), | ||
| 597 | - )) | ||
| 598 | - .toList() | ||
| 599 | - ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 600 | - final restingHeartRate = restingHeartRatePoints | ||
| 601 | - .map((point) => HealthRawMarkedDataPoint( | ||
| 602 | - raw: point, | ||
| 603 | - flags: _flagsForPoint( | ||
| 604 | - point, | ||
| 605 | - sleepIntervals: sleepIntervals, | ||
| 606 | - workoutIntervals: workoutIntervals, | ||
| 607 | - ), | ||
| 608 | - )) | ||
| 609 | - .toList() | ||
| 610 | - ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 611 | - | ||
| 612 | - return HealthRawMarkedDataSet( | ||
| 613 | - hrv: hrv, | ||
| 614 | - heartRate: heartRate, | ||
| 615 | - restingHeartRate: restingHeartRate, | ||
| 616 | - ); | ||
| 617 | - } | ||
| 618 | - | ||
| 619 | - HealthRawPointFlags _flagsForPoint( | ||
| 620 | - HealthKitRawDataPoint point, { | ||
| 621 | - required List<HealthKitRawDataPoint> sleepIntervals, | ||
| 622 | - required List<HealthKitRawWorkoutDataPoint> workoutIntervals, | ||
| 623 | - }) { | ||
| 624 | - final isWorkout = workoutIntervals.any( | ||
| 625 | - (workout) => | ||
| 626 | - point.endTime > workout.startTime && point.endTime <= workout.endTime, | ||
| 627 | - ); | ||
| 628 | - final isWorkoutRecovery = !isWorkout && | ||
| 629 | - workoutIntervals.any( | ||
| 630 | - (workout) => | ||
| 631 | - point.endTime > workout.endTime && | ||
| 632 | - point.endTime <= workout.endTime + 30 * 60, | ||
| 633 | - ); | ||
| 634 | - return HealthRawPointFlags( | ||
| 635 | - isWorkout: isWorkout, | ||
| 636 | - isWorkoutRecovery: isWorkoutRecovery, | ||
| 637 | - isSleepLikely: sleepIntervals.any( | ||
| 638 | - (sleep) => | ||
| 639 | - point.endTime > sleep.startTime && point.endTime <= sleep.endTime, | ||
| 640 | - ), | ||
| 641 | - isSuspectedActivity: point.isMotionLike == true, | ||
| 642 | - ); | ||
| 643 | - } | ||
| 644 | - | ||
| 645 | - List<HealthRawHrvStressPoint> _calculateHrvStressPoints({ | ||
| 646 | - required int userId, | ||
| 647 | - required List<HealthRawMarkedDataPoint> hrvPoints, | ||
| 648 | - required List<HealthRawMarkedDataPoint> heartRatePoints, | ||
| 649 | - required List<HealthRawMarkedDataPoint> restingHeartRatePoints, | ||
| 650 | - }) { | ||
| 651 | - final validHrvHistory = <HealthRawMarkedDataPoint>[]; | ||
| 652 | - final results = <HealthRawHrvStressPoint>[]; | ||
| 653 | - | ||
| 654 | - for (final point in hrvPoints) { | ||
| 655 | - final pointValue = point.value!; | ||
| 656 | - final awakeHrvHistory = _latestBefore( | ||
| 657 | - validHrvHistory.where((e) => e.flags.isAwakeBaselineEligible).toList(), | ||
| 658 | - point.endTime, | ||
| 659 | - limit: 50, | ||
| 660 | - ); | ||
| 661 | - final sleepHrvHistory = _latestBefore( | ||
| 662 | - validHrvHistory | ||
| 663 | - .where((e) => e.flags.isSleepHrvBaselineEligible) | ||
| 664 | - .toList(), | ||
| 665 | - point.endTime, | ||
| 666 | - limit: 50, | ||
| 667 | - ); | ||
| 668 | - final baselineAwakeHrv = _baseline( | ||
| 669 | - awakeHrvHistory.map((e) => e.value!).toList(), | ||
| 670 | - requiredCount: 50, | ||
| 671 | - fallback: 50, | ||
| 672 | - ); | ||
| 673 | - final isSleep = point.flags.isSleepLikely && | ||
| 674 | - !point.flags.isWorkout && | ||
| 675 | - !point.flags.isWorkoutRecovery; | ||
| 676 | - final baselineSleepHrv = _baseline( | ||
| 677 | - sleepHrvHistory.map((e) => e.value!).toList(), | ||
| 678 | - requiredCount: 50, | ||
| 679 | - fallback: baselineAwakeHrv, | ||
| 680 | - ); | ||
| 681 | - final baselineHrv = isSleep ? baselineSleepHrv : baselineAwakeHrv; | ||
| 682 | - final restingHistory = _latestBefore( | ||
| 683 | - restingHeartRatePoints | ||
| 684 | - .where((e) => e.flags.isAwakeHrBaselineEligible) | ||
| 685 | - .toList(), | ||
| 686 | - point.endTime, | ||
| 687 | - limit: 50, | ||
| 688 | - ); | ||
| 689 | - final baselineRestingHr = _baseline( | ||
| 690 | - restingHistory.map((e) => e.value!).toList(), | ||
| 691 | - requiredCount: 50, | ||
| 692 | - fallback: 65, | ||
| 693 | - ); | ||
| 694 | - final hrWindow = _pointsInRange( | ||
| 695 | - heartRatePoints, | ||
| 696 | - point.endTime - 5 * 60, | ||
| 697 | - point.endTime, | ||
| 698 | - ).where((e) => e.flags.isCurrentAwakeHrEligible).toList(); | ||
| 699 | - final currentHr = _median(hrWindow.map((e) => e.value!).toList()); | ||
| 700 | - final adjustedHrvState = pointValue >= baselineHrv | ||
| 701 | - ? baselineHrv + (pointValue - baselineHrv) * 0.8 | ||
| 702 | - : baselineHrv + (pointValue - baselineHrv); | ||
| 703 | - | ||
| 704 | - final double trendHrv; | ||
| 705 | - if (currentHr != null) { | ||
| 706 | - final hrRatio = currentHr / baselineRestingHr; | ||
| 707 | - final double hrFactor; | ||
| 708 | - if (hrRatio >= 1) { | ||
| 709 | - hrFactor = _clamp(1 - 0.5 * (hrRatio - 1), 0.75, 1.10); | ||
| 710 | - } else if (pointValue >= baselineHrv) { | ||
| 711 | - hrFactor = _clamp(1 + 0.3 * (1 - hrRatio), 0.75, 1.10); | ||
| 712 | - } else { | ||
| 713 | - hrFactor = 1; | ||
| 714 | - } | ||
| 715 | - | ||
| 716 | - final hrvRatio = pointValue / baselineHrv; | ||
| 717 | - final mismatch = math.max(hrRatio - hrvRatio, 0).toDouble(); | ||
| 718 | - final matchFactor = _clamp(1 - 0.5 * mismatch, 0.90, 1.00); | ||
| 719 | - trendHrv = _clamp( | ||
| 720 | - adjustedHrvState * hrFactor * matchFactor, | ||
| 721 | - 5, | ||
| 722 | - baselineHrv * 1.8, | ||
| 723 | - ); | ||
| 724 | - } else { | ||
| 725 | - trendHrv = _clamp(adjustedHrvState, 5, baselineHrv * 1.8); | ||
| 726 | - } | ||
| 727 | - | ||
| 728 | - final sourceRange = _timeRange([ | ||
| 729 | - ...awakeHrvHistory, | ||
| 730 | - ...sleepHrvHistory, | ||
| 731 | - point, | ||
| 732 | - ...hrWindow, | ||
| 733 | - ...restingHistory, | ||
| 734 | - ]); | ||
| 735 | - results.add( | ||
| 736 | - HealthRawHrvStressPoint( | ||
| 737 | - userId: userId, | ||
| 738 | - rawEndTime: point.endTime, | ||
| 739 | - rawHrv: pointValue, | ||
| 740 | - result: trendHrv, | ||
| 741 | - sourceStartTime: sourceRange.start, | ||
| 742 | - sourceEndTime: sourceRange.end, | ||
| 743 | - state: _hrvState(trendHrv), | ||
| 744 | - baselineHrv: baselineHrv, | ||
| 745 | - baselineAwakeHrv: baselineAwakeHrv, | ||
| 746 | - baselineSleepHrv: isSleep ? baselineSleepHrv : null, | ||
| 747 | - baselineRestingHr: baselineRestingHr, | ||
| 748 | - flags: point.flags, | ||
| 749 | - ), | ||
| 750 | - ); | ||
| 751 | - validHrvHistory.add(point); | ||
| 752 | - } | ||
| 753 | - | ||
| 754 | - return results; | ||
| 755 | - } | ||
| 756 | - | ||
| 757 | - List<HealthRawRealtimeStressPoint> _calculateRealtimeStressPoints({ | ||
| 758 | - required int userId, | ||
| 759 | - required List<HealthRawMarkedDataPoint> heartRatePoints, | ||
| 760 | - required List<HealthRawMarkedDataPoint> restingHeartRatePoints, | ||
| 761 | - required List<HealthRawHrvStressPoint> hrvStressPoints, | ||
| 762 | - required int startTime, | ||
| 763 | - required int endTime, | ||
| 764 | - }) { | ||
| 765 | - final results = <HealthRawRealtimeStressPoint>[]; | ||
| 766 | - final days = _daysInRange(startTime, endTime); | ||
| 767 | - double? previousAwakeStress; | ||
| 768 | - int? previousAwakeStressTime; | ||
| 769 | - double? previousSleepStress; | ||
| 770 | - int? previousSleepStressTime; | ||
| 771 | - double? awakeHrvAnchorStress; | ||
| 772 | - double? sleepHrvAnchorStress; | ||
| 773 | - | ||
| 774 | - for (final dayStart in days) { | ||
| 775 | - final dayEnd = math.min(dayStart + Duration.secondsPerDay, endTime); | ||
| 776 | - final dayHr = heartRatePoints | ||
| 777 | - .where((e) => e.endTime >= dayStart && e.endTime <= dayEnd) | ||
| 778 | - .toList(); | ||
| 779 | - if (dayHr.isEmpty) continue; | ||
| 780 | - | ||
| 781 | - var pointTime = dayHr.first.endTime; | ||
| 782 | - while (pointTime <= dayEnd) { | ||
| 783 | - final hrWindow = _pointsInRange( | ||
| 784 | - dayHr, | ||
| 785 | - pointTime - 6 * 60 + 1, | ||
| 786 | - pointTime, | ||
| 787 | - ); | ||
| 788 | - final currentRawPoint = hrWindow.isEmpty ? null : hrWindow.last; | ||
| 789 | - final context = currentRawPoint?.flags.context; | ||
| 790 | - final contextWindow = context == null | ||
| 791 | - ? <HealthRawMarkedDataPoint>[] | ||
| 792 | - : hrWindow.where((e) => e.flags.matchesContext(context)).toList(); | ||
| 793 | - final currentHr = _median(contextWindow.map((e) => e.value!).toList()); | ||
| 794 | - if (currentRawPoint != null && currentHr != null) { | ||
| 795 | - final isSleep = context == HealthRawPointContext.sleep; | ||
| 796 | - final isActivity = context == HealthRawPointContext.workout || | ||
| 797 | - context == HealthRawPointContext.workoutRecovery || | ||
| 798 | - context == HealthRawPointContext.suspectedActivity; | ||
| 799 | - final awakeHistory = _latestBefore( | ||
| 800 | - restingHeartRatePoints | ||
| 801 | - .where((e) => e.flags.isAwakeHrBaselineEligible) | ||
| 802 | - .toList(), | ||
| 803 | - pointTime, | ||
| 804 | - limit: 50, | ||
| 805 | - ); | ||
| 806 | - final baselineAwakeHr = _baseline( | ||
| 807 | - awakeHistory.map((e) => e.value!).toList(), | ||
| 808 | - requiredCount: 50, | ||
| 809 | - fallback: 65, | ||
| 810 | - ); | ||
| 811 | - final sleepHistory = _latestBefore( | ||
| 812 | - restingHeartRatePoints | ||
| 813 | - .where((e) => e.flags.isSleepHrBaselineEligible) | ||
| 814 | - .toList(), | ||
| 815 | - pointTime, | ||
| 816 | - limit: 50, | ||
| 817 | - ); | ||
| 818 | - final baselineSleepHr = _sleepBaseline( | ||
| 819 | - sleepHistory.map((e) => e.value!).toList(), | ||
| 820 | - awakeHistoryCount: awakeHistory.length, | ||
| 821 | - baselineAwakeHr: baselineAwakeHr, | ||
| 822 | - ); | ||
| 823 | - final restingHistory = _latestBefore( | ||
| 824 | - restingHeartRatePoints, | ||
| 825 | - pointTime, | ||
| 826 | - limit: 50, | ||
| 827 | - ); | ||
| 828 | - final baselineRestingHr = isSleep ? baselineSleepHr : baselineAwakeHr; | ||
| 829 | - final hrChange = | ||
| 830 | - (currentHr - baselineRestingHr) / baselineRestingHr * 100; | ||
| 831 | - final hrStress = _clamp(_hrStress(hrChange), 1, 100); | ||
| 832 | - final smoothStress = isActivity | ||
| 833 | - ? hrStress | ||
| 834 | - : _smoothedStress( | ||
| 835 | - hrStress: hrStress, | ||
| 836 | - pointTime: pointTime, | ||
| 837 | - previousStress: | ||
| 838 | - isSleep ? previousSleepStress : previousAwakeStress, | ||
| 839 | - previousStressTime: isSleep | ||
| 840 | - ? previousSleepStressTime | ||
| 841 | - : previousAwakeStressTime, | ||
| 842 | - ); | ||
| 843 | - final latestHrv = isActivity | ||
| 844 | - ? null | ||
| 845 | - : hrvStressPoints.lastWhereOrNull( | ||
| 846 | - (e) => | ||
| 847 | - e.rawEndTime <= pointTime && e.flags.context == context, | ||
| 848 | - ); | ||
| 849 | - final adjustment = _adjustedStress( | ||
| 850 | - smoothStress: smoothStress, | ||
| 851 | - pointTime: pointTime, | ||
| 852 | - latestHrv: latestHrv, | ||
| 853 | - hrvAnchorStress: | ||
| 854 | - isSleep ? sleepHrvAnchorStress : awakeHrvAnchorStress, | ||
| 855 | - ); | ||
| 856 | - if (adjustment.updatedAnchorStress != null) { | ||
| 857 | - if (isSleep) { | ||
| 858 | - sleepHrvAnchorStress = adjustment.updatedAnchorStress; | ||
| 859 | - } else { | ||
| 860 | - awakeHrvAnchorStress = adjustment.updatedAnchorStress; | ||
| 861 | - } | ||
| 862 | - } | ||
| 863 | - | ||
| 864 | - final stress = _round(_clamp(adjustment.value, 1, 100), 1); | ||
| 865 | - final sourceRange = _sourceRangeForRealtime( | ||
| 866 | - hrWindow: hrWindow, | ||
| 867 | - restingHistory: restingHistory, | ||
| 868 | - latestHrv: latestHrv, | ||
| 869 | - ); | ||
| 870 | - results.add( | ||
| 871 | - HealthRawRealtimeStressPoint( | ||
| 872 | - userId: userId, | ||
| 873 | - rawEndTime: currentRawPoint.endTime, | ||
| 874 | - result: stress, | ||
| 875 | - sourceStartTime: sourceRange.start, | ||
| 876 | - sourceEndTime: sourceRange.end, | ||
| 877 | - flags: currentRawPoint.flags, | ||
| 878 | - ), | ||
| 879 | - ); | ||
| 880 | - if (!isActivity) { | ||
| 881 | - if (isSleep) { | ||
| 882 | - previousSleepStress = stress; | ||
| 883 | - previousSleepStressTime = pointTime; | ||
| 884 | - } else { | ||
| 885 | - previousAwakeStress = stress; | ||
| 886 | - previousAwakeStressTime = pointTime; | ||
| 887 | - } | ||
| 888 | - } | ||
| 889 | - } | ||
| 890 | - | ||
| 891 | - pointTime += 6 * 60; | ||
| 892 | - } | ||
| 893 | - } | ||
| 894 | - | ||
| 895 | - return results; | ||
| 896 | - } | ||
| 897 | - | ||
| 898 | - static ({int start, int end}) _sourceRangeForRealtime({ | ||
| 899 | - required List<HealthRawMarkedDataPoint> hrWindow, | ||
| 900 | - required List<HealthRawMarkedDataPoint> restingHistory, | ||
| 901 | - required HealthRawHrvStressPoint? latestHrv, | ||
| 902 | - }) { | ||
| 903 | - final pointRanges = _timeRange([...hrWindow, ...restingHistory]); | ||
| 904 | - if (latestHrv == null) return pointRanges; | ||
| 905 | - return ( | ||
| 906 | - start: math.min(pointRanges.start, latestHrv.sourceStartTime), | ||
| 907 | - end: math.max(pointRanges.end, latestHrv.sourceEndTime), | ||
| 908 | - ); | ||
| 909 | - } | ||
| 910 | - | ||
| 911 | - static List<T> _latestBefore<T extends HealthRawMarkedDataPoint>( | ||
| 912 | - List<T> points, | ||
| 913 | - int time, { | ||
| 914 | - required int limit, | ||
| 915 | - }) { | ||
| 916 | - final history = points.where((e) => e.endTime < time).toList(); | ||
| 917 | - if (history.length <= limit) return history; | ||
| 918 | - return history.sublist(history.length - limit); | ||
| 919 | - } | ||
| 920 | - | ||
| 921 | - static List<T> _pointsInRange<T extends HealthRawMarkedDataPoint>( | ||
| 922 | - List<T> points, | ||
| 923 | - int start, | ||
| 924 | - int end, | ||
| 925 | - ) { | ||
| 926 | - return points.where((e) => e.endTime >= start && e.endTime <= end).toList(); | ||
| 927 | - } | ||
| 928 | - | ||
| 929 | - static ({int start, int end}) _timeRange( | ||
| 930 | - List<HealthRawMarkedDataPoint> points) { | ||
| 931 | - if (points.isEmpty) return (start: 0, end: 0); | ||
| 932 | - var start = points.first.startTime; | ||
| 933 | - var end = points.first.endTime; | ||
| 934 | - for (final point in points.skip(1)) { | ||
| 935 | - start = math.min(start, point.startTime); | ||
| 936 | - end = math.max(end, point.endTime); | ||
| 937 | - } | ||
| 938 | - return (start: start, end: end); | ||
| 939 | - } | ||
| 940 | - | ||
| 941 | - static bool _isValidHrv(double value) => value >= 5 && value <= 200; | ||
| 942 | - | ||
| 943 | - static bool _isValidHr(double value) => value >= 30 && value <= 220; | ||
| 944 | - | ||
| 945 | - static double _baseline( | ||
| 946 | - List<double> values, { | ||
| 947 | - required int requiredCount, | ||
| 948 | - required double fallback, | ||
| 949 | - }) { | ||
| 950 | - if (values.length < requiredCount) return fallback; | ||
| 951 | - final suffix = values.sublist(values.length - requiredCount); | ||
| 952 | - return _median(suffix) ?? fallback; | ||
| 953 | - } | ||
| 954 | - | ||
| 955 | - static double _sleepBaseline( | ||
| 956 | - List<double> sleepValues, { | ||
| 957 | - required int awakeHistoryCount, | ||
| 958 | - required double baselineAwakeHr, | ||
| 959 | - }) { | ||
| 960 | - if (sleepValues.length >= 50) { | ||
| 961 | - return _median(sleepValues.sublist(sleepValues.length - 50)) ?? 65; | ||
| 962 | - } | ||
| 963 | - if (sleepValues.length >= 10) { | ||
| 964 | - return _median(sleepValues) ?? 65; | ||
| 965 | - } | ||
| 966 | - return awakeHistoryCount >= 50 ? baselineAwakeHr : 65; | ||
| 967 | - } | ||
| 968 | - | ||
| 969 | - static double? _median(List<double> values) { | ||
| 970 | - if (values.isEmpty) return null; | ||
| 971 | - final sorted = [...values]..sort(); | ||
| 972 | - final middle = sorted.length ~/ 2; | ||
| 973 | - if (sorted.length.isEven) { | ||
| 974 | - return (sorted[middle - 1] + sorted[middle]) / 2; | ||
| 975 | - } | ||
| 976 | - return sorted[middle]; | ||
| 977 | - } | ||
| 978 | - | ||
| 979 | - static double _hrStress(double hrChange) { | ||
| 980 | - if (hrChange <= -20) return 5; | ||
| 981 | - if (hrChange <= -10) return 5 + (hrChange + 20) * 0.9; | ||
| 982 | - if (hrChange <= 0) return 14 + (hrChange + 10) * 1.4; | ||
| 983 | - if (hrChange <= 10) return 28 + hrChange * 1.6; | ||
| 984 | - if (hrChange <= 20) return 44 + (hrChange - 10) * 1.6; | ||
| 985 | - if (hrChange <= 30) return 60 + (hrChange - 20) * 1.5; | ||
| 986 | - if (hrChange <= 40) return 75 + (hrChange - 30) * 1.3; | ||
| 987 | - if (hrChange < 55) return 88 + (hrChange - 40) * 0.8; | ||
| 988 | - return 100; | ||
| 989 | - } | ||
| 990 | - | ||
| 991 | - static double _smoothedStress({ | ||
| 992 | - required double hrStress, | ||
| 993 | - required int pointTime, | ||
| 994 | - required double? previousStress, | ||
| 995 | - required int? previousStressTime, | ||
| 996 | - }) { | ||
| 997 | - if (previousStress == null || | ||
| 998 | - previousStressTime == null || | ||
| 999 | - pointTime - previousStressTime > 12 * 60) { | ||
| 1000 | - return hrStress; | ||
| 1001 | - } | ||
| 1002 | - | ||
| 1003 | - final stressDiff = (hrStress - previousStress).abs(); | ||
| 1004 | - if (stressDiff < 30) { | ||
| 1005 | - return hrStress * 0.60 + previousStress * 0.40; | ||
| 1006 | - } | ||
| 1007 | - return hrStress * 0.80 + previousStress * 0.20; | ||
| 1008 | - } | ||
| 1009 | - | ||
| 1010 | - static ({double value, double? updatedAnchorStress}) _adjustedStress({ | ||
| 1011 | - required double smoothStress, | ||
| 1012 | - required int pointTime, | ||
| 1013 | - required HealthRawHrvStressPoint? latestHrv, | ||
| 1014 | - required double? hrvAnchorStress, | ||
| 1015 | - }) { | ||
| 1016 | - if (latestHrv == null) { | ||
| 1017 | - return (value: smoothStress, updatedAnchorStress: null); | ||
| 1018 | - } | ||
| 1019 | - | ||
| 1020 | - final hrvAge = (pointTime - latestHrv.rawEndTime) / 60; | ||
| 1021 | - if (hrvAge >= 0 && hrvAge <= 6) { | ||
| 1022 | - final adjusted = switch (latestHrv.state) { | ||
| 1023 | - HealthRawStressState.excellent => _clamp(smoothStress, 1, 20.9), | ||
| 1024 | - HealthRawStressState.normal => _clamp(smoothStress, 21, 60.9), | ||
| 1025 | - HealthRawStressState.attention => _clamp(smoothStress, 61, 80.9), | ||
| 1026 | - HealthRawStressState.overload => _clamp(smoothStress, 81, 100), | ||
| 1027 | - }; | ||
| 1028 | - return (value: adjusted, updatedAnchorStress: adjusted); | ||
| 1029 | - } | ||
| 1030 | - | ||
| 1031 | - if (hrvAge > 6 && hrvAge <= 30) { | ||
| 1032 | - return switch (latestHrv.state) { | ||
| 1033 | - HealthRawStressState.excellent => ( | ||
| 1034 | - value: math.min(smoothStress, 60.9).toDouble(), | ||
| 1035 | - updatedAnchorStress: null, | ||
| 1036 | - ), | ||
| 1037 | - HealthRawStressState.normal => ( | ||
| 1038 | - value: math.min(smoothStress, 80.9).toDouble(), | ||
| 1039 | - updatedAnchorStress: null, | ||
| 1040 | - ), | ||
| 1041 | - HealthRawStressState.attention => ( | ||
| 1042 | - value: math.max(smoothStress, 21).toDouble(), | ||
| 1043 | - updatedAnchorStress: null, | ||
| 1044 | - ), | ||
| 1045 | - HealthRawStressState.overload => ( | ||
| 1046 | - value: math.max(smoothStress, 61).toDouble(), | ||
| 1047 | - updatedAnchorStress: null, | ||
| 1048 | - ), | ||
| 1049 | - }; | ||
| 1050 | - } | ||
| 1051 | - | ||
| 1052 | - if (hrvAge > 30 && hrvAge <= 60 && hrvAnchorStress != null) { | ||
| 1053 | - final hrvWeight = (60 - hrvAge) / 30 * 0.20; | ||
| 1054 | - return ( | ||
| 1055 | - value: smoothStress * (1 - hrvWeight) + hrvAnchorStress * hrvWeight, | ||
| 1056 | - updatedAnchorStress: null, | ||
| 1057 | - ); | ||
| 1058 | - } | ||
| 1059 | - | ||
| 1060 | - return (value: smoothStress, updatedAnchorStress: null); | ||
| 1061 | - } | ||
| 1062 | - | ||
| 1063 | - static HealthRawStressState _hrvState(double value) { | ||
| 1064 | - if (value >= 30) return HealthRawStressState.excellent; | ||
| 1065 | - if (value >= 21) return HealthRawStressState.normal; | ||
| 1066 | - if (value >= 17) return HealthRawStressState.attention; | ||
| 1067 | - return HealthRawStressState.overload; | ||
| 1068 | - } | ||
| 1069 | - | ||
| 1070 | - static double _clamp(double value, double lower, double upper) { | ||
| 1071 | - return math.min(math.max(value, lower), upper).toDouble(); | ||
| 1072 | - } | ||
| 1073 | - | ||
| 1074 | - static double _round(double value, int places) { | ||
| 1075 | - final scale = math.pow(10, places).toDouble(); | ||
| 1076 | - return (value * scale).round() / scale; | ||
| 1077 | - } | ||
| 1078 | - | ||
| 1079 | - static List<int> _daysInRange(int startTime, int endTime) { | ||
| 1080 | - final start = DateTime.fromMillisecondsSinceEpoch(startTime * 1000); | ||
| 1081 | - final end = DateTime.fromMillisecondsSinceEpoch(endTime * 1000); | ||
| 1082 | - var day = DateTime(start.year, start.month, start.day); | ||
| 1083 | - final finalDay = DateTime(end.year, end.month, end.day); | ||
| 1084 | - final days = <int>[]; | ||
| 1085 | - while (!day.isAfter(finalDay)) { | ||
| 1086 | - days.add(day.millisecondsSinceEpoch ~/ 1000); | ||
| 1087 | - day = day.add(const Duration(days: 1)); | ||
| 1088 | - } | ||
| 1089 | - return days; | ||
| 1090 | - } | ||
| 1091 | - | ||
| 1092 | static int? _minNullable(int? a, int? b) { | 537 | static int? _minNullable(int? a, int? b) { |
| 1093 | if (a == null) return b; | 538 | if (a == null) return b; |
| 1094 | if (b == null) return a; | 539 | if (b == null) return a; |
| @@ -1779,13 +1224,3 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | @@ -1779,13 +1224,3 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( | ||
| 1779 | ); | 1224 | ); |
| 1780 | } | 1225 | } |
| 1781 | } | 1226 | } |
| 1782 | - | ||
| 1783 | -extension _IterableFirstWhereOrNull<T> on Iterable<T> { | ||
| 1784 | - T? lastWhereOrNull(bool Function(T element) test) { | ||
| 1785 | - T? result; | ||
| 1786 | - for (final element in this) { | ||
| 1787 | - if (test(element)) result = element; | ||
| 1788 | - } | ||
| 1789 | - return result; | ||
| 1790 | - } | ||
| 1791 | -} |
| 1 | +import 'dart:math' as math; | ||
| 2 | + | ||
| 3 | +import '../../pigeon/health_kit_raw_data_api.g.dart'; | ||
| 4 | +import 'health_raw_data_core_service.dart'; | ||
| 5 | + | ||
| 6 | +class HealthRawStressCalculator { | ||
| 7 | + const HealthRawStressCalculator({required this.userId}); | ||
| 8 | + | ||
| 9 | + final int userId; | ||
| 10 | + | ||
| 11 | + HealthRawStressCalculationResult calculate({ | ||
| 12 | + required List<HealthKitRawDataPoint> hrvPoints, | ||
| 13 | + required List<HealthKitRawDataPoint> heartRatePoints, | ||
| 14 | + required List<HealthKitRawDataPoint> restingHeartRatePoints, | ||
| 15 | + List<HealthKitRawDataPoint> sleepIntervals = | ||
| 16 | + const <HealthKitRawDataPoint>[], | ||
| 17 | + List<HealthKitRawWorkoutDataPoint> workoutIntervals = | ||
| 18 | + const <HealthKitRawWorkoutDataPoint>[], | ||
| 19 | + required int startTime, | ||
| 20 | + required int endTime, | ||
| 21 | + }) { | ||
| 22 | + final marked = _markRawData( | ||
| 23 | + hrvPoints: hrvPoints, | ||
| 24 | + heartRatePoints: heartRatePoints, | ||
| 25 | + restingHeartRatePoints: restingHeartRatePoints, | ||
| 26 | + sleepIntervals: sleepIntervals, | ||
| 27 | + workoutIntervals: workoutIntervals, | ||
| 28 | + ); | ||
| 29 | + final sortedHrv = marked.hrv.where((e) { | ||
| 30 | + final value = e.value; | ||
| 31 | + return value != null && _isValidHrv(value); | ||
| 32 | + }).toList() | ||
| 33 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 34 | + final sortedHr = marked.heartRate.where((e) { | ||
| 35 | + final value = e.value; | ||
| 36 | + return value != null && _isValidHr(value); | ||
| 37 | + }).toList() | ||
| 38 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 39 | + final sortedRestingHr = marked.restingHeartRate.where((e) { | ||
| 40 | + final value = e.value; | ||
| 41 | + return value != null && _isValidHr(value); | ||
| 42 | + }).toList() | ||
| 43 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 44 | + | ||
| 45 | + final hrvStressPoints = _calculateHrvStressPoints( | ||
| 46 | + hrvPoints: sortedHrv, | ||
| 47 | + heartRatePoints: sortedHr, | ||
| 48 | + restingHeartRatePoints: sortedRestingHr, | ||
| 49 | + ); | ||
| 50 | + final realtimeStressPoints = _calculateRealtimeStressPoints( | ||
| 51 | + heartRatePoints: sortedHr, | ||
| 52 | + restingHeartRatePoints: sortedRestingHr, | ||
| 53 | + hrvStressPoints: hrvStressPoints, | ||
| 54 | + startTime: startTime, | ||
| 55 | + endTime: endTime, | ||
| 56 | + ); | ||
| 57 | + | ||
| 58 | + return HealthRawStressCalculationResult( | ||
| 59 | + userId: userId, | ||
| 60 | + hrvStressPoints: hrvStressPoints, | ||
| 61 | + realtimeStressPoints: realtimeStressPoints, | ||
| 62 | + ); | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + HealthRawMarkedDataSet _markRawData({ | ||
| 66 | + required List<HealthKitRawDataPoint> hrvPoints, | ||
| 67 | + required List<HealthKitRawDataPoint> heartRatePoints, | ||
| 68 | + required List<HealthKitRawDataPoint> restingHeartRatePoints, | ||
| 69 | + required List<HealthKitRawDataPoint> sleepIntervals, | ||
| 70 | + required List<HealthKitRawWorkoutDataPoint> workoutIntervals, | ||
| 71 | + }) { | ||
| 72 | + final hrv = hrvPoints | ||
| 73 | + .map((point) => HealthRawMarkedDataPoint( | ||
| 74 | + raw: point, | ||
| 75 | + flags: _flagsForPoint( | ||
| 76 | + point, | ||
| 77 | + sleepIntervals: sleepIntervals, | ||
| 78 | + workoutIntervals: workoutIntervals, | ||
| 79 | + ), | ||
| 80 | + )) | ||
| 81 | + .toList() | ||
| 82 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 83 | + final heartRate = heartRatePoints | ||
| 84 | + .map((point) => HealthRawMarkedDataPoint( | ||
| 85 | + raw: point, | ||
| 86 | + flags: _flagsForPoint( | ||
| 87 | + point, | ||
| 88 | + sleepIntervals: sleepIntervals, | ||
| 89 | + workoutIntervals: workoutIntervals, | ||
| 90 | + ), | ||
| 91 | + )) | ||
| 92 | + .toList() | ||
| 93 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 94 | + final restingHeartRate = restingHeartRatePoints | ||
| 95 | + .map((point) => HealthRawMarkedDataPoint( | ||
| 96 | + raw: point, | ||
| 97 | + flags: _flagsForPoint( | ||
| 98 | + point, | ||
| 99 | + sleepIntervals: sleepIntervals, | ||
| 100 | + workoutIntervals: workoutIntervals, | ||
| 101 | + ), | ||
| 102 | + )) | ||
| 103 | + .toList() | ||
| 104 | + ..sort((a, b) => a.endTime.compareTo(b.endTime)); | ||
| 105 | + | ||
| 106 | + return HealthRawMarkedDataSet( | ||
| 107 | + hrv: hrv, | ||
| 108 | + heartRate: heartRate, | ||
| 109 | + restingHeartRate: restingHeartRate, | ||
| 110 | + ); | ||
| 111 | + } | ||
| 112 | + | ||
| 113 | + HealthRawPointFlags _flagsForPoint( | ||
| 114 | + HealthKitRawDataPoint point, { | ||
| 115 | + required List<HealthKitRawDataPoint> sleepIntervals, | ||
| 116 | + required List<HealthKitRawWorkoutDataPoint> workoutIntervals, | ||
| 117 | + }) { | ||
| 118 | + final isWorkout = workoutIntervals.any( | ||
| 119 | + (workout) => | ||
| 120 | + point.endTime > workout.startTime && point.endTime <= workout.endTime, | ||
| 121 | + ); | ||
| 122 | + final isWorkoutRecovery = !isWorkout && | ||
| 123 | + workoutIntervals.any( | ||
| 124 | + (workout) => | ||
| 125 | + point.endTime > workout.endTime && | ||
| 126 | + point.endTime <= workout.endTime + 30 * 60, | ||
| 127 | + ); | ||
| 128 | + return HealthRawPointFlags( | ||
| 129 | + isWorkout: isWorkout, | ||
| 130 | + isWorkoutRecovery: isWorkoutRecovery, | ||
| 131 | + isSleepLikely: sleepIntervals.any( | ||
| 132 | + (sleep) => | ||
| 133 | + point.endTime > sleep.startTime && point.endTime <= sleep.endTime, | ||
| 134 | + ), | ||
| 135 | + isSuspectedActivity: point.isMotionLike == true, | ||
| 136 | + ); | ||
| 137 | + } | ||
| 138 | + | ||
| 139 | + List<HealthRawHrvStressPoint> _calculateHrvStressPoints({ | ||
| 140 | + required List<HealthRawMarkedDataPoint> hrvPoints, | ||
| 141 | + required List<HealthRawMarkedDataPoint> heartRatePoints, | ||
| 142 | + required List<HealthRawMarkedDataPoint> restingHeartRatePoints, | ||
| 143 | + }) { | ||
| 144 | + final validHrvHistory = <HealthRawMarkedDataPoint>[]; | ||
| 145 | + final results = <HealthRawHrvStressPoint>[]; | ||
| 146 | + | ||
| 147 | + for (final point in hrvPoints) { | ||
| 148 | + final pointValue = point.value!; | ||
| 149 | + final awakeHrvHistory = _latestBefore( | ||
| 150 | + validHrvHistory.where((e) => e.flags.isAwakeBaselineEligible).toList(), | ||
| 151 | + point.endTime, | ||
| 152 | + limit: 50, | ||
| 153 | + ); | ||
| 154 | + final sleepHrvHistory = _latestBefore( | ||
| 155 | + validHrvHistory | ||
| 156 | + .where((e) => e.flags.isSleepHrvBaselineEligible) | ||
| 157 | + .toList(), | ||
| 158 | + point.endTime, | ||
| 159 | + limit: 50, | ||
| 160 | + ); | ||
| 161 | + final baselineAwakeHrv = _baseline( | ||
| 162 | + awakeHrvHistory.map((e) => e.value!).toList(), | ||
| 163 | + requiredCount: 50, | ||
| 164 | + fallback: 50, | ||
| 165 | + ); | ||
| 166 | + final isSleep = point.flags.isSleepLikely && | ||
| 167 | + !point.flags.isWorkout && | ||
| 168 | + !point.flags.isWorkoutRecovery; | ||
| 169 | + final baselineSleepHrv = _baseline( | ||
| 170 | + sleepHrvHistory.map((e) => e.value!).toList(), | ||
| 171 | + requiredCount: 50, | ||
| 172 | + fallback: baselineAwakeHrv, | ||
| 173 | + ); | ||
| 174 | + final baselineHrv = isSleep ? baselineSleepHrv : baselineAwakeHrv; | ||
| 175 | + final restingHistory = _latestBefore( | ||
| 176 | + restingHeartRatePoints | ||
| 177 | + .where((e) => e.flags.isAwakeHrBaselineEligible) | ||
| 178 | + .toList(), | ||
| 179 | + point.endTime, | ||
| 180 | + limit: 50, | ||
| 181 | + ); | ||
| 182 | + final baselineRestingHr = _baseline( | ||
| 183 | + restingHistory.map((e) => e.value!).toList(), | ||
| 184 | + requiredCount: 50, | ||
| 185 | + fallback: 65, | ||
| 186 | + ); | ||
| 187 | + final hrWindow = _pointsInRange( | ||
| 188 | + heartRatePoints, | ||
| 189 | + point.endTime - 5 * 60, | ||
| 190 | + point.endTime, | ||
| 191 | + ).where((e) => e.flags.isCurrentAwakeHrEligible).toList(); | ||
| 192 | + final currentHr = _median(hrWindow.map((e) => e.value!).toList()); | ||
| 193 | + final adjustedHrvState = pointValue >= baselineHrv | ||
| 194 | + ? baselineHrv + (pointValue - baselineHrv) * 0.8 | ||
| 195 | + : baselineHrv + (pointValue - baselineHrv); | ||
| 196 | + | ||
| 197 | + final double trendHrv; | ||
| 198 | + if (currentHr != null) { | ||
| 199 | + final hrRatio = currentHr / baselineRestingHr; | ||
| 200 | + final double hrFactor; | ||
| 201 | + if (hrRatio >= 1) { | ||
| 202 | + hrFactor = _clamp(1 - 0.5 * (hrRatio - 1), 0.75, 1.10); | ||
| 203 | + } else if (pointValue >= baselineHrv) { | ||
| 204 | + hrFactor = _clamp(1 + 0.3 * (1 - hrRatio), 0.75, 1.10); | ||
| 205 | + } else { | ||
| 206 | + hrFactor = 1; | ||
| 207 | + } | ||
| 208 | + | ||
| 209 | + final hrvRatio = pointValue / baselineHrv; | ||
| 210 | + final mismatch = math.max(hrRatio - hrvRatio, 0).toDouble(); | ||
| 211 | + final matchFactor = _clamp(1 - 0.5 * mismatch, 0.90, 1.00); | ||
| 212 | + trendHrv = _clamp( | ||
| 213 | + adjustedHrvState * hrFactor * matchFactor, | ||
| 214 | + 5, | ||
| 215 | + baselineHrv * 1.8, | ||
| 216 | + ); | ||
| 217 | + } else { | ||
| 218 | + trendHrv = _clamp(adjustedHrvState, 5, baselineHrv * 1.8); | ||
| 219 | + } | ||
| 220 | + | ||
| 221 | + final sourceRange = _timeRange([ | ||
| 222 | + ...awakeHrvHistory, | ||
| 223 | + ...sleepHrvHistory, | ||
| 224 | + point, | ||
| 225 | + ...hrWindow, | ||
| 226 | + ...restingHistory, | ||
| 227 | + ]); | ||
| 228 | + results.add( | ||
| 229 | + HealthRawHrvStressPoint( | ||
| 230 | + userId: userId, | ||
| 231 | + rawEndTime: point.endTime, | ||
| 232 | + rawHrv: pointValue, | ||
| 233 | + result: trendHrv, | ||
| 234 | + sourceStartTime: sourceRange.start, | ||
| 235 | + sourceEndTime: sourceRange.end, | ||
| 236 | + state: _hrvState(trendHrv), | ||
| 237 | + baselineHrv: baselineHrv, | ||
| 238 | + baselineAwakeHrv: baselineAwakeHrv, | ||
| 239 | + baselineSleepHrv: isSleep ? baselineSleepHrv : null, | ||
| 240 | + baselineRestingHr: baselineRestingHr, | ||
| 241 | + flags: point.flags, | ||
| 242 | + ), | ||
| 243 | + ); | ||
| 244 | + validHrvHistory.add(point); | ||
| 245 | + } | ||
| 246 | + | ||
| 247 | + return results; | ||
| 248 | + } | ||
| 249 | + | ||
| 250 | + List<HealthRawRealtimeStressPoint> _calculateRealtimeStressPoints({ | ||
| 251 | + required List<HealthRawMarkedDataPoint> heartRatePoints, | ||
| 252 | + required List<HealthRawMarkedDataPoint> restingHeartRatePoints, | ||
| 253 | + required List<HealthRawHrvStressPoint> hrvStressPoints, | ||
| 254 | + required int startTime, | ||
| 255 | + required int endTime, | ||
| 256 | + }) { | ||
| 257 | + final results = <HealthRawRealtimeStressPoint>[]; | ||
| 258 | + final days = _daysInRange(startTime, endTime); | ||
| 259 | + double? previousAwakeStress; | ||
| 260 | + int? previousAwakeStressTime; | ||
| 261 | + double? previousSleepStress; | ||
| 262 | + int? previousSleepStressTime; | ||
| 263 | + double? awakeHrvAnchorStress; | ||
| 264 | + double? sleepHrvAnchorStress; | ||
| 265 | + | ||
| 266 | + for (final dayStart in days) { | ||
| 267 | + final dayEnd = math.min(dayStart + Duration.secondsPerDay, endTime); | ||
| 268 | + final dayHr = heartRatePoints | ||
| 269 | + .where((e) => e.endTime >= dayStart && e.endTime <= dayEnd) | ||
| 270 | + .toList(); | ||
| 271 | + if (dayHr.isEmpty) continue; | ||
| 272 | + | ||
| 273 | + var pointTime = dayHr.first.endTime; | ||
| 274 | + while (pointTime <= dayEnd) { | ||
| 275 | + final hrWindow = _pointsInRange( | ||
| 276 | + dayHr, | ||
| 277 | + pointTime - 6 * 60 + 1, | ||
| 278 | + pointTime, | ||
| 279 | + ); | ||
| 280 | + final currentRawPoint = hrWindow.isEmpty ? null : hrWindow.last; | ||
| 281 | + final context = currentRawPoint?.flags.context; | ||
| 282 | + final contextWindow = context == null | ||
| 283 | + ? <HealthRawMarkedDataPoint>[] | ||
| 284 | + : hrWindow.where((e) => e.flags.matchesContext(context)).toList(); | ||
| 285 | + final currentHr = _median(contextWindow.map((e) => e.value!).toList()); | ||
| 286 | + if (currentRawPoint != null && currentHr != null) { | ||
| 287 | + final isSleep = context == HealthRawPointContext.sleep; | ||
| 288 | + final isActivity = context == HealthRawPointContext.workout || | ||
| 289 | + context == HealthRawPointContext.workoutRecovery || | ||
| 290 | + context == HealthRawPointContext.suspectedActivity; | ||
| 291 | + final awakeHistory = _latestBefore( | ||
| 292 | + restingHeartRatePoints | ||
| 293 | + .where((e) => e.flags.isAwakeHrBaselineEligible) | ||
| 294 | + .toList(), | ||
| 295 | + pointTime, | ||
| 296 | + limit: 50, | ||
| 297 | + ); | ||
| 298 | + final baselineAwakeHr = _baseline( | ||
| 299 | + awakeHistory.map((e) => e.value!).toList(), | ||
| 300 | + requiredCount: 50, | ||
| 301 | + fallback: 65, | ||
| 302 | + ); | ||
| 303 | + final sleepHistory = _latestBefore( | ||
| 304 | + restingHeartRatePoints | ||
| 305 | + .where((e) => e.flags.isSleepHrBaselineEligible) | ||
| 306 | + .toList(), | ||
| 307 | + pointTime, | ||
| 308 | + limit: 50, | ||
| 309 | + ); | ||
| 310 | + final baselineSleepHr = _sleepBaseline( | ||
| 311 | + sleepHistory.map((e) => e.value!).toList(), | ||
| 312 | + awakeHistoryCount: awakeHistory.length, | ||
| 313 | + baselineAwakeHr: baselineAwakeHr, | ||
| 314 | + ); | ||
| 315 | + final restingHistory = _latestBefore( | ||
| 316 | + restingHeartRatePoints, | ||
| 317 | + pointTime, | ||
| 318 | + limit: 50, | ||
| 319 | + ); | ||
| 320 | + final baselineRestingHr = isSleep ? baselineSleepHr : baselineAwakeHr; | ||
| 321 | + final hrChange = | ||
| 322 | + (currentHr - baselineRestingHr) / baselineRestingHr * 100; | ||
| 323 | + final hrStress = _clamp(_hrStress(hrChange), 1, 100); | ||
| 324 | + final smoothStress = isActivity | ||
| 325 | + ? hrStress | ||
| 326 | + : _smoothedStress( | ||
| 327 | + hrStress: hrStress, | ||
| 328 | + pointTime: pointTime, | ||
| 329 | + previousStress: | ||
| 330 | + isSleep ? previousSleepStress : previousAwakeStress, | ||
| 331 | + previousStressTime: isSleep | ||
| 332 | + ? previousSleepStressTime | ||
| 333 | + : previousAwakeStressTime, | ||
| 334 | + ); | ||
| 335 | + final latestHrv = isActivity | ||
| 336 | + ? null | ||
| 337 | + : _lastHrvStressWhere( | ||
| 338 | + hrvStressPoints, | ||
| 339 | + (e) => | ||
| 340 | + e.rawEndTime <= pointTime && e.flags.context == context, | ||
| 341 | + ); | ||
| 342 | + final adjustment = _adjustedStress( | ||
| 343 | + smoothStress: smoothStress, | ||
| 344 | + pointTime: pointTime, | ||
| 345 | + latestHrv: latestHrv, | ||
| 346 | + hrvAnchorStress: | ||
| 347 | + isSleep ? sleepHrvAnchorStress : awakeHrvAnchorStress, | ||
| 348 | + ); | ||
| 349 | + if (adjustment.updatedAnchorStress != null) { | ||
| 350 | + if (isSleep) { | ||
| 351 | + sleepHrvAnchorStress = adjustment.updatedAnchorStress; | ||
| 352 | + } else { | ||
| 353 | + awakeHrvAnchorStress = adjustment.updatedAnchorStress; | ||
| 354 | + } | ||
| 355 | + } | ||
| 356 | + | ||
| 357 | + final stress = _round(_clamp(adjustment.value, 1, 100), 1); | ||
| 358 | + final sourceRange = _sourceRangeForRealtime( | ||
| 359 | + hrWindow: hrWindow, | ||
| 360 | + restingHistory: restingHistory, | ||
| 361 | + latestHrv: latestHrv, | ||
| 362 | + ); | ||
| 363 | + results.add( | ||
| 364 | + HealthRawRealtimeStressPoint( | ||
| 365 | + userId: userId, | ||
| 366 | + rawEndTime: currentRawPoint.endTime, | ||
| 367 | + result: stress, | ||
| 368 | + sourceStartTime: sourceRange.start, | ||
| 369 | + sourceEndTime: sourceRange.end, | ||
| 370 | + flags: currentRawPoint.flags, | ||
| 371 | + ), | ||
| 372 | + ); | ||
| 373 | + if (!isActivity) { | ||
| 374 | + if (isSleep) { | ||
| 375 | + previousSleepStress = stress; | ||
| 376 | + previousSleepStressTime = pointTime; | ||
| 377 | + } else { | ||
| 378 | + previousAwakeStress = stress; | ||
| 379 | + previousAwakeStressTime = pointTime; | ||
| 380 | + } | ||
| 381 | + } | ||
| 382 | + } | ||
| 383 | + | ||
| 384 | + pointTime += 6 * 60; | ||
| 385 | + } | ||
| 386 | + } | ||
| 387 | + | ||
| 388 | + return results; | ||
| 389 | + } | ||
| 390 | + | ||
| 391 | + static ({int start, int end}) _sourceRangeForRealtime({ | ||
| 392 | + required List<HealthRawMarkedDataPoint> hrWindow, | ||
| 393 | + required List<HealthRawMarkedDataPoint> restingHistory, | ||
| 394 | + required HealthRawHrvStressPoint? latestHrv, | ||
| 395 | + }) { | ||
| 396 | + final pointRanges = _timeRange([...hrWindow, ...restingHistory]); | ||
| 397 | + if (latestHrv == null) return pointRanges; | ||
| 398 | + return ( | ||
| 399 | + start: math.min(pointRanges.start, latestHrv.sourceStartTime), | ||
| 400 | + end: math.max(pointRanges.end, latestHrv.sourceEndTime), | ||
| 401 | + ); | ||
| 402 | + } | ||
| 403 | + | ||
| 404 | + static List<T> _latestBefore<T extends HealthRawMarkedDataPoint>( | ||
| 405 | + List<T> points, | ||
| 406 | + int time, { | ||
| 407 | + required int limit, | ||
| 408 | + }) { | ||
| 409 | + final history = points.where((e) => e.endTime < time).toList(); | ||
| 410 | + if (history.length <= limit) return history; | ||
| 411 | + return history.sublist(history.length - limit); | ||
| 412 | + } | ||
| 413 | + | ||
| 414 | + static List<T> _pointsInRange<T extends HealthRawMarkedDataPoint>( | ||
| 415 | + List<T> points, | ||
| 416 | + int start, | ||
| 417 | + int end, | ||
| 418 | + ) { | ||
| 419 | + return points.where((e) => e.endTime >= start && e.endTime <= end).toList(); | ||
| 420 | + } | ||
| 421 | + | ||
| 422 | + static ({int start, int end}) _timeRange( | ||
| 423 | + List<HealthRawMarkedDataPoint> points) { | ||
| 424 | + if (points.isEmpty) return (start: 0, end: 0); | ||
| 425 | + var start = points.first.startTime; | ||
| 426 | + var end = points.first.endTime; | ||
| 427 | + for (final point in points.skip(1)) { | ||
| 428 | + start = math.min(start, point.startTime); | ||
| 429 | + end = math.max(end, point.endTime); | ||
| 430 | + } | ||
| 431 | + return (start: start, end: end); | ||
| 432 | + } | ||
| 433 | + | ||
| 434 | + static bool _isValidHrv(double value) => value >= 5 && value <= 200; | ||
| 435 | + | ||
| 436 | + static bool _isValidHr(double value) => value >= 30 && value <= 220; | ||
| 437 | + | ||
| 438 | + static double _baseline( | ||
| 439 | + List<double> values, { | ||
| 440 | + required int requiredCount, | ||
| 441 | + required double fallback, | ||
| 442 | + }) { | ||
| 443 | + if (values.length < requiredCount) return fallback; | ||
| 444 | + final suffix = values.sublist(values.length - requiredCount); | ||
| 445 | + return _median(suffix) ?? fallback; | ||
| 446 | + } | ||
| 447 | + | ||
| 448 | + static double _sleepBaseline( | ||
| 449 | + List<double> sleepValues, { | ||
| 450 | + required int awakeHistoryCount, | ||
| 451 | + required double baselineAwakeHr, | ||
| 452 | + }) { | ||
| 453 | + if (sleepValues.length >= 50) { | ||
| 454 | + return _median(sleepValues.sublist(sleepValues.length - 50)) ?? 65; | ||
| 455 | + } | ||
| 456 | + if (sleepValues.length >= 10) { | ||
| 457 | + return _median(sleepValues) ?? 65; | ||
| 458 | + } | ||
| 459 | + return awakeHistoryCount >= 50 ? baselineAwakeHr : 65; | ||
| 460 | + } | ||
| 461 | + | ||
| 462 | + static double? _median(List<double> values) { | ||
| 463 | + if (values.isEmpty) return null; | ||
| 464 | + final sorted = [...values]..sort(); | ||
| 465 | + final middle = sorted.length ~/ 2; | ||
| 466 | + if (sorted.length.isEven) { | ||
| 467 | + return (sorted[middle - 1] + sorted[middle]) / 2; | ||
| 468 | + } | ||
| 469 | + return sorted[middle]; | ||
| 470 | + } | ||
| 471 | + | ||
| 472 | + static double _hrStress(double hrChange) { | ||
| 473 | + if (hrChange <= -20) return 5; | ||
| 474 | + if (hrChange <= -10) return 5 + (hrChange + 20) * 0.9; | ||
| 475 | + if (hrChange <= 0) return 14 + (hrChange + 10) * 1.4; | ||
| 476 | + if (hrChange <= 10) return 28 + hrChange * 1.6; | ||
| 477 | + if (hrChange <= 20) return 44 + (hrChange - 10) * 1.6; | ||
| 478 | + if (hrChange <= 30) return 60 + (hrChange - 20) * 1.5; | ||
| 479 | + if (hrChange <= 40) return 75 + (hrChange - 30) * 1.3; | ||
| 480 | + if (hrChange < 55) return 88 + (hrChange - 40) * 0.8; | ||
| 481 | + return 100; | ||
| 482 | + } | ||
| 483 | + | ||
| 484 | + static double _smoothedStress({ | ||
| 485 | + required double hrStress, | ||
| 486 | + required int pointTime, | ||
| 487 | + required double? previousStress, | ||
| 488 | + required int? previousStressTime, | ||
| 489 | + }) { | ||
| 490 | + if (previousStress == null || | ||
| 491 | + previousStressTime == null || | ||
| 492 | + pointTime - previousStressTime > 12 * 60) { | ||
| 493 | + return hrStress; | ||
| 494 | + } | ||
| 495 | + | ||
| 496 | + final stressDiff = (hrStress - previousStress).abs(); | ||
| 497 | + if (stressDiff < 30) { | ||
| 498 | + return hrStress * 0.60 + previousStress * 0.40; | ||
| 499 | + } | ||
| 500 | + return hrStress * 0.80 + previousStress * 0.20; | ||
| 501 | + } | ||
| 502 | + | ||
| 503 | + static ({double value, double? updatedAnchorStress}) _adjustedStress({ | ||
| 504 | + required double smoothStress, | ||
| 505 | + required int pointTime, | ||
| 506 | + required HealthRawHrvStressPoint? latestHrv, | ||
| 507 | + required double? hrvAnchorStress, | ||
| 508 | + }) { | ||
| 509 | + if (latestHrv == null) { | ||
| 510 | + return (value: smoothStress, updatedAnchorStress: null); | ||
| 511 | + } | ||
| 512 | + | ||
| 513 | + final hrvAge = (pointTime - latestHrv.rawEndTime) / 60; | ||
| 514 | + if (hrvAge >= 0 && hrvAge <= 6) { | ||
| 515 | + final adjusted = switch (latestHrv.state) { | ||
| 516 | + HealthRawStressState.excellent => _clamp(smoothStress, 1, 20.9), | ||
| 517 | + HealthRawStressState.normal => _clamp(smoothStress, 21, 60.9), | ||
| 518 | + HealthRawStressState.attention => _clamp(smoothStress, 61, 80.9), | ||
| 519 | + HealthRawStressState.overload => _clamp(smoothStress, 81, 100), | ||
| 520 | + }; | ||
| 521 | + return (value: adjusted, updatedAnchorStress: adjusted); | ||
| 522 | + } | ||
| 523 | + | ||
| 524 | + if (hrvAge > 6 && hrvAge <= 30) { | ||
| 525 | + return switch (latestHrv.state) { | ||
| 526 | + HealthRawStressState.excellent => ( | ||
| 527 | + value: math.min(smoothStress, 60.9).toDouble(), | ||
| 528 | + updatedAnchorStress: null, | ||
| 529 | + ), | ||
| 530 | + HealthRawStressState.normal => ( | ||
| 531 | + value: math.min(smoothStress, 80.9).toDouble(), | ||
| 532 | + updatedAnchorStress: null, | ||
| 533 | + ), | ||
| 534 | + HealthRawStressState.attention => ( | ||
| 535 | + value: math.max(smoothStress, 21).toDouble(), | ||
| 536 | + updatedAnchorStress: null, | ||
| 537 | + ), | ||
| 538 | + HealthRawStressState.overload => ( | ||
| 539 | + value: math.max(smoothStress, 61).toDouble(), | ||
| 540 | + updatedAnchorStress: null, | ||
| 541 | + ), | ||
| 542 | + }; | ||
| 543 | + } | ||
| 544 | + | ||
| 545 | + if (hrvAge > 30 && hrvAge <= 60 && hrvAnchorStress != null) { | ||
| 546 | + final hrvWeight = (60 - hrvAge) / 30 * 0.20; | ||
| 547 | + return ( | ||
| 548 | + value: smoothStress * (1 - hrvWeight) + hrvAnchorStress * hrvWeight, | ||
| 549 | + updatedAnchorStress: null, | ||
| 550 | + ); | ||
| 551 | + } | ||
| 552 | + | ||
| 553 | + return (value: smoothStress, updatedAnchorStress: null); | ||
| 554 | + } | ||
| 555 | + | ||
| 556 | + static HealthRawStressState _hrvState(double value) { | ||
| 557 | + if (value >= 30) return HealthRawStressState.excellent; | ||
| 558 | + if (value >= 21) return HealthRawStressState.normal; | ||
| 559 | + if (value >= 17) return HealthRawStressState.attention; | ||
| 560 | + return HealthRawStressState.overload; | ||
| 561 | + } | ||
| 562 | + | ||
| 563 | + static double _clamp(double value, double lower, double upper) { | ||
| 564 | + return math.min(math.max(value, lower), upper).toDouble(); | ||
| 565 | + } | ||
| 566 | + | ||
| 567 | + static double _round(double value, int places) { | ||
| 568 | + final scale = math.pow(10, places).toDouble(); | ||
| 569 | + return (value * scale).round() / scale; | ||
| 570 | + } | ||
| 571 | + | ||
| 572 | + static List<int> _daysInRange(int startTime, int endTime) { | ||
| 573 | + final start = DateTime.fromMillisecondsSinceEpoch(startTime * 1000); | ||
| 574 | + final end = DateTime.fromMillisecondsSinceEpoch(endTime * 1000); | ||
| 575 | + var day = DateTime(start.year, start.month, start.day); | ||
| 576 | + final finalDay = DateTime(end.year, end.month, end.day); | ||
| 577 | + final days = <int>[]; | ||
| 578 | + while (!day.isAfter(finalDay)) { | ||
| 579 | + days.add(day.millisecondsSinceEpoch ~/ 1000); | ||
| 580 | + day = day.add(const Duration(days: 1)); | ||
| 581 | + } | ||
| 582 | + return days; | ||
| 583 | + } | ||
| 584 | + | ||
| 585 | + static HealthRawHrvStressPoint? _lastHrvStressWhere( | ||
| 586 | + Iterable<HealthRawHrvStressPoint> points, | ||
| 587 | + bool Function(HealthRawHrvStressPoint point) test, | ||
| 588 | + ) { | ||
| 589 | + HealthRawHrvStressPoint? result; | ||
| 590 | + for (final point in points) { | ||
| 591 | + if (test(point)) result = point; | ||
| 592 | + } | ||
| 593 | + return result; | ||
| 594 | + } | ||
| 595 | +} |
| @@ -30,7 +30,6 @@ class HealthDataSourceWrapper implements HealthDataSource { | @@ -30,7 +30,6 @@ class HealthDataSourceWrapper implements HealthDataSource { | ||
| 30 | if (queryUserId == null || _myUserId == queryUserId) { | 30 | if (queryUserId == null || _myUserId == queryUserId) { |
| 31 | dataSource = localDataSource; | 31 | dataSource = localDataSource; |
| 32 | } | 32 | } |
| 33 | - | ||
| 34 | return dataSource.getSleepStatistics( | 33 | return dataSource.getSleepStatistics( |
| 35 | dateRangeType, | 34 | dateRangeType, |
| 36 | startDate, | 35 | startDate, |
| @@ -15,6 +15,7 @@ const _sleepTypeAsleepCore = 3; | @@ -15,6 +15,7 @@ const _sleepTypeAsleepCore = 3; | ||
| 15 | const _sleepTypeAsleepDeep = 4; | 15 | 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 | 19 | ||
| 19 | class LocalHealthDataConvert { | 20 | class LocalHealthDataConvert { |
| 20 | const LocalHealthDataConvert._(); | 21 | const LocalHealthDataConvert._(); |
| @@ -50,8 +51,9 @@ class LocalHealthDataConvert { | @@ -50,8 +51,9 @@ class LocalHealthDataConvert { | ||
| 50 | final dayStart = unixSeconds(day); | 51 | final dayStart = unixSeconds(day); |
| 51 | final dayEnd = unixSeconds(day.add(const Duration(days: 1))); | 52 | final dayEnd = unixSeconds(day.add(const Duration(days: 1))); |
| 52 | final queryStart = unixSeconds(day.subtract(const Duration(days: 1))); | 53 | final queryStart = unixSeconds(day.subtract(const Duration(days: 1))); |
| 54 | + final queryEnd = unixSeconds(day.add(const Duration(days: 2))); | ||
| 53 | final candidates = sleepIntervals | 55 | final candidates = sleepIntervals |
| 54 | - .where((e) => e.endTime > queryStart && e.startTime < dayEnd) | 56 | + .where((e) => e.endTime > queryStart && e.startTime < queryEnd) |
| 55 | .toList() | 57 | .toList() |
| 56 | ..sort((a, b) { | 58 | ..sort((a, b) { |
| 57 | final startCompare = a.startTime.compareTo(b.startTime); | 59 | final startCompare = a.startTime.compareTo(b.startTime); |
| @@ -60,33 +62,28 @@ class LocalHealthDataConvert { | @@ -60,33 +62,28 @@ class LocalHealthDataConvert { | ||
| 60 | }); | 62 | }); |
| 61 | if (candidates.isEmpty) return const <HealthKitRawDataPoint>[]; | 63 | if (candidates.isEmpty) return const <HealthKitRawDataPoint>[]; |
| 62 | 64 | ||
| 63 | - final anchorIndex = candidates.indexWhere( | ||
| 64 | - (e) => e.endTime > dayStart && e.startTime < dayEnd, | ||
| 65 | - ); | ||
| 66 | - if (anchorIndex < 0) return const <HealthKitRawDataPoint>[]; | ||
| 67 | - | ||
| 68 | - var rangeStart = candidates[anchorIndex].startTime; | ||
| 69 | - var rangeEnd = candidates[anchorIndex].endTime; | ||
| 70 | - var expanded = true; | ||
| 71 | - while (expanded) { | ||
| 72 | - expanded = false; | ||
| 73 | - for (final interval in candidates) { | ||
| 74 | - final isConnected = | ||
| 75 | - interval.startTime <= rangeEnd && interval.endTime >= rangeStart; | ||
| 76 | - if (!isConnected) continue; | ||
| 77 | - final nextStart = math.min(rangeStart, interval.startTime); | ||
| 78 | - final nextEnd = math.max(rangeEnd, interval.endTime); | ||
| 79 | - if (nextStart != rangeStart || nextEnd != rangeEnd) { | ||
| 80 | - rangeStart = nextStart; | ||
| 81 | - rangeEnd = nextEnd; | ||
| 82 | - expanded = true; | ||
| 83 | - } | 65 | + final groups = <List<HealthKitRawDataPoint>>[]; |
| 66 | + for (final interval in candidates) { | ||
| 67 | + if (groups.isEmpty) { | ||
| 68 | + groups.add([interval]); | ||
| 69 | + continue; | ||
| 70 | + } | ||
| 71 | + final latestGroup = groups.last; | ||
| 72 | + final latestEnd = latestGroup.map((e) => e.endTime).reduce(math.max); | ||
| 73 | + if (interval.startTime <= latestEnd + _sleepContinuityToleranceSeconds) { | ||
| 74 | + latestGroup.add(interval); | ||
| 75 | + } else { | ||
| 76 | + groups.add([interval]); | ||
| 84 | } | 77 | } |
| 85 | } | 78 | } |
| 86 | 79 | ||
| 87 | - return candidates | ||
| 88 | - .where((e) => e.endTime > rangeStart && e.startTime < rangeEnd) | ||
| 89 | - .toList(); | 80 | + for (final group in groups) { |
| 81 | + final groupEnd = group.map((e) => e.endTime).reduce(math.max); | ||
| 82 | + if (groupEnd > dayStart && groupEnd <= dayEnd) { | ||
| 83 | + return group; | ||
| 84 | + } | ||
| 85 | + } | ||
| 86 | + return const <HealthKitRawDataPoint>[]; | ||
| 90 | } | 87 | } |
| 91 | 88 | ||
| 92 | static HealthKitRawDataPoint? mergeContinuousSleepIntervals( | 89 | static HealthKitRawDataPoint? mergeContinuousSleepIntervals( |
| @@ -181,6 +178,7 @@ class LocalHealthDataConvert { | @@ -181,6 +178,7 @@ class LocalHealthDataConvert { | ||
| 181 | latestSleepInfo: _latestSleepInfo(validSleep), | 178 | latestSleepInfo: _latestSleepInfo(validSleep), |
| 182 | hrList: shouldIncludeHeartRate | 179 | hrList: shouldIncludeHeartRate |
| 183 | ? [ | 180 | ? [ |
| 181 | + // SleepHrItem(time: 1784091398, value: 63), | ||
| 184 | for (final point in allSleepHr) | 182 | for (final point in allSleepHr) |
| 185 | SleepHrItem(time: point.endTime, value: point.value), | 183 | SleepHrItem(time: point.endTime, value: point.value), |
| 186 | ] | 184 | ] |
| @@ -476,11 +474,13 @@ class LocalHealthDataConvert { | @@ -476,11 +474,13 @@ class LocalHealthDataConvert { | ||
| 476 | point.endTime > interval.startTime && | 474 | point.endTime > interval.startTime && |
| 477 | point.endTime <= interval.endTime)) | 475 | point.endTime <= interval.endTime)) |
| 478 | .toList(); | 476 | .toList(); |
| 477 | + final sleepDuration = windowEnd - windowStart; | ||
| 478 | + | ||
| 479 | return _SleepDaySummary( | 479 | return _SleepDaySummary( |
| 480 | day: day, | 480 | day: day, |
| 481 | sleepIntervals: intervals, | 481 | sleepIntervals: intervals, |
| 482 | mergeSleepTimeRange: mergedInterval, | 482 | mergeSleepTimeRange: mergedInterval, |
| 483 | - durationSeconds: (summary.totalAsleepMinutes * 60).round(), | 483 | + durationSeconds: sleepDuration.round(), |
| 484 | asleepTime: intervals.isEmpty | 484 | asleepTime: intervals.isEmpty |
| 485 | ? null | 485 | ? null |
| 486 | : intervals.map((e) => e.startTime).reduce(math.min), | 486 | : intervals.map((e) => e.startTime).reduce(math.min), |
| 1 | +import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.dart'; | ||
| 2 | +import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; | ||
| 3 | +import 'package:flutter_test/flutter_test.dart'; | ||
| 4 | + | ||
| 5 | +void main() { | ||
| 6 | + group('LocalHealthDataConvert sleep intervals', () { | ||
| 7 | + test('keeps previous-day sleep start for next-day sleep statistics', () { | ||
| 8 | + final july15 = DateTime(2026, 7, 15); | ||
| 9 | + final july16 = DateTime(2026, 7, 16); | ||
| 10 | + final sleepStart = DateTime(2026, 7, 15, 23, 56); | ||
| 11 | + final midnight = DateTime(2026, 7, 16); | ||
| 12 | + final sleepEnd = DateTime(2026, 7, 16, 7); | ||
| 13 | + final intervals = [ | ||
| 14 | + HealthKitRawDataPoint( | ||
| 15 | + dataType: 3, | ||
| 16 | + startTime: LocalHealthDataConvert.unixSeconds(sleepStart), | ||
| 17 | + endTime: LocalHealthDataConvert.unixSeconds(midnight), | ||
| 18 | + ), | ||
| 19 | + HealthKitRawDataPoint( | ||
| 20 | + dataType: 3, | ||
| 21 | + startTime: LocalHealthDataConvert.unixSeconds(midnight), | ||
| 22 | + endTime: LocalHealthDataConvert.unixSeconds(sleepEnd), | ||
| 23 | + ), | ||
| 24 | + ]; | ||
| 25 | + | ||
| 26 | + final previousDayIntervals = | ||
| 27 | + LocalHealthDataConvert.completeSleepIntervalsForDay( | ||
| 28 | + july15, | ||
| 29 | + intervals, | ||
| 30 | + ); | ||
| 31 | + final currentDayIntervals = | ||
| 32 | + LocalHealthDataConvert.completeSleepIntervalsForDay( | ||
| 33 | + july16, | ||
| 34 | + intervals, | ||
| 35 | + ); | ||
| 36 | + final statistics = LocalHealthDataConvert.sleepStatistics( | ||
| 37 | + dateRangeType: 3, | ||
| 38 | + days: [july16], | ||
| 39 | + previousDays: const [], | ||
| 40 | + sleepIntervals: intervals, | ||
| 41 | + heartRate: const [], | ||
| 42 | + ); | ||
| 43 | + | ||
| 44 | + expect(previousDayIntervals, isEmpty); | ||
| 45 | + expect(currentDayIntervals, hasLength(2)); | ||
| 46 | + expect( | ||
| 47 | + currentDayIntervals.first.startTime, | ||
| 48 | + LocalHealthDataConvert.unixSeconds(sleepStart), | ||
| 49 | + ); | ||
| 50 | + expect( | ||
| 51 | + statistics.asleepTimeTrendList?.single.asleepTime, | ||
| 52 | + LocalHealthDataConvert.unixSeconds(sleepStart), | ||
| 53 | + ); | ||
| 54 | + expect( | ||
| 55 | + statistics.sleepTrendList?.single.totalTime, | ||
| 56 | + const Duration(hours: 7, minutes: 4).inSeconds, | ||
| 57 | + ); | ||
| 58 | + }); | ||
| 59 | + }); | ||
| 60 | +} |
-
Please register or login to post a comment