Showing
11 changed files
with
1342 additions
and
363 deletions
| @@ -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 [], |
-
Please register or login to post a comment