Commit 18097cd429c71ce097d7bbc6d7375261a634ab72

Authored by 权海
1 parent d5b95558

feat(ui):修改睡眠截取跨天

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 +}