Commit 5863b8ae682f70a24e119a1702476fd312c5a551

Authored by 权海
1 parent 2a66a694

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

... ... @@ -391,8 +391,12 @@ interface PlatformHostApi {
* width.height 切图后输出的大小
*/
fun performCropImage(imageUrl: String, maxKB: Long?, width: Long?, height: Long?, callback: (Result<String?>) -> Unit)
/** 发起本地推送 */
fun sendLocalNotification(title: String, content: String, link: String, callback: (Result<Boolean>) -> Unit)
/**
* 发起本地推送
* dataType: 0: hr ,1: hrv , 2: sleep
* dateTime: 数据对应的时间戳
*/
fun sendLocalNotification(dataType: Long, dateTime: Long, title: String, content: String, link: String, callback: (Result<Boolean>) -> Unit)
companion object {
/** The codec used by PlatformHostApi. */
... ... @@ -750,10 +754,12 @@ interface PlatformHostApi {
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val titleArg = args[0] as String
val contentArg = args[1] as String
val linkArg = args[2] as String
api.sendLocalNotification(titleArg, contentArg, linkArg) { result: Result<Boolean> ->
val dataTypeArg = args[0] as Long
val dateTimeArg = args[1] as Long
val titleArg = args[2] as String
val contentArg = args[3] as String
val linkArg = args[4] as String
api.sendLocalNotification(dataTypeArg, dateTimeArg, titleArg, contentArg, linkArg) { result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PlatformApiPigeonUtils.wrapError(error))
... ...
... ... @@ -26,6 +26,8 @@ final class LocalNotificationSender {
}
func send(
dataType: Int64,
dateTime: Int64,
title: String,
body: String,
link: String,
... ... @@ -41,7 +43,11 @@ final class LocalNotificationSender {
content.title = title
content.body = body
content.sound = .default
content.userInfo = notificationUserInfo(link: link)
content.userInfo = notificationUserInfo(
link: link,
dataType: dataType,
dateTime: dateTime
)
let request = UNNotificationRequest(
identifier: "doublefeel.local.\(UUID().uuidString)",
... ... @@ -71,11 +77,19 @@ final class LocalNotificationSender {
}
}
private func notificationUserInfo(link: String) -> [String: Any] {
guard !link.isEmpty else { return [:] }
return [
"url": link,
"link": link,
private func notificationUserInfo(
link: String,
dataType: Int64,
dateTime: Int64
) -> [String: Any] {
var userInfo: [String: Any] = [
"data_type": dataType,
"date_time": dateTime,
]
if !link.isEmpty {
userInfo["url"] = link
userInfo["link"] = link
}
return userInfo
}
}
... ...
import Foundation
import UIKit
final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
private var localNotificationDebugFileURL: URL {
FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
.appendingPathComponent("local_notification_debug_events.json")
}
func saveLocalNotificationRecord(notificationType: String, timestamp: Int64, completion: @escaping (Result<Bool, any Error>) -> Void) {
saveLocalNotificationDebugEvent(
event: [
"event": "nativeLocalNotificationRecord",
"notification_type": notificationType,
"timestamp": timestamp,
],
completion: completion
)
}
func saveLocalNotificationDebugEvent(event: [String: Any?], completion: @escaping (Result<Bool, any Error>) -> Void) {
do {
var payload = sanitizeJSONObject(event) as? [String: Any] ?? [:]
let now = Date()
payload["saved_at_unix"] = now.timeIntervalSince1970
payload["saved_at"] = ISO8601DateFormatter().string(from: now)
payload["process_name"] = ProcessInfo.processInfo.processName
var events = try readLocalNotificationDebugEvents()
events.append(payload)
let data = try JSONSerialization.data(
withJSONObject: events,
options: [.prettyPrinted, .sortedKeys]
)
try data.write(to: localNotificationDebugFileURL, options: .atomic)
completion(.success(true))
} catch {
completion(.failure(error))
}
}
func shareLocalNotificationDebugRecord(completion: @escaping (Result<Bool, any Error>) -> Void) {
do {
if !FileManager.default.fileExists(atPath: localNotificationDebugFileURL.path) {
try Data("[]".utf8).write(to: localNotificationDebugFileURL, options: .atomic)
}
shareItems([localNotificationDebugFileURL], completion: completion)
} catch {
completion(.failure(error))
}
}
func shareUploadTaskRecord(completion: @escaping (Result<Bool, any Error>) -> Void) {
}
... ... @@ -38,9 +87,88 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
private let service: HealthKitService
init(service: HealthKitService = .shared) {
init(service: HealthKitService = .shared) {
self.service = service
}
private func readLocalNotificationDebugEvents() throws -> [[String: Any]] {
guard FileManager.default.fileExists(atPath: localNotificationDebugFileURL.path) else {
return []
}
let data = try Data(contentsOf: localNotificationDebugFileURL)
guard !data.isEmpty else { return [] }
let decoded = try JSONSerialization.jsonObject(with: data)
return decoded as? [[String: Any]] ?? []
}
private func sanitizeJSONObject(_ value: Any?) -> Any {
guard let value else { return NSNull() }
if let dictionary = value as? [String: Any?] {
return dictionary.mapValues { sanitizeJSONObject($0) }
}
if let dictionary = value as? [String: Any] {
return dictionary.mapValues { sanitizeJSONObject($0) }
}
if let array = value as? [Any?] {
return array.map { sanitizeJSONObject($0) }
}
if JSONSerialization.isValidJSONObject([value]) {
return value
}
return String(describing: value)
}
private func shareItems(_ items: [Any], completion: @escaping (Result<Bool, any Error>) -> Void) {
DispatchQueue.main.async {
guard let viewController = Self.topViewController() else {
completion(.failure(NSError(
domain: "HealthKitRawDataHostApiImpl.Share",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Unable to find a view controller for sharing."]
)))
return
}
let activityController = UIActivityViewController(
activityItems: items,
applicationActivities: nil
)
activityController.popoverPresentationController?.sourceView = viewController.view
activityController.popoverPresentationController?.sourceRect = CGRect(
x: viewController.view.bounds.midX,
y: viewController.view.bounds.midY,
width: 1,
height: 1
)
activityController.completionWithItemsHandler = { _, completed, _, error in
if let error {
completion(.failure(error))
} else {
completion(.success(completed))
}
}
viewController.present(activityController, animated: true)
}
}
private static func topViewController(
base: UIViewController? = UIApplication.shared
.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }?
.rootViewController
) -> UIViewController? {
if let navigationController = base as? UINavigationController {
return topViewController(base: navigationController.visibleViewController)
}
if let tabBarController = base as? UITabBarController {
return topViewController(base: tabBarController.selectedViewController)
}
if let presented = base?.presentedViewController {
return topViewController(base: presented)
}
return base
}
func hasHealthData(completion: @escaping (Result<Bool, Error>) -> Void) {
Task {
... ...
... ... @@ -412,7 +412,9 @@ protocol PlatformHostApi {
/// width.height 切图后输出的大小
func performCropImage(imageUrl: String, maxKB: Int64?, width: Int64?, height: Int64?, completion: @escaping (Result<String?, Error>) -> Void)
/// 发起本地推送
func sendLocalNotification(title: String, content: String, link: String, completion: @escaping (Result<Bool, Error>) -> Void)
/// dataType: 0: hr ,1: hrv , 2: sleep
/// dateTime: 数据对应的时间戳
func sendLocalNotification(dataType: Int64, dateTime: Int64, title: String, content: String, link: String, completion: @escaping (Result<Bool, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
... ... @@ -739,14 +741,18 @@ class PlatformHostApiSetup {
performCropImageChannel.setMessageHandler(nil)
}
/// 发起本地推送
/// dataType: 0: hr ,1: hrv , 2: sleep
/// dateTime: 数据对应的时间戳
let sendLocalNotificationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
sendLocalNotificationChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let titleArg = args[0] as! String
let contentArg = args[1] as! String
let linkArg = args[2] as! String
api.sendLocalNotification(title: titleArg, content: contentArg, link: linkArg) { result in
let dataTypeArg = args[0] as! Int64
let dateTimeArg = args[1] as! Int64
let titleArg = args[2] as! String
let contentArg = args[3] as! String
let linkArg = args[4] as! String
api.sendLocalNotification(dataType: dataTypeArg, dateTime: dateTimeArg, title: titleArg, content: contentArg, link: linkArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
... ...
... ... @@ -323,10 +323,12 @@ final class PlatformHostApiImpl: PlatformHostApi {
return userAgent
}
func sendLocalNotification(title: String, content: String, link: String, completion: @escaping (Result<Bool, any Error>) -> Void) {
func sendLocalNotification(dataType: Int64, dateTime: Int64, title: String, content: String, link: String, completion: @escaping (Result<Bool, any Error>) -> Void) {
Task {
do {
let sent = try await LocalNotificationSender.shared.send(
dataType: dataType,
dateTime: dateTime,
title: title,
body: content,
link: link
... ...
... ... @@ -405,6 +405,7 @@ class HealthRawDataCoreService {
await _sendLocalNotificationsAfterCalculation(
result: storedResult,
hasExistingHrv: latestHrvRawEndTime != null,
hasExistingSleep: latestSleepResultTime != null,
);
final calculateFinishedAt = DateTime.now();
_logInfo(
... ... @@ -416,12 +417,14 @@ class HealthRawDataCoreService {
Future<void> _sendLocalNotificationsAfterCalculation({
required HealthRawStressCalculationResult result,
required bool hasExistingHrv,
required bool hasExistingSleep,
}) async {
await _saveLocalNotificationDebugEvent(
<String, Object?>{
'event': 'flutterNotificationCalculationFinished',
'user_id': result.userId,
'has_existing_hrv': hasExistingHrv,
'has_existing_sleep': hasExistingSleep,
'hrv_count': result.hrvStressPoints.length,
'realtime_count': result.realtimeStressPoints.length,
'sleep_count': result.sleepResults.length,
... ... @@ -495,6 +498,7 @@ class HealthRawDataCoreService {
'decisions': _localNotificationDecisionPayloads(
result: notificationResult,
hasExistingHrv: hasExistingHrv,
hasExistingSleep: hasExistingSleep,
realtimeWindow: realtimeWindow,
record: record,
),
... ... @@ -503,6 +507,7 @@ class HealthRawDataCoreService {
final notifications = HealthRawLocalNotificationBuilder(l10n).build(
result: notificationResult,
hasExistingHrv: hasExistingHrv,
hasExistingSleep: hasExistingSleep,
realtimeWindow: realtimeWindow,
record: record,
);
... ... @@ -648,11 +653,15 @@ class HealthRawDataCoreService {
List<Map<String, Object?>> _localNotificationDecisionPayloads({
required HealthRawStressCalculationResult result,
required bool hasExistingHrv,
required bool hasExistingSleep,
required List<HealthRawRealtimeStressPoint> realtimeWindow,
required HealthRawLocalNotificationRecord record,
}) {
return <Map<String, Object?>>[
_sleepNotificationDecisionPayload(result.sleepResults),
_sleepNotificationDecisionPayload(
result.sleepResults,
hasExistingSleep: hasExistingSleep,
),
_hrvNotificationDecisionPayload(
result.hrvStressPoints,
hasExistingHrv: hasExistingHrv,
... ... @@ -667,13 +676,23 @@ class HealthRawDataCoreService {
}
Map<String, Object?> _sleepNotificationDecisionPayload(
List<HealthRawSleepResult> sleepResults,
) {
List<HealthRawSleepResult> sleepResults, {
required bool hasExistingSleep,
}) {
if (!hasExistingSleep) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.sleep.name,
'will_build': false,
'reason': 'first_calculation_no_existing_sleep',
'has_existing_sleep': hasExistingSleep,
};
}
if (sleepResults.isEmpty) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.sleep.name,
'will_build': false,
'reason': 'no_sleep_results',
'reason': 'no_new_sleep_results',
'has_existing_sleep': hasExistingSleep,
};
}
final sorted = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date));
... ... @@ -684,6 +703,7 @@ class HealthRawDataCoreService {
'type': HealthRawLocalNotificationRecordType.sleep.name,
'will_build': false,
'reason': 'invalid_sleep_state',
'has_existing_sleep': hasExistingSleep,
'latest_sleep': _sleepResultPayload(latest),
};
}
... ... @@ -692,6 +712,7 @@ class HealthRawDataCoreService {
'type': HealthRawLocalNotificationRecordType.sleep.name,
'will_build': false,
'reason': 'invalid_sleep_minutes',
'has_existing_sleep': hasExistingSleep,
'latest_sleep': _sleepResultPayload(latest),
};
}
... ... @@ -699,6 +720,7 @@ class HealthRawDataCoreService {
'type': HealthRawLocalNotificationRecordType.sleep.name,
'will_build': true,
'reason': 'candidate',
'has_existing_sleep': hasExistingSleep,
'latest_sleep': _sleepResultPayload(latest),
};
}
... ... @@ -727,15 +749,6 @@ class HealthRawDataCoreService {
final sorted = [...hrvPoints]
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
final latest = sorted.last;
if (record.lastHrvTime == latest.rawEndTime) {
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.hrv.name,
'will_build': false,
'reason': 'duplicate_hrv_record_time',
'last_hrv_time': record.lastHrvTime,
'latest_hrv': _hrvStressPointPayload(latest),
};
}
return <String, Object?>{
'type': HealthRawLocalNotificationRecordType.hrv.name,
'will_build': true,
... ...
... ... @@ -46,13 +46,15 @@ class HealthRawLocalNotificationBuilder {
List<HealthRawLocalNotification> build({
required HealthRawStressCalculationResult result,
required bool hasExistingHrv,
required bool hasExistingSleep,
required List<HealthRawRealtimeStressPoint> realtimeWindow,
required HealthRawLocalNotificationRecord? record,
}) {
return [
if (_sleepNotification(result.sleepResults) case final notification?)
if (_sleepNotification(result.sleepResults, hasExistingSleep)
case final notification?)
notification,
if (_hrvNotification(result.hrvStressPoints, hasExistingHrv, record)
if (_hrvNotification(result.hrvStressPoints, hasExistingHrv)
case final notification?)
notification,
if (_realtimeStressNotification(
... ... @@ -69,7 +71,9 @@ class HealthRawLocalNotificationBuilder {
HealthRawLocalNotification? _sleepNotification(
List<HealthRawSleepResult> sleepResults,
bool hasExistingSleep,
) {
if (!hasExistingSleep) return null;
if (sleepResults.isEmpty) return null;
final latest = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date));
final sleep = latest.last;
... ... @@ -88,14 +92,12 @@ class HealthRawLocalNotificationBuilder {
HealthRawLocalNotification? _hrvNotification(
List<HealthRawHrvStressPoint> hrvPoints,
bool hasExistingHrv,
HealthRawLocalNotificationRecord? record,
) {
if (!hasExistingHrv) return null;
if (hrvPoints.isEmpty) return null;
final sorted = [...hrvPoints]
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
final latest = sorted.last;
if (record?.lastHrvTime == latest.rawEndTime) return null;
return HealthRawLocalNotification(
title: l10n.healthLocalNotificationHrvTitle(
... ... @@ -253,6 +255,8 @@ class HealthRawLocalNotificationDispatcher {
required HealthRawLocalNotification notification,
}) async {
final sent = await _platformApi.sendLocalNotification(
_notificationDataType(notification.recordType),
notification.recordTime,
notification.title,
notification.content,
notification.link,
... ... @@ -263,6 +267,14 @@ class HealthRawLocalNotificationDispatcher {
return true;
}
int _notificationDataType(HealthRawLocalNotificationRecordType recordType) {
return switch (recordType) {
HealthRawLocalNotificationRecordType.realtimeStress => 0,
HealthRawLocalNotificationRecordType.hrv => 1,
HealthRawLocalNotificationRecordType.sleep => 2,
};
}
Future<HealthRawLocalNotificationRecord> readRecord(int userId) {
return _recordStore.read(userId);
}
... ...
... ... @@ -873,14 +873,16 @@ class PlatformHostApi {
}
/// 发起本地推送
Future<bool> sendLocalNotification(String title, String content, String link) async {
/// dataType: 0: hr ,1: hrv , 2: sleep
/// dateTime: 数据对应的时间戳
Future<bool> sendLocalNotification(int dataType, int dateTime, String title, String content, String link) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[title, content, link]);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[dataType, dateTime, title, content, link]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
... ...
... ... @@ -188,6 +188,9 @@ abstract class PlatformHostApi {
String imageUrl, int? maxKB, int? width, int? height);
/// 发起本地推送
/// dataType: 0: hr ,1: hrv , 2: sleep
/// dateTime: 数据对应的时间戳
@async
bool sendLocalNotification(String title, String content, String link);
bool sendLocalNotification(
int dataType, int dateTime, String title, String content, String link);
}
... ...
... ... @@ -2,6 +2,7 @@ import 'dart:async';
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/services/health_raw_local_notification.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_local_datasource.dart';
import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
... ... @@ -527,6 +528,201 @@ void main() {
});
test(
'startCoreCaculate sends hrv and sleep notifications for new non-first results',
() async {
final now = DateTime.now();
final day = DateTime(now.year, now.month, now.day);
final base = LocalHealthDataConvert.unixSeconds(day);
final api = _FakeHealthKitRawDataHostApi();
api.setPoints(HealthDataUploadType.hrv.type, [
_point(base + 120, 35),
_point(base + 420, 18),
_point(base + 720, 40),
]);
api.setPoints(HealthDataUploadType.heartRate.type, [
_point(base + 60, 68),
_point(base + 120, 70),
_point(base + 180, 72),
_point(base + 420, 90),
_point(base + 480, 88),
_point(base + 720, 76),
]);
api.setPoints(HealthDataUploadType.restingHeartRate.type, [
_point(base + 30, 60),
_point(base + 390, 62),
_point(base + 710, 61),
]);
final sleepStart = day.subtract(const Duration(hours: 1));
final sleepEnd = day.add(const Duration(hours: 7));
api.setSleepPoints([
HealthKitRawDataPoint(
dataType: 3,
startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
endTime: LocalHealthDataConvert.unixSeconds(sleepEnd),
),
]);
final store = _MemoryHealthRawStressLocalStore();
await store.upsertResult(
HealthRawStressCalculationResult(
userId: 42,
hrvStressPoints: [_hrvStressPoint(base + 10)],
realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
),
);
await store.upsertSleepResults(
userId: 42,
results: [
HealthRawSleepResult(
userId: 42,
date: base - Duration.secondsPerDay,
startDate: base - Duration.secondsPerDay - 3600,
sleepScore: 80,
sleepState: 2,
inBedMinutes: 480,
awakMinutes: 10,
sleepMinutes: 470,
),
],
);
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher();
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
localNotificationDispatcher: notificationDispatcher,
);
await service.startCoreCaculate(
endTime: base + 800,
readChunkDays: 1,
);
expect(
notificationDispatcher.sentNotifications.where(
(e) => e.recordType == HealthRawLocalNotificationRecordType.hrv),
hasLength(1),
);
expect(
notificationDispatcher.sentNotifications.where(
(e) => e.recordType == HealthRawLocalNotificationRecordType.sleep,
),
hasLength(1),
);
});
test(
'startCoreCaculate skips hrv and sleep notifications without new results',
() async {
const base = 1800000000;
final api = _FakeHealthKitRawDataHostApi();
final store = _MemoryHealthRawStressLocalStore();
await store.upsertResult(
HealthRawStressCalculationResult(
userId: 42,
hrvStressPoints: [_hrvStressPoint(base + 10)],
realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
),
);
await store.upsertSleepResults(
userId: 42,
results: const [
HealthRawSleepResult(
userId: 42,
date: base + 20,
startDate: base,
sleepScore: 80,
sleepState: 2,
inBedMinutes: 480,
awakMinutes: 10,
sleepMinutes: 470,
),
],
);
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher();
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
localNotificationDispatcher: notificationDispatcher,
);
await service.startCoreCaculate(
endTime: base + 40,
readChunkDays: 1,
);
expect(
notificationDispatcher.sentNotifications.where(
(e) =>
e.recordType == HealthRawLocalNotificationRecordType.hrv ||
e.recordType == HealthRawLocalNotificationRecordType.sleep,
),
isEmpty,
);
});
test(
'startCoreCaculate skips hrv and sleep notifications on first calculation',
() async {
final now = DateTime.now();
final day = DateTime(now.year, now.month, now.day);
final base = LocalHealthDataConvert.unixSeconds(day);
final api = _FakeHealthKitRawDataHostApi();
api.setPoints(HealthDataUploadType.hrv.type, [
_point(base + 120, 35),
_point(base + 420, 18),
]);
api.setPoints(HealthDataUploadType.heartRate.type, [
_point(base + 60, 68),
_point(base + 120, 70),
_point(base + 180, 72),
_point(base + 420, 90),
]);
api.setPoints(HealthDataUploadType.restingHeartRate.type, [
_point(base + 30, 60),
_point(base + 390, 62),
]);
final sleepStart = day.subtract(const Duration(hours: 1));
final sleepEnd = day.add(const Duration(hours: 7));
api.setSleepPoints([
HealthKitRawDataPoint(
dataType: 3,
startTime: LocalHealthDataConvert.unixSeconds(sleepStart),
endTime: LocalHealthDataConvert.unixSeconds(sleepEnd),
),
]);
final notificationDispatcher = _FakeHealthRawLocalNotificationDispatcher();
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: _MemoryHealthRawStressLocalStore(),
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
localNotificationDispatcher: notificationDispatcher,
);
await service.startCoreCaculate(
endTime: base + 800,
readChunkDays: 1,
);
expect(
notificationDispatcher.sentNotifications.where(
(e) =>
e.recordType == HealthRawLocalNotificationRecordType.hrv ||
e.recordType == HealthRawLocalNotificationRecordType.sleep,
),
isEmpty,
);
});
test(
'startCoreCaculate uploads pending rows even when no new data calculates',
() async {
const base = 1800000000;
... ... @@ -854,6 +1050,35 @@ class _FakeHealthKitHostApi extends HealthKitHostApi {
}
}
class _FakeHealthRawLocalNotificationDispatcher
extends HealthRawLocalNotificationDispatcher {
final sentNotifications = <HealthRawLocalNotification>[];
var record = const HealthRawLocalNotificationRecord();
@override
Future<HealthRawLocalNotificationRecord> readRecord(int userId) async {
return record;
}
@override
Future<bool> sendOne({
required int userId,
required HealthRawLocalNotification notification,
}) async {
sentNotifications.add(notification);
record = record.withNotification(notification);
return true;
}
@override
Future<void> recordRealtimeStressTime({
required int userId,
required int recordTime,
}) async {
record = record.copyWith(lastRealtimeStressTime: recordTime);
}
}
class _ReadCall {
const _ReadCall(this.dataType, this.startTime, this.endTime);
... ...
... ... @@ -29,6 +29,7 @@ void main() {
],
),
hasExistingHrv: false,
hasExistingSleep: true,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
... ... @@ -39,6 +40,54 @@ void main() {
expect(notifications.single.link, healthRawTodayLink);
});
test('skips sleep notification on first calculation without existing sleep',
() {
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: const [],
dailyStressPoints: const [],
sleepResults: const [
HealthRawSleepResult(
userId: 1,
date: 1000,
startDate: 100,
sleepScore: 80,
sleepState: 2,
inBedMinutes: 430,
awakMinutes: 25,
sleepMinutes: 405,
),
],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, isEmpty);
});
test('skips sleep notification without new sleep result', () {
final notifications = builder.build(
result: const HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: [],
realtimeStressPoints: [],
dailyStressPoints: [],
sleepResults: [],
),
hasExistingHrv: false,
hasExistingSleep: true,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, isEmpty);
});
test('builds hrv notification when new hrv is calculated after first run',
() {
final latestTime = _seconds(DateTime(2026, 1, 1, 10));
... ... @@ -52,6 +101,7 @@ void main() {
dailyStressPoints: const [],
),
hasExistingHrv: true,
hasExistingSleep: false,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
... ... @@ -73,6 +123,7 @@ void main() {
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
... ... @@ -80,6 +131,27 @@ void main() {
expect(notifications, isEmpty);
});
test('builds hrv notification even when record has same hrv time', () {
final latestTime = _seconds(DateTime(2026, 1, 1, 10));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: [
_hrvPoint(latestTime),
],
realtimeStressPoints: const [],
dailyStressPoints: const [],
),
hasExistingHrv: true,
hasExistingSleep: false,
realtimeWindow: const [],
record: HealthRawLocalNotificationRecord(lastHrvTime: latestTime),
);
expect(notifications, hasLength(1));
expect(notifications.single.recordTime, latestTime);
});
test('builds realtime stress notification from latest 60 minute window', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
... ... @@ -90,6 +162,7 @@ void main() {
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
],
... ... @@ -116,6 +189,7 @@ void main() {
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70),
_realtimePoint(base + 9 * 300, 70, isSleepLikely: true),
... ... @@ -138,6 +212,7 @@ void main() {
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70),
_realtimePoint(base + 9 * 300, 70, isWorkout: true),
... ... @@ -160,6 +235,7 @@ void main() {
dailyStressPoints: const [],
),
hasExistingHrv: false,
hasExistingSleep: false,
realtimeWindow: [
for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70),
_realtimePoint(base + 9 * 300, 70, isWorkoutRecovery: true),
... ...