Commit 5863b8ae682f70a24e119a1702476fd312c5a551

Authored by 权海
1 parent 2a66a694

feat(ui):对非首次的hrv和sleep计算结果不采用筛选,直接让原生发通知,避免遗漏,原生本地自己通过时间戳来做去重

@@ -391,8 +391,12 @@ interface PlatformHostApi { @@ -391,8 +391,12 @@ interface PlatformHostApi {
391 * width.height 切图后输出的大小 391 * width.height 切图后输出的大小
392 */ 392 */
393 fun performCropImage(imageUrl: String, maxKB: Long?, width: Long?, height: Long?, callback: (Result<String?>) -> Unit) 393 fun performCropImage(imageUrl: String, maxKB: Long?, width: Long?, height: Long?, callback: (Result<String?>) -> Unit)
394 - /** 发起本地推送 */  
395 - fun sendLocalNotification(title: String, content: String, link: String, callback: (Result<Boolean>) -> Unit) 394 + /**
  395 + * 发起本地推送
  396 + * dataType: 0: hr ,1: hrv , 2: sleep
  397 + * dateTime: 数据对应的时间戳
  398 + */
  399 + fun sendLocalNotification(dataType: Long, dateTime: Long, title: String, content: String, link: String, callback: (Result<Boolean>) -> Unit)
396 400
397 companion object { 401 companion object {
398 /** The codec used by PlatformHostApi. */ 402 /** The codec used by PlatformHostApi. */
@@ -750,10 +754,12 @@ interface PlatformHostApi { @@ -750,10 +754,12 @@ interface PlatformHostApi {
750 if (api != null) { 754 if (api != null) {
751 channel.setMessageHandler { message, reply -> 755 channel.setMessageHandler { message, reply ->
752 val args = message as List<Any?> 756 val args = message as List<Any?>
753 - val titleArg = args[0] as String  
754 - val contentArg = args[1] as String  
755 - val linkArg = args[2] as String  
756 - api.sendLocalNotification(titleArg, contentArg, linkArg) { result: Result<Boolean> -> 757 + val dataTypeArg = args[0] as Long
  758 + val dateTimeArg = args[1] as Long
  759 + val titleArg = args[2] as String
  760 + val contentArg = args[3] as String
  761 + val linkArg = args[4] as String
  762 + api.sendLocalNotification(dataTypeArg, dateTimeArg, titleArg, contentArg, linkArg) { result: Result<Boolean> ->
757 val error = result.exceptionOrNull() 763 val error = result.exceptionOrNull()
758 if (error != null) { 764 if (error != null) {
759 reply.reply(PlatformApiPigeonUtils.wrapError(error)) 765 reply.reply(PlatformApiPigeonUtils.wrapError(error))
@@ -26,6 +26,8 @@ final class LocalNotificationSender { @@ -26,6 +26,8 @@ final class LocalNotificationSender {
26 } 26 }
27 27
28 func send( 28 func send(
  29 + dataType: Int64,
  30 + dateTime: Int64,
29 title: String, 31 title: String,
30 body: String, 32 body: String,
31 link: String, 33 link: String,
@@ -41,7 +43,11 @@ final class LocalNotificationSender { @@ -41,7 +43,11 @@ final class LocalNotificationSender {
41 content.title = title 43 content.title = title
42 content.body = body 44 content.body = body
43 content.sound = .default 45 content.sound = .default
44 - content.userInfo = notificationUserInfo(link: link) 46 + content.userInfo = notificationUserInfo(
  47 + link: link,
  48 + dataType: dataType,
  49 + dateTime: dateTime
  50 + )
45 51
46 let request = UNNotificationRequest( 52 let request = UNNotificationRequest(
47 identifier: "doublefeel.local.\(UUID().uuidString)", 53 identifier: "doublefeel.local.\(UUID().uuidString)",
@@ -71,11 +77,19 @@ final class LocalNotificationSender { @@ -71,11 +77,19 @@ final class LocalNotificationSender {
71 } 77 }
72 } 78 }
73 79
74 - private func notificationUserInfo(link: String) -> [String: Any] {  
75 - guard !link.isEmpty else { return [:] }  
76 - return [  
77 - "url": link,  
78 - "link": link, 80 + private func notificationUserInfo(
  81 + link: String,
  82 + dataType: Int64,
  83 + dateTime: Int64
  84 + ) -> [String: Any] {
  85 + var userInfo: [String: Any] = [
  86 + "data_type": dataType,
  87 + "date_time": dateTime,
79 ] 88 ]
  89 + if !link.isEmpty {
  90 + userInfo["url"] = link
  91 + userInfo["link"] = link
  92 + }
  93 + return userInfo
80 } 94 }
81 } 95 }
1 import Foundation 1 import Foundation
  2 +import UIKit
2 3
3 final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { 4 final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
  5 + private var localNotificationDebugFileURL: URL {
  6 + FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
  7 + .appendingPathComponent("local_notification_debug_events.json")
  8 + }
  9 +
  10 + func saveLocalNotificationRecord(notificationType: String, timestamp: Int64, completion: @escaping (Result<Bool, any Error>) -> Void) {
  11 + saveLocalNotificationDebugEvent(
  12 + event: [
  13 + "event": "nativeLocalNotificationRecord",
  14 + "notification_type": notificationType,
  15 + "timestamp": timestamp,
  16 + ],
  17 + completion: completion
  18 + )
  19 + }
  20 +
  21 + func saveLocalNotificationDebugEvent(event: [String: Any?], completion: @escaping (Result<Bool, any Error>) -> Void) {
  22 + do {
  23 + var payload = sanitizeJSONObject(event) as? [String: Any] ?? [:]
  24 + let now = Date()
  25 + payload["saved_at_unix"] = now.timeIntervalSince1970
  26 + payload["saved_at"] = ISO8601DateFormatter().string(from: now)
  27 + payload["process_name"] = ProcessInfo.processInfo.processName
  28 +
  29 + var events = try readLocalNotificationDebugEvents()
  30 + events.append(payload)
  31 + let data = try JSONSerialization.data(
  32 + withJSONObject: events,
  33 + options: [.prettyPrinted, .sortedKeys]
  34 + )
  35 + try data.write(to: localNotificationDebugFileURL, options: .atomic)
  36 + completion(.success(true))
  37 + } catch {
  38 + completion(.failure(error))
  39 + }
  40 + }
  41 +
  42 + func shareLocalNotificationDebugRecord(completion: @escaping (Result<Bool, any Error>) -> Void) {
  43 + do {
  44 + if !FileManager.default.fileExists(atPath: localNotificationDebugFileURL.path) {
  45 + try Data("[]".utf8).write(to: localNotificationDebugFileURL, options: .atomic)
  46 + }
  47 + shareItems([localNotificationDebugFileURL], completion: completion)
  48 + } catch {
  49 + completion(.failure(error))
  50 + }
  51 + }
  52 +
4 func shareUploadTaskRecord(completion: @escaping (Result<Bool, any Error>) -> Void) { 53 func shareUploadTaskRecord(completion: @escaping (Result<Bool, any Error>) -> Void) {
5 54
6 } 55 }
@@ -38,9 +87,88 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { @@ -38,9 +87,88 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
38 87
39 private let service: HealthKitService 88 private let service: HealthKitService
40 89
41 - init(service: HealthKitService = .shared) { 90 + init(service: HealthKitService = .shared) {
42 self.service = service 91 self.service = service
43 } 92 }
  93 +
  94 + private func readLocalNotificationDebugEvents() throws -> [[String: Any]] {
  95 + guard FileManager.default.fileExists(atPath: localNotificationDebugFileURL.path) else {
  96 + return []
  97 + }
  98 + let data = try Data(contentsOf: localNotificationDebugFileURL)
  99 + guard !data.isEmpty else { return [] }
  100 + let decoded = try JSONSerialization.jsonObject(with: data)
  101 + return decoded as? [[String: Any]] ?? []
  102 + }
  103 +
  104 + private func sanitizeJSONObject(_ value: Any?) -> Any {
  105 + guard let value else { return NSNull() }
  106 + if let dictionary = value as? [String: Any?] {
  107 + return dictionary.mapValues { sanitizeJSONObject($0) }
  108 + }
  109 + if let dictionary = value as? [String: Any] {
  110 + return dictionary.mapValues { sanitizeJSONObject($0) }
  111 + }
  112 + if let array = value as? [Any?] {
  113 + return array.map { sanitizeJSONObject($0) }
  114 + }
  115 + if JSONSerialization.isValidJSONObject([value]) {
  116 + return value
  117 + }
  118 + return String(describing: value)
  119 + }
  120 +
  121 + private func shareItems(_ items: [Any], completion: @escaping (Result<Bool, any Error>) -> Void) {
  122 + DispatchQueue.main.async {
  123 + guard let viewController = Self.topViewController() else {
  124 + completion(.failure(NSError(
  125 + domain: "HealthKitRawDataHostApiImpl.Share",
  126 + code: 1,
  127 + userInfo: [NSLocalizedDescriptionKey: "Unable to find a view controller for sharing."]
  128 + )))
  129 + return
  130 + }
  131 + let activityController = UIActivityViewController(
  132 + activityItems: items,
  133 + applicationActivities: nil
  134 + )
  135 + activityController.popoverPresentationController?.sourceView = viewController.view
  136 + activityController.popoverPresentationController?.sourceRect = CGRect(
  137 + x: viewController.view.bounds.midX,
  138 + y: viewController.view.bounds.midY,
  139 + width: 1,
  140 + height: 1
  141 + )
  142 + activityController.completionWithItemsHandler = { _, completed, _, error in
  143 + if let error {
  144 + completion(.failure(error))
  145 + } else {
  146 + completion(.success(completed))
  147 + }
  148 + }
  149 + viewController.present(activityController, animated: true)
  150 + }
  151 + }
  152 +
  153 + private static func topViewController(
  154 + base: UIViewController? = UIApplication.shared
  155 + .connectedScenes
  156 + .compactMap { $0 as? UIWindowScene }
  157 + .flatMap { $0.windows }
  158 + .first { $0.isKeyWindow }?
  159 + .rootViewController
  160 + ) -> UIViewController? {
  161 + if let navigationController = base as? UINavigationController {
  162 + return topViewController(base: navigationController.visibleViewController)
  163 + }
  164 + if let tabBarController = base as? UITabBarController {
  165 + return topViewController(base: tabBarController.selectedViewController)
  166 + }
  167 + if let presented = base?.presentedViewController {
  168 + return topViewController(base: presented)
  169 + }
  170 + return base
  171 + }
44 172
45 func hasHealthData(completion: @escaping (Result<Bool, Error>) -> Void) { 173 func hasHealthData(completion: @escaping (Result<Bool, Error>) -> Void) {
46 Task { 174 Task {
@@ -412,7 +412,9 @@ protocol PlatformHostApi { @@ -412,7 +412,9 @@ protocol PlatformHostApi {
412 /// width.height 切图后输出的大小 412 /// width.height 切图后输出的大小
413 func performCropImage(imageUrl: String, maxKB: Int64?, width: Int64?, height: Int64?, completion: @escaping (Result<String?, Error>) -> Void) 413 func performCropImage(imageUrl: String, maxKB: Int64?, width: Int64?, height: Int64?, completion: @escaping (Result<String?, Error>) -> Void)
414 /// 发起本地推送 414 /// 发起本地推送
415 - func sendLocalNotification(title: String, content: String, link: String, completion: @escaping (Result<Bool, Error>) -> Void) 415 + /// dataType: 0: hr ,1: hrv , 2: sleep
  416 + /// dateTime: 数据对应的时间戳
  417 + func sendLocalNotification(dataType: Int64, dateTime: Int64, title: String, content: String, link: String, completion: @escaping (Result<Bool, Error>) -> Void)
416 } 418 }
417 419
418 /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. 420 /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -739,14 +741,18 @@ class PlatformHostApiSetup { @@ -739,14 +741,18 @@ class PlatformHostApiSetup {
739 performCropImageChannel.setMessageHandler(nil) 741 performCropImageChannel.setMessageHandler(nil)
740 } 742 }
741 /// 发起本地推送 743 /// 发起本地推送
  744 + /// dataType: 0: hr ,1: hrv , 2: sleep
  745 + /// dateTime: 数据对应的时间戳
742 let sendLocalNotificationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) 746 let sendLocalNotificationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
743 if let api = api { 747 if let api = api {
744 sendLocalNotificationChannel.setMessageHandler { message, reply in 748 sendLocalNotificationChannel.setMessageHandler { message, reply in
745 let args = message as! [Any?] 749 let args = message as! [Any?]
746 - let titleArg = args[0] as! String  
747 - let contentArg = args[1] as! String  
748 - let linkArg = args[2] as! String  
749 - api.sendLocalNotification(title: titleArg, content: contentArg, link: linkArg) { result in 750 + let dataTypeArg = args[0] as! Int64
  751 + let dateTimeArg = args[1] as! Int64
  752 + let titleArg = args[2] as! String
  753 + let contentArg = args[3] as! String
  754 + let linkArg = args[4] as! String
  755 + api.sendLocalNotification(dataType: dataTypeArg, dateTime: dateTimeArg, title: titleArg, content: contentArg, link: linkArg) { result in
750 switch result { 756 switch result {
751 case .success(let res): 757 case .success(let res):
752 reply(wrapResult(res)) 758 reply(wrapResult(res))
@@ -323,10 +323,12 @@ final class PlatformHostApiImpl: PlatformHostApi { @@ -323,10 +323,12 @@ final class PlatformHostApiImpl: PlatformHostApi {
323 return userAgent 323 return userAgent
324 } 324 }
325 325
326 - func sendLocalNotification(title: String, content: String, link: String, completion: @escaping (Result<Bool, any Error>) -> Void) { 326 + func sendLocalNotification(dataType: Int64, dateTime: Int64, title: String, content: String, link: String, completion: @escaping (Result<Bool, any Error>) -> Void) {
327 Task { 327 Task {
328 do { 328 do {
329 let sent = try await LocalNotificationSender.shared.send( 329 let sent = try await LocalNotificationSender.shared.send(
  330 + dataType: dataType,
  331 + dateTime: dateTime,
330 title: title, 332 title: title,
331 body: content, 333 body: content,
332 link: link 334 link: link
@@ -405,6 +405,7 @@ class HealthRawDataCoreService { @@ -405,6 +405,7 @@ class HealthRawDataCoreService {
405 await _sendLocalNotificationsAfterCalculation( 405 await _sendLocalNotificationsAfterCalculation(
406 result: storedResult, 406 result: storedResult,
407 hasExistingHrv: latestHrvRawEndTime != null, 407 hasExistingHrv: latestHrvRawEndTime != null,
  408 + hasExistingSleep: latestSleepResultTime != null,
408 ); 409 );
409 final calculateFinishedAt = DateTime.now(); 410 final calculateFinishedAt = DateTime.now();
410 _logInfo( 411 _logInfo(
@@ -416,12 +417,14 @@ class HealthRawDataCoreService { @@ -416,12 +417,14 @@ class HealthRawDataCoreService {
416 Future<void> _sendLocalNotificationsAfterCalculation({ 417 Future<void> _sendLocalNotificationsAfterCalculation({
417 required HealthRawStressCalculationResult result, 418 required HealthRawStressCalculationResult result,
418 required bool hasExistingHrv, 419 required bool hasExistingHrv,
  420 + required bool hasExistingSleep,
419 }) async { 421 }) async {
420 await _saveLocalNotificationDebugEvent( 422 await _saveLocalNotificationDebugEvent(
421 <String, Object?>{ 423 <String, Object?>{
422 'event': 'flutterNotificationCalculationFinished', 424 'event': 'flutterNotificationCalculationFinished',
423 'user_id': result.userId, 425 'user_id': result.userId,
424 'has_existing_hrv': hasExistingHrv, 426 'has_existing_hrv': hasExistingHrv,
  427 + 'has_existing_sleep': hasExistingSleep,
425 'hrv_count': result.hrvStressPoints.length, 428 'hrv_count': result.hrvStressPoints.length,
426 'realtime_count': result.realtimeStressPoints.length, 429 'realtime_count': result.realtimeStressPoints.length,
427 'sleep_count': result.sleepResults.length, 430 'sleep_count': result.sleepResults.length,
@@ -495,6 +498,7 @@ class HealthRawDataCoreService { @@ -495,6 +498,7 @@ class HealthRawDataCoreService {
495 'decisions': _localNotificationDecisionPayloads( 498 'decisions': _localNotificationDecisionPayloads(
496 result: notificationResult, 499 result: notificationResult,
497 hasExistingHrv: hasExistingHrv, 500 hasExistingHrv: hasExistingHrv,
  501 + hasExistingSleep: hasExistingSleep,
498 realtimeWindow: realtimeWindow, 502 realtimeWindow: realtimeWindow,
499 record: record, 503 record: record,
500 ), 504 ),
@@ -503,6 +507,7 @@ class HealthRawDataCoreService { @@ -503,6 +507,7 @@ class HealthRawDataCoreService {
503 final notifications = HealthRawLocalNotificationBuilder(l10n).build( 507 final notifications = HealthRawLocalNotificationBuilder(l10n).build(
504 result: notificationResult, 508 result: notificationResult,
505 hasExistingHrv: hasExistingHrv, 509 hasExistingHrv: hasExistingHrv,
  510 + hasExistingSleep: hasExistingSleep,
506 realtimeWindow: realtimeWindow, 511 realtimeWindow: realtimeWindow,
507 record: record, 512 record: record,
508 ); 513 );
@@ -648,11 +653,15 @@ class HealthRawDataCoreService { @@ -648,11 +653,15 @@ class HealthRawDataCoreService {
648 List<Map<String, Object?>> _localNotificationDecisionPayloads({ 653 List<Map<String, Object?>> _localNotificationDecisionPayloads({
649 required HealthRawStressCalculationResult result, 654 required HealthRawStressCalculationResult result,
650 required bool hasExistingHrv, 655 required bool hasExistingHrv,
  656 + required bool hasExistingSleep,
651 required List<HealthRawRealtimeStressPoint> realtimeWindow, 657 required List<HealthRawRealtimeStressPoint> realtimeWindow,
652 required HealthRawLocalNotificationRecord record, 658 required HealthRawLocalNotificationRecord record,
653 }) { 659 }) {
654 return <Map<String, Object?>>[ 660 return <Map<String, Object?>>[
655 - _sleepNotificationDecisionPayload(result.sleepResults), 661 + _sleepNotificationDecisionPayload(
  662 + result.sleepResults,
  663 + hasExistingSleep: hasExistingSleep,
  664 + ),
656 _hrvNotificationDecisionPayload( 665 _hrvNotificationDecisionPayload(
657 result.hrvStressPoints, 666 result.hrvStressPoints,
658 hasExistingHrv: hasExistingHrv, 667 hasExistingHrv: hasExistingHrv,
@@ -667,13 +676,23 @@ class HealthRawDataCoreService { @@ -667,13 +676,23 @@ class HealthRawDataCoreService {
667 } 676 }
668 677
669 Map<String, Object?> _sleepNotificationDecisionPayload( 678 Map<String, Object?> _sleepNotificationDecisionPayload(
670 - List<HealthRawSleepResult> sleepResults,  
671 - ) { 679 + List<HealthRawSleepResult> sleepResults, {
  680 + required bool hasExistingSleep,
  681 + }) {
  682 + if (!hasExistingSleep) {
  683 + return <String, Object?>{
  684 + 'type': HealthRawLocalNotificationRecordType.sleep.name,
  685 + 'will_build': false,
  686 + 'reason': 'first_calculation_no_existing_sleep',
  687 + 'has_existing_sleep': hasExistingSleep,
  688 + };
  689 + }
672 if (sleepResults.isEmpty) { 690 if (sleepResults.isEmpty) {
673 return <String, Object?>{ 691 return <String, Object?>{
674 'type': HealthRawLocalNotificationRecordType.sleep.name, 692 'type': HealthRawLocalNotificationRecordType.sleep.name,
675 'will_build': false, 693 'will_build': false,
676 - 'reason': 'no_sleep_results', 694 + 'reason': 'no_new_sleep_results',
  695 + 'has_existing_sleep': hasExistingSleep,
677 }; 696 };
678 } 697 }
679 final sorted = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date)); 698 final sorted = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date));
@@ -684,6 +703,7 @@ class HealthRawDataCoreService { @@ -684,6 +703,7 @@ class HealthRawDataCoreService {
684 'type': HealthRawLocalNotificationRecordType.sleep.name, 703 'type': HealthRawLocalNotificationRecordType.sleep.name,
685 'will_build': false, 704 'will_build': false,
686 'reason': 'invalid_sleep_state', 705 'reason': 'invalid_sleep_state',
  706 + 'has_existing_sleep': hasExistingSleep,
687 'latest_sleep': _sleepResultPayload(latest), 707 'latest_sleep': _sleepResultPayload(latest),
688 }; 708 };
689 } 709 }
@@ -692,6 +712,7 @@ class HealthRawDataCoreService { @@ -692,6 +712,7 @@ class HealthRawDataCoreService {
692 'type': HealthRawLocalNotificationRecordType.sleep.name, 712 'type': HealthRawLocalNotificationRecordType.sleep.name,
693 'will_build': false, 713 'will_build': false,
694 'reason': 'invalid_sleep_minutes', 714 'reason': 'invalid_sleep_minutes',
  715 + 'has_existing_sleep': hasExistingSleep,
695 'latest_sleep': _sleepResultPayload(latest), 716 'latest_sleep': _sleepResultPayload(latest),
696 }; 717 };
697 } 718 }
@@ -699,6 +720,7 @@ class HealthRawDataCoreService { @@ -699,6 +720,7 @@ class HealthRawDataCoreService {
699 'type': HealthRawLocalNotificationRecordType.sleep.name, 720 'type': HealthRawLocalNotificationRecordType.sleep.name,
700 'will_build': true, 721 'will_build': true,
701 'reason': 'candidate', 722 'reason': 'candidate',
  723 + 'has_existing_sleep': hasExistingSleep,
702 'latest_sleep': _sleepResultPayload(latest), 724 'latest_sleep': _sleepResultPayload(latest),
703 }; 725 };
704 } 726 }
@@ -727,15 +749,6 @@ class HealthRawDataCoreService { @@ -727,15 +749,6 @@ class HealthRawDataCoreService {
727 final sorted = [...hrvPoints] 749 final sorted = [...hrvPoints]
728 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 750 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
729 final latest = sorted.last; 751 final latest = sorted.last;
730 - if (record.lastHrvTime == latest.rawEndTime) {  
731 - return <String, Object?>{  
732 - 'type': HealthRawLocalNotificationRecordType.hrv.name,  
733 - 'will_build': false,  
734 - 'reason': 'duplicate_hrv_record_time',  
735 - 'last_hrv_time': record.lastHrvTime,  
736 - 'latest_hrv': _hrvStressPointPayload(latest),  
737 - };  
738 - }  
739 return <String, Object?>{ 752 return <String, Object?>{
740 'type': HealthRawLocalNotificationRecordType.hrv.name, 753 'type': HealthRawLocalNotificationRecordType.hrv.name,
741 'will_build': true, 754 'will_build': true,
@@ -46,13 +46,15 @@ class HealthRawLocalNotificationBuilder { @@ -46,13 +46,15 @@ class HealthRawLocalNotificationBuilder {
46 List<HealthRawLocalNotification> build({ 46 List<HealthRawLocalNotification> build({
47 required HealthRawStressCalculationResult result, 47 required HealthRawStressCalculationResult result,
48 required bool hasExistingHrv, 48 required bool hasExistingHrv,
  49 + required bool hasExistingSleep,
49 required List<HealthRawRealtimeStressPoint> realtimeWindow, 50 required List<HealthRawRealtimeStressPoint> realtimeWindow,
50 required HealthRawLocalNotificationRecord? record, 51 required HealthRawLocalNotificationRecord? record,
51 }) { 52 }) {
52 return [ 53 return [
53 - if (_sleepNotification(result.sleepResults) case final notification?) 54 + if (_sleepNotification(result.sleepResults, hasExistingSleep)
  55 + case final notification?)
54 notification, 56 notification,
55 - if (_hrvNotification(result.hrvStressPoints, hasExistingHrv, record) 57 + if (_hrvNotification(result.hrvStressPoints, hasExistingHrv)
56 case final notification?) 58 case final notification?)
57 notification, 59 notification,
58 if (_realtimeStressNotification( 60 if (_realtimeStressNotification(
@@ -69,7 +71,9 @@ class HealthRawLocalNotificationBuilder { @@ -69,7 +71,9 @@ class HealthRawLocalNotificationBuilder {
69 71
70 HealthRawLocalNotification? _sleepNotification( 72 HealthRawLocalNotification? _sleepNotification(
71 List<HealthRawSleepResult> sleepResults, 73 List<HealthRawSleepResult> sleepResults,
  74 + bool hasExistingSleep,
72 ) { 75 ) {
  76 + if (!hasExistingSleep) return null;
73 if (sleepResults.isEmpty) return null; 77 if (sleepResults.isEmpty) return null;
74 final latest = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date)); 78 final latest = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date));
75 final sleep = latest.last; 79 final sleep = latest.last;
@@ -88,14 +92,12 @@ class HealthRawLocalNotificationBuilder { @@ -88,14 +92,12 @@ class HealthRawLocalNotificationBuilder {
88 HealthRawLocalNotification? _hrvNotification( 92 HealthRawLocalNotification? _hrvNotification(
89 List<HealthRawHrvStressPoint> hrvPoints, 93 List<HealthRawHrvStressPoint> hrvPoints,
90 bool hasExistingHrv, 94 bool hasExistingHrv,
91 - HealthRawLocalNotificationRecord? record,  
92 ) { 95 ) {
93 if (!hasExistingHrv) return null; 96 if (!hasExistingHrv) return null;
94 if (hrvPoints.isEmpty) return null; 97 if (hrvPoints.isEmpty) return null;
95 final sorted = [...hrvPoints] 98 final sorted = [...hrvPoints]
96 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime)); 99 ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
97 final latest = sorted.last; 100 final latest = sorted.last;
98 - if (record?.lastHrvTime == latest.rawEndTime) return null;  
99 101
100 return HealthRawLocalNotification( 102 return HealthRawLocalNotification(
101 title: l10n.healthLocalNotificationHrvTitle( 103 title: l10n.healthLocalNotificationHrvTitle(
@@ -253,6 +255,8 @@ class HealthRawLocalNotificationDispatcher { @@ -253,6 +255,8 @@ class HealthRawLocalNotificationDispatcher {
253 required HealthRawLocalNotification notification, 255 required HealthRawLocalNotification notification,
254 }) async { 256 }) async {
255 final sent = await _platformApi.sendLocalNotification( 257 final sent = await _platformApi.sendLocalNotification(
  258 + _notificationDataType(notification.recordType),
  259 + notification.recordTime,
256 notification.title, 260 notification.title,
257 notification.content, 261 notification.content,
258 notification.link, 262 notification.link,
@@ -263,6 +267,14 @@ class HealthRawLocalNotificationDispatcher { @@ -263,6 +267,14 @@ class HealthRawLocalNotificationDispatcher {
263 return true; 267 return true;
264 } 268 }
265 269
  270 + int _notificationDataType(HealthRawLocalNotificationRecordType recordType) {
  271 + return switch (recordType) {
  272 + HealthRawLocalNotificationRecordType.realtimeStress => 0,
  273 + HealthRawLocalNotificationRecordType.hrv => 1,
  274 + HealthRawLocalNotificationRecordType.sleep => 2,
  275 + };
  276 + }
  277 +
266 Future<HealthRawLocalNotificationRecord> readRecord(int userId) { 278 Future<HealthRawLocalNotificationRecord> readRecord(int userId) {
267 return _recordStore.read(userId); 279 return _recordStore.read(userId);
268 } 280 }
@@ -873,14 +873,16 @@ class PlatformHostApi { @@ -873,14 +873,16 @@ class PlatformHostApi {
873 } 873 }
874 874
875 /// 发起本地推送 875 /// 发起本地推送
876 - Future<bool> sendLocalNotification(String title, String content, String link) async { 876 + /// dataType: 0: hr ,1: hrv , 2: sleep
  877 + /// dateTime: 数据对应的时间戳
  878 + Future<bool> sendLocalNotification(int dataType, int dateTime, String title, String content, String link) async {
877 final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification$pigeonVar_messageChannelSuffix'; 879 final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification$pigeonVar_messageChannelSuffix';
878 final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>( 880 final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
879 pigeonVar_channelName, 881 pigeonVar_channelName,
880 pigeonChannelCodec, 882 pigeonChannelCodec,
881 binaryMessenger: pigeonVar_binaryMessenger, 883 binaryMessenger: pigeonVar_binaryMessenger,
882 ); 884 );
883 - final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[title, content, link]); 885 + final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[dataType, dateTime, title, content, link]);
884 final List<Object?>? pigeonVar_replyList = 886 final List<Object?>? pigeonVar_replyList =
885 await pigeonVar_sendFuture as List<Object?>?; 887 await pigeonVar_sendFuture as List<Object?>?;
886 if (pigeonVar_replyList == null) { 888 if (pigeonVar_replyList == null) {
@@ -188,6 +188,9 @@ abstract class PlatformHostApi { @@ -188,6 +188,9 @@ abstract class PlatformHostApi {
188 String imageUrl, int? maxKB, int? width, int? height); 188 String imageUrl, int? maxKB, int? width, int? height);
189 189
190 /// 发起本地推送 190 /// 发起本地推送
  191 + /// dataType: 0: hr ,1: hrv , 2: sleep
  192 + /// dateTime: 数据对应的时间戳
191 @async 193 @async
192 - bool sendLocalNotification(String title, String content, String link); 194 + bool sendLocalNotification(
  195 + int dataType, int dateTime, String title, String content, String link);
193 } 196 }
@@ -2,6 +2,7 @@ import 'dart:async'; @@ -2,6 +2,7 @@ import 'dart:async';
2 2
3 import 'package:doublefeel_flutter/core/result/app_result.dart'; 3 import 'package:doublefeel_flutter/core/result/app_result.dart';
4 import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart'; 4 import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
  5 +import 'package:doublefeel_flutter/core/services/health_raw_local_notification.dart';
5 import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.dart'; 6 import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.dart';
6 import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart'; 7 import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart';
7 import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; 8 import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
@@ -527,6 +528,201 @@ void main() { @@ -527,6 +528,201 @@ void main() {
527 }); 528 });
528 529
529 test( 530 test(
  531 + 'startCoreCaculate sends hrv and sleep notifications for new non-first results',
  532 + () async {
  533 + final now = DateTime.now();
  534 + final day = DateTime(now.year, now.month, now.day);
  535 + final base = LocalHealthDataConvert.unixSeconds(day);
  536 + final api = _FakeHealthKitRawDataHostApi();
  537 + api.setPoints(HealthDataUploadType.hrv.type, [
  538 + _point(base + 120, 35),
  539 + _point(base + 420, 18),
  540 + _point(base + 720, 40),
  541 + ]);
  542 + api.setPoints(HealthDataUploadType.heartRate.type, [
  543 + _point(base + 60, 68),
  544 + _point(base + 120, 70),
  545 + _point(base + 180, 72),
  546 + _point(base + 420, 90),
  547 + _point(base + 480, 88),
  548 + _point(base + 720, 76),
  549 + ]);
  550 + api.setPoints(HealthDataUploadType.restingHeartRate.type, [
  551 + _point(base + 30, 60),
  552 + _point(base + 390, 62),
  553 + _point(base + 710, 61),
  554 + ]);
  555 + final sleepStart = day.subtract(const Duration(hours: 1));
  556 + final sleepEnd = day.add(const Duration(hours: 7));
  557 + api.setSleepPoints([
  558 + HealthKitRawDataPoint(
  559 + dataType: 3,
  560 + startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
  561 + endTime: LocalHealthDataConvert.unixSeconds(sleepEnd),
  562 + ),
  563 + ]);
  564 + final store = _MemoryHealthRawStressLocalStore();
  565 + await store.upsertResult(
  566 + HealthRawStressCalculationResult(
  567 + userId: 42,
  568 + hrvStressPoints: [_hrvStressPoint(base + 10)],
  569 + realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
  570 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
  571 + ),
  572 + );
  573 + await store.upsertSleepResults(
  574 + userId: 42,
  575 + results: [
  576 + HealthRawSleepResult(
  577 + userId: 42,
  578 + date: base - Duration.secondsPerDay,
  579 + startDate: base - Duration.secondsPerDay - 3600,
  580 + sleepScore: 80,
  581 + sleepState: 2,
  582 + inBedMinutes: 480,
  583 + awakMinutes: 10,
  584 + sleepMinutes: 470,
  585 + ),
  586 + ],
  587 + );
  588 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher();
  589 + final service = HealthRawDataCoreService(
  590 + healthApi: _FakeHealthKitHostApi(),
  591 + rawDataApi: api,
  592 + localStore: store,
  593 + userIdProvider: () => 42,
  594 + uploadResultsAfterCalculation: false,
  595 + localNotificationDispatcher: notificationDispatcher,
  596 + );
  597 +
  598 + await service.startCoreCaculate(
  599 + endTime: base + 800,
  600 + readChunkDays: 1,
  601 + );
  602 +
  603 + expect(
  604 + notificationDispatcher.sentNotifications.where(
  605 + (e) => e.recordType == HealthRawLocalNotificationRecordType.hrv),
  606 + hasLength(1),
  607 + );
  608 + expect(
  609 + notificationDispatcher.sentNotifications.where(
  610 + (e) => e.recordType == HealthRawLocalNotificationRecordType.sleep,
  611 + ),
  612 + hasLength(1),
  613 + );
  614 + });
  615 +
  616 + test(
  617 + 'startCoreCaculate skips hrv and sleep notifications without new results',
  618 + () async {
  619 + const base = 1800000000;
  620 + final api = _FakeHealthKitRawDataHostApi();
  621 + final store = _MemoryHealthRawStressLocalStore();
  622 + await store.upsertResult(
  623 + HealthRawStressCalculationResult(
  624 + userId: 42,
  625 + hrvStressPoints: [_hrvStressPoint(base + 10)],
  626 + realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
  627 + dailyStressPoints: const <HealthRawDailyStressPoint>[],
  628 + ),
  629 + );
  630 + await store.upsertSleepResults(
  631 + userId: 42,
  632 + results: const [
  633 + HealthRawSleepResult(
  634 + userId: 42,
  635 + date: base + 20,
  636 + startDate: base,
  637 + sleepScore: 80,
  638 + sleepState: 2,
  639 + inBedMinutes: 480,
  640 + awakMinutes: 10,
  641 + sleepMinutes: 470,
  642 + ),
  643 + ],
  644 + );
  645 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher();
  646 + final service = HealthRawDataCoreService(
  647 + healthApi: _FakeHealthKitHostApi(),
  648 + rawDataApi: api,
  649 + localStore: store,
  650 + userIdProvider: () => 42,
  651 + uploadResultsAfterCalculation: false,
  652 + localNotificationDispatcher: notificationDispatcher,
  653 + );
  654 +
  655 + await service.startCoreCaculate(
  656 + endTime: base + 40,
  657 + readChunkDays: 1,
  658 + );
  659 +
  660 + expect(
  661 + notificationDispatcher.sentNotifications.where(
  662 + (e) =>
  663 + e.recordType == HealthRawLocalNotificationRecordType.hrv ||
  664 + e.recordType == HealthRawLocalNotificationRecordType.sleep,
  665 + ),
  666 + isEmpty,
  667 + );
  668 + });
  669 +
  670 + test(
  671 + 'startCoreCaculate skips hrv and sleep notifications on first calculation',
  672 + () async {
  673 + final now = DateTime.now();
  674 + final day = DateTime(now.year, now.month, now.day);
  675 + final base = LocalHealthDataConvert.unixSeconds(day);
  676 + final api = _FakeHealthKitRawDataHostApi();
  677 + api.setPoints(HealthDataUploadType.hrv.type, [
  678 + _point(base + 120, 35),
  679 + _point(base + 420, 18),
  680 + ]);
  681 + api.setPoints(HealthDataUploadType.heartRate.type, [
  682 + _point(base + 60, 68),
  683 + _point(base + 120, 70),
  684 + _point(base + 180, 72),
  685 + _point(base + 420, 90),
  686 + ]);
  687 + api.setPoints(HealthDataUploadType.restingHeartRate.type, [
  688 + _point(base + 30, 60),
  689 + _point(base + 390, 62),
  690 + ]);
  691 + final sleepStart = day.subtract(const Duration(hours: 1));
  692 + final sleepEnd = day.add(const Duration(hours: 7));
  693 + api.setSleepPoints([
  694 + HealthKitRawDataPoint(
  695 + dataType: 3,
  696 + startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
  697 + endTime: LocalHealthDataConvert.unixSeconds(sleepEnd),
  698 + ),
  699 + ]);
  700 + final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher();
  701 + final service = HealthRawDataCoreService(
  702 + healthApi: _FakeHealthKitHostApi(),
  703 + rawDataApi: api,
  704 + localStore: _MemoryHealthRawStressLocalStore(),
  705 + userIdProvider: () => 42,
  706 + uploadResultsAfterCalculation: false,
  707 + localNotificationDispatcher: notificationDispatcher,
  708 + );
  709 +
  710 + await service.startCoreCaculate(
  711 + endTime: base + 800,
  712 + readChunkDays: 1,
  713 + );
  714 +
  715 + expect(
  716 + notificationDispatcher.sentNotifications.where(
  717 + (e) =>
  718 + e.recordType == HealthRawLocalNotificationRecordType.hrv ||
  719 + e.recordType == HealthRawLocalNotificationRecordType.sleep,
  720 + ),
  721 + isEmpty,
  722 + );
  723 + });
  724 +
  725 + test(
530 'startCoreCaculate uploads pending rows even when no new data calculates', 726 'startCoreCaculate uploads pending rows even when no new data calculates',
531 () async { 727 () async {
532 const base = 1800000000; 728 const base = 1800000000;
@@ -854,6 +1050,35 @@ class _FakeHealthKitHostApi extends HealthKitHostApi { @@ -854,6 +1050,35 @@ class _FakeHealthKitHostApi extends HealthKitHostApi {
854 } 1050 }
855 } 1051 }
856 1052
  1053 +class _FakeHealthRawLocalNotificationDispatcher
  1054 + extends HealthRawLocalNotificationDispatcher {
  1055 + final sentNotifications = <HealthRawLocalNotification>[];
  1056 + var record = const HealthRawLocalNotificationRecord();
  1057 +
  1058 + @override
  1059 + Future<HealthRawLocalNotificationRecord> readRecord(int userId) async {
  1060 + return record;
  1061 + }
  1062 +
  1063 + @override
  1064 + Future<bool> sendOne({
  1065 + required int userId,
  1066 + required HealthRawLocalNotification notification,
  1067 + }) async {
  1068 + sentNotifications.add(notification);
  1069 + record = record.withNotification(notification);
  1070 + return true;
  1071 + }
  1072 +
  1073 + @override
  1074 + Future<void> recordRealtimeStressTime({
  1075 + required int userId,
  1076 + required int recordTime,
  1077 + }) async {
  1078 + record = record.copyWith(lastRealtimeStressTime: recordTime);
  1079 + }
  1080 +}
  1081 +
857 class _ReadCall { 1082 class _ReadCall {
858 const _ReadCall(this.dataType, this.startTime, this.endTime); 1083 const _ReadCall(this.dataType, this.startTime, this.endTime);
859 1084
@@ -29,6 +29,7 @@ void main() { @@ -29,6 +29,7 @@ void main() {
29 ], 29 ],
30 ), 30 ),
31 hasExistingHrv: false, 31 hasExistingHrv: false,
  32 + hasExistingSleep: true,
32 realtimeWindow: const [], 33 realtimeWindow: const [],
33 record: const HealthRawLocalNotificationRecord(), 34 record: const HealthRawLocalNotificationRecord(),
34 ); 35 );
@@ -39,6 +40,54 @@ void main() { @@ -39,6 +40,54 @@ void main() {
39 expect(notifications.single.link, healthRawTodayLink); 40 expect(notifications.single.link, healthRawTodayLink);
40 }); 41 });
41 42
  43 + test('skips sleep notification on first calculation without existing sleep',
  44 + () {
  45 + final notifications = builder.build(
  46 + result: HealthRawStressCalculationResult(
  47 + userId: 1,
  48 + hrvStressPoints: const [],
  49 + realtimeStressPoints: const [],
  50 + dailyStressPoints: const [],
  51 + sleepResults: const [
  52 + HealthRawSleepResult(
  53 + userId: 1,
  54 + date: 1000,
  55 + startDate: 100,
  56 + sleepScore: 80,
  57 + sleepState: 2,
  58 + inBedMinutes: 430,
  59 + awakMinutes: 25,
  60 + sleepMinutes: 405,
  61 + ),
  62 + ],
  63 + ),
  64 + hasExistingHrv: false,
  65 + hasExistingSleep: false,
  66 + realtimeWindow: const [],
  67 + record: const HealthRawLocalNotificationRecord(),
  68 + );
  69 +
  70 + expect(notifications, isEmpty);
  71 + });
  72 +
  73 + test('skips sleep notification without new sleep result', () {
  74 + final notifications = builder.build(
  75 + result: const HealthRawStressCalculationResult(
  76 + userId: 1,
  77 + hrvStressPoints: [],
  78 + realtimeStressPoints: [],
  79 + dailyStressPoints: [],
  80 + sleepResults: [],
  81 + ),
  82 + hasExistingHrv: false,
  83 + hasExistingSleep: true,
  84 + realtimeWindow: const [],
  85 + record: const HealthRawLocalNotificationRecord(),
  86 + );
  87 +
  88 + expect(notifications, isEmpty);
  89 + });
  90 +
42 test('builds hrv notification when new hrv is calculated after first run', 91 test('builds hrv notification when new hrv is calculated after first run',
43 () { 92 () {
44 final latestTime = _seconds(DateTime(2026, 1, 1, 10)); 93 final latestTime = _seconds(DateTime(2026, 1, 1, 10));
@@ -52,6 +101,7 @@ void main() { @@ -52,6 +101,7 @@ void main() {
52 dailyStressPoints: const [], 101 dailyStressPoints: const [],
53 ), 102 ),
54 hasExistingHrv: true, 103 hasExistingHrv: true,
  104 + hasExistingSleep: false,
55 realtimeWindow: const [], 105 realtimeWindow: const [],
56 record: const HealthRawLocalNotificationRecord(), 106 record: const HealthRawLocalNotificationRecord(),
57 ); 107 );
@@ -73,6 +123,7 @@ void main() { @@ -73,6 +123,7 @@ void main() {
73 dailyStressPoints: const [], 123 dailyStressPoints: const [],
74 ), 124 ),
75 hasExistingHrv: false, 125 hasExistingHrv: false,
  126 + hasExistingSleep: false,
76 realtimeWindow: const [], 127 realtimeWindow: const [],
77 record: const HealthRawLocalNotificationRecord(), 128 record: const HealthRawLocalNotificationRecord(),
78 ); 129 );
@@ -80,6 +131,27 @@ void main() { @@ -80,6 +131,27 @@ void main() {
80 expect(notifications, isEmpty); 131 expect(notifications, isEmpty);
81 }); 132 });
82 133
  134 + test('builds hrv notification even when record has same hrv time', () {
  135 + final latestTime = _seconds(DateTime(2026, 1, 1, 10));
  136 + final notifications = builder.build(
  137 + result: HealthRawStressCalculationResult(
  138 + userId: 1,
  139 + hrvStressPoints: [
  140 + _hrvPoint(latestTime),
  141 + ],
  142 + realtimeStressPoints: const [],
  143 + dailyStressPoints: const [],
  144 + ),
  145 + hasExistingHrv: true,
  146 + hasExistingSleep: false,
  147 + realtimeWindow: const [],
  148 + record: HealthRawLocalNotificationRecord(lastHrvTime: latestTime),
  149 + );
  150 +
  151 + expect(notifications, hasLength(1));
  152 + expect(notifications.single.recordTime, latestTime);
  153 + });
  154 +
83 test('builds realtime stress notification from latest 60 minute window', () { 155 test('builds realtime stress notification from latest 60 minute window', () {
84 final base = _seconds(DateTime(2026, 1, 1, 9)); 156 final base = _seconds(DateTime(2026, 1, 1, 9));
85 final notifications = builder.build( 157 final notifications = builder.build(
@@ -90,6 +162,7 @@ void main() { @@ -90,6 +162,7 @@ void main() {
90 dailyStressPoints: const [], 162 dailyStressPoints: const [],
91 ), 163 ),
92 hasExistingHrv: false, 164 hasExistingHrv: false,
  165 + hasExistingSleep: false,
93 realtimeWindow: [ 166 realtimeWindow: [
94 for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70), 167 for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
95 ], 168 ],
@@ -116,6 +189,7 @@ void main() { @@ -116,6 +189,7 @@ void main() {
116 dailyStressPoints: const [], 189 dailyStressPoints: const [],
117 ), 190 ),
118 hasExistingHrv: false, 191 hasExistingHrv: false,
  192 + hasExistingSleep: false,
119 realtimeWindow: [ 193 realtimeWindow: [
120 for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70), 194 for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70),
121 _realtimePoint(base + 9 * 300, 70, isSleepLikely: true), 195 _realtimePoint(base + 9 * 300, 70, isSleepLikely: true),
@@ -138,6 +212,7 @@ void main() { @@ -138,6 +212,7 @@ void main() {
138 dailyStressPoints: const [], 212 dailyStressPoints: const [],
139 ), 213 ),
140 hasExistingHrv: false, 214 hasExistingHrv: false,
  215 + hasExistingSleep: false,
141 realtimeWindow: [ 216 realtimeWindow: [
142 for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70), 217 for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70),
143 _realtimePoint(base + 9 * 300, 70, isWorkout: true), 218 _realtimePoint(base + 9 * 300, 70, isWorkout: true),
@@ -160,6 +235,7 @@ void main() { @@ -160,6 +235,7 @@ void main() {
160 dailyStressPoints: const [], 235 dailyStressPoints: const [],
161 ), 236 ),
162 hasExistingHrv: false, 237 hasExistingHrv: false,
  238 + hasExistingSleep: false,
163 realtimeWindow: [ 239 realtimeWindow: [
164 for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70), 240 for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70),
165 _realtimePoint(base + 9 * 300, 70, isWorkoutRecovery: true), 241 _realtimePoint(base + 9 * 300, 70, isWorkoutRecovery: true),