Commit 5863b8ae682f70a24e119a1702476fd312c5a551
1 parent
2a66a694
feat(ui):对非首次的hrv和sleep计算结果不采用筛选,直接让原生发通知,避免遗漏,原生本地自己通过时间戳来做去重
Showing
11 changed files
with
526 additions
and
39 deletions
| @@ -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), |
-
Please register or login to post a comment