Showing
14 changed files
with
1008 additions
and
3 deletions
| @@ -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 | } |
ios/Runner/LocalNotificationSender.swift
0 → 100644
| 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 | +} |
-
Please register or login to post a comment