Commit 9a7310bb289af804bf5e0cea3fc43e08b7b97789

Authored by 权海
1 parent f45a4bdc

feat(ui):增加本地推送

@@ -389,6 +389,8 @@ interface PlatformHostApi { @@ -389,6 +389,8 @@ interface PlatformHostApi {
389 * width.height 切图后输出的大小 389 * width.height 切图后输出的大小
390 */ 390 */
391 fun performCropImage(imageUrl: String, maxKB: Long?, width: Long?, height: Long?, callback: (Result<String?>) -> Unit) 391 fun performCropImage(imageUrl: String, maxKB: Long?, width: Long?, height: Long?, callback: (Result<String?>) -> Unit)
  392 + /** 发起本地推送 */
  393 + fun sendLocalNotification(title: String, content: String, link: String, callback: (Result<Boolean>) -> Unit)
392 394
393 companion object { 395 companion object {
394 /** The codec used by PlatformHostApi. */ 396 /** The codec used by PlatformHostApi. */
@@ -723,6 +725,28 @@ interface PlatformHostApi { @@ -723,6 +725,28 @@ interface PlatformHostApi {
723 channel.setMessageHandler(null) 725 channel.setMessageHandler(null)
724 } 726 }
725 } 727 }
  728 + run {
  729 + val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification$separatedMessageChannelSuffix", codec)
  730 + if (api != null) {
  731 + channel.setMessageHandler { message, reply ->
  732 + val args = message as List<Any?>
  733 + val titleArg = args[0] as String
  734 + val contentArg = args[1] as String
  735 + val linkArg = args[2] as String
  736 + api.sendLocalNotification(titleArg, contentArg, linkArg) { result: Result<Boolean> ->
  737 + val error = result.exceptionOrNull()
  738 + if (error != null) {
  739 + reply.reply(PlatformApiPigeonUtils.wrapError(error))
  740 + } else {
  741 + val data = result.getOrNull()
  742 + reply.reply(PlatformApiPigeonUtils.wrapResult(data))
  743 + }
  744 + }
  745 + }
  746 + } else {
  747 + channel.setMessageHandler(null)
  748 + }
  749 + }
726 } 750 }
727 } 751 }
728 } 752 }
  1 +import Foundation
  2 +import UIKit
  3 +import UserNotifications
  4 +
  5 +enum LocalNotificationSenderError: LocalizedError {
  6 + case notificationsDenied
  7 + case notificationsUnsupportedStatus(Int)
  8 +
  9 + var errorDescription: String? {
  10 + switch self {
  11 + case .notificationsDenied:
  12 + return "Notifications permission is denied."
  13 + case .notificationsUnsupportedStatus(let status):
  14 + return "Notifications permission status is unsupported: \(status)."
  15 + }
  16 + }
  17 +}
  18 +
  19 +final class LocalNotificationSender {
  20 + static let shared = LocalNotificationSender()
  21 +
  22 + private let center: UNUserNotificationCenter
  23 +
  24 + init(center: UNUserNotificationCenter = .current()) {
  25 + self.center = center
  26 + }
  27 +
  28 + func send(
  29 + title: String,
  30 + body: String,
  31 + link: String,
  32 + onlyWhenAppNotActive: Bool = true
  33 + ) async throws -> Bool {
  34 + if onlyWhenAppNotActive, UIApplication.shared.applicationState == .active {
  35 + return false
  36 + }
  37 +
  38 + try await ensureNotificationPermission()
  39 +
  40 + let content = UNMutableNotificationContent()
  41 + content.title = title
  42 + content.body = body
  43 + content.sound = .default
  44 + content.userInfo = notificationUserInfo(link: link)
  45 +
  46 + let request = UNNotificationRequest(
  47 + identifier: "doublefeel.local.\(UUID().uuidString)",
  48 + content: content,
  49 + trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
  50 + )
  51 + try await center.add(request)
  52 + return true
  53 + }
  54 +
  55 + private func ensureNotificationPermission() async throws {
  56 + let settings = await center.notificationSettings()
  57 + switch settings.authorizationStatus {
  58 + case .authorized, .provisional, .ephemeral:
  59 + return
  60 + case .notDetermined:
  61 + let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
  62 + if !granted {
  63 + throw LocalNotificationSenderError.notificationsDenied
  64 + }
  65 + case .denied:
  66 + throw LocalNotificationSenderError.notificationsDenied
  67 + @unknown default:
  68 + throw LocalNotificationSenderError.notificationsUnsupportedStatus(
  69 + settings.authorizationStatus.rawValue
  70 + )
  71 + }
  72 + }
  73 +
  74 + private func notificationUserInfo(link: String) -> [String: Any] {
  75 + guard !link.isEmpty else { return [:] }
  76 + return [
  77 + "url": link,
  78 + "link": link,
  79 + ]
  80 + }
  81 +}
@@ -409,6 +409,8 @@ protocol PlatformHostApi { @@ -409,6 +409,8 @@ protocol PlatformHostApi {
409 /// maxKB: 压缩大小 409 /// maxKB: 压缩大小
410 /// width.height 切图后输出的大小 410 /// width.height 切图后输出的大小
411 func performCropImage(imageUrl: String, maxKB: Int64?, width: Int64?, height: Int64?, completion: @escaping (Result<String?, Error>) -> Void) 411 func performCropImage(imageUrl: String, maxKB: Int64?, width: Int64?, height: Int64?, completion: @escaping (Result<String?, Error>) -> Void)
  412 + /// 发起本地推送
  413 + func sendLocalNotification(title: String, content: String, link: String, completion: @escaping (Result<Bool, Error>) -> Void)
412 } 414 }
413 415
414 /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. 416 /// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
@@ -718,5 +720,25 @@ class PlatformHostApiSetup { @@ -718,5 +720,25 @@ class PlatformHostApiSetup {
718 } else { 720 } else {
719 performCropImageChannel.setMessageHandler(nil) 721 performCropImageChannel.setMessageHandler(nil)
720 } 722 }
  723 + /// 发起本地推送
  724 + let sendLocalNotificationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
  725 + if let api = api {
  726 + sendLocalNotificationChannel.setMessageHandler { message, reply in
  727 + let args = message as! [Any?]
  728 + let titleArg = args[0] as! String
  729 + let contentArg = args[1] as! String
  730 + let linkArg = args[2] as! String
  731 + api.sendLocalNotification(title: titleArg, content: contentArg, link: linkArg) { result in
  732 + switch result {
  733 + case .success(let res):
  734 + reply(wrapResult(res))
  735 + case .failure(let error):
  736 + reply(wrapError(error))
  737 + }
  738 + }
  739 + }
  740 + } else {
  741 + sendLocalNotificationChannel.setMessageHandler(nil)
  742 + }
721 } 743 }
722 } 744 }
@@ -293,6 +293,22 @@ final class PlatformHostApiImpl: PlatformHostApi { @@ -293,6 +293,22 @@ final class PlatformHostApiImpl: PlatformHostApi {
293 print("[PlatformHostApiImpl.getFullUserAgent] return: \(userAgent)") 293 print("[PlatformHostApiImpl.getFullUserAgent] return: \(userAgent)")
294 return userAgent 294 return userAgent
295 } 295 }
  296 +
  297 + func sendLocalNotification(title: String, content: String, link: String, completion: @escaping (Result<Bool, any Error>) -> Void) {
  298 + Task {
  299 + do {
  300 + let sent = try await LocalNotificationSender.shared.send(
  301 + title: title,
  302 + body: content,
  303 + link: link
  304 + )
  305 + completion(.success(sent))
  306 + } catch {
  307 + completion(.failure(error))
  308 + }
  309 + }
  310 + }
  311 +
296 } 312 }
297 313
298 //MARK: - 分享 314 //MARK: - 分享
@@ -9,11 +9,13 @@ import 'package:path_provider/path_provider.dart'; @@ -9,11 +9,13 @@ import 'package:path_provider/path_provider.dart';
9 import 'package:sqflite/sqflite.dart'; 9 import 'package:sqflite/sqflite.dart';
10 10
11 import '../../data/models/enums/app_enums.dart'; 11 import '../../data/models/enums/app_enums.dart';
  12 +import '../../l10n/l10n_extensions.dart';
12 import '../../pigeon/health_kit_api.g.dart'; 13 import '../../pigeon/health_kit_api.g.dart';
13 import '../../pigeon/health_kit_raw_data_api.g.dart'; 14 import '../../pigeon/health_kit_raw_data_api.g.dart';
14 import '../config/app_environment_config.dart'; 15 import '../config/app_environment_config.dart';
15 import '../logging/app_logger.dart'; 16 import '../logging/app_logger.dart';
16 import '../util/app_toast.dart'; 17 import '../util/app_toast.dart';
  18 +import 'health_raw_local_notification.dart';
17 import 'health_raw_stress_calculator.dart'; 19 import 'health_raw_stress_calculator.dart';
18 import 'health_sleep_calculator.dart'; 20 import 'health_sleep_calculator.dart';
19 21
@@ -28,12 +30,15 @@ class HealthRawDataCoreService { @@ -28,12 +30,15 @@ class HealthRawDataCoreService {
28 AppEnvironmentConfig? environmentConfig, 30 AppEnvironmentConfig? environmentConfig,
29 int Function()? userIdProvider, 31 int Function()? userIdProvider,
30 bool uploadResultsAfterCalculation = true, 32 bool uploadResultsAfterCalculation = true,
  33 + HealthRawLocalNotificationDispatcher? localNotificationDispatcher,
31 }) : _healthApi = healthApi ?? HealthKitHostApi(), 34 }) : _healthApi = healthApi ?? HealthKitHostApi(),
32 _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(), 35 _rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(),
33 _localStore = localStore ?? HealthRawStressLocalStore(), 36 _localStore = localStore ?? HealthRawStressLocalStore(),
34 _environmentConfig = environmentConfig, 37 _environmentConfig = environmentConfig,
35 _userIdProvider = userIdProvider, 38 _userIdProvider = userIdProvider,
36 - _uploadResultsAfterCalculation = uploadResultsAfterCalculation; 39 + _uploadResultsAfterCalculation = uploadResultsAfterCalculation,
  40 + _localNotificationDispatcher = localNotificationDispatcher ??
  41 + HealthRawLocalNotificationDispatcher();
37 42
38 final HealthKitHostApi _healthApi; 43 final HealthKitHostApi _healthApi;
39 final HealthKitRawDataHostApi _rawDataApi; 44 final HealthKitRawDataHostApi _rawDataApi;
@@ -41,6 +46,7 @@ class HealthRawDataCoreService { @@ -41,6 +46,7 @@ class HealthRawDataCoreService {
41 final AppEnvironmentConfig? _environmentConfig; 46 final AppEnvironmentConfig? _environmentConfig;
42 final int Function()? _userIdProvider; 47 final int Function()? _userIdProvider;
43 final bool _uploadResultsAfterCalculation; 48 final bool _uploadResultsAfterCalculation;
  49 + final HealthRawLocalNotificationDispatcher _localNotificationDispatcher;
44 final StreamController<HealthRawDataUpdatedEvent> 50 final StreamController<HealthRawDataUpdatedEvent>
45 _healthDataUpdatedController = 51 _healthDataUpdatedController =
46 StreamController<HealthRawDataUpdatedEvent>.broadcast(); 52 StreamController<HealthRawDataUpdatedEvent>.broadcast();
@@ -374,6 +380,10 @@ class HealthRawDataCoreService { @@ -374,6 +380,10 @@ class HealthRawDataCoreService {
374 dailyStressPoints: dailyStressPoints, 380 dailyStressPoints: dailyStressPoints,
375 sleepResults: sleepResults, 381 sleepResults: sleepResults,
376 ); 382 );
  383 + await _sendLocalNotificationsAfterCalculation(
  384 + result: storedResult,
  385 + previousHrvRawEndTime: latestHrvRawEndTime,
  386 + );
377 if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) { 387 if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) {
378 AppToast.show('首次计算完成'); 388 AppToast.show('首次计算完成');
379 } 389 }
@@ -384,6 +394,49 @@ class HealthRawDataCoreService { @@ -384,6 +394,49 @@ class HealthRawDataCoreService {
384 return storedResult; 394 return storedResult;
385 } 395 }
386 396
  397 + Future<void> _sendLocalNotificationsAfterCalculation({
  398 + required HealthRawStressCalculationResult result,
  399 + required int? previousHrvRawEndTime,
  400 + }) async {
  401 + if (result.hrvStressPoints.isEmpty &&
  402 + result.realtimeStressPoints.isEmpty &&
  403 + result.sleepResults.isEmpty) {
  404 + return;
  405 + }
  406 + try {
  407 + final latestRealtimeStressPoint = result.realtimeStressPoints.isEmpty
  408 + ? null
  409 + : result.realtimeStressPoints.reduce(
  410 + (a, b) => a.rawEndTime >= b.rawEndTime ? a : b,
  411 + );
  412 + final realtimeWindow = latestRealtimeStressPoint == null
  413 + ? const <HealthRawRealtimeStressPoint>[]
  414 + : await _localStore.queryRealtimeStressPoints(
  415 + userId: result.userId,
  416 + startTime: latestRealtimeStressPoint.rawEndTime -
  417 + Duration.secondsPerHour +
  418 + 1,
  419 + endTime: latestRealtimeStressPoint.rawEndTime,
  420 + );
  421 + final record = await _localNotificationDispatcher.readRecord(
  422 + result.userId,
  423 + );
  424 + final notifications = HealthRawLocalNotificationBuilder(l10n).build(
  425 + result: result,
  426 + previousHrvRawEndTime: previousHrvRawEndTime,
  427 + realtimeWindow: realtimeWindow,
  428 + record: record,
  429 + );
  430 + if (notifications.isEmpty) return;
  431 + await _localNotificationDispatcher.sendAll(
  432 + userId: result.userId,
  433 + notifications: notifications,
  434 + );
  435 + } catch (error, stackTrace) {
  436 + _logError('send local health notifications failed', error, stackTrace);
  437 + }
  438 + }
  439 +
387 Future<bool> _hasHealthReadAuthorization() async { 440 Future<bool> _hasHealthReadAuthorization() async {
388 final authorization = await _healthApi.checkHealthAppAuthorization(); 441 final authorization = await _healthApi.checkHealthAppAuthorization();
389 return authorization.status == 1; 442 return authorization.status == 1;
  1 +import 'dart:convert';
  2 +import 'dart:io';
  3 +import 'dart:math' as math;
  4 +
  5 +import 'package:path_provider/path_provider.dart';
  6 +
  7 +import '../../l10n/gen/app_localizations.dart';
  8 +import '../../pigeon/platform_api.g.dart';
  9 +import 'health_raw_data_core_service.dart';
  10 +
  11 +const healthRawTodayLink = 'doublefeel://flutter/home?tab=today';
  12 +const healthRawHrvChangeLink =
  13 + 'doublefeel://flutter/home?tab=today&is_hrv_change=1';
  14 +
  15 +class HealthRawLocalNotification {
  16 + const HealthRawLocalNotification({
  17 + required this.title,
  18 + required this.content,
  19 + required this.link,
  20 + required this.recordType,
  21 + required this.recordTime,
  22 + });
  23 +
  24 + final String title;
  25 + final String content;
  26 + final String link;
  27 + final HealthRawLocalNotificationRecordType recordType;
  28 + final int recordTime;
  29 +}
  30 +
  31 +enum HealthRawLocalNotificationRecordType {
  32 + sleep,
  33 + hrv,
  34 + realtimeStress,
  35 +}
  36 +
  37 +class HealthRawLocalNotificationBuilder {
  38 + const HealthRawLocalNotificationBuilder(this.l10n);
  39 +
  40 + static const int _hrvMinIntervalSeconds = 2 * 60 * 60;
  41 + static const int _realtimeWindowSeconds = 60 * 60;
  42 + static const int _realtimeMinPointCount = 10;
  43 +
  44 + final AppLocalizations l10n;
  45 +
  46 + List<HealthRawLocalNotification> build({
  47 + required HealthRawStressCalculationResult result,
  48 + required int? previousHrvRawEndTime,
  49 + required List<HealthRawRealtimeStressPoint> realtimeWindow,
  50 + required HealthRawLocalNotificationRecord? record,
  51 + }) {
  52 + return [
  53 + if (_sleepNotification(result.sleepResults) case final notification?)
  54 + notification,
  55 + if (_hrvNotification(
  56 + result.hrvStressPoints,
  57 + previousHrvRawEndTime,
  58 + record,
  59 + )
  60 + case final notification?)
  61 + notification,
  62 + if (_realtimeStressNotification(realtimeWindow, record)
  63 + case final notification?)
  64 + notification,
  65 + ];
  66 + }
  67 +
  68 + HealthRawLocalNotification? _sleepNotification(
  69 + List<HealthRawSleepResult> sleepResults,
  70 + ) {
  71 + if (sleepResults.isEmpty) return null;
  72 + final latest = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date));
  73 + final sleep = latest.last;
  74 + final state = _sleepStateLabel(sleep.sleepState);
  75 + if (state == null || sleep.sleepMinutes <= 0) return null;
  76 + final duration = _sleepDurationText(sleep.sleepMinutes);
  77 + return HealthRawLocalNotification(
  78 + title: l10n.healthLocalNotificationSleepTitle(duration, state),
  79 + content: l10n.healthLocalNotificationSleepContent,
  80 + link: healthRawTodayLink,
  81 + recordType: HealthRawLocalNotificationRecordType.sleep,
  82 + recordTime: sleep.date,
  83 + );
  84 + }
  85 +
  86 + HealthRawLocalNotification? _hrvNotification(
  87 + List<HealthRawHrvStressPoint> hrvPoints,
  88 + int? previousHrvRawEndTime,
  89 + HealthRawLocalNotificationRecord? record,
  90 + ) {
  91 + if (hrvPoints.isEmpty) return null;
  92 + final sorted = [...hrvPoints]
  93 + ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
  94 + final latest = sorted.last;
  95 + final previousTime = sorted.length >= 2
  96 + ? sorted[sorted.length - 2].rawEndTime
  97 + : previousHrvRawEndTime;
  98 + if (previousTime == null ||
  99 + latest.rawEndTime - previousTime < _hrvMinIntervalSeconds) {
  100 + return null;
  101 + }
  102 + if (record?.lastHrvTime == latest.rawEndTime) return null;
  103 +
  104 + return HealthRawLocalNotification(
  105 + title: l10n.healthLocalNotificationHrvTitle(
  106 + latest.result.floor(),
  107 + _stressStateLabel(latest.state),
  108 + _timeText(latest.rawEndTime),
  109 + ),
  110 + content: _hrvContent(latest),
  111 + link: healthRawHrvChangeLink,
  112 + recordType: HealthRawLocalNotificationRecordType.hrv,
  113 + recordTime: latest.rawEndTime,
  114 + );
  115 + }
  116 +
  117 + HealthRawLocalNotification? _realtimeStressNotification(
  118 + List<HealthRawRealtimeStressPoint> realtimeWindow,
  119 + HealthRawLocalNotificationRecord? record,
  120 + ) {
  121 + final valid = realtimeWindow
  122 + .where((e) => e.result >= 1 && e.result <= 100)
  123 + .toList()
  124 + ..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
  125 + if (valid.length < _realtimeMinPointCount) return null;
  126 +
  127 + final latest = valid.last;
  128 + if (latest.isSleepLikely) return null;
  129 + if (record?.lastRealtimeStressTime case final lastPushTime?) {
  130 + if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) {
  131 + return null;
  132 + }
  133 + }
  134 +
  135 + final windowStart = math.max(
  136 + valid.first.rawEndTime,
  137 + latest.rawEndTime - _realtimeWindowSeconds + 1,
  138 + );
  139 + final average =
  140 + valid.map((e) => e.result).reduce((a, b) => a + b) / valid.length;
  141 + final state = healthRawRealtimeStressState(average);
  142 + return HealthRawLocalNotification(
  143 + title: l10n.healthLocalNotificationRealtimeStressTitle(
  144 + _stressStateLabel(state),
  145 + _timeText(windowStart),
  146 + _timeText(latest.rawEndTime),
  147 + ),
  148 + content: _realtimeStressContent(state),
  149 + link: healthRawTodayLink,
  150 + recordType: HealthRawLocalNotificationRecordType.realtimeStress,
  151 + recordTime: latest.rawEndTime,
  152 + );
  153 + }
  154 +
  155 + String _sleepDurationText(int minutes) {
  156 + final hours = minutes ~/ 60;
  157 + final remainingMinutes = minutes % 60;
  158 + return l10n.healthLocalNotificationSleepDuration(hours, remainingMinutes);
  159 + }
  160 +
  161 + String? _sleepStateLabel(int value) {
  162 + return switch (value) {
  163 + 1 => l10n.sleepQualityGreat,
  164 + 2 => l10n.sleepQualityGood,
  165 + 3 => l10n.sleepQualityPoor,
  166 + _ => null,
  167 + };
  168 + }
  169 +
  170 + String _stressStateLabel(HealthRawStressState state) {
  171 + return switch (state) {
  172 + HealthRawStressState.excellent => l10n.inExcellentCondition,
  173 + HealthRawStressState.normal => l10n.statusNormal,
  174 + HealthRawStressState.attention => l10n.beMindfulOfStress,
  175 + HealthRawStressState.overload => l10n.pressureOverload,
  176 + };
  177 + }
  178 +
  179 + String _hrvContent(HealthRawHrvStressPoint latest) {
  180 + final isAboveBaseline = latest.result >= latest.baselineHrv;
  181 + return switch (latest.state) {
  182 + HealthRawStressState.excellent => isAboveBaseline
  183 + ? l10n.latestHrvTipExcellentAboveBaseline
  184 + : l10n.latestHrvTipExcellentBelowBaseline,
  185 + HealthRawStressState.normal => isAboveBaseline
  186 + ? l10n.latestHrvTipNormalAboveBaseline
  187 + : l10n.latestHrvTipNormalBelowBaseline,
  188 + HealthRawStressState.attention => isAboveBaseline
  189 + ? l10n.latestHrvTipAttentionAboveBaseline
  190 + : l10n.latestHrvTipAttentionBelowBaseline,
  191 + HealthRawStressState.overload => isAboveBaseline
  192 + ? l10n.latestHrvTipOverloadAboveBaseline
  193 + : l10n.latestHrvTipOverloadBelowBaseline,
  194 + };
  195 + }
  196 +
  197 + String _realtimeStressContent(HealthRawStressState state) {
  198 + return switch (state) {
  199 + HealthRawStressState.excellent =>
  200 + l10n.healthLocalNotificationRealtimeStressExcellentContent,
  201 + HealthRawStressState.normal =>
  202 + l10n.healthLocalNotificationRealtimeStressNormalContent,
  203 + HealthRawStressState.attention =>
  204 + l10n.healthLocalNotificationRealtimeStressAttentionContent,
  205 + HealthRawStressState.overload =>
  206 + l10n.healthLocalNotificationRealtimeStressOverloadContent,
  207 + };
  208 + }
  209 +
  210 + String _timeText(int seconds) {
  211 + final time = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
  212 + return '${_twoDigits(time.hour)}:${_twoDigits(time.minute)}';
  213 + }
  214 +
  215 + String _twoDigits(int value) => value.toString().padLeft(2, '0');
  216 +}
  217 +
  218 +class HealthRawLocalNotificationDispatcher {
  219 + HealthRawLocalNotificationDispatcher({
  220 + PlatformHostApi? platformApi,
  221 + HealthRawLocalNotificationRecordStore? recordStore,
  222 + }) : _platformApi = platformApi ?? PlatformHostApi(),
  223 + _recordStore = recordStore ?? HealthRawLocalNotificationRecordStore();
  224 +
  225 + final PlatformHostApi _platformApi;
  226 + final HealthRawLocalNotificationRecordStore _recordStore;
  227 +
  228 + Future<void> sendAll({
  229 + required int userId,
  230 + required Iterable<HealthRawLocalNotification> notifications,
  231 + }) async {
  232 + var record = await _recordStore.read(userId);
  233 + for (final notification in notifications) {
  234 + final sent = await _platformApi.sendLocalNotification(
  235 + notification.title,
  236 + notification.content,
  237 + notification.link,
  238 + );
  239 + if (!sent) continue;
  240 + record = record.withNotification(notification);
  241 + await _recordStore.write(userId, record);
  242 + }
  243 + }
  244 +
  245 + Future<HealthRawLocalNotificationRecord> readRecord(int userId) {
  246 + return _recordStore.read(userId);
  247 + }
  248 +}
  249 +
  250 +class HealthRawLocalNotificationRecord {
  251 + const HealthRawLocalNotificationRecord({
  252 + this.lastSleepTime,
  253 + this.lastHrvTime,
  254 + this.lastRealtimeStressTime,
  255 + });
  256 +
  257 + final int? lastSleepTime;
  258 + final int? lastHrvTime;
  259 + final int? lastRealtimeStressTime;
  260 +
  261 + factory HealthRawLocalNotificationRecord.fromJson(Map<String, Object?> json) {
  262 + return HealthRawLocalNotificationRecord(
  263 + lastSleepTime: (json['last_sleep_time'] as num?)?.toInt(),
  264 + lastHrvTime: (json['last_hrv_time'] as num?)?.toInt(),
  265 + lastRealtimeStressTime:
  266 + (json['last_realtime_stress_time'] as num?)?.toInt(),
  267 + );
  268 + }
  269 +
  270 + Map<String, Object?> toJson() {
  271 + return <String, Object?>{
  272 + if (lastSleepTime != null) 'last_sleep_time': lastSleepTime,
  273 + if (lastHrvTime != null) 'last_hrv_time': lastHrvTime,
  274 + if (lastRealtimeStressTime != null)
  275 + 'last_realtime_stress_time': lastRealtimeStressTime,
  276 + };
  277 + }
  278 +
  279 + HealthRawLocalNotificationRecord withNotification(
  280 + HealthRawLocalNotification notification,
  281 + ) {
  282 + return switch (notification.recordType) {
  283 + HealthRawLocalNotificationRecordType.sleep => copyWith(
  284 + lastSleepTime: notification.recordTime,
  285 + ),
  286 + HealthRawLocalNotificationRecordType.hrv => copyWith(
  287 + lastHrvTime: notification.recordTime,
  288 + ),
  289 + HealthRawLocalNotificationRecordType.realtimeStress => copyWith(
  290 + lastRealtimeStressTime: notification.recordTime,
  291 + ),
  292 + };
  293 + }
  294 +
  295 + HealthRawLocalNotificationRecord copyWith({
  296 + int? lastSleepTime,
  297 + int? lastHrvTime,
  298 + int? lastRealtimeStressTime,
  299 + }) {
  300 + return HealthRawLocalNotificationRecord(
  301 + lastSleepTime: lastSleepTime ?? this.lastSleepTime,
  302 + lastHrvTime: lastHrvTime ?? this.lastHrvTime,
  303 + lastRealtimeStressTime:
  304 + lastRealtimeStressTime ?? this.lastRealtimeStressTime,
  305 + );
  306 + }
  307 +}
  308 +
  309 +class HealthRawLocalNotificationRecordStore {
  310 + HealthRawLocalNotificationRecordStore({Directory? rootDirectory})
  311 + : _rootDirectory = rootDirectory;
  312 +
  313 + static const _fileName = 'health_raw_local_notification_records.json';
  314 +
  315 + final Directory? _rootDirectory;
  316 +
  317 + Future<HealthRawLocalNotificationRecord> read(int userId) async {
  318 + final records = await _readRecords();
  319 + final userRecord = records[userId.toString()];
  320 + if (userRecord is Map<String, Object?>) {
  321 + return HealthRawLocalNotificationRecord.fromJson(userRecord);
  322 + }
  323 + if (userRecord is Map) {
  324 + return HealthRawLocalNotificationRecord.fromJson(
  325 + userRecord.cast<String, Object?>(),
  326 + );
  327 + }
  328 + return const HealthRawLocalNotificationRecord();
  329 + }
  330 +
  331 + Future<void> write(
  332 + int userId,
  333 + HealthRawLocalNotificationRecord record,
  334 + ) async {
  335 + final records = await _readRecords();
  336 + records[userId.toString()] = record.toJson();
  337 + final file = await _file();
  338 + await file.writeAsString(jsonEncode(records), flush: true);
  339 + }
  340 +
  341 + Future<Map<String, Object?>> _readRecords() async {
  342 + final file = await _file();
  343 + if (!await file.exists()) return <String, Object?>{};
  344 + final content = await file.readAsString();
  345 + if (content.trim().isEmpty) return <String, Object?>{};
  346 + final decoded = jsonDecode(content);
  347 + if (decoded is Map<String, Object?>) return decoded;
  348 + if (decoded is Map) return decoded.cast<String, Object?>();
  349 + return <String, Object?>{};
  350 + }
  351 +
  352 + Future<File> _file() async {
  353 + final dir = _rootDirectory ?? await getApplicationDocumentsDirectory();
  354 + if (!await dir.exists()) {
  355 + await dir.create(recursive: true);
  356 + }
  357 + return File('${dir.path}/$_fileName');
  358 + }
  359 +}
@@ -711,6 +711,65 @@ @@ -711,6 +711,65 @@
711 "latestHrvTipAttentionBelowBaseline": "Your HRV is clearly below your usual level. Recent stress may be elevated, so try to rest and adjust your state.", 711 "latestHrvTipAttentionBelowBaseline": "Your HRV is clearly below your usual level. Recent stress may be elevated, so try to rest and adjust your state.",
712 "latestHrvTipOverloadAboveBaseline": "Your HRV is at a relatively low level. Your body may be under higher stress. If this is after exercise, a lower HRV can be normal. Rest and recover in time.", 712 "latestHrvTipOverloadAboveBaseline": "Your HRV is at a relatively low level. Your body may be under higher stress. If this is after exercise, a lower HRV can be normal. Rest and recover in time.",
713 "latestHrvTipOverloadBelowBaseline": "Your HRV is clearly below your usual level. Your body may be under high stress. If this is after exercise, a lower HRV can be normal. Reduce exertion, rest in time, and support sleep recovery.", 713 "latestHrvTipOverloadBelowBaseline": "Your HRV is clearly below your usual level. Your body may be under high stress. If this is after exercise, a lower HRV can be normal. Reduce exertion, rest in time, and support sleep recovery.",
  714 + "healthLocalNotificationSleepDuration": "{hours}h {minutes}m",
  715 + "@healthLocalNotificationSleepDuration": {
  716 + "description": "Sleep duration text in local notifications",
  717 + "placeholders": {
  718 + "hours": {
  719 + "type": "int"
  720 + },
  721 + "minutes": {
  722 + "type": "int"
  723 + }
  724 + }
  725 + },
  726 + "healthLocalNotificationSleepTitle": "Sleep {duration} · {state}",
  727 + "@healthLocalNotificationSleepTitle": {
  728 + "description": "Local notification title after sleep analysis completes",
  729 + "placeholders": {
  730 + "duration": {
  731 + "type": "String"
  732 + },
  733 + "state": {
  734 + "type": "String"
  735 + }
  736 + }
  737 + },
  738 + "healthLocalNotificationSleepContent": "Today's sleep report is ready. Tap to view your detailed sleep data.",
  739 + "healthLocalNotificationHrvTitle": "HRV {hrv}ms · {state} · {time}",
  740 + "@healthLocalNotificationHrvTitle": {
  741 + "description": "Local notification title for new HRV data",
  742 + "placeholders": {
  743 + "hrv": {
  744 + "type": "int"
  745 + },
  746 + "state": {
  747 + "type": "String"
  748 + },
  749 + "time": {
  750 + "type": "String"
  751 + }
  752 + }
  753 + },
  754 + "healthLocalNotificationRealtimeStressTitle": "{state} · {startTime}-{endTime}",
  755 + "@healthLocalNotificationRealtimeStressTitle": {
  756 + "description": "Local notification title for 60-minute realtime stress summary",
  757 + "placeholders": {
  758 + "state": {
  759 + "type": "String"
  760 + },
  761 + "startTime": {
  762 + "type": "String"
  763 + },
  764 + "endTime": {
  765 + "type": "String"
  766 + }
  767 + }
  768 + },
  769 + "healthLocalNotificationRealtimeStressExcellentContent": "Your realtime stress stayed low over the past 60 minutes. You seem relaxed overall. Keep your current rhythm.",
  770 + "healthLocalNotificationRealtimeStressNormalContent": "Your stress state was stable over the past 60 minutes. Your current rhythm looks normal.",
  771 + "healthLocalNotificationRealtimeStressAttentionContent": "Your stress was elevated over the past 60 minutes. Consider relaxing and making time for rest and recovery.",
  772 + "healthLocalNotificationRealtimeStressOverloadContent": "You stayed in a high-stress state over the past 60 minutes. Reduce exertion and prioritize rest and sleep.",
714 "turnOnNotifications": "Turn on Notifications", 773 "turnOnNotifications": "Turn on Notifications",
715 "stayUpToDateOnChangesInYourOwnAndYourFriendsHealth": "Stay up to date on changes in your own and your friends' health" 774 "stayUpToDateOnChangesInYourOwnAndYourFriendsHealth": "Stay up to date on changes in your own and your friends' health"
716 -}  
  775 +}
@@ -1104,6 +1104,65 @@ @@ -1104,6 +1104,65 @@
1104 "latestHrvTipAttentionBelowBaseline": "你的 HRV 明显低于日常水平,近期可能压力偏高,建议尽量休息与调整状态。", 1104 "latestHrvTipAttentionBelowBaseline": "你的 HRV 明显低于日常水平,近期可能压力偏高,建议尽量休息与调整状态。",
1105 "latestHrvTipOverloadAboveBaseline": "你的 HRV 处于较低水平,身体可能正在承受较高压力(刚运动完 HRV 降低则属于正常情况),建议及时休息与恢复。", 1105 "latestHrvTipOverloadAboveBaseline": "你的 HRV 处于较低水平,身体可能正在承受较高压力(刚运动完 HRV 降低则属于正常情况),建议及时休息与恢复。",
1106 "latestHrvTipOverloadBelowBaseline": "你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。", 1106 "latestHrvTipOverloadBelowBaseline": "你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。",
  1107 + "healthLocalNotificationSleepDuration": "{hours}小时{minutes}分钟",
  1108 + "@healthLocalNotificationSleepDuration": {
  1109 + "description": "本地通知中的睡眠时长文本",
  1110 + "placeholders": {
  1111 + "hours": {
  1112 + "type": "int"
  1113 + },
  1114 + "minutes": {
  1115 + "type": "int"
  1116 + }
  1117 + }
  1118 + },
  1119 + "healthLocalNotificationSleepTitle": "睡眠时长{duration}·{state}",
  1120 + "@healthLocalNotificationSleepTitle": {
  1121 + "description": "睡眠计算完成后的本地通知标题",
  1122 + "placeholders": {
  1123 + "duration": {
  1124 + "type": "String"
  1125 + },
  1126 + "state": {
  1127 + "type": "String"
  1128 + }
  1129 + }
  1130 + },
  1131 + "healthLocalNotificationSleepContent": "你今天的睡眠报告已出炉,点击查看详细睡眠数据。",
  1132 + "healthLocalNotificationHrvTitle": "HRV {hrv}ms · {state} · {time}",
  1133 + "@healthLocalNotificationHrvTitle": {
  1134 + "description": "HRV 新数据本地通知标题",
  1135 + "placeholders": {
  1136 + "hrv": {
  1137 + "type": "int"
  1138 + },
  1139 + "state": {
  1140 + "type": "String"
  1141 + },
  1142 + "time": {
  1143 + "type": "String"
  1144 + }
  1145 + }
  1146 + },
  1147 + "healthLocalNotificationRealtimeStressTitle": "{state} · {startTime}-{endTime}",
  1148 + "@healthLocalNotificationRealtimeStressTitle": {
  1149 + "description": "实时压力 60 分钟总结本地通知标题",
  1150 + "placeholders": {
  1151 + "state": {
  1152 + "type": "String"
  1153 + },
  1154 + "startTime": {
  1155 + "type": "String"
  1156 + },
  1157 + "endTime": {
  1158 + "type": "String"
  1159 + }
  1160 + }
  1161 + },
  1162 + "healthLocalNotificationRealtimeStressExcellentContent": "你过去60分钟的实时压力较低,整体状态较放松,继续保持当前节奏。",
  1163 + "healthLocalNotificationRealtimeStressNormalContent": "你过去60分钟的压力状态整体稳定,当前节奏正常。",
  1164 + "healthLocalNotificationRealtimeStressAttentionContent": "你过去60分钟压力偏高,建议适当放松,并注意休息与恢复。",
  1165 + "healthLocalNotificationRealtimeStressOverloadContent": "你过去60分钟持续处于高压力状态,建议减少消耗,并优先保证休息与睡眠。",
1107 "turnOnNotifications": "开启通知", 1166 "turnOnNotifications": "开启通知",
1108 "stayUpToDateOnChangesInYourOwnAndYourFriendsHealth": "及时了解自己和好友的健康波动" 1167 "stayUpToDateOnChangesInYourOwnAndYourFriendsHealth": "及时了解自己和好友的健康波动"
1109 -}  
  1168 +}
@@ -4083,6 +4083,61 @@ abstract class AppLocalizations { @@ -4083,6 +4083,61 @@ abstract class AppLocalizations {
4083 /// **'你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。'** 4083 /// **'你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。'**
4084 String get latestHrvTipOverloadBelowBaseline; 4084 String get latestHrvTipOverloadBelowBaseline;
4085 4085
  4086 + /// 本地通知中的睡眠时长文本
  4087 + ///
  4088 + /// In zh, this message translates to:
  4089 + /// **'{hours}小时{minutes}分钟'**
  4090 + String healthLocalNotificationSleepDuration(int hours, int minutes);
  4091 +
  4092 + /// 睡眠计算完成后的本地通知标题
  4093 + ///
  4094 + /// In zh, this message translates to:
  4095 + /// **'睡眠时长{duration}·{state}'**
  4096 + String healthLocalNotificationSleepTitle(String duration, String state);
  4097 +
  4098 + /// No description provided for @healthLocalNotificationSleepContent.
  4099 + ///
  4100 + /// In zh, this message translates to:
  4101 + /// **'你今天的睡眠报告已出炉,点击查看详细睡眠数据。'**
  4102 + String get healthLocalNotificationSleepContent;
  4103 +
  4104 + /// HRV 新数据本地通知标题
  4105 + ///
  4106 + /// In zh, this message translates to:
  4107 + /// **'HRV {hrv}ms · {state} · {time}'**
  4108 + String healthLocalNotificationHrvTitle(int hrv, String state, String time);
  4109 +
  4110 + /// 实时压力 60 分钟总结本地通知标题
  4111 + ///
  4112 + /// In zh, this message translates to:
  4113 + /// **'{state} · {startTime}-{endTime}'**
  4114 + String healthLocalNotificationRealtimeStressTitle(
  4115 + String state, String startTime, String endTime);
  4116 +
  4117 + /// No description provided for @healthLocalNotificationRealtimeStressExcellentContent.
  4118 + ///
  4119 + /// In zh, this message translates to:
  4120 + /// **'你过去60分钟的实时压力较低,整体状态较放松,继续保持当前节奏。'**
  4121 + String get healthLocalNotificationRealtimeStressExcellentContent;
  4122 +
  4123 + /// No description provided for @healthLocalNotificationRealtimeStressNormalContent.
  4124 + ///
  4125 + /// In zh, this message translates to:
  4126 + /// **'你过去60分钟的压力状态整体稳定,当前节奏正常。'**
  4127 + String get healthLocalNotificationRealtimeStressNormalContent;
  4128 +
  4129 + /// No description provided for @healthLocalNotificationRealtimeStressAttentionContent.
  4130 + ///
  4131 + /// In zh, this message translates to:
  4132 + /// **'你过去60分钟压力偏高,建议适当放松,并注意休息与恢复。'**
  4133 + String get healthLocalNotificationRealtimeStressAttentionContent;
  4134 +
  4135 + /// No description provided for @healthLocalNotificationRealtimeStressOverloadContent.
  4136 + ///
  4137 + /// In zh, this message translates to:
  4138 + /// **'你过去60分钟持续处于高压力状态,建议减少消耗,并优先保证休息与睡眠。'**
  4139 + String get healthLocalNotificationRealtimeStressOverloadContent;
  4140 +
4086 /// No description provided for @turnOnNotifications. 4141 /// No description provided for @turnOnNotifications.
4087 /// 4142 ///
4088 /// In zh, this message translates to: 4143 /// In zh, this message translates to:
@@ -2290,6 +2290,47 @@ class AppLocalizationsEn extends AppLocalizations { @@ -2290,6 +2290,47 @@ class AppLocalizationsEn extends AppLocalizations {
2290 'Your HRV is clearly below your usual level. Your body may be under high stress. If this is after exercise, a lower HRV can be normal. Reduce exertion, rest in time, and support sleep recovery.'; 2290 'Your HRV is clearly below your usual level. Your body may be under high stress. If this is after exercise, a lower HRV can be normal. Reduce exertion, rest in time, and support sleep recovery.';
2291 2291
2292 @override 2292 @override
  2293 + String healthLocalNotificationSleepDuration(int hours, int minutes) {
  2294 + return '${hours}h ${minutes}m';
  2295 + }
  2296 +
  2297 + @override
  2298 + String healthLocalNotificationSleepTitle(String duration, String state) {
  2299 + return 'Sleep $duration · $state';
  2300 + }
  2301 +
  2302 + @override
  2303 + String get healthLocalNotificationSleepContent =>
  2304 + 'Today\'s sleep report is ready. Tap to view your detailed sleep data.';
  2305 +
  2306 + @override
  2307 + String healthLocalNotificationHrvTitle(int hrv, String state, String time) {
  2308 + return 'HRV ${hrv}ms · $state · $time';
  2309 + }
  2310 +
  2311 + @override
  2312 + String healthLocalNotificationRealtimeStressTitle(
  2313 + String state, String startTime, String endTime) {
  2314 + return '$state · $startTime-$endTime';
  2315 + }
  2316 +
  2317 + @override
  2318 + String get healthLocalNotificationRealtimeStressExcellentContent =>
  2319 + 'Your realtime stress stayed low over the past 60 minutes. You seem relaxed overall. Keep your current rhythm.';
  2320 +
  2321 + @override
  2322 + String get healthLocalNotificationRealtimeStressNormalContent =>
  2323 + 'Your stress state was stable over the past 60 minutes. Your current rhythm looks normal.';
  2324 +
  2325 + @override
  2326 + String get healthLocalNotificationRealtimeStressAttentionContent =>
  2327 + 'Your stress was elevated over the past 60 minutes. Consider relaxing and making time for rest and recovery.';
  2328 +
  2329 + @override
  2330 + String get healthLocalNotificationRealtimeStressOverloadContent =>
  2331 + 'You stayed in a high-stress state over the past 60 minutes. Reduce exertion and prioritize rest and sleep.';
  2332 +
  2333 + @override
2293 String get turnOnNotifications => 'Turn on Notifications'; 2334 String get turnOnNotifications => 'Turn on Notifications';
2294 2335
2295 @override 2336 @override
@@ -2187,6 +2187,46 @@ class AppLocalizationsZh extends AppLocalizations { @@ -2187,6 +2187,46 @@ class AppLocalizationsZh extends AppLocalizations {
2187 '你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。'; 2187 '你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。';
2188 2188
2189 @override 2189 @override
  2190 + String healthLocalNotificationSleepDuration(int hours, int minutes) {
  2191 + return '$hours小时$minutes分钟';
  2192 + }
  2193 +
  2194 + @override
  2195 + String healthLocalNotificationSleepTitle(String duration, String state) {
  2196 + return '睡眠时长$duration·$state';
  2197 + }
  2198 +
  2199 + @override
  2200 + String get healthLocalNotificationSleepContent => '你今天的睡眠报告已出炉,点击查看详细睡眠数据。';
  2201 +
  2202 + @override
  2203 + String healthLocalNotificationHrvTitle(int hrv, String state, String time) {
  2204 + return 'HRV ${hrv}ms · $state · $time';
  2205 + }
  2206 +
  2207 + @override
  2208 + String healthLocalNotificationRealtimeStressTitle(
  2209 + String state, String startTime, String endTime) {
  2210 + return '$state · $startTime-$endTime';
  2211 + }
  2212 +
  2213 + @override
  2214 + String get healthLocalNotificationRealtimeStressExcellentContent =>
  2215 + '你过去60分钟的实时压力较低,整体状态较放松,继续保持当前节奏。';
  2216 +
  2217 + @override
  2218 + String get healthLocalNotificationRealtimeStressNormalContent =>
  2219 + '你过去60分钟的压力状态整体稳定,当前节奏正常。';
  2220 +
  2221 + @override
  2222 + String get healthLocalNotificationRealtimeStressAttentionContent =>
  2223 + '你过去60分钟压力偏高,建议适当放松,并注意休息与恢复。';
  2224 +
  2225 + @override
  2226 + String get healthLocalNotificationRealtimeStressOverloadContent =>
  2227 + '你过去60分钟持续处于高压力状态,建议减少消耗,并优先保证休息与睡眠。';
  2228 +
  2229 + @override
2190 String get turnOnNotifications => '开启通知'; 2230 String get turnOnNotifications => '开启通知';
2191 2231
2192 @override 2232 @override
@@ -842,4 +842,33 @@ class PlatformHostApi { @@ -842,4 +842,33 @@ class PlatformHostApi {
842 return (pigeonVar_replyList[0] as String?); 842 return (pigeonVar_replyList[0] as String?);
843 } 843 }
844 } 844 }
  845 +
  846 + /// 发起本地推送
  847 + Future<bool> sendLocalNotification(String title, String content, String link) async {
  848 + final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification$pigeonVar_messageChannelSuffix';
  849 + final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
  850 + pigeonVar_channelName,
  851 + pigeonChannelCodec,
  852 + binaryMessenger: pigeonVar_binaryMessenger,
  853 + );
  854 + final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[title, content, link]);
  855 + final List<Object?>? pigeonVar_replyList =
  856 + await pigeonVar_sendFuture as List<Object?>?;
  857 + if (pigeonVar_replyList == null) {
  858 + throw _createConnectionError(pigeonVar_channelName);
  859 + } else if (pigeonVar_replyList.length > 1) {
  860 + throw PlatformException(
  861 + code: pigeonVar_replyList[0]! as String,
  862 + message: pigeonVar_replyList[1] as String?,
  863 + details: pigeonVar_replyList[2],
  864 + );
  865 + } else if (pigeonVar_replyList[0] == null) {
  866 + throw PlatformException(
  867 + code: 'null-error',
  868 + message: 'Host platform returned null value for non-null return value.',
  869 + );
  870 + } else {
  871 + return (pigeonVar_replyList[0] as bool?)!;
  872 + }
  873 + }
845 } 874 }
@@ -182,4 +182,8 @@ abstract class PlatformHostApi { @@ -182,4 +182,8 @@ abstract class PlatformHostApi {
182 @async 182 @async
183 String? performCropImage( 183 String? performCropImage(
184 String imageUrl, int? maxKB, int? width, int? height); 184 String imageUrl, int? maxKB, int? width, int? height);
  185 +
  186 + /// 发起本地推送
  187 + @async
  188 + bool sendLocalNotification(String title, String content, String link);
185 } 189 }
  1 +import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
  2 +import 'package:doublefeel_flutter/core/services/health_raw_local_notification.dart';
  3 +import 'package:doublefeel_flutter/l10n/gen/app_localizations_zh.dart';
  4 +import 'package:flutter_test/flutter_test.dart';
  5 +
  6 +void main() {
  7 + final builder = HealthRawLocalNotificationBuilder(AppLocalizationsZh());
  8 +
  9 + test('builds sleep notification after new sleep result', () {
  10 + final notifications = builder.build(
  11 + result: HealthRawStressCalculationResult(
  12 + userId: 1,
  13 + hrvStressPoints: const [],
  14 + realtimeStressPoints: const [],
  15 + dailyStressPoints: const [],
  16 + sleepResults: const [
  17 + HealthRawSleepResult(
  18 + userId: 1,
  19 + date: 1000,
  20 + startDate: 100,
  21 + sleepScore: 80,
  22 + sleepState: 2,
  23 + inBedMinutes: 430,
  24 + awakMinutes: 25,
  25 + sleepMinutes: 405,
  26 + ),
  27 + ],
  28 + ),
  29 + previousHrvRawEndTime: null,
  30 + realtimeWindow: const [],
  31 + record: const HealthRawLocalNotificationRecord(),
  32 + );
  33 +
  34 + expect(notifications, hasLength(1));
  35 + expect(notifications.single.title, '睡眠时长6小时45分钟·睡得不错');
  36 + expect(notifications.single.content, '你今天的睡眠报告已出炉,点击查看详细睡眠数据。');
  37 + expect(notifications.single.link, healthRawTodayLink);
  38 + });
  39 +
  40 + test('builds hrv notification when previous hrv is at least two hours away',
  41 + () {
  42 + final latestTime = _seconds(DateTime(2026, 1, 1, 10));
  43 + final notifications = builder.build(
  44 + result: HealthRawStressCalculationResult(
  45 + userId: 1,
  46 + hrvStressPoints: [
  47 + _hrvPoint(latestTime),
  48 + ],
  49 + realtimeStressPoints: const [],
  50 + dailyStressPoints: const [],
  51 + ),
  52 + previousHrvRawEndTime: latestTime - Duration.secondsPerHour * 2,
  53 + realtimeWindow: const [],
  54 + record: const HealthRawLocalNotificationRecord(),
  55 + );
  56 +
  57 + expect(notifications, hasLength(1));
  58 + expect(notifications.single.title, 'HRV 32ms · 状态优秀 · 10:00');
  59 + expect(notifications.single.link, healthRawHrvChangeLink);
  60 + });
  61 +
  62 + test('skips hrv notification when previous hrv is too close', () {
  63 + final latestTime = _seconds(DateTime(2026, 1, 1, 10));
  64 + final notifications = builder.build(
  65 + result: HealthRawStressCalculationResult(
  66 + userId: 1,
  67 + hrvStressPoints: [
  68 + _hrvPoint(latestTime),
  69 + ],
  70 + realtimeStressPoints: const [],
  71 + dailyStressPoints: const [],
  72 + ),
  73 + previousHrvRawEndTime: latestTime - Duration.secondsPerHour * 2 + 1,
  74 + realtimeWindow: const [],
  75 + record: const HealthRawLocalNotificationRecord(),
  76 + );
  77 +
  78 + expect(notifications, isEmpty);
  79 + });
  80 +
  81 + test('builds realtime stress notification from latest 60 minute window', () {
  82 + final base = _seconds(DateTime(2026, 1, 1, 9));
  83 + final notifications = builder.build(
  84 + result: HealthRawStressCalculationResult(
  85 + userId: 1,
  86 + hrvStressPoints: const [],
  87 + realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
  88 + dailyStressPoints: const [],
  89 + ),
  90 + previousHrvRawEndTime: null,
  91 + realtimeWindow: [
  92 + for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
  93 + ],
  94 + record: const HealthRawLocalNotificationRecord(),
  95 + );
  96 +
  97 + expect(notifications, hasLength(1));
  98 + expect(notifications.single.title, '注意压力 · 09:00-09:45');
  99 + expect(notifications.single.content, '你过去60分钟压力偏高,建议适当放松,并注意休息与恢复。');
  100 + expect(notifications.single.link, healthRawTodayLink);
  101 + });
  102 +
  103 + test('skips realtime stress notification during likely sleep', () {
  104 + final base = _seconds(DateTime(2026, 1, 1, 9));
  105 + final notifications = builder.build(
  106 + result: HealthRawStressCalculationResult(
  107 + userId: 1,
  108 + hrvStressPoints: const [],
  109 + realtimeStressPoints: [
  110 + _realtimePoint(base + 9 * 300, 70, isSleepLikely: true),
  111 + ],
  112 + dailyStressPoints: const [],
  113 + ),
  114 + previousHrvRawEndTime: null,
  115 + realtimeWindow: [
  116 + for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70),
  117 + _realtimePoint(base + 9 * 300, 70, isSleepLikely: true),
  118 + ],
  119 + record: const HealthRawLocalNotificationRecord(),
  120 + );
  121 +
  122 + expect(notifications, isEmpty);
  123 + });
  124 +}
  125 +
  126 +int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000;
  127 +
  128 +HealthRawHrvStressPoint _hrvPoint(int rawEndTime) {
  129 + return HealthRawHrvStressPoint(
  130 + userId: 1,
  131 + rawEndTime: rawEndTime,
  132 + rawHrv: 35,
  133 + result: 32,
  134 + sourceStartTime: rawEndTime,
  135 + sourceEndTime: rawEndTime,
  136 + state: HealthRawStressState.excellent,
  137 + baselineHrv: 30,
  138 + baselineAwakeHrv: 30,
  139 + baselineSleepHrv: null,
  140 + baselineRestingHr: 60,
  141 + );
  142 +}
  143 +
  144 +HealthRawRealtimeStressPoint _realtimePoint(
  145 + int rawEndTime,
  146 + double result, {
  147 + bool isSleepLikely = false,
  148 +}) {
  149 + return HealthRawRealtimeStressPoint(
  150 + userId: 1,
  151 + rawEndTime: rawEndTime,
  152 + rawHr: 70,
  153 + result: result,
  154 + sourceStartTime: rawEndTime,
  155 + sourceEndTime: rawEndTime,
  156 + flags: HealthRawPointFlags(
  157 + isSleepLikely: isSleepLikely,
  158 + isWorkout: false,
  159 + isWorkoutRecovery: false,
  160 + isSuspectedActivity: false,
  161 + ),
  162 + );
  163 +}