Commit bae130e9a08c2b51ecfe1bf862995ef7a9468363

Authored by 权海
1 parent a5aac34f

feat(ui):增加几种计算结果的上传

@@ -5,6 +5,7 @@ enum HealthRawStressUploadKind { @@ -5,6 +5,7 @@ enum HealthRawStressUploadKind {
5 case hrv 5 case hrv
6 case realtimeStress 6 case realtimeStress
7 case dailyStress 7 case dailyStress
  8 + case sleepScore
8 } 9 }
9 10
10 enum HealthRawStressSQLiteUploadError: LocalizedError { 11 enum HealthRawStressSQLiteUploadError: LocalizedError {
@@ -117,16 +118,17 @@ final class HealthRawStressSQLiteUploader { @@ -117,16 +118,17 @@ final class HealthRawStressSQLiteUploader {
117 return uploadedUntil 118 return uploadedUntil
118 } 119 }
119 120
120 - func uploadDailyStress(sqliteFilePath: String) async throws -> Bool { 121 + func uploadDailyStress(sqliteFilePath: String) async throws -> Int64 {
121 let rows = try queryRows( 122 let rows = try queryRows(
122 sqliteFilePath: sqliteFilePath, 123 sqliteFilePath: sqliteFilePath,
123 sql: """ 124 sql: """
124 - SELECT stress_value, stress_score, state, data_time 125 + SELECT date, stress_value, stress_score, state, data_time
125 FROM daily_stress_results 126 FROM daily_stress_results
126 WHERE uploaded != 1 127 WHERE uploaded != 1
127 ORDER BY date ASC 128 ORDER BY date ASC
128 """ 129 """
129 ) 130 )
  131 + var uploadedUntil: Int64 = 0
130 for batch in batches(rows) { 132 for batch in batches(rows) {
131 let list = batch.map { row in 133 let list = batch.map { row in
132 [ 134 [
@@ -140,8 +142,40 @@ final class HealthRawStressSQLiteUploader { @@ -140,8 +142,40 @@ final class HealthRawStressSQLiteUploader {
140 path: "/client/doublefeel/health/v2/stress_score/", 142 path: "/client/doublefeel/health/v2/stress_score/",
141 body: ["data_list": list] 143 body: ["data_list": list]
142 ) 144 )
  145 + uploadedUntil = batch.last?.int64("date") ?? uploadedUntil
  146 + }
  147 + return uploadedUntil
  148 + }
  149 +
  150 + func uploadSleepScore(sqliteFilePath: String) async throws -> Int64 {
  151 + let rows = try queryRows(
  152 + sqliteFilePath: sqliteFilePath,
  153 + sql: """
  154 + SELECT date, sleep_score, sleep_state, in_bed_minutes, awak_minutes, sleep_minutes
  155 + FROM sleep_results
  156 + WHERE uploaded != 1
  157 + ORDER BY date ASC
  158 + """
  159 + )
  160 + var uploadedUntil: Int64 = 0
  161 + for batch in batches(rows) {
  162 + let list = batch.map { row in
  163 + [
  164 + "date": row.int64("date"),
  165 + "sleep_score": row.int("sleep_score"),
  166 + "sleep_state": row.int("sleep_state"),
  167 + "in_bed_minutes": row.int("in_bed_minutes"),
  168 + "awak_minutes": row.int("awak_minutes"),
  169 + "sleep_minutes": row.int("sleep_minutes"),
  170 + ] as [String: Any]
  171 + }
  172 + try await upload(
  173 + path: "/client/doublefeel/health/v2/sleep_score/",
  174 + body: ["data_list": list]
  175 + )
  176 + uploadedUntil = batch.last?.int64("date") ?? uploadedUntil
143 } 177 }
144 - return true 178 + return uploadedUntil
145 } 179 }
146 180
147 private func queryRows(sqliteFilePath: String, sql: String) throws -> [SQLiteRow] { 181 private func queryRows(sqliteFilePath: String, sql: String) throws -> [SQLiteRow] {
@@ -374,8 +374,10 @@ protocol HealthKitRawDataHostApi { @@ -374,8 +374,10 @@ protocol HealthKitRawDataHostApi {
374 func performHRDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void) 374 func performHRDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void)
375 /// 上传HRV - HRV压力数据, 返回上传截止的时间戳,方便flutter 修改数据库 375 /// 上传HRV - HRV压力数据, 返回上传截止的时间戳,方便flutter 修改数据库
376 func performHRVDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void) 376 func performHRVDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void)
  377 + /// 上传睡眠统计数据
  378 + func performSleepAnalysisDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void)
377 /// 上传当前实时压力(统计维度是当天) 379 /// 上传当前实时压力(统计维度是当天)
378 - func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Bool, Error>) -> Void) 380 + func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void)
379 /// 从从AppleHealth中读取睡眠数据 381 /// 从从AppleHealth中读取睡眠数据
380 func getHealthKitRawSleepData(startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthKitRawSleepDataPoint], Error>) -> Void) 382 func getHealthKitRawSleepData(startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthKitRawSleepDataPoint], Error>) -> Void)
381 /// 从从AppleHealth中读取活动统计数据 383 /// 从从AppleHealth中读取活动统计数据
@@ -478,6 +480,24 @@ class HealthKitRawDataHostApiSetup { @@ -478,6 +480,24 @@ class HealthKitRawDataHostApiSetup {
478 } else { 480 } else {
479 performHRVDataUploadChannel.setMessageHandler(nil) 481 performHRVDataUploadChannel.setMessageHandler(nil)
480 } 482 }
  483 + /// 上传睡眠统计数据
  484 + let performSleepAnalysisDataUploadChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performSleepAnalysisDataUpload\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
  485 + if let api = api {
  486 + performSleepAnalysisDataUploadChannel.setMessageHandler { message, reply in
  487 + let args = message as! [Any?]
  488 + let sqliteFilePathArg = args[0] as! String
  489 + api.performSleepAnalysisDataUpload(sqliteFilePath: sqliteFilePathArg) { result in
  490 + switch result {
  491 + case .success(let res):
  492 + reply(wrapResult(res))
  493 + case .failure(let error):
  494 + reply(wrapError(error))
  495 + }
  496 + }
  497 + }
  498 + } else {
  499 + performSleepAnalysisDataUploadChannel.setMessageHandler(nil)
  500 + }
481 /// 上传当前实时压力(统计维度是当天) 501 /// 上传当前实时压力(统计维度是当天)
482 let performAvgRealtimeStressDataUploadChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performAvgRealtimeStressDataUpload\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) 502 let performAvgRealtimeStressDataUploadChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performAvgRealtimeStressDataUpload\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
483 if let api = api { 503 if let api = api {
@@ -46,12 +46,24 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { @@ -46,12 +46,24 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
46 } 46 }
47 } 47 }
48 48
49 - func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Bool, any Error>) -> Void) { 49 + func performAvgRealtimeStressDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) {
50 print("trigger AvgRealtimeStressDataUpload") 50 print("trigger AvgRealtimeStressDataUpload")
51 Task { 51 Task {
52 do { 52 do {
53 - let success = try await HealthRawStressSQLiteUploader.shared.uploadDailyStress(sqliteFilePath: sqliteFilePath)  
54 - completion(.success(success)) 53 + let uploadedUntil = try await HealthRawStressSQLiteUploader.shared.uploadDailyStress(sqliteFilePath: sqliteFilePath)
  54 + completion(.success(uploadedUntil))
  55 + } catch {
  56 + completion(.failure(error))
  57 + }
  58 + }
  59 + }
  60 +
  61 + func performSleepAnalysisDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) {
  62 + print("trigger SleepAnalysisDataUpload")
  63 + Task {
  64 + do {
  65 + let uploadedUntil = try await HealthRawStressSQLiteUploader.shared.uploadSleepScore(sqliteFilePath: sqliteFilePath)
  66 + completion(.success(uploadedUntil))
55 } catch { 67 } catch {
56 completion(.failure(error)) 68 completion(.failure(error))
57 } 69 }
@@ -118,7 +130,6 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { @@ -118,7 +130,6 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
118 completion(.success([])) 130 completion(.success([]))
119 return 131 return
120 } 132 }
121 - print("trigger getHealthKitRawSleepData back: \(points)")  
122 completion(.success([ 133 completion(.success([
123 HealthKitRawSleepDataPoint( 134 HealthKitRawSleepDataPoint(
124 dataType: Int64(NativeHealthDataType.sleep.rawValue), 135 dataType: Int64(NativeHealthDataType.sleep.rawValue),
@@ -14,6 +14,7 @@ import '../config/app_environment_config.dart'; @@ -14,6 +14,7 @@ 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 import 'health_raw_stress_calculator.dart';
  17 +import 'health_sleep_calculator.dart';
17 18
18 class HealthRawDataCoreService { 19 class HealthRawDataCoreService {
19 static const int defaultLookbackDays = 183; 20 static const int defaultLookbackDays = 183;
@@ -48,6 +49,7 @@ class HealthRawDataCoreService { @@ -48,6 +49,7 @@ class HealthRawDataCoreService {
48 bool _isUploadingHrvResults = false; 49 bool _isUploadingHrvResults = false;
49 bool _isUploadingRealtimeStressResults = false; 50 bool _isUploadingRealtimeStressResults = false;
50 bool _isUploadingDailyStressResults = false; 51 bool _isUploadingDailyStressResults = false;
  52 + bool _isUploadingSleepResults = false;
51 53
52 int get _userId { 54 int get _userId {
53 final userId = _userIdProvider?.call() ?? 0; 55 final userId = _userIdProvider?.call() ?? 0;
@@ -222,6 +224,12 @@ class HealthRawDataCoreService { @@ -222,6 +224,12 @@ class HealthRawDataCoreService {
222 final hrvContextStart = await _localStore.latestHrvSourceStartTime(userId); 224 final hrvContextStart = await _localStore.latestHrvSourceStartTime(userId);
223 final realtimeContextStart = 225 final realtimeContextStart =
224 await _localStore.latestRealtimeSourceStartTime(userId); 226 await _localStore.latestRealtimeSourceStartTime(userId);
  227 + final latestHrvRawEndTime = await _localStore.latestHrvRawEndTime(userId);
  228 + final latestRealtimeRawEndTime =
  229 + await _localStore.latestRealtimeRawEndTime(userId);
  230 + final latestSleepResultTime = await _localStore.latestSleepResultTime(
  231 + userId,
  232 + );
225 final isFirstCalculation = 233 final isFirstCalculation =
226 hrvContextStart == null && realtimeContextStart == null; 234 hrvContextStart == null && realtimeContextStart == null;
227 final hrvStartTime = math.max( 235 final hrvStartTime = math.max(
@@ -237,6 +245,12 @@ class HealthRawDataCoreService { @@ -237,6 +245,12 @@ class HealthRawDataCoreService {
237 earliestStartTime, 245 earliestStartTime,
238 ); 246 );
239 final restingHeartRateStartTime = heartRateStartTime; 247 final restingHeartRateStartTime = heartRateStartTime;
  248 + final sleepStartTime = math.max(
  249 + latestSleepResultTime == null
  250 + ? requestedStartTime
  251 + : latestSleepResultTime - Duration.secondsPerDay,
  252 + earliestStartTime,
  253 + );
240 254
241 final hrvPoints = await _fetchRawDataInChunks( 255 final hrvPoints = await _fetchRawDataInChunks(
242 HealthDataUploadType.hrv.type, 256 HealthDataUploadType.hrv.type,
@@ -257,7 +271,7 @@ class HealthRawDataCoreService { @@ -257,7 +271,7 @@ class HealthRawDataCoreService {
257 readChunkDays: readChunkDays, 271 readChunkDays: readChunkDays,
258 ); 272 );
259 final sleepIntervals = await _fetchSleepIntervalsInChunks( 273 final sleepIntervals = await _fetchSleepIntervalsInChunks(
260 - heartRateStartTime, 274 + sleepStartTime,
261 effectiveEndTime, 275 effectiveEndTime,
262 readChunkDays: readChunkDays, 276 readChunkDays: readChunkDays,
263 ); 277 );
@@ -276,20 +290,39 @@ class HealthRawDataCoreService { @@ -276,20 +290,39 @@ class HealthRawDataCoreService {
276 startTime: math.min(hrvStartTime, heartRateStartTime), 290 startTime: math.min(hrvStartTime, heartRateStartTime),
277 endTime: effectiveEndTime, 291 endTime: effectiveEndTime,
278 ); 292 );
279 - await _localStore.upsertResult(result); 293 + final newResult = result.copyWith(
  294 + hrvStressPoints: _filterNewHrvStressPoints(
  295 + result.hrvStressPoints,
  296 + latestHrvRawEndTime,
  297 + ),
  298 + realtimeStressPoints: _filterNewRealtimeStressPoints(
  299 + result.realtimeStressPoints,
  300 + latestRealtimeRawEndTime,
  301 + ),
  302 + );
  303 + await _localStore.upsertResult(newResult);
280 if (_uploadResultsAfterCalculation) { 304 if (_uploadResultsAfterCalculation) {
281 await _uploadHrvResults(); 305 await _uploadHrvResults();
282 await _uploadRealtimeStressResults(); 306 await _uploadRealtimeStressResults();
283 } 307 }
284 final dailyStressPoints = await _calculateAndStoreDailyStressPoints( 308 final dailyStressPoints = await _calculateAndStoreDailyStressPoints(
285 userId: userId, 309 userId: userId,
286 - realtimePoints: result.realtimeStressPoints, 310 + realtimePoints: newResult.realtimeStressPoints,
287 nowSeconds: effectiveEndTime, 311 nowSeconds: effectiveEndTime,
288 ); 312 );
  313 + final sleepResults = await _calculateAndStoreSleepResults(
  314 + userId: userId,
  315 + sleepIntervals: sleepIntervals,
  316 + latestSleepResultTime: latestSleepResultTime,
  317 + );
289 if (_uploadResultsAfterCalculation) { 318 if (_uploadResultsAfterCalculation) {
290 await _uploadDailyStressResults(); 319 await _uploadDailyStressResults();
  320 + await _uploadSleepResults();
291 } 321 }
292 - final storedResult = result.copyWith(dailyStressPoints: dailyStressPoints); 322 + final storedResult = newResult.copyWith(
  323 + dailyStressPoints: dailyStressPoints,
  324 + sleepResults: sleepResults,
  325 + );
293 if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) { 326 if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) {
294 AppToast.show('首次计算完成'); 327 AppToast.show('首次计算完成');
295 } 328 }
@@ -409,6 +442,17 @@ class HealthRawDataCoreService { @@ -409,6 +442,17 @@ class HealthRawDataCoreService {
409 ); 442 );
410 } 443 }
411 444
  445 + Future<List<HealthRawSleepResult>> querySleepResults({
  446 + required int startTime,
  447 + required int endTime,
  448 + }) {
  449 + return _localStore.querySleepResults(
  450 + userId: _userId,
  451 + startTime: startTime,
  452 + endTime: endTime,
  453 + );
  454 + }
  455 +
412 Future<List<HealthKitRawDataPoint>> queryRawDataPoints({ 456 Future<List<HealthKitRawDataPoint>> queryRawDataPoints({
413 required int dataType, 457 required int dataType,
414 required int startTime, 458 required int startTime,
@@ -535,11 +579,15 @@ class HealthRawDataCoreService { @@ -535,11 +579,15 @@ class HealthRawDataCoreService {
535 } 579 }
536 _isUploadingDailyStressResults = true; 580 _isUploadingDailyStressResults = true;
537 try { 581 try {
538 - final success = await _rawDataApi.performAvgRealtimeStressDataUpload( 582 + final uploadedUntil =
  583 + await _rawDataApi.performAvgRealtimeStressDataUpload(
539 sqliteFilePath: await _localStore.dbPath(_userId), 584 sqliteFilePath: await _localStore.dbPath(_userId),
540 ); 585 );
541 - if (success) {  
542 - await _localStore.markDailyStressUploaded(userId: _userId); 586 + if (uploadedUntil > 0) {
  587 + await _localStore.markDailyStressUploadedUntil(
  588 + userId: _userId,
  589 + date: uploadedUntil,
  590 + );
543 } 591 }
544 } catch (error, stackTrace) { 592 } catch (error, stackTrace) {
545 _logError('upload daily stress results failed', error, stackTrace); 593 _logError('upload daily stress results failed', error, stackTrace);
@@ -548,6 +596,31 @@ class HealthRawDataCoreService { @@ -548,6 +596,31 @@ class HealthRawDataCoreService {
548 } 596 }
549 } 597 }
550 598
  599 + Future<void> _uploadSleepResults() async {
  600 + if (_isUploadingSleepResults) {
  601 + return;
  602 + }
  603 + if (!await _localStore.hasPendingSleepResultUploads(userId: _userId)) {
  604 + return;
  605 + }
  606 + _isUploadingSleepResults = true;
  607 + try {
  608 + final uploadedUntil = await _rawDataApi.performSleepAnalysisDataUpload(
  609 + sqliteFilePath: await _localStore.dbPath(_userId),
  610 + );
  611 + if (uploadedUntil > 0) {
  612 + await _localStore.markSleepResultsUploadedUntil(
  613 + userId: _userId,
  614 + date: uploadedUntil,
  615 + );
  616 + }
  617 + } catch (error, stackTrace) {
  618 + _logError('upload sleep results failed', error, stackTrace);
  619 + } finally {
  620 + _isUploadingSleepResults = false;
  621 + }
  622 + }
  623 +
551 HealthRawStressCalculationResult calculate({ 624 HealthRawStressCalculationResult calculate({
552 required List<HealthKitRawDataPoint> hrvPoints, 625 required List<HealthKitRawDataPoint> hrvPoints,
553 required List<HealthKitRawDataPoint> heartRatePoints, 626 required List<HealthKitRawDataPoint> heartRatePoints,
@@ -621,6 +694,73 @@ class HealthRawDataCoreService { @@ -621,6 +694,73 @@ class HealthRawDataCoreService {
621 return dailyStressPoints; 694 return dailyStressPoints;
622 } 695 }
623 696
  697 + Future<List<HealthRawSleepResult>> _calculateAndStoreSleepResults({
  698 + required int userId,
  699 + required List<HealthKitRawDataPoint> sleepIntervals,
  700 + required int? latestSleepResultTime,
  701 + }) async {
  702 + if (sleepIntervals.isEmpty) return const <HealthRawSleepResult>[];
  703 + final days = {
  704 + for (final interval in sleepIntervals)
  705 + DateTime.fromMillisecondsSinceEpoch(interval.endTime * 1000)
  706 + }.map((date) => DateTime(date.year, date.month, date.day)).toList()
  707 + ..sort((a, b) => a.compareTo(b));
  708 + final results = <HealthRawSleepResult>[];
  709 + for (final day in days) {
  710 + final calculation = HealthSleepCalculator.calculateDay(
  711 + day: day,
  712 + sleepIntervals: sleepIntervals,
  713 + );
  714 + final merged = calculation.mergeSleepTimeRange;
  715 + final score = calculation.score;
  716 + final state = calculation.state;
  717 + if (merged == null || score == null || state == null) continue;
  718 + if (!calculation.hasValidSleep) continue;
  719 + if (latestSleepResultTime != null &&
  720 + merged.endTime <= latestSleepResultTime) {
  721 + continue;
  722 + }
  723 + results.add(
  724 + HealthRawSleepResult(
  725 + userId: userId,
  726 + date: merged.endTime,
  727 + startDate: merged.startTime,
  728 + sleepScore: score,
  729 + sleepState: state.value,
  730 + inBedMinutes: calculation.summary.timeInBedMinutes,
  731 + awakMinutes: calculation.summary.awakeMinutes,
  732 + sleepMinutes: calculation.summary.sleepMinutes,
  733 + uploaded: false,
  734 + ),
  735 + );
  736 + }
  737 + await _localStore.upsertSleepResults(
  738 + userId: userId,
  739 + results: results,
  740 + );
  741 + return results;
  742 + }
  743 +
  744 + List<HealthRawHrvStressPoint> _filterNewHrvStressPoints(
  745 + List<HealthRawHrvStressPoint> points,
  746 + int? latestRawEndTime,
  747 + ) {
  748 + if (latestRawEndTime == null) return points;
  749 + return points
  750 + .where((point) => point.rawEndTime > latestRawEndTime)
  751 + .toList();
  752 + }
  753 +
  754 + List<HealthRawRealtimeStressPoint> _filterNewRealtimeStressPoints(
  755 + List<HealthRawRealtimeStressPoint> points,
  756 + int? latestRawEndTime,
  757 + ) {
  758 + if (latestRawEndTime == null) return points;
  759 + return points
  760 + .where((point) => point.rawEndTime > latestRawEndTime)
  761 + .toList();
  762 + }
  763 +
624 Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks( 764 Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks(
625 int dataType, 765 int dataType,
626 int startTime, 766 int startTime,
@@ -740,23 +880,27 @@ class HealthRawStressCalculationResult { @@ -740,23 +880,27 @@ class HealthRawStressCalculationResult {
740 required this.hrvStressPoints, 880 required this.hrvStressPoints,
741 required this.realtimeStressPoints, 881 required this.realtimeStressPoints,
742 required this.dailyStressPoints, 882 required this.dailyStressPoints,
  883 + this.sleepResults = const <HealthRawSleepResult>[],
743 }); 884 });
744 885
745 final int userId; 886 final int userId;
746 final List<HealthRawHrvStressPoint> hrvStressPoints; 887 final List<HealthRawHrvStressPoint> hrvStressPoints;
747 final List<HealthRawRealtimeStressPoint> realtimeStressPoints; 888 final List<HealthRawRealtimeStressPoint> realtimeStressPoints;
748 final List<HealthRawDailyStressPoint> dailyStressPoints; 889 final List<HealthRawDailyStressPoint> dailyStressPoints;
  890 + final List<HealthRawSleepResult> sleepResults;
749 891
750 HealthRawStressCalculationResult copyWith({ 892 HealthRawStressCalculationResult copyWith({
751 List<HealthRawHrvStressPoint>? hrvStressPoints, 893 List<HealthRawHrvStressPoint>? hrvStressPoints,
752 List<HealthRawRealtimeStressPoint>? realtimeStressPoints, 894 List<HealthRawRealtimeStressPoint>? realtimeStressPoints,
753 List<HealthRawDailyStressPoint>? dailyStressPoints, 895 List<HealthRawDailyStressPoint>? dailyStressPoints,
  896 + List<HealthRawSleepResult>? sleepResults,
754 }) { 897 }) {
755 return HealthRawStressCalculationResult( 898 return HealthRawStressCalculationResult(
756 userId: userId, 899 userId: userId,
757 hrvStressPoints: hrvStressPoints ?? this.hrvStressPoints, 900 hrvStressPoints: hrvStressPoints ?? this.hrvStressPoints,
758 realtimeStressPoints: realtimeStressPoints ?? this.realtimeStressPoints, 901 realtimeStressPoints: realtimeStressPoints ?? this.realtimeStressPoints,
759 dailyStressPoints: dailyStressPoints ?? this.dailyStressPoints, 902 dailyStressPoints: dailyStressPoints ?? this.dailyStressPoints,
  903 + sleepResults: sleepResults ?? this.sleepResults,
760 ); 904 );
761 } 905 }
762 } 906 }
@@ -1002,6 +1146,53 @@ class HealthRawDailyStressPoint { @@ -1002,6 +1146,53 @@ class HealthRawDailyStressPoint {
1002 } 1146 }
1003 } 1147 }
1004 1148
  1149 +class HealthRawSleepResult {
  1150 + const HealthRawSleepResult({
  1151 + required this.userId,
  1152 + required this.date,
  1153 + required this.startDate,
  1154 + required this.sleepScore,
  1155 + required this.sleepState,
  1156 + required this.inBedMinutes,
  1157 + required this.awakMinutes,
  1158 + required this.sleepMinutes,
  1159 + this.uploaded = false,
  1160 + });
  1161 +
  1162 + final int userId;
  1163 + final int date;
  1164 + final int startDate;
  1165 + final int sleepScore;
  1166 + final int sleepState;
  1167 + final int inBedMinutes;
  1168 + final int awakMinutes;
  1169 + final int sleepMinutes;
  1170 + final bool uploaded;
  1171 +
  1172 + factory HealthRawSleepResult.fromDb(Map<String, Object?> row) {
  1173 + return HealthRawSleepResult(
  1174 + userId: row['user_id'] as int,
  1175 + date: row['date'] as int,
  1176 + startDate: row['start_date'] as int,
  1177 + sleepScore: row['sleep_score'] as int,
  1178 + sleepState: row['sleep_state'] as int,
  1179 + inBedMinutes: row['in_bed_minutes'] as int,
  1180 + awakMinutes: row['awak_minutes'] as int,
  1181 + sleepMinutes: row['sleep_minutes'] as int,
  1182 + uploaded: (row['uploaded'] as int? ?? 0) == 1,
  1183 + );
  1184 + }
  1185 +
  1186 + int get sleepStateValue => sleepState;
  1187 +
  1188 + String? get sleepStateDescription {
  1189 + for (final state in HealthSleepState.values) {
  1190 + if (state.value == sleepState) return state.description;
  1191 + }
  1192 + return null;
  1193 + }
  1194 +}
  1195 +
1005 class HealthRawDailyStressCalculator { 1196 class HealthRawDailyStressCalculator {
1006 const HealthRawDailyStressCalculator._(); 1197 const HealthRawDailyStressCalculator._();
1007 1198
@@ -1138,11 +1329,13 @@ class HealthRawStressDbRows { @@ -1138,11 +1329,13 @@ class HealthRawStressDbRows {
1138 required this.hrvRows, 1329 required this.hrvRows,
1139 required this.realtimeRows, 1330 required this.realtimeRows,
1140 required this.dailyStressRows, 1331 required this.dailyStressRows,
  1332 + required this.sleepRows,
1141 }); 1333 });
1142 1334
1143 final List<Map<String, Object?>> hrvRows; 1335 final List<Map<String, Object?>> hrvRows;
1144 final List<Map<String, Object?>> realtimeRows; 1336 final List<Map<String, Object?>> realtimeRows;
1145 final List<Map<String, Object?>> dailyStressRows; 1337 final List<Map<String, Object?>> dailyStressRows;
  1338 + final List<Map<String, Object?>> sleepRows;
1146 } 1339 }
1147 1340
1148 class HealthRawStressLocalStore { 1341 class HealthRawStressLocalStore {
@@ -1155,6 +1348,7 @@ class HealthRawStressLocalStore { @@ -1155,6 +1348,7 @@ class HealthRawStressLocalStore {
1155 static const hrvResultsTable = 'hrv_results'; 1348 static const hrvResultsTable = 'hrv_results';
1156 static const realtimeStressResultsTable = 'realtime_stress_results'; 1349 static const realtimeStressResultsTable = 'realtime_stress_results';
1157 static const dailyStressResultsTable = 'daily_stress_results'; 1350 static const dailyStressResultsTable = 'daily_stress_results';
  1351 + static const sleepResultsTable = 'sleep_results';
1158 1352
1159 final Directory? _rootDirectory; 1353 final Directory? _rootDirectory;
1160 final DatabaseFactory? _databaseFactory; 1354 final DatabaseFactory? _databaseFactory;
@@ -1192,6 +1386,18 @@ class HealthRawStressLocalStore { @@ -1192,6 +1386,18 @@ class HealthRawStressLocalStore {
1192 }); 1386 });
1193 } 1387 }
1194 1388
  1389 + Future<void> upsertSleepResults({
  1390 + required int userId,
  1391 + required Iterable<HealthRawSleepResult> results,
  1392 + }) async {
  1393 + final db = await _database(userId);
  1394 + await db.transaction((txn) async {
  1395 + for (final result in results) {
  1396 + await _upsertSleepResultResettingUploaded(txn, _sleepRow(result));
  1397 + }
  1398 + });
  1399 + }
  1400 +
1195 Future<Set<int>> existingDailyStressDates({ 1401 Future<Set<int>> existingDailyStressDates({
1196 required int userId, 1402 required int userId,
1197 required Iterable<int> dates, 1403 required Iterable<int> dates,
@@ -1292,6 +1498,21 @@ class HealthRawStressLocalStore { @@ -1292,6 +1498,21 @@ class HealthRawStressLocalStore {
1292 return rows.map(HealthRawDailyStressPoint.fromDb).toList(); 1498 return rows.map(HealthRawDailyStressPoint.fromDb).toList();
1293 } 1499 }
1294 1500
  1501 + Future<List<HealthRawSleepResult>> querySleepResults({
  1502 + required int userId,
  1503 + required int startTime,
  1504 + required int endTime,
  1505 + }) async {
  1506 + final db = await _database(userId);
  1507 + final rows = await db.query(
  1508 + sleepResultsTable,
  1509 + where: 'date >= ? AND date <= ?',
  1510 + whereArgs: [startTime, endTime],
  1511 + orderBy: 'date ASC',
  1512 + );
  1513 + return rows.map(HealthRawSleepResult.fromDb).toList();
  1514 + }
  1515 +
1295 Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { 1516 Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async {
1296 final db = await _database(userId); 1517 final db = await _database(userId);
1297 final hrvRows = await db.query( 1518 final hrvRows = await db.query(
@@ -1306,10 +1527,15 @@ class HealthRawStressLocalStore { @@ -1306,10 +1527,15 @@ class HealthRawStressLocalStore {
1306 dailyStressResultsTable, 1527 dailyStressResultsTable,
1307 orderBy: 'date ASC', 1528 orderBy: 'date ASC',
1308 ); 1529 );
  1530 + final sleepRows = await db.query(
  1531 + sleepResultsTable,
  1532 + orderBy: 'date ASC',
  1533 + );
1309 return HealthRawStressDbRows( 1534 return HealthRawStressDbRows(
1310 hrvRows: hrvRows, 1535 hrvRows: hrvRows,
1311 realtimeRows: realtimeRows, 1536 realtimeRows: realtimeRows,
1312 dailyStressRows: dailyStressRows, 1537 dailyStressRows: dailyStressRows,
  1538 + sleepRows: sleepRows,
1313 ); 1539 );
1314 } 1540 }
1315 1541
@@ -1321,6 +1547,26 @@ class HealthRawStressLocalStore { @@ -1321,6 +1547,26 @@ class HealthRawStressLocalStore {
1321 return _latestSourceStartTime(userId, realtimeStressResultsTable); 1547 return _latestSourceStartTime(userId, realtimeStressResultsTable);
1322 } 1548 }
1323 1549
  1550 + Future<int?> latestHrvRawEndTime(int userId) {
  1551 + return _latestRawEndTime(userId, hrvResultsTable);
  1552 + }
  1553 +
  1554 + Future<int?> latestRealtimeRawEndTime(int userId) {
  1555 + return _latestRawEndTime(userId, realtimeStressResultsTable);
  1556 + }
  1557 +
  1558 + Future<int?> latestSleepResultTime(int userId) async {
  1559 + final db = await _database(userId);
  1560 + final rows = await db.query(
  1561 + sleepResultsTable,
  1562 + columns: ['date'],
  1563 + orderBy: 'date DESC',
  1564 + limit: 1,
  1565 + );
  1566 + if (rows.isEmpty) return null;
  1567 + return rows.first['date'] as int;
  1568 + }
  1569 +
1324 Future<void> markHrvStressUploaded({ 1570 Future<void> markHrvStressUploaded({
1325 required int userId, 1571 required int userId,
1326 required Iterable<int> rawEndTimes, 1572 required Iterable<int> rawEndTimes,
@@ -1367,12 +1613,27 @@ class HealthRawStressLocalStore { @@ -1367,12 +1613,27 @@ class HealthRawStressLocalStore {
1367 ); 1613 );
1368 } 1614 }
1369 1615
1370 - Future<void> markDailyStressUploaded({required int userId}) async {  
1371 - final db = await _database(userId);  
1372 - await db.update(  
1373 - dailyStressResultsTable,  
1374 - {'uploaded': 1},  
1375 - where: 'uploaded != 1', 1616 + Future<void> markDailyStressUploadedUntil({
  1617 + required int userId,
  1618 + required int date,
  1619 + }) {
  1620 + return _markUploadedUntil(
  1621 + userId: userId,
  1622 + table: dailyStressResultsTable,
  1623 + timeColumn: 'date',
  1624 + time: date,
  1625 + );
  1626 + }
  1627 +
  1628 + Future<void> markSleepResultsUploadedUntil({
  1629 + required int userId,
  1630 + required int date,
  1631 + }) {
  1632 + return _markUploadedUntil(
  1633 + userId: userId,
  1634 + table: sleepResultsTable,
  1635 + timeColumn: 'date',
  1636 + time: date,
1376 ); 1637 );
1377 } 1638 }
1378 1639
@@ -1389,6 +1650,10 @@ class HealthRawStressLocalStore { @@ -1389,6 +1650,10 @@ class HealthRawStressLocalStore {
1389 return _hasPendingUploads(userId: userId, table: dailyStressResultsTable); 1650 return _hasPendingUploads(userId: userId, table: dailyStressResultsTable);
1390 } 1651 }
1391 1652
  1653 + Future<bool> hasPendingSleepResultUploads({required int userId}) {
  1654 + return _hasPendingUploads(userId: userId, table: sleepResultsTable);
  1655 + }
  1656 +
1392 Future<String> dbPath(int userId) async { 1657 Future<String> dbPath(int userId) async {
1393 final dir = _rootDirectory ?? await getApplicationDocumentsDirectory(); 1658 final dir = _rootDirectory ?? await getApplicationDocumentsDirectory();
1394 return '${dir.path}/hrv_result_$userId.sqlite'; 1659 return '${dir.path}/hrv_result_$userId.sqlite';
@@ -1429,7 +1694,7 @@ class HealthRawStressLocalStore { @@ -1429,7 +1694,7 @@ class HealthRawStressLocalStore {
1429 final db = await factory.openDatabase( 1694 final db = await factory.openDatabase(
1430 path, 1695 path,
1431 options: OpenDatabaseOptions( 1696 options: OpenDatabaseOptions(
1432 - version: 6, 1697 + version: 7,
1433 onCreate: (db, version) async { 1698 onCreate: (db, version) async {
1434 await _createTables(db); 1699 await _createTables(db);
1435 }, 1700 },
@@ -1450,6 +1715,9 @@ class HealthRawStressLocalStore { @@ -1450,6 +1715,9 @@ class HealthRawStressLocalStore {
1450 if (oldVersion < 6) { 1715 if (oldVersion < 6) {
1451 await _addRealtimeRawHrColumn(db); 1716 await _addRealtimeRawHrColumn(db);
1452 } 1717 }
  1718 + if (oldVersion < 7) {
  1719 + await _createSleepResultsTable(db);
  1720 + }
1453 }, 1721 },
1454 ), 1722 ),
1455 ); 1723 );
@@ -1494,6 +1762,7 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable ( @@ -1494,6 +1762,7 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
1494 ) 1762 )
1495 '''); 1763 ''');
1496 await _createDailyStressTable(db); 1764 await _createDailyStressTable(db);
  1765 + await _createSleepResultsTable(db);
1497 } 1766 }
1498 1767
1499 Future<void> _createDailyStressTable(DatabaseExecutor db) async { 1768 Future<void> _createDailyStressTable(DatabaseExecutor db) async {
@@ -1510,6 +1779,22 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( @@ -1510,6 +1779,22 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
1510 '''); 1779 ''');
1511 } 1780 }
1512 1781
  1782 + Future<void> _createSleepResultsTable(DatabaseExecutor db) async {
  1783 + await db.execute('''
  1784 +CREATE TABLE IF NOT EXISTS $sleepResultsTable (
  1785 + date INTEGER PRIMARY KEY,
  1786 + user_id INTEGER NOT NULL,
  1787 + start_date INTEGER NOT NULL,
  1788 + sleep_score INTEGER NOT NULL,
  1789 + sleep_state INTEGER NOT NULL,
  1790 + in_bed_minutes INTEGER NOT NULL,
  1791 + awak_minutes INTEGER NOT NULL,
  1792 + sleep_minutes INTEGER NOT NULL,
  1793 + uploaded INTEGER NOT NULL DEFAULT 0
  1794 +)
  1795 +''');
  1796 + }
  1797 +
1513 Future<void> _addFlagColumns(DatabaseExecutor db, String table) async { 1798 Future<void> _addFlagColumns(DatabaseExecutor db, String table) async {
1514 for (final column in const [ 1799 for (final column in const [
1515 'is_workout', 1800 'is_workout',
@@ -1581,6 +1866,18 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( @@ -1581,6 +1866,18 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
1581 return rows.first['source_start_time'] as int; 1866 return rows.first['source_start_time'] as int;
1582 } 1867 }
1583 1868
  1869 + Future<int?> _latestRawEndTime(int userId, String table) async {
  1870 + final db = await _database(userId);
  1871 + final rows = await db.query(
  1872 + table,
  1873 + columns: ['raw_end_time'],
  1874 + orderBy: 'raw_end_time DESC',
  1875 + limit: 1,
  1876 + );
  1877 + if (rows.isEmpty) return null;
  1878 + return rows.first['raw_end_time'] as int;
  1879 + }
  1880 +
1584 Future<void> _markUploaded({ 1881 Future<void> _markUploaded({
1585 required int userId, 1882 required int userId,
1586 required String table, 1883 required String table,
@@ -1670,6 +1967,20 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( @@ -1670,6 +1967,20 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
1670 }; 1967 };
1671 } 1968 }
1672 1969
  1970 + Map<String, Object?> _sleepRow(HealthRawSleepResult result) {
  1971 + return <String, Object?>{
  1972 + 'date': result.date,
  1973 + 'user_id': result.userId,
  1974 + 'start_date': result.startDate,
  1975 + 'sleep_score': result.sleepScore,
  1976 + 'sleep_state': result.sleepState,
  1977 + 'in_bed_minutes': result.inBedMinutes,
  1978 + 'awak_minutes': result.awakMinutes,
  1979 + 'sleep_minutes': result.sleepMinutes,
  1980 + 'uploaded': result.uploaded ? 1 : 0,
  1981 + };
  1982 + }
  1983 +
1673 Map<String, Object?> _flagsRow(HealthRawPointFlags flags) { 1984 Map<String, Object?> _flagsRow(HealthRawPointFlags flags) {
1674 return <String, Object?>{ 1985 return <String, Object?>{
1675 'is_workout': flags.isWorkout ? 1 : 0, 1986 'is_workout': flags.isWorkout ? 1 : 0,
@@ -1768,6 +2079,51 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable ( @@ -1768,6 +2079,51 @@ CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
1768 ); 2079 );
1769 } 2080 }
1770 2081
  2082 + Future<void> _upsertSleepResultResettingUploaded(
  2083 + DatabaseExecutor db,
  2084 + Map<String, Object?> row,
  2085 + ) async {
  2086 + final existing = await db.query(
  2087 + sleepResultsTable,
  2088 + where: 'date = ?',
  2089 + whereArgs: [row['date']],
  2090 + limit: 1,
  2091 + );
  2092 + if (existing.isEmpty) {
  2093 + await db.insert(sleepResultsTable, row);
  2094 + return;
  2095 + }
  2096 + final existingRow = existing.first;
  2097 + final valueChanged = !_matchesStoredValues(
  2098 + existingRow,
  2099 + row,
  2100 + const [
  2101 + 'user_id',
  2102 + 'start_date',
  2103 + 'sleep_score',
  2104 + 'sleep_state',
  2105 + 'in_bed_minutes',
  2106 + 'awak_minutes',
  2107 + 'sleep_minutes',
  2108 + ],
  2109 + );
  2110 + await db.update(
  2111 + sleepResultsTable,
  2112 + <String, Object?>{
  2113 + 'user_id': row['user_id'],
  2114 + 'start_date': row['start_date'],
  2115 + 'sleep_score': row['sleep_score'],
  2116 + 'sleep_state': row['sleep_state'],
  2117 + 'in_bed_minutes': row['in_bed_minutes'],
  2118 + 'awak_minutes': row['awak_minutes'],
  2119 + 'sleep_minutes': row['sleep_minutes'],
  2120 + 'uploaded': valueChanged ? 0 : existingRow['uploaded'],
  2121 + },
  2122 + where: 'date = ?',
  2123 + whereArgs: [row['date']],
  2124 + );
  2125 + }
  2126 +
1771 List<String> _rawResultStoredValueKeys(Map<String, Object?> row) { 2127 List<String> _rawResultStoredValueKeys(Map<String, Object?> row) {
1772 return <String>[ 2128 return <String>[
1773 'user_id', 2129 'user_id',
  1 +import 'dart:math' as math;
  2 +
  3 +import '../../pigeon/health_kit_raw_data_api.g.dart';
  4 +
  5 +const healthSleepGoalMinutes = 8 * 60;
  6 +const _sleepContinuityToleranceSeconds = 1;
  7 +const _sleepTypeInBed = 0;
  8 +const _sleepTypeAsleep = 1;
  9 +const _sleepTypeAsleepUnspecified = 1;
  10 +const _sleepTypeAwake = 2;
  11 +const _sleepTypeAsleepCore = 3;
  12 +const _sleepTypeAsleepDeep = 4;
  13 +const _sleepTypeAsleepRem = 5;
  14 +
  15 +class HealthSleepCalculator {
  16 + const HealthSleepCalculator._();
  17 +
  18 + static HealthSleepDayCalculation calculateDay({
  19 + required DateTime day,
  20 + required List<HealthKitRawDataPoint> sleepIntervals,
  21 + }) {
  22 + final intervals = completeSleepIntervalsForDay(day, sleepIntervals);
  23 + final mergedInterval = mergeContinuousSleepIntervals(intervals);
  24 + final windowStart = mergedInterval?.startTime ?? unixSeconds(day);
  25 + final windowEnd = mergedInterval?.endTime ??
  26 + unixSeconds(day.add(const Duration(days: 1)));
  27 + final summary = sleepSummary(intervals, windowStart, windowEnd);
  28 + final score = sleepScore(summary);
  29 + final nullableScore = score == 0 ? null : score;
  30 + final state = sleepState(score);
  31 + final durationSeconds =
  32 + nullableScore == null || state == null ? 0 : summary.sleepMinutes * 60;
  33 +
  34 + return HealthSleepDayCalculation(
  35 + day: day,
  36 + sleepIntervals: intervals,
  37 + mergeSleepTimeRange: mergedInterval,
  38 + summary: summary,
  39 + durationSeconds: durationSeconds,
  40 + asleepTime: intervals.isEmpty
  41 + ? null
  42 + : intervals.map((e) => e.startTime).reduce(math.min),
  43 + score: nullableScore,
  44 + state: state,
  45 + );
  46 + }
  47 +
  48 + static List<HealthKitRawDataPoint> completeSleepIntervalsForDay(
  49 + DateTime day,
  50 + List<HealthKitRawDataPoint> sleepIntervals,
  51 + ) {
  52 + final dayStart = unixSeconds(day);
  53 + final dayEnd = unixSeconds(day.add(const Duration(days: 1)));
  54 + final queryStart = unixSeconds(day.subtract(const Duration(days: 1)));
  55 + final queryEnd = unixSeconds(day.add(const Duration(days: 2)));
  56 + final candidates = sleepIntervals
  57 + .where((e) => e.endTime > queryStart && e.startTime < queryEnd)
  58 + .toList()
  59 + ..sort((a, b) {
  60 + final startCompare = a.startTime.compareTo(b.startTime);
  61 + if (startCompare != 0) return startCompare;
  62 + return a.endTime.compareTo(b.endTime);
  63 + });
  64 + if (candidates.isEmpty) return const <HealthKitRawDataPoint>[];
  65 +
  66 + final groups = <List<HealthKitRawDataPoint>>[];
  67 + for (final interval in candidates) {
  68 + if (groups.isEmpty) {
  69 + groups.add([interval]);
  70 + continue;
  71 + }
  72 + final latestGroup = groups.last;
  73 + final latestEnd = latestGroup.map((e) => e.endTime).reduce(math.max);
  74 + if (interval.startTime <= latestEnd + _sleepContinuityToleranceSeconds) {
  75 + latestGroup.add(interval);
  76 + } else {
  77 + groups.add([interval]);
  78 + }
  79 + }
  80 +
  81 + for (final group in groups) {
  82 + final groupEnd = group.map((e) => e.endTime).reduce(math.max);
  83 + if (groupEnd > dayStart && groupEnd <= dayEnd) {
  84 + return group;
  85 + }
  86 + }
  87 + return const <HealthKitRawDataPoint>[];
  88 + }
  89 +
  90 + static HealthKitRawDataPoint? mergeContinuousSleepIntervals(
  91 + List<HealthKitRawDataPoint> sleepIntervals,
  92 + ) {
  93 + if (sleepIntervals.isEmpty) return null;
  94 + final sorted = [...sleepIntervals]..sort((a, b) {
  95 + final startCompare = a.startTime.compareTo(b.startTime);
  96 + if (startCompare != 0) return startCompare;
  97 + return a.endTime.compareTo(b.endTime);
  98 + });
  99 + var start = sorted.first.startTime;
  100 + var end = sorted.first.endTime;
  101 + for (final interval in sorted.skip(1)) {
  102 + if (interval.startTime > end) break;
  103 + end = math.max(end, interval.endTime);
  104 + }
  105 + return HealthKitRawDataPoint(
  106 + dataType: sorted.first.dataType,
  107 + startTime: start,
  108 + endTime: end,
  109 + );
  110 + }
  111 +
  112 + static HealthSleepSummary sleepSummary(
  113 + List<HealthKitRawDataPoint> intervals,
  114 + int windowStart,
  115 + int windowEnd,
  116 + ) {
  117 + var inBedSeconds = 0;
  118 + var asleepSeconds = 0;
  119 + var awakeSeconds = 0;
  120 + var coreSeconds = 0;
  121 + var deepSeconds = 0;
  122 + var remSeconds = 0;
  123 + var unknownSleepSeconds = 0;
  124 + var wakeCount = 0;
  125 + var earliestStart = windowEnd;
  126 + var latestEnd = windowStart;
  127 +
  128 + for (final interval in intervals) {
  129 + final clippedStart = math.max(interval.startTime, windowStart);
  130 + final clippedEnd = math.min(interval.endTime, windowEnd);
  131 + final seconds = math.max(0, clippedEnd - clippedStart);
  132 + if (seconds <= 0) continue;
  133 + earliestStart = math.min(earliestStart, clippedStart);
  134 + latestEnd = math.max(latestEnd, clippedEnd);
  135 + switch (interval.dataType) {
  136 + case _sleepTypeInBed:
  137 + inBedSeconds += seconds;
  138 + break;
  139 + case _sleepTypeAwake:
  140 + awakeSeconds += seconds;
  141 + wakeCount += 1;
  142 + break;
  143 + case _sleepTypeAsleepCore:
  144 + coreSeconds += seconds;
  145 + asleepSeconds += seconds;
  146 + break;
  147 + case _sleepTypeAsleepDeep:
  148 + deepSeconds += seconds;
  149 + asleepSeconds += seconds;
  150 + break;
  151 + case _sleepTypeAsleepRem:
  152 + remSeconds += seconds;
  153 + asleepSeconds += seconds;
  154 + break;
  155 + default:
  156 + if (isAsleepSleepType(interval.dataType)) {
  157 + unknownSleepSeconds += seconds;
  158 + asleepSeconds += seconds;
  159 + }
  160 + break;
  161 + }
  162 + }
  163 +
  164 + if (inBedSeconds <= 0 && latestEnd > earliestStart) {
  165 + inBedSeconds = latestEnd - earliestStart;
  166 + }
  167 + if (inBedSeconds <= 0) {
  168 + inBedSeconds = asleepSeconds + awakeSeconds;
  169 + }
  170 +
  171 + return HealthSleepSummary(
  172 + timeInBedMinutes: secondsToRoundedMinutes(inBedSeconds),
  173 + sleepMinutes: secondsToRoundedMinutes(asleepSeconds),
  174 + awakeMinutes: secondsToRoundedMinutes(awakeSeconds),
  175 + coreMinutes: secondsToRoundedMinutes(coreSeconds),
  176 + deepMinutes: secondsToRoundedMinutes(deepSeconds),
  177 + remMinutes: secondsToRoundedMinutes(remSeconds),
  178 + unknownSleepMinutes: secondsToRoundedMinutes(unknownSleepSeconds),
  179 + wakeCount: wakeCount,
  180 + sleepGoalMinutes: healthSleepGoalMinutes,
  181 + );
  182 + }
  183 +
  184 + static int sleepScore(HealthSleepSummary sleep) {
  185 + if (sleep.timeInBedMinutes <= 0 || sleep.sleepMinutes <= 0) return 0;
  186 + final totalAsleepMinutes = sleep.sleepMinutes.toDouble();
  187 + final timeInBedMinutes = sleep.timeInBedMinutes.toDouble();
  188 + final sleepGoalMinutes = sleep.sleepGoalMinutes.toDouble();
  189 + final deepMinutes = sleep.deepMinutes.toDouble();
  190 + final remMinutes = sleep.remMinutes.toDouble();
  191 + final awakeMinutes = sleep.awakeMinutes.toDouble();
  192 + final durationRatio = math.min(
  193 + totalAsleepMinutes / sleepGoalMinutes,
  194 + 1.0,
  195 + );
  196 + final durationScore = durationRatio * 40;
  197 + final efficiency = totalAsleepMinutes / timeInBedMinutes;
  198 + final efficiencyScore = math.min(efficiency / 0.9, 1.0) * 25;
  199 + final deepRatio = deepMinutes / totalAsleepMinutes;
  200 + final deepScore = math.min(deepRatio / 0.18, 1.0) * 15;
  201 + final remRatio = remMinutes / totalAsleepMinutes;
  202 + final remScore = math.min(remRatio / 0.22, 1.0) * 10;
  203 + final awakePenalty = math.min(
  204 + sleep.wakeCount * 2 + awakeMinutes / 10,
  205 + 10,
  206 + );
  207 + final rawScore =
  208 + durationScore + efficiencyScore + deepScore + remScore - awakePenalty;
  209 + return mappedSleepScore(rawScore);
  210 + }
  211 +
  212 + static int mappedSleepScore(num rawScore) {
  213 + final clamped = rawScore.clamp(0, 100).toDouble();
  214 + final mapped = switch (clamped) {
  215 + < 64 => clamped / 64 * 60,
  216 + < 74 => 60 + (clamped - 64) / 10 * 25,
  217 + _ => math.min(math.max(86, 85 + (clamped - 74) / 26 * 15), 100),
  218 + };
  219 + return mapped.round().clamp(0, 100);
  220 + }
  221 +
  222 + static HealthSleepState? sleepState(int score) {
  223 + if (score <= 0) return null;
  224 + if (score >= 85) return HealthSleepState.great;
  225 + if (score >= 60) return HealthSleepState.good;
  226 + return HealthSleepState.poor;
  227 + }
  228 +
  229 + static int secondsToRoundedMinutes(int seconds) {
  230 + return (seconds / 60.0).round();
  231 + }
  232 +
  233 + static bool isAsleepSleepType(int dataType) {
  234 + return dataType == _sleepTypeAsleep ||
  235 + dataType == _sleepTypeAsleepUnspecified ||
  236 + dataType == _sleepTypeAsleepCore ||
  237 + dataType == _sleepTypeAsleepDeep ||
  238 + dataType == _sleepTypeAsleepRem;
  239 + }
  240 +
  241 + static int unixSeconds(DateTime date) => date.millisecondsSinceEpoch ~/ 1000;
  242 +}
  243 +
  244 +class HealthSleepDayCalculation {
  245 + const HealthSleepDayCalculation({
  246 + required this.day,
  247 + required this.sleepIntervals,
  248 + required this.mergeSleepTimeRange,
  249 + required this.summary,
  250 + required this.durationSeconds,
  251 + required this.asleepTime,
  252 + required this.score,
  253 + required this.state,
  254 + });
  255 +
  256 + final DateTime day;
  257 + final List<HealthKitRawDataPoint> sleepIntervals;
  258 + final HealthKitRawDataPoint? mergeSleepTimeRange;
  259 + final HealthSleepSummary summary;
  260 + final int durationSeconds;
  261 + final int? asleepTime;
  262 + final int? score;
  263 + final HealthSleepState? state;
  264 +
  265 + bool get hasValidSleep =>
  266 + durationSeconds > 0 && score != null && state != null;
  267 +}
  268 +
  269 +class HealthSleepSummary {
  270 + const HealthSleepSummary({
  271 + required this.timeInBedMinutes,
  272 + required this.sleepMinutes,
  273 + required this.awakeMinutes,
  274 + required this.coreMinutes,
  275 + required this.deepMinutes,
  276 + required this.remMinutes,
  277 + required this.unknownSleepMinutes,
  278 + required this.wakeCount,
  279 + required this.sleepGoalMinutes,
  280 + });
  281 +
  282 + final int timeInBedMinutes;
  283 + final int sleepMinutes;
  284 + final int awakeMinutes;
  285 + final int coreMinutes;
  286 + final int deepMinutes;
  287 + final int remMinutes;
  288 + final int unknownSleepMinutes;
  289 + final int wakeCount;
  290 + final int sleepGoalMinutes;
  291 +}
  292 +
  293 +enum HealthSleepState {
  294 + great(1, '睡得很棒'),
  295 + good(2, '睡得不错'),
  296 + poor(3, '睡的差');
  297 +
  298 + const HealthSleepState(this.value, this.description);
  299 +
  300 + final int value;
  301 + final String description;
  302 +}
1 import 'dart:math' as math; 1 import 'dart:math' as math;
2 2
3 import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; 3 import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
  4 +import 'package:doublefeel_flutter/core/services/health_sleep_calculator.dart';
4 import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; 5 import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
5 import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart'; 6 import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_statistics_data_v2.dart';
6 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; 7 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
@@ -8,16 +9,6 @@ import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.da @@ -8,16 +9,6 @@ import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.da
8 import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart'; 9 import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart';
9 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; 10 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
10 11
11 -const _sleepTypeInBed = 0;  
12 -const _sleepTypeAsleep = 1;  
13 -const _sleepTypeAsleepUnspecified = 1;  
14 -const _sleepTypeAwake = 2;  
15 -const _sleepTypeAsleepCore = 3;  
16 -const _sleepTypeAsleepDeep = 4;  
17 -const _sleepTypeAsleepRem = 5;  
18 -const _sleepGoalMinutes = 8 * 60.0;  
19 -const _sleepContinuityToleranceSeconds = 1;  
20 -  
21 class LocalHealthDataConvert { 12 class LocalHealthDataConvert {
22 const LocalHealthDataConvert._(); 13 const LocalHealthDataConvert._();
23 14
@@ -50,64 +41,16 @@ class LocalHealthDataConvert { @@ -50,64 +41,16 @@ class LocalHealthDataConvert {
50 DateTime day, 41 DateTime day,
51 List<HealthKitRawDataPoint> sleepIntervals, 42 List<HealthKitRawDataPoint> sleepIntervals,
52 ) { 43 ) {
53 - final dayStart = unixSeconds(day);  
54 - final dayEnd = unixSeconds(day.add(const Duration(days: 1)));  
55 - final queryStart = unixSeconds(day.subtract(const Duration(days: 1)));  
56 - final queryEnd = unixSeconds(day.add(const Duration(days: 2)));  
57 - final candidates = sleepIntervals  
58 - .where((e) => e.endTime > queryStart && e.startTime < queryEnd)  
59 - .toList()  
60 - ..sort((a, b) {  
61 - final startCompare = a.startTime.compareTo(b.startTime);  
62 - if (startCompare != 0) return startCompare;  
63 - return a.endTime.compareTo(b.endTime);  
64 - });  
65 - if (candidates.isEmpty) return const <HealthKitRawDataPoint>[];  
66 -  
67 - final groups = <List<HealthKitRawDataPoint>>[];  
68 - for (final interval in candidates) {  
69 - if (groups.isEmpty) {  
70 - groups.add([interval]);  
71 - continue;  
72 - }  
73 - final latestGroup = groups.last;  
74 - final latestEnd = latestGroup.map((e) => e.endTime).reduce(math.max);  
75 - if (interval.startTime <= latestEnd + _sleepContinuityToleranceSeconds) {  
76 - latestGroup.add(interval);  
77 - } else {  
78 - groups.add([interval]);  
79 - }  
80 - }  
81 -  
82 - for (final group in groups) {  
83 - final groupEnd = group.map((e) => e.endTime).reduce(math.max);  
84 - if (groupEnd > dayStart && groupEnd <= dayEnd) {  
85 - return group;  
86 - }  
87 - }  
88 - return const <HealthKitRawDataPoint>[]; 44 + return HealthSleepCalculator.completeSleepIntervalsForDay(
  45 + day,
  46 + sleepIntervals,
  47 + );
89 } 48 }
90 49
91 static HealthKitRawDataPoint? mergeContinuousSleepIntervals( 50 static HealthKitRawDataPoint? mergeContinuousSleepIntervals(
92 List<HealthKitRawDataPoint> sleepIntervals, 51 List<HealthKitRawDataPoint> sleepIntervals,
93 ) { 52 ) {
94 - if (sleepIntervals.isEmpty) return null;  
95 - final sorted = [...sleepIntervals]..sort((a, b) {  
96 - final startCompare = a.startTime.compareTo(b.startTime);  
97 - if (startCompare != 0) return startCompare;  
98 - return a.endTime.compareTo(b.endTime);  
99 - });  
100 - var start = sorted.first.startTime;  
101 - var end = sorted.first.endTime;  
102 - for (final interval in sorted.skip(1)) {  
103 - if (interval.startTime > end) break;  
104 - end = math.max(end, interval.endTime);  
105 - }  
106 - return HealthKitRawDataPoint(  
107 - dataType: sorted.first.dataType,  
108 - startTime: start,  
109 - endTime: end,  
110 - ); 53 + return HealthSleepCalculator.mergeContinuousSleepIntervals(sleepIntervals);
111 } 54 }
112 55
113 static SleepStatisticsData sleepStatistics({ 56 static SleepStatisticsData sleepStatistics({
@@ -119,24 +62,24 @@ class LocalHealthDataConvert { @@ -119,24 +62,24 @@ class LocalHealthDataConvert {
119 }) { 62 }) {
120 final daily = [ 63 final daily = [
121 for (final day in days) 64 for (final day in days)
122 - _sleepDaySummary(  
123 - day,  
124 - sleepIntervals,  
125 - heartRate, 65 + HealthSleepCalculator.calculateDay(
  66 + day: day,
  67 + sleepIntervals: sleepIntervals,
126 ), 68 ),
127 ]; 69 ];
128 final validSleep = daily.where((e) => e.durationSeconds > 0).toList(); 70 final validSleep = daily.where((e) => e.durationSeconds > 0).toList();
129 final previousDaily = [ 71 final previousDaily = [
130 for (final day in previousDays) 72 for (final day in previousDays)
131 - _sleepDaySummary(  
132 - day,  
133 - sleepIntervals,  
134 - heartRate, 73 + HealthSleepCalculator.calculateDay(
  74 + day: day,
  75 + sleepIntervals: sleepIntervals,
135 ), 76 ),
136 ]; 77 ];
137 final previousValidSleep = 78 final previousValidSleep =
138 previousDaily.where((e) => e.durationSeconds > 0).toList(); 79 previousDaily.where((e) => e.durationSeconds > 0).toList();
139 - final allSleepHr = validSleep.expand((e) => e.sleepHr).toList(); 80 + final allSleepHr = validSleep
  81 + .expand((e) => _sleepHeartRate(e.sleepIntervals, heartRate))
  82 + .toList();
140 final shouldIncludeHeartRate = dateRangeType == 3; 83 final shouldIncludeHeartRate = dateRangeType == 3;
141 84
142 return SleepStatisticsData( 85 return SleepStatisticsData(
@@ -147,7 +90,7 @@ class LocalHealthDataConvert { @@ -147,7 +90,7 @@ class LocalHealthDataConvert {
147 validSleep.map((e) => e.score).whereType<num>().toList(), 90 validSleep.map((e) => e.score).whereType<num>().toList(),
148 ), 91 ),
149 avgSleepEvaluate: _averageOrNull( 92 avgSleepEvaluate: _averageOrNull(
150 - validSleep.map((e) => e.evaluate).whereType<num>().toList(), 93 + validSleep.map((e) => e.state?.value).whereType<num>().toList(),
151 ), 94 ),
152 qoqAvgSleepDuration: _averageOrNull( 95 qoqAvgSleepDuration: _averageOrNull(
153 previousValidSleep.map((e) => e.durationSeconds).toList(), 96 previousValidSleep.map((e) => e.durationSeconds).toList(),
@@ -156,7 +99,7 @@ class LocalHealthDataConvert { @@ -156,7 +99,7 @@ class LocalHealthDataConvert {
156 previousValidSleep.map((e) => e.score).whereType<num>().toList(), 99 previousValidSleep.map((e) => e.score).whereType<num>().toList(),
157 ), 100 ),
158 qoqSleepEvaluate: _averageOrNull( 101 qoqSleepEvaluate: _averageOrNull(
159 - previousValidSleep.map((e) => e.evaluate).whereType<num>().toList(), 102 + previousValidSleep.map((e) => e.state?.value).whereType<num>().toList(),
160 ), 103 ),
161 sleepTrendList: [ 104 sleepTrendList: [
162 for (final item in daily) 105 for (final item in daily)
@@ -164,7 +107,7 @@ class LocalHealthDataConvert { @@ -164,7 +107,7 @@ class LocalHealthDataConvert {
164 timeKey: dateKey(item.day), 107 timeKey: dateKey(item.day),
165 totalTime: item.durationSeconds, 108 totalTime: item.durationSeconds,
166 score: item.score, 109 score: item.score,
167 - sleepEvaluate: item.evaluate, 110 + sleepEvaluate: item.state?.value,
168 ), 111 ),
169 ], 112 ],
170 asleepTimeTrendList: [ 113 asleepTimeTrendList: [
@@ -200,7 +143,97 @@ class LocalHealthDataConvert { @@ -200,7 +143,97 @@ class LocalHealthDataConvert {
200 allSleepHr.map((e) => e.value).whereType<num>().toList(), 143 allSleepHr.map((e) => e.value).whereType<num>().toList(),
201 ) 144 )
202 : null, 145 : null,
203 - sleepTargetDuration: (_sleepGoalMinutes * 60).round(), 146 + sleepTargetDuration: healthSleepGoalMinutes * 60,
  147 + );
  148 + }
  149 +
  150 + static SleepStatisticsData sleepStatisticsFromResults({
  151 + required int dateRangeType,
  152 + required List<DateTime> days,
  153 + required List<DateTime> previousDays,
  154 + required List<HealthRawSleepResult> sleepResults,
  155 + required List<HealthKitRawDataPoint> heartRate,
  156 + }) {
  157 + final daily = [
  158 + for (final day in days) _sleepResultForDay(day, sleepResults),
  159 + ];
  160 + final validSleep = daily.whereType<HealthRawSleepResult>().toList();
  161 + final previousDaily = [
  162 + for (final day in previousDays) _sleepResultForDay(day, sleepResults),
  163 + ];
  164 + final previousValidSleep =
  165 + previousDaily.whereType<HealthRawSleepResult>().toList();
  166 + final shouldIncludeHeartRate = dateRangeType == 3;
  167 + final allSleepHr = shouldIncludeHeartRate
  168 + ? _sleepResultHeartRate(validSleep, heartRate)
  169 + : const <HealthKitRawDataPoint>[];
  170 +
  171 + return SleepStatisticsData(
  172 + avgSleepDuration: _averageOrNull(
  173 + validSleep.map((e) => e.sleepMinutes * 60).toList(),
  174 + ),
  175 + avgSleepScore: _averageOrNull(
  176 + validSleep.map((e) => e.sleepScore).toList(),
  177 + ),
  178 + avgSleepEvaluate: _averageOrNull(
  179 + validSleep.map((e) => e.sleepStateValue).whereType<num>().toList(),
  180 + ),
  181 + qoqAvgSleepDuration: _averageOrNull(
  182 + previousValidSleep.map((e) => e.sleepMinutes * 60).toList(),
  183 + ),
  184 + qoqSleepScore: _averageOrNull(
  185 + previousValidSleep.map((e) => e.sleepScore).toList(),
  186 + ),
  187 + qoqSleepEvaluate: _averageOrNull(
  188 + previousValidSleep
  189 + .map((e) => e.sleepStateValue)
  190 + .whereType<num>()
  191 + .toList(),
  192 + ),
  193 + sleepTrendList: [
  194 + for (var index = 0; index < days.length; index++)
  195 + SleepTrendList(
  196 + timeKey: dateKey(days[index]),
  197 + totalTime: daily[index]?.sleepMinutes == null
  198 + ? 0
  199 + : daily[index]!.sleepMinutes * 60,
  200 + score: daily[index]?.sleepScore,
  201 + sleepEvaluate: daily[index]?.sleepStateValue,
  202 + ),
  203 + ],
  204 + asleepTimeTrendList: [
  205 + for (var index = 0; index < days.length; index++)
  206 + AsleepTimeTrendList(
  207 + timeKey: dateKey(days[index]),
  208 + asleepTime: daily[index]?.startDate,
  209 + ),
  210 + ],
  211 + bestSleepInfo: _bestSleepResultInfo(validSleep, best: true),
  212 + worstSleepInfo: _worstSleepResultInfo(validSleep),
  213 + earliestSleepInfo: _earliestSleepResultInfo(validSleep),
  214 + latestSleepInfo: _latestSleepResultInfo(validSleep),
  215 + hrList: shouldIncludeHeartRate
  216 + ? [
  217 + for (final point in allSleepHr)
  218 + SleepHrItem(time: point.endTime, value: point.value),
  219 + ]
  220 + : null,
  221 + avgHr: shouldIncludeHeartRate
  222 + ? _averageOrNull(
  223 + allSleepHr.map((e) => e.value).whereType<num>().toList(),
  224 + )
  225 + : null,
  226 + maxHr: shouldIncludeHeartRate
  227 + ? _maxOrNull(
  228 + allSleepHr.map((e) => e.value).whereType<num>().toList(),
  229 + )
  230 + : null,
  231 + minHr: shouldIncludeHeartRate
  232 + ? _minOrNull(
  233 + allSleepHr.map((e) => e.value).whereType<num>().toList(),
  234 + )
  235 + : null,
  236 + sleepTargetDuration: healthSleepGoalMinutes * 60,
204 ); 237 );
205 } 238 }
206 239
@@ -372,7 +405,7 @@ class LocalHealthDataConvert { @@ -372,7 +405,7 @@ class LocalHealthDataConvert {
372 ? null 405 ? null
373 : latestWithGoal!.exerciseTimeGoal!.round(), 406 : latestWithGoal!.exerciseTimeGoal!.round(),
374 updateTime: latestWithGoal?.endTime, 407 updateTime: latestWithGoal?.endTime,
375 - sleepTargetDuration: (60 * _sleepGoalMinutes).round(), 408 + sleepTargetDuration: 60 * healthSleepGoalMinutes,
376 ); 409 );
377 } 410 }
378 411
@@ -385,10 +418,9 @@ class LocalHealthDataConvert { @@ -385,10 +418,9 @@ class LocalHealthDataConvert {
385 required List<HealthKitRawDataPoint> sleepIntervals, 418 required List<HealthKitRawDataPoint> sleepIntervals,
386 }) { 419 }) {
387 final activitySummary = _activityDaySummary(day, activity); 420 final activitySummary = _activityDaySummary(day, activity);
388 - final sleepSummary = _sleepDaySummary(  
389 - day,  
390 - sleepIntervals,  
391 - sleepingHeartRate, 421 + final sleepSummary = HealthSleepCalculator.calculateDay(
  422 + day: day,
  423 + sleepIntervals: sleepIntervals,
392 ); 424 );
393 final dayStart = unixSeconds(day); 425 final dayStart = unixSeconds(day);
394 final dayEnd = unixSeconds(day.add(const Duration(days: 1))); 426 final dayEnd = unixSeconds(day.add(const Duration(days: 1)));
@@ -410,7 +442,10 @@ class LocalHealthDataConvert { @@ -410,7 +442,10 @@ class LocalHealthDataConvert {
410 lastRestingHrValue: 442 lastRestingHrValue:
411 dayRestingHr.isEmpty ? null : dayRestingHr.last.value?.round(), 443 dayRestingHr.isEmpty ? null : dayRestingHr.last.value?.round(),
412 hrAvg: _averageOrNull( 444 hrAvg: _averageOrNull(
413 - sleepSummary.sleepHr.map((e) => e.value).whereType<num>().toList(), 445 + _sleepHeartRate(sleepSummary.sleepIntervals, sleepingHeartRate)
  446 + .map((e) => e.value)
  447 + .whereType<num>()
  448 + .toList(),
414 )?.round(), 449 )?.round(),
415 move: activitySummary.move.round(), 450 move: activitySummary.move.round(),
416 exercise: activitySummary.exerciseSeconds.round(), 451 exercise: activitySummary.exerciseSeconds.round(),
@@ -418,7 +453,52 @@ class LocalHealthDataConvert { @@ -418,7 +453,52 @@ class LocalHealthDataConvert {
418 steps: activitySummary.steps.round(), 453 steps: activitySummary.steps.round(),
419 sleepDuration: sleepSummary.durationSeconds.round(), 454 sleepDuration: sleepSummary.durationSeconds.round(),
420 sleepScore: sleepSummary.score?.toDouble(), 455 sleepScore: sleepSummary.score?.toDouble(),
421 - sleepState: sleepSummary.evaluate?.round(), 456 + sleepState: sleepSummary.state?.value,
  457 + sleepTimeList: sleepTimeList,
  458 + );
  459 + }
  460 +
  461 + static V2HealthData v2HealthDataFromSleepResult({
  462 + required DateTime day,
  463 + required List<HealthRawHrvStressPoint> hrvPoints,
  464 + required List<HealthKitRawActivityDataPoint> activity,
  465 + required List<HealthKitRawDataPoint> sleepingHeartRate,
  466 + required List<HealthKitRawDataPoint> restingHeartRate,
  467 + required HealthRawSleepResult? sleepResult,
  468 + }) {
  469 + final activitySummary = _activityDaySummary(day, activity);
  470 + final dayStart = unixSeconds(day);
  471 + final dayEnd = unixSeconds(day.add(const Duration(days: 1)));
  472 + final sleepTimeList = sleepResult == null
  473 + ? <V2SleepTimeRange>[]
  474 + : [
  475 + V2SleepTimeRange(
  476 + fromTime: sleepResult.startDate,
  477 + toTime: sleepResult.date,
  478 + ),
  479 + ];
  480 + final sleepHr = sleepResult == null
  481 + ? const <HealthKitRawDataPoint>[]
  482 + : _sleepResultHeartRate([sleepResult], sleepingHeartRate);
  483 + final dayRestingHr = restingHeartRate
  484 + .where((e) => e.endTime >= dayStart && e.endTime < dayEnd)
  485 + .toList()
  486 + ..sort((a, b) => a.endTime.compareTo(b.endTime));
  487 +
  488 + return V2HealthData(
  489 + hrvAvg: _averageOrNull(hrvPoints.map((e) => e.result).toList())?.round(),
  490 + lastRestingHrValue:
  491 + dayRestingHr.isEmpty ? null : dayRestingHr.last.value?.round(),
  492 + hrAvg: _averageOrNull(
  493 + sleepHr.map((e) => e.value).whereType<num>().toList(),
  494 + )?.round(),
  495 + move: activitySummary.move.round(),
  496 + exercise: activitySummary.exerciseSeconds.round(),
  497 + stand: activitySummary.standHours.round(),
  498 + steps: activitySummary.steps.round(),
  499 + sleepDuration: sleepResult == null ? 0 : sleepResult.sleepMinutes * 60,
  500 + sleepScore: sleepResult?.sleepScore.toDouble(),
  501 + sleepState: sleepResult?.sleepStateValue,
422 sleepTimeList: sleepTimeList, 502 sleepTimeList: sleepTimeList,
423 ); 503 );
424 } 504 }
@@ -479,43 +559,6 @@ class LocalHealthDataConvert { @@ -479,43 +559,6 @@ class LocalHealthDataConvert {
479 ); 559 );
480 } 560 }
481 561
482 - static _SleepDaySummary _sleepDaySummary(  
483 - DateTime day,  
484 - List<HealthKitRawDataPoint> sleepIntervals,  
485 - List<HealthKitRawDataPoint> heartRate,  
486 - ) {  
487 - final intervals = completeSleepIntervalsForDay(day, sleepIntervals);  
488 - final mergedInterval = mergeContinuousSleepIntervals(intervals);  
489 - final windowStart = mergedInterval?.startTime ?? unixSeconds(day);  
490 - final windowEnd = mergedInterval?.endTime ??  
491 - unixSeconds(day.add(const Duration(days: 1)));  
492 - final summary = _sleepSummary(intervals, windowStart, windowEnd);  
493 - final score = _sleepScore(summary);  
494 - final sleepHr = heartRate  
495 - .where((point) => intervals.any((interval) =>  
496 - point.endTime > interval.startTime &&  
497 - point.endTime <= interval.endTime))  
498 - .toList();  
499 - final nullableScore = score == 0 ? null : score;  
500 - final sleepEvaluate = _sleepEvaluate(score);  
501 - final sleepDuration = nullableScore == null || sleepEvaluate == null  
502 - ? 0  
503 - : summary.totalAsleepMinutes.round() * 60;  
504 -  
505 - return _SleepDaySummary(  
506 - day: day,  
507 - sleepIntervals: intervals,  
508 - mergeSleepTimeRange: mergedInterval,  
509 - durationSeconds: sleepDuration.round(),  
510 - asleepTime: intervals.isEmpty  
511 - ? null  
512 - : intervals.map((e) => e.startTime).reduce(math.min),  
513 - score: nullableScore,  
514 - evaluate: sleepEvaluate,  
515 - sleepHr: sleepHr,  
516 - );  
517 - }  
518 -  
519 static _ActivityDaySummary _activityDaySummary( 562 static _ActivityDaySummary _activityDaySummary(
520 DateTime day, 563 DateTime day,
521 List<HealthKitRawActivityDataPoint> activity, 564 List<HealthKitRawActivityDataPoint> activity,
@@ -653,12 +696,15 @@ class LocalHealthDataConvert { @@ -653,12 +696,15 @@ class LocalHealthDataConvert {
653 return result; 696 return result;
654 } 697 }
655 698
656 - static BestSleepInfo? _bestSleepInfo(List<_SleepDaySummary> daily) {  
657 - final item = daily.where((e) => e.score != null).fold<_SleepDaySummary?>(  
658 - null,  
659 - (best, item) =>  
660 - best == null || item.score! > best.score! ? item : best,  
661 - ); 699 + static BestSleepInfo? _bestSleepInfo(
  700 + List<HealthSleepDayCalculation> daily,
  701 + ) {
  702 + final item =
  703 + daily.where((e) => e.score != null).fold<HealthSleepDayCalculation?>(
  704 + null,
  705 + (best, item) =>
  706 + best == null || item.score! > best.score! ? item : best,
  707 + );
662 return item == null 708 return item == null
663 ? null 709 ? null
664 : BestSleepInfo( 710 : BestSleepInfo(
@@ -668,12 +714,15 @@ class LocalHealthDataConvert { @@ -668,12 +714,15 @@ class LocalHealthDataConvert {
668 ); 714 );
669 } 715 }
670 716
671 - static WorstSleepInfo? _worstSleepInfo(List<_SleepDaySummary> daily) {  
672 - final item = daily.where((e) => e.score != null).fold<_SleepDaySummary?>(  
673 - null,  
674 - (worst, item) =>  
675 - worst == null || item.score! < worst.score! ? item : worst,  
676 - ); 717 + static WorstSleepInfo? _worstSleepInfo(
  718 + List<HealthSleepDayCalculation> daily,
  719 + ) {
  720 + final item =
  721 + daily.where((e) => e.score != null).fold<HealthSleepDayCalculation?>(
  722 + null,
  723 + (worst, item) =>
  724 + worst == null || item.score! < worst.score! ? item : worst,
  725 + );
677 return item == null 726 return item == null
678 ? null 727 ? null
679 : WorstSleepInfo( 728 : WorstSleepInfo(
@@ -683,7 +732,9 @@ class LocalHealthDataConvert { @@ -683,7 +732,9 @@ class LocalHealthDataConvert {
683 ); 732 );
684 } 733 }
685 734
686 - static EarliestSleepInfo? _earliestSleepInfo(List<_SleepDaySummary> daily) { 735 + static EarliestSleepInfo? _earliestSleepInfo(
  736 + List<HealthSleepDayCalculation> daily,
  737 + ) {
687 final sorted = daily.where((e) => e.asleepTime != null).toList() 738 final sorted = daily.where((e) => e.asleepTime != null).toList()
688 ..sort((a, b) => a.asleepTime!.compareTo(b.asleepTime!)); 739 ..sort((a, b) => a.asleepTime!.compareTo(b.asleepTime!));
689 final item = sorted.isEmpty ? null : sorted.first; 740 final item = sorted.isEmpty ? null : sorted.first;
@@ -695,132 +746,81 @@ class LocalHealthDataConvert { @@ -695,132 +746,81 @@ class LocalHealthDataConvert {
695 ); 746 );
696 } 747 }
697 748
698 - static LatestSleepInfo? _latestSleepInfo(List<_SleepDaySummary> daily) {  
699 - final sorted = daily.where((e) => e.asleepTime != null).toList()  
700 - ..sort((a, b) => b.asleepTime!.compareTo(a.asleepTime!));  
701 - final item = sorted.isEmpty ? null : sorted.first;  
702 - return item == null  
703 - ? null  
704 - : LatestSleepInfo(  
705 - timeKey: dateKey(item.day),  
706 - asleepTime: item.asleepTime,  
707 - );  
708 - }  
709 -  
710 - static _SleepSummary _sleepSummary(  
711 - List<HealthKitRawDataPoint> intervals,  
712 - int windowStart,  
713 - int windowEnd,  
714 - ) {  
715 - var inBedMinutes = 0.0;  
716 - var asleepMinutes = 0.0;  
717 - var awakeMinutes = 0.0;  
718 - var coreMinutes = 0.0;  
719 - var deepMinutes = 0.0;  
720 - var remMinutes = 0.0;  
721 - var wakeCount = 0;  
722 - var earliestStart = windowEnd;  
723 - var latestEnd = windowStart;  
724 -  
725 - for (final interval in intervals) {  
726 - final clippedStart = math.max(interval.startTime, windowStart);  
727 - final clippedEnd = math.min(interval.endTime, windowEnd);  
728 - final minutes = math.max(0, clippedEnd - clippedStart) / 60.0;  
729 - if (minutes <= 0) continue;  
730 - earliestStart = math.min(earliestStart, clippedStart);  
731 - latestEnd = math.max(latestEnd, clippedEnd);  
732 - switch (interval.dataType) {  
733 - case _sleepTypeInBed:  
734 - inBedMinutes += minutes;  
735 - break;  
736 - case _sleepTypeAwake:  
737 - awakeMinutes += minutes;  
738 - wakeCount += 1;  
739 - break;  
740 - case _sleepTypeAsleepCore:  
741 - coreMinutes += minutes;  
742 - asleepMinutes += minutes;  
743 - break;  
744 - case _sleepTypeAsleepDeep:  
745 - deepMinutes += minutes;  
746 - asleepMinutes += minutes;  
747 - break;  
748 - case _sleepTypeAsleepRem:  
749 - remMinutes += minutes;  
750 - asleepMinutes += minutes;  
751 - break;  
752 - default:  
753 - if (_isAsleepSleepType(interval.dataType)) {  
754 - asleepMinutes += minutes;  
755 - }  
756 - break;  
757 - }  
758 - }  
759 -  
760 - if (inBedMinutes <= 0 && latestEnd > earliestStart) {  
761 - inBedMinutes = (latestEnd - earliestStart) / 60.0;  
762 - }  
763 - if (inBedMinutes <= 0) {  
764 - inBedMinutes = asleepMinutes + awakeMinutes;  
765 - }  
766 -  
767 - return _SleepSummary(  
768 - timeInBedMinutes: inBedMinutes,  
769 - totalAsleepMinutes: asleepMinutes,  
770 - awakeMinutes: awakeMinutes,  
771 - coreMinutes: coreMinutes,  
772 - deepMinutes: deepMinutes,  
773 - remMinutes: remMinutes,  
774 - wakeCount: wakeCount,  
775 - sleepGoalMinutes: _sleepGoalMinutes, 749 + static BestSleepInfo? _bestSleepResultInfo(
  750 + List<HealthRawSleepResult> sleepResults, {
  751 + required bool best,
  752 + }) {
  753 + final item = sleepResults.fold<HealthRawSleepResult?>(
  754 + null,
  755 + (selected, item) {
  756 + if (selected == null) return item;
  757 + if (best && item.sleepScore > selected.sleepScore) return item;
  758 + if (!best && item.sleepScore < selected.sleepScore) return item;
  759 + return selected;
  760 + },
  761 + );
  762 + if (item == null) return null;
  763 + return BestSleepInfo(
  764 + timeKey: dateKey(dayOf(item.date)),
  765 + totalTime: item.sleepMinutes * 60,
  766 + score: item.sleepScore,
776 ); 767 );
777 } 768 }
778 769
779 - static int _sleepScore(_SleepSummary sleep) {  
780 - if (sleep.timeInBedMinutes <= 0 || sleep.totalAsleepMinutes <= 0) return 0;  
781 - final durationRatio = math.min(  
782 - sleep.totalAsleepMinutes / sleep.sleepGoalMinutes,  
783 - 1.0, 770 + static WorstSleepInfo? _worstSleepResultInfo(
  771 + List<HealthRawSleepResult> sleepResults,
  772 + ) {
  773 + final item = sleepResults.fold<HealthRawSleepResult?>(
  774 + null,
  775 + (worst, item) =>
  776 + worst == null || item.sleepScore < worst.sleepScore ? item : worst,
784 ); 777 );
785 - final durationScore = durationRatio * 40;  
786 - final efficiency = sleep.totalAsleepMinutes / sleep.timeInBedMinutes;  
787 - final efficiencyScore = math.min(efficiency / 0.9, 1.0) * 25;  
788 - final deepRatio = sleep.deepMinutes / sleep.totalAsleepMinutes;  
789 - final deepScore = math.min(deepRatio / 0.18, 1.0) * 15;  
790 - final remRatio = sleep.remMinutes / sleep.totalAsleepMinutes;  
791 - final remScore = math.min(remRatio / 0.22, 1.0) * 10;  
792 - final awakePenalty = math.min(  
793 - sleep.wakeCount * 2 + sleep.awakeMinutes / 10,  
794 - 10, 778 + if (item == null) return null;
  779 + return WorstSleepInfo(
  780 + timeKey: dateKey(dayOf(item.date)),
  781 + totalTime: item.sleepMinutes * 60,
  782 + score: item.sleepScore,
795 ); 783 );
796 - final rawScore =  
797 - durationScore + efficiencyScore + deepScore + remScore - awakePenalty;  
798 - return _mappedSleepScore(rawScore);  
799 } 784 }
800 785
801 - static int _mappedSleepScore(num rawScore) {  
802 - final clamped = rawScore.clamp(0, 100).toDouble();  
803 - final mapped = switch (clamped) {  
804 - < 64 => clamped / 64 * 60,  
805 - < 74 => 60 + (clamped - 64) / 10 * 25,  
806 - _ => math.min(math.max(86, 85 + (clamped - 74) / 26 * 15), 100),  
807 - };  
808 - return mapped.round().clamp(0, 100); 786 + static EarliestSleepInfo? _earliestSleepResultInfo(
  787 + List<HealthRawSleepResult> sleepResults,
  788 + ) {
  789 + final sorted = [...sleepResults]
  790 + ..sort((a, b) => a.startDate.compareTo(b.startDate));
  791 + final item = sorted.isEmpty ? null : sorted.first;
  792 + if (item == null) return null;
  793 + return EarliestSleepInfo(
  794 + timeKey: dateKey(dayOf(item.date)),
  795 + asleepTime: item.startDate,
  796 + );
809 } 797 }
810 798
811 - static num? _sleepEvaluate(int score) {  
812 - if (score <= 0) return null;  
813 - if (score >= 85) return 1;  
814 - if (score >= 60) return 2;  
815 - return 3; 799 + static LatestSleepInfo? _latestSleepResultInfo(
  800 + List<HealthRawSleepResult> sleepResults,
  801 + ) {
  802 + final sorted = [...sleepResults]
  803 + ..sort((a, b) => b.startDate.compareTo(a.startDate));
  804 + final item = sorted.isEmpty ? null : sorted.first;
  805 + if (item == null) return null;
  806 + return LatestSleepInfo(
  807 + timeKey: dateKey(dayOf(item.date)),
  808 + asleepTime: item.startDate,
  809 + );
816 } 810 }
817 811
818 - static bool _isAsleepSleepType(int dataType) {  
819 - return dataType == _sleepTypeAsleep ||  
820 - dataType == _sleepTypeAsleepUnspecified ||  
821 - dataType == _sleepTypeAsleepCore ||  
822 - dataType == _sleepTypeAsleepDeep ||  
823 - dataType == _sleepTypeAsleepRem; 812 + static LatestSleepInfo? _latestSleepInfo(
  813 + List<HealthSleepDayCalculation> daily,
  814 + ) {
  815 + final sorted = daily.where((e) => e.asleepTime != null).toList()
  816 + ..sort((a, b) => b.asleepTime!.compareTo(a.asleepTime!));
  817 + final item = sorted.isEmpty ? null : sorted.first;
  818 + return item == null
  819 + ? null
  820 + : LatestSleepInfo(
  821 + timeKey: dateKey(item.day),
  822 + asleepTime: item.asleepTime,
  823 + );
824 } 824 }
825 825
826 static int? _modeState(Iterable<int> states) { 826 static int? _modeState(Iterable<int> states) {
@@ -836,6 +836,40 @@ class LocalHealthDataConvert { @@ -836,6 +836,40 @@ class LocalHealthDataConvert {
836 return intervals.any((e) => time > e.startTime && time <= e.endTime); 836 return intervals.any((e) => time > e.startTime && time <= e.endTime);
837 } 837 }
838 838
  839 + static List<HealthKitRawDataPoint> _sleepHeartRate(
  840 + List<HealthKitRawDataPoint> intervals,
  841 + List<HealthKitRawDataPoint> heartRate,
  842 + ) {
  843 + return heartRate
  844 + .where((point) => intervals.any((interval) =>
  845 + point.endTime > interval.startTime &&
  846 + point.endTime <= interval.endTime))
  847 + .toList();
  848 + }
  849 +
  850 + static HealthRawSleepResult? _sleepResultForDay(
  851 + DateTime day,
  852 + List<HealthRawSleepResult> sleepResults,
  853 + ) {
  854 + final start = unixSeconds(day);
  855 + final end = unixSeconds(day.add(const Duration(days: 1)));
  856 + final dayResults = sleepResults
  857 + .where((e) => e.date >= start && e.date < end)
  858 + .toList()
  859 + ..sort((a, b) => a.date.compareTo(b.date));
  860 + return dayResults.isEmpty ? null : dayResults.last;
  861 + }
  862 +
  863 + static List<HealthKitRawDataPoint> _sleepResultHeartRate(
  864 + List<HealthRawSleepResult> sleepResults,
  865 + List<HealthKitRawDataPoint> heartRate,
  866 + ) {
  867 + return heartRate
  868 + .where((point) => sleepResults.any((result) =>
  869 + point.endTime > result.startDate && point.endTime <= result.date))
  870 + .toList();
  871 + }
  872 +
839 static DateTime dateFromKey(int key) { 873 static DateTime dateFromKey(int key) {
840 final year = key ~/ 10000; 874 final year = key ~/ 10000;
841 final month = (key ~/ 100) % 100; 875 final month = (key ~/ 100) % 100;
@@ -873,50 +907,6 @@ class LocalHealthDataConvert { @@ -873,50 +907,6 @@ class LocalHealthDataConvert {
873 } 907 }
874 } 908 }
875 909
876 -class _SleepDaySummary {  
877 - const _SleepDaySummary({  
878 - required this.day,  
879 - required this.sleepIntervals,  
880 - required this.mergeSleepTimeRange,  
881 - required this.durationSeconds,  
882 - required this.asleepTime,  
883 - required this.score,  
884 - required this.evaluate,  
885 - required this.sleepHr,  
886 - });  
887 -  
888 - final DateTime day;  
889 - final List<HealthKitRawDataPoint> sleepIntervals;  
890 - final HealthKitRawDataPoint? mergeSleepTimeRange;  
891 - final num durationSeconds;  
892 - final num? asleepTime;  
893 - final num? score;  
894 - final num? evaluate;  
895 - final List<HealthKitRawDataPoint> sleepHr;  
896 -}  
897 -  
898 -class _SleepSummary {  
899 - const _SleepSummary({  
900 - required this.timeInBedMinutes,  
901 - required this.totalAsleepMinutes,  
902 - required this.awakeMinutes,  
903 - required this.coreMinutes,  
904 - required this.deepMinutes,  
905 - required this.remMinutes,  
906 - required this.wakeCount,  
907 - required this.sleepGoalMinutes,  
908 - });  
909 -  
910 - final double timeInBedMinutes;  
911 - final double totalAsleepMinutes;  
912 - final double awakeMinutes;  
913 - final double coreMinutes;  
914 - final double deepMinutes;  
915 - final double remMinutes;  
916 - final int wakeCount;  
917 - final double sleepGoalMinutes;  
918 -}  
919 -  
920 class _ActivityDaySummary { 910 class _ActivityDaySummary {
921 const _ActivityDaySummary({ 911 const _ActivityDaySummary({
922 required this.day, 912 required this.day,
@@ -6,6 +6,7 @@ import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_sta @@ -6,6 +6,7 @@ import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_sta
6 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; 6 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
7 import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart'; 7 import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart';
8 import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart'; 8 import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart';
  9 +import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
9 10
10 import 'health_datasource.dart'; 11 import 'health_datasource.dart';
11 import 'health_local_data_convert.dart'; 12 import 'health_local_data_convert.dart';
@@ -31,27 +32,26 @@ class LocalHealthDataSource implements HealthDataSource { @@ -31,27 +32,26 @@ class LocalHealthDataSource implements HealthDataSource {
31 ); 32 );
32 final allDays = [...previousDays, ...days]; 33 final allDays = [...previousDays, ...days];
33 final queryStart = LocalHealthDataConvert.unixSeconds( 34 final queryStart = LocalHealthDataConvert.unixSeconds(
34 - allDays.first.subtract(const Duration(days: 1)), 35 + allDays.first,
35 ); 36 );
36 final queryEnd = LocalHealthDataConvert.unixSeconds( 37 final queryEnd = LocalHealthDataConvert.unixSeconds(
37 - allDays.last.add(const Duration(days: 1)),  
38 - );  
39 - final sleepIntervals = await coreService.queryRawSleepIntervals(  
40 - startTime: queryStart,  
41 - endTime: queryEnd,  
42 - );  
43 - final heartRate = await coreService.queryRawDataPoints(  
44 - dataType: HealthDataUploadType.heartRate.type, 38 + allDays.last.add(const Duration(days: 1)),
  39 + ) -
  40 + 1;
  41 + final sleepResults = await coreService.querySleepResults(
45 startTime: queryStart, 42 startTime: queryStart,
46 endTime: queryEnd, 43 endTime: queryEnd,
47 ); 44 );
  45 + final heartRate = dateRangeType == 3
  46 + ? await _querySleepHeartRate(sleepResults)
  47 + : const <HealthKitRawDataPoint>[];
48 48
49 return AppSuccess( 49 return AppSuccess(
50 - LocalHealthDataConvert.sleepStatistics( 50 + LocalHealthDataConvert.sleepStatisticsFromResults(
51 dateRangeType: dateRangeType, 51 dateRangeType: dateRangeType,
52 days: days, 52 days: days,
53 previousDays: previousDays, 53 previousDays: previousDays,
54 - sleepIntervals: sleepIntervals, 54 + sleepResults: sleepResults,
55 heartRate: heartRate, 55 heartRate: heartRate,
56 ), 56 ),
57 ); 57 );
@@ -188,12 +188,6 @@ class LocalHealthDataSource implements HealthDataSource { @@ -188,12 +188,6 @@ class LocalHealthDataSource implements HealthDataSource {
188 day.add(const Duration(days: 1)), 188 day.add(const Duration(days: 1)),
189 ) - 189 ) -
190 1; 190 1;
191 - final sleepStart = LocalHealthDataConvert.unixSeconds(  
192 - day.subtract(const Duration(days: 1)),  
193 - );  
194 - final sleepEnd = LocalHealthDataConvert.unixSeconds(  
195 - day.add(const Duration(days: 1)),  
196 - );  
197 final hrvPoints = await coreService.queryHrvStressPoints( 191 final hrvPoints = await coreService.queryHrvStressPoints(
198 startTime: dayStart, 192 startTime: dayStart,
199 endTime: dayEnd, 193 endTime: dayEnd,
@@ -202,28 +196,27 @@ class LocalHealthDataSource implements HealthDataSource { @@ -202,28 +196,27 @@ class LocalHealthDataSource implements HealthDataSource {
202 startTime: dayStart, 196 startTime: dayStart,
203 endTime: dayEnd, 197 endTime: dayEnd,
204 ); 198 );
205 - final sleepingHeartRate = await coreService.queryRawDataPoints(  
206 - dataType: HealthDataUploadType.heartRate.type,  
207 - startTime: sleepStart,  
208 - endTime: sleepEnd, 199 + final sleepResults = await coreService.querySleepResults(
  200 + startTime: dayStart,
  201 + endTime: dayEnd,
  202 + );
  203 + final sleepResult = sleepResults.isEmpty ? null : sleepResults.last;
  204 + final sleepingHeartRate = await _querySleepHeartRate(
  205 + sleepResult == null ? const [] : [sleepResult],
209 ); 206 );
210 final restingHeartRate = await coreService.queryRawDataPoints( 207 final restingHeartRate = await coreService.queryRawDataPoints(
211 dataType: HealthDataUploadType.restingHeartRate.type, 208 dataType: HealthDataUploadType.restingHeartRate.type,
212 startTime: dayStart, 209 startTime: dayStart,
213 endTime: dayEnd, 210 endTime: dayEnd,
214 ); 211 );
215 - final sleepIntervals = await coreService.queryRawSleepIntervals(  
216 - startTime: sleepStart,  
217 - endTime: sleepEnd,  
218 - );  
219 return AppSuccess( 212 return AppSuccess(
220 - LocalHealthDataConvert.v2HealthData( 213 + LocalHealthDataConvert.v2HealthDataFromSleepResult(
221 day: day, 214 day: day,
222 hrvPoints: hrvPoints, 215 hrvPoints: hrvPoints,
223 activity: activity, 216 activity: activity,
224 sleepingHeartRate: sleepingHeartRate, 217 sleepingHeartRate: sleepingHeartRate,
225 restingHeartRate: restingHeartRate, 218 restingHeartRate: restingHeartRate,
226 - sleepIntervals: sleepIntervals, 219 + sleepResult: sleepResult,
227 ), 220 ),
228 ); 221 );
229 } catch (error) { 222 } catch (error) {
@@ -303,4 +296,19 @@ class LocalHealthDataSource implements HealthDataSource { @@ -303,4 +296,19 @@ class LocalHealthDataSource implements HealthDataSource {
303 LocalHealthDataConvert.unixSeconds(day.add(const Duration(days: 1))) - 1, 296 LocalHealthDataConvert.unixSeconds(day.add(const Duration(days: 1))) - 1,
304 ); 297 );
305 } 298 }
  299 +
  300 + Future<List<HealthKitRawDataPoint>> _querySleepHeartRate(
  301 + List<HealthRawSleepResult> sleepResults,
  302 + ) async {
  303 + if (sleepResults.isEmpty) return const [];
  304 + final startTime =
  305 + sleepResults.map((e) => e.startDate).reduce((a, b) => a < b ? a : b);
  306 + final endTime =
  307 + sleepResults.map((e) => e.date).reduce((a, b) => a > b ? a : b);
  308 + return coreService.queryRawDataPoints(
  309 + dataType: HealthDataUploadType.heartRate.type,
  310 + startTime: startTime,
  311 + endTime: endTime,
  312 + );
  313 + }
306 } 314 }
@@ -525,8 +525,37 @@ class HealthKitRawDataHostApi { @@ -525,8 +525,37 @@ class HealthKitRawDataHostApi {
525 } 525 }
526 } 526 }
527 527
  528 + /// 上传睡眠统计数据
  529 + Future<int> performSleepAnalysisDataUpload({required String sqliteFilePath}) async {
  530 + final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performSleepAnalysisDataUpload$pigeonVar_messageChannelSuffix';
  531 + final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
  532 + pigeonVar_channelName,
  533 + pigeonChannelCodec,
  534 + binaryMessenger: pigeonVar_binaryMessenger,
  535 + );
  536 + final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[sqliteFilePath]);
  537 + final List<Object?>? pigeonVar_replyList =
  538 + await pigeonVar_sendFuture as List<Object?>?;
  539 + if (pigeonVar_replyList == null) {
  540 + throw _createConnectionError(pigeonVar_channelName);
  541 + } else if (pigeonVar_replyList.length > 1) {
  542 + throw PlatformException(
  543 + code: pigeonVar_replyList[0]! as String,
  544 + message: pigeonVar_replyList[1] as String?,
  545 + details: pigeonVar_replyList[2],
  546 + );
  547 + } else if (pigeonVar_replyList[0] == null) {
  548 + throw PlatformException(
  549 + code: 'null-error',
  550 + message: 'Host platform returned null value for non-null return value.',
  551 + );
  552 + } else {
  553 + return (pigeonVar_replyList[0] as int?)!;
  554 + }
  555 + }
  556 +
528 /// 上传当前实时压力(统计维度是当天) 557 /// 上传当前实时压力(统计维度是当天)
529 - Future<bool> performAvgRealtimeStressDataUpload({required String sqliteFilePath}) async { 558 + Future<int> performAvgRealtimeStressDataUpload({required String sqliteFilePath}) async {
530 final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performAvgRealtimeStressDataUpload$pigeonVar_messageChannelSuffix'; 559 final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performAvgRealtimeStressDataUpload$pigeonVar_messageChannelSuffix';
531 final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( 560 final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
532 pigeonVar_channelName, 561 pigeonVar_channelName,
@@ -550,7 +579,7 @@ class HealthKitRawDataHostApi { @@ -550,7 +579,7 @@ class HealthKitRawDataHostApi {
550 message: 'Host platform returned null value for non-null return value.', 579 message: 'Host platform returned null value for non-null return value.',
551 ); 580 );
552 } else { 581 } else {
553 - return (pigeonVar_replyList[0] as bool?)!; 582 + return (pigeonVar_replyList[0] as int?)!;
554 } 583 }
555 } 584 }
556 585
@@ -110,9 +110,13 @@ abstract class HealthKitRawDataHostApi { @@ -110,9 +110,13 @@ abstract class HealthKitRawDataHostApi {
110 @async 110 @async
111 int performHRVDataUpload({required String sqliteFilePath}); 111 int performHRVDataUpload({required String sqliteFilePath});
112 112
  113 + /// 上传睡眠统计数据
  114 + @async
  115 + int performSleepAnalysisDataUpload({required String sqliteFilePath});
  116 +
113 /// 上传当前实时压力(统计维度是当天) 117 /// 上传当前实时压力(统计维度是当天)
114 @async 118 @async
115 - bool performAvgRealtimeStressDataUpload({required String sqliteFilePath}); 119 + int performAvgRealtimeStressDataUpload({required String sqliteFilePath});
116 120
117 /// 从从AppleHealth中读取睡眠数据 121 /// 从从AppleHealth中读取睡眠数据
118 @async 122 @async
1 import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; 1 import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
  2 +import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.dart';
2 import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; 3 import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
3 import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart'; 4 import 'package:doublefeel_flutter/pigeon/health_kit_api.g.dart';
4 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; 5 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
@@ -69,11 +70,16 @@ void main() { @@ -69,11 +70,16 @@ void main() {
69 ); 70 );
70 71
71 api.calls.clear(); 72 api.calls.clear();
72 - await service.startCoreCaculate( 73 + final second = await service.startCoreCaculate(
73 endTime: base + 800, 74 endTime: base + 800,
74 readChunkDays: 1, 75 readChunkDays: 1,
75 ); 76 );
76 77
  78 + expect(second.hrvStressPoints.map((e) => e.rawEndTime), [base + 720]);
  79 + expect(
  80 + second.realtimeStressPoints.map((e) => e.rawEndTime),
  81 + [base + 720],
  82 + );
77 expect( 83 expect(
78 api.firstCallStart(HealthDataUploadType.hrv.type), 84 api.firstCallStart(HealthDataUploadType.hrv.type),
79 lessThanOrEqualTo(base + 120), 85 lessThanOrEqualTo(base + 120),
@@ -318,6 +324,93 @@ void main() { @@ -318,6 +324,93 @@ void main() {
318 [HealthDataUploadType.hrv.type, HealthDataUploadType.heartRate.type], 324 [HealthDataUploadType.hrv.type, HealthDataUploadType.heartRate.type],
319 ); 325 );
320 }); 326 });
  327 +
  328 + test('startCoreCaculate calculates and stores sleep results', () async {
  329 + final now = DateTime.now();
  330 + final day = DateTime(now.year, now.month, now.day);
  331 + final sleepStart = day.subtract(const Duration(hours: 1, minutes: 30));
  332 + final sleepEnd = day.add(const Duration(hours: 6, minutes: 30));
  333 + final api = _FakeHealthKitRawDataHostApi();
  334 + api.setSleepPoints([
  335 + HealthKitRawDataPoint(
  336 + dataType: 3,
  337 + startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
  338 + endTime: LocalHealthDataConvert.unixSeconds(sleepEnd),
  339 + ),
  340 + ]);
  341 + final store = _MemoryHealthRawStressLocalStore();
  342 + final service = HealthRawDataCoreService(
  343 + healthApi: _FakeHealthKitHostApi(),
  344 + rawDataApi: api,
  345 + localStore: store,
  346 + userIdProvider: () => 42,
  347 + uploadResultsAfterCalculation: false,
  348 + );
  349 +
  350 + final result = await service.startCoreCaculate(
  351 + endTime: LocalHealthDataConvert.unixSeconds(day.add(
  352 + const Duration(hours: 12),
  353 + )),
  354 + readChunkDays: 1,
  355 + );
  356 + final rows = await store.debugQueryAllRows(42);
  357 +
  358 + expect(result.sleepResults, hasLength(1));
  359 + expect(rows.sleepRows, hasLength(1));
  360 + expect(rows.sleepRows.single['date'],
  361 + LocalHealthDataConvert.unixSeconds(sleepEnd));
  362 + expect(rows.sleepRows.single['start_date'],
  363 + LocalHealthDataConvert.unixSeconds(sleepStart));
  364 + expect(rows.sleepRows.single['sleep_state'], 2);
  365 + expect(rows.sleepRows.single['sleep_minutes'], 480);
  366 + expect(rows.sleepRows.single['uploaded'], 0);
  367 +
  368 + final second = await service.startCoreCaculate(
  369 + endTime: LocalHealthDataConvert.unixSeconds(day.add(
  370 + const Duration(hours: 12),
  371 + )),
  372 + readChunkDays: 1,
  373 + );
  374 + final secondRows = await store.debugQueryAllRows(42);
  375 +
  376 + expect(second.sleepResults, isEmpty);
  377 + expect(secondRows.sleepRows, hasLength(1));
  378 + });
  379 +
  380 + test('startCoreCaculate uploads sleep results and marks uploaded', () async {
  381 + final now = DateTime.now();
  382 + final day = DateTime(now.year, now.month, now.day);
  383 + final sleepStart = day.subtract(const Duration(hours: 1));
  384 + final sleepEnd = day.add(const Duration(hours: 7));
  385 + final sleepEndSeconds = LocalHealthDataConvert.unixSeconds(sleepEnd);
  386 + final api = _FakeHealthKitRawDataHostApi()
  387 + ..sleepUploadUntil = sleepEndSeconds;
  388 + api.setSleepPoints([
  389 + HealthKitRawDataPoint(
  390 + dataType: 3,
  391 + startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
  392 + endTime: sleepEndSeconds,
  393 + ),
  394 + ]);
  395 + final store = _MemoryHealthRawStressLocalStore();
  396 + final service = HealthRawDataCoreService(
  397 + healthApi: _FakeHealthKitHostApi(),
  398 + rawDataApi: api,
  399 + localStore: store,
  400 + userIdProvider: () => 42,
  401 + );
  402 +
  403 + await service.startCoreCaculate(
  404 + endTime: LocalHealthDataConvert.unixSeconds(day.add(
  405 + const Duration(hours: 12),
  406 + )),
  407 + readChunkDays: 1,
  408 + );
  409 + final rows = await store.debugQueryAllRows(42);
  410 +
  411 + expect(api.sleepUploadCallCount, 1);
  412 + expect(rows.sleepRows.single['uploaded'], 1);
  413 + });
321 } 414 }
322 415
323 HealthKitRawDataPoint _point( 416 HealthKitRawDataPoint _point(
@@ -337,9 +430,12 @@ HealthKitRawDataPoint _point( @@ -337,9 +430,12 @@ HealthKitRawDataPoint _point(
337 class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { 430 class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
338 final Map<int, List<HealthKitRawDataPoint>> _pointsByType = {}; 431 final Map<int, List<HealthKitRawDataPoint>> _pointsByType = {};
339 final List<HealthKitRawWorkoutDataPoint> _workoutPoints = []; 432 final List<HealthKitRawWorkoutDataPoint> _workoutPoints = [];
  433 + final List<HealthKitRawDataPoint> _sleepPoints = [];
340 final List<_ReadCall> calls = []; 434 final List<_ReadCall> calls = [];
341 var sleepCallCount = 0; 435 var sleepCallCount = 0;
342 var workoutCallCount = 0; 436 var workoutCallCount = 0;
  437 + var sleepUploadCallCount = 0;
  438 + var sleepUploadUntil = 0;
343 439
344 void setPoints(int dataType, List<HealthKitRawDataPoint> points) { 440 void setPoints(int dataType, List<HealthKitRawDataPoint> points) {
345 _pointsByType[dataType] = points; 441 _pointsByType[dataType] = points;
@@ -351,6 +447,12 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { @@ -351,6 +447,12 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
351 ..addAll(points); 447 ..addAll(points);
352 } 448 }
353 449
  450 + void setSleepPoints(List<HealthKitRawDataPoint> points) {
  451 + _sleepPoints
  452 + ..clear()
  453 + ..addAll(points);
  454 + }
  455 +
354 int firstCallStart(int dataType) { 456 int firstCallStart(int dataType) {
355 return calls.firstWhere((e) => e.dataType == dataType).startTime; 457 return calls.firstWhere((e) => e.dataType == dataType).startTime;
356 } 458 }
@@ -374,10 +476,18 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { @@ -374,10 +476,18 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
374 Future<int> performHRVDataUpload({required String sqliteFilePath}) async => 0; 476 Future<int> performHRVDataUpload({required String sqliteFilePath}) async => 0;
375 477
376 @override 478 @override
377 - Future<bool> performAvgRealtimeStressDataUpload({ 479 + Future<int> performAvgRealtimeStressDataUpload({
378 required String sqliteFilePath, 480 required String sqliteFilePath,
379 }) async => 481 }) async =>
380 - false; 482 + 0;
  483 +
  484 + @override
  485 + Future<int> performSleepAnalysisDataUpload({
  486 + required String sqliteFilePath,
  487 + }) async {
  488 + sleepUploadCallCount += 1;
  489 + return sleepUploadUntil;
  490 + }
381 491
382 @override 492 @override
383 Future<List<HealthKitRawSleepDataPoint>> getHealthKitRawSleepData( 493 Future<List<HealthKitRawSleepDataPoint>> getHealthKitRawSleepData(
@@ -385,7 +495,16 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi { @@ -385,7 +495,16 @@ class _FakeHealthKitRawDataHostApi extends HealthKitRawDataHostApi {
385 int endTime, 495 int endTime,
386 ) async { 496 ) async {
387 sleepCallCount += 1; 497 sleepCallCount += 1;
388 - return const <HealthKitRawSleepDataPoint>[]; 498 + final points = _sleepPoints
  499 + .where((e) => e.endTime >= startTime && e.startTime <= endTime)
  500 + .toList();
  501 + if (points.isEmpty) return const <HealthKitRawSleepDataPoint>[];
  502 + return [
  503 + HealthKitRawSleepDataPoint(
  504 + dataType: 3,
  505 + sleepDataPoints: points,
  506 + ),
  507 + ];
389 } 508 }
390 509
391 @override 510 @override
@@ -423,6 +542,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -423,6 +542,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
423 final Map<int, List<HealthRawHrvStressPoint>> _hrv = {}; 542 final Map<int, List<HealthRawHrvStressPoint>> _hrv = {};
424 final Map<int, List<HealthRawRealtimeStressPoint>> _realtime = {}; 543 final Map<int, List<HealthRawRealtimeStressPoint>> _realtime = {};
425 final Map<int, List<HealthRawDailyStressPoint>> _daily = {}; 544 final Map<int, List<HealthRawDailyStressPoint>> _daily = {};
  545 + final Map<int, List<HealthRawSleepResult>> _sleep = {};
426 final List<String> operationLog = []; 546 final List<String> operationLog = [];
427 547
428 void insertDailyStress(HealthRawDailyStressPoint point) { 548 void insertDailyStress(HealthRawDailyStressPoint point) {
@@ -442,6 +562,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -442,6 +562,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
442 Future<void> prepareDatabaseFileForShare(int userId) async {} 562 Future<void> prepareDatabaseFileForShare(int userId) async {}
443 563
444 @override 564 @override
  565 + Future<String> dbPath(int userId) async => 'memory-$userId.sqlite';
  566 +
  567 + @override
445 Future<void> upsertResult(HealthRawStressCalculationResult result) async { 568 Future<void> upsertResult(HealthRawStressCalculationResult result) async {
446 operationLog.add('upsertResult'); 569 operationLog.add('upsertResult');
447 final hrvByTime = <int, HealthRawHrvStressPoint>{ 570 final hrvByTime = <int, HealthRawHrvStressPoint>{
@@ -526,6 +649,22 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -526,6 +649,22 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
526 } 649 }
527 650
528 @override 651 @override
  652 + Future<void> upsertSleepResults({
  653 + required int userId,
  654 + required Iterable<HealthRawSleepResult> results,
  655 + }) async {
  656 + final byDate = <int, HealthRawSleepResult>{
  657 + for (final result in _sleep[userId] ?? <HealthRawSleepResult>[])
  658 + result.date: result,
  659 + };
  660 + for (final result in results) {
  661 + byDate[result.date] = result;
  662 + }
  663 + _sleep[userId] = byDate.values.toList()
  664 + ..sort((a, b) => a.date.compareTo(b.date));
  665 + }
  666 +
  667 + @override
529 Future<Set<int>> existingDailyStressDates({ 668 Future<Set<int>> existingDailyStressDates({
530 required int userId, 669 required int userId,
531 required Iterable<int> dates, 670 required Iterable<int> dates,
@@ -583,6 +722,17 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -583,6 +722,17 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
583 } 722 }
584 723
585 @override 724 @override
  725 + Future<List<HealthRawSleepResult>> querySleepResults({
  726 + required int userId,
  727 + required int startTime,
  728 + required int endTime,
  729 + }) async {
  730 + return (_sleep[userId] ?? <HealthRawSleepResult>[])
  731 + .where((e) => e.date >= startTime && e.date <= endTime)
  732 + .toList();
  733 + }
  734 +
  735 + @override
586 Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { 736 Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async {
587 return HealthRawStressDbRows( 737 return HealthRawStressDbRows(
588 hrvRows: 738 hrvRows:
@@ -593,6 +743,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -593,6 +743,8 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
593 dailyStressRows: (_daily[userId] ?? <HealthRawDailyStressPoint>[]) 743 dailyStressRows: (_daily[userId] ?? <HealthRawDailyStressPoint>[])
594 .map(_dailyStressRow) 744 .map(_dailyStressRow)
595 .toList(), 745 .toList(),
  746 + sleepRows:
  747 + (_sleep[userId] ?? <HealthRawSleepResult>[]).map(_sleepRow).toList(),
596 ); 748 );
597 } 749 }
598 750
@@ -611,6 +763,27 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -611,6 +763,27 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
611 } 763 }
612 764
613 @override 765 @override
  766 + Future<int?> latestHrvRawEndTime(int userId) async {
  767 + final points = _hrv[userId];
  768 + if (points == null || points.isEmpty) return null;
  769 + return points.last.rawEndTime;
  770 + }
  771 +
  772 + @override
  773 + Future<int?> latestRealtimeRawEndTime(int userId) async {
  774 + final points = _realtime[userId];
  775 + if (points == null || points.isEmpty) return null;
  776 + return points.last.rawEndTime;
  777 + }
  778 +
  779 + @override
  780 + Future<int?> latestSleepResultTime(int userId) async {
  781 + final results = _sleep[userId];
  782 + if (results == null || results.isEmpty) return null;
  783 + return results.last.date;
  784 + }
  785 +
  786 + @override
614 Future<void> markHrvStressUploaded({ 787 Future<void> markHrvStressUploaded({
615 required int userId, 788 required int userId,
616 required Iterable<int> rawEndTimes, 789 required Iterable<int> rawEndTimes,
@@ -681,18 +854,44 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -681,18 +854,44 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
681 } 854 }
682 855
683 @override 856 @override
684 - Future<void> markDailyStressUploaded({required int userId}) async {  
685 - _daily[userId] = (_daily[userId] ?? <HealthRawDailyStressPoint>[])  
686 - .map((point) => HealthRawDailyStressPoint(  
687 - userId: point.userId,  
688 - date: point.date,  
689 - stressValue: point.stressValue,  
690 - stressScore: point.stressScore,  
691 - state: point.state,  
692 - dataTime: point.dataTime,  
693 - uploaded: true,  
694 - ))  
695 - .toList(); 857 + Future<void> markDailyStressUploadedUntil({
  858 + required int userId,
  859 + required int date,
  860 + }) async {
  861 + _daily[userId] =
  862 + (_daily[userId] ?? <HealthRawDailyStressPoint>[]).map((point) {
  863 + if (point.date > date) return point;
  864 + return HealthRawDailyStressPoint(
  865 + userId: point.userId,
  866 + date: point.date,
  867 + stressValue: point.stressValue,
  868 + stressScore: point.stressScore,
  869 + state: point.state,
  870 + dataTime: point.dataTime,
  871 + uploaded: true,
  872 + );
  873 + }).toList();
  874 + }
  875 +
  876 + @override
  877 + Future<void> markSleepResultsUploadedUntil({
  878 + required int userId,
  879 + required int date,
  880 + }) async {
  881 + _sleep[userId] = (_sleep[userId] ?? <HealthRawSleepResult>[]).map((result) {
  882 + if (result.date > date) return result;
  883 + return HealthRawSleepResult(
  884 + userId: result.userId,
  885 + date: result.date,
  886 + startDate: result.startDate,
  887 + sleepScore: result.sleepScore,
  888 + sleepState: result.sleepState,
  889 + inBedMinutes: result.inBedMinutes,
  890 + awakMinutes: result.awakMinutes,
  891 + sleepMinutes: result.sleepMinutes,
  892 + uploaded: true,
  893 + );
  894 + }).toList();
696 } 895 }
697 896
698 @override 897 @override
@@ -713,6 +912,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -713,6 +912,12 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
713 .any((point) => !point.uploaded); 912 .any((point) => !point.uploaded);
714 } 913 }
715 914
  915 + @override
  916 + Future<bool> hasPendingSleepResultUploads({required int userId}) async {
  917 + return (_sleep[userId] ?? <HealthRawSleepResult>[])
  918 + .any((result) => !result.uploaded);
  919 + }
  920 +
716 bool _sameHrvStressPoint( 921 bool _sameHrvStressPoint(
717 HealthRawHrvStressPoint a, 922 HealthRawHrvStressPoint a,
718 HealthRawHrvStressPoint b, 923 HealthRawHrvStressPoint b,
@@ -793,4 +998,18 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore { @@ -793,4 +998,18 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
793 'uploaded': point.uploaded ? 1 : 0, 998 'uploaded': point.uploaded ? 1 : 0,
794 }; 999 };
795 } 1000 }
  1001 +
  1002 + Map<String, Object?> _sleepRow(HealthRawSleepResult result) {
  1003 + return <String, Object?>{
  1004 + 'date': result.date,
  1005 + 'user_id': result.userId,
  1006 + 'start_date': result.startDate,
  1007 + 'sleep_score': result.sleepScore,
  1008 + 'sleep_state': result.sleepState,
  1009 + 'in_bed_minutes': result.inBedMinutes,
  1010 + 'awak_minutes': result.awakMinutes,
  1011 + 'sleep_minutes': result.sleepMinutes,
  1012 + 'uploaded': result.uploaded ? 1 : 0,
  1013 + };
  1014 + }
796 } 1015 }
@@ -124,10 +124,11 @@ void main() { @@ -124,10 +124,11 @@ void main() {
124 ); 124 );
125 }); 125 });
126 126
127 - test('sleep duration is rounded to displayed Apple Health minutes', () { 127 + test('sleep duration is rounded after summing sleep stage seconds', () {
128 final day = DateTime(2026, 7, 16); 128 final day = DateTime(2026, 7, 16);
129 final sleepStart = DateTime(2026, 7, 15, 23, 56, 20); 129 final sleepStart = DateTime(2026, 7, 15, 23, 56, 20);
130 - final sleepEnd = DateTime(2026, 7, 16, 8, 20, 50); 130 + final coreEnd = DateTime(2026, 7, 16, 4, 10, 50);
  131 + final remEnd = DateTime(2026, 7, 16, 8, 20, 51);
131 final statistics = LocalHealthDataConvert.sleepStatistics( 132 final statistics = LocalHealthDataConvert.sleepStatistics(
132 dateRangeType: 3, 133 dateRangeType: 3,
133 days: [day], 134 days: [day],
@@ -136,7 +137,12 @@ void main() { @@ -136,7 +137,12 @@ void main() {
136 HealthKitRawDataPoint( 137 HealthKitRawDataPoint(
137 dataType: 3, 138 dataType: 3,
138 startTime: LocalHealthDataConvert.unixSeconds(sleepStart), 139 startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
139 - endTime: LocalHealthDataConvert.unixSeconds(sleepEnd), 140 + endTime: LocalHealthDataConvert.unixSeconds(coreEnd),
  141 + ),
  142 + HealthKitRawDataPoint(
  143 + dataType: 5,
  144 + startTime: LocalHealthDataConvert.unixSeconds(coreEnd),
  145 + endTime: LocalHealthDataConvert.unixSeconds(remEnd),
140 ), 146 ),
141 ], 147 ],
142 heartRate: const [], 148 heartRate: const [],