Commit 9a7310bb289af804bf5e0cea3fc43e08b7b97789

Authored by 权海
1 parent f45a4bdc

feat(ui):增加本地推送

... ... @@ -389,6 +389,8 @@ interface PlatformHostApi {
* width.height 切图后输出的大小
*/
fun performCropImage(imageUrl: String, maxKB: Long?, width: Long?, height: Long?, callback: (Result<String?>) -> Unit)
/** 发起本地推送 */
fun sendLocalNotification(title: String, content: String, link: String, callback: (Result<Boolean>) -> Unit)
companion object {
/** The codec used by PlatformHostApi. */
... ... @@ -723,6 +725,28 @@ interface PlatformHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val titleArg = args[0] as String
val contentArg = args[1] as String
val linkArg = args[2] as String
api.sendLocalNotification(titleArg, contentArg, linkArg) { result: Result<Boolean> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(PlatformApiPigeonUtils.wrapError(error))
} else {
val data = result.getOrNull()
reply.reply(PlatformApiPigeonUtils.wrapResult(data))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
... ...
import Foundation
import UIKit
import UserNotifications
enum LocalNotificationSenderError: LocalizedError {
case notificationsDenied
case notificationsUnsupportedStatus(Int)
var errorDescription: String? {
switch self {
case .notificationsDenied:
return "Notifications permission is denied."
case .notificationsUnsupportedStatus(let status):
return "Notifications permission status is unsupported: \(status)."
}
}
}
final class LocalNotificationSender {
static let shared = LocalNotificationSender()
private let center: UNUserNotificationCenter
init(center: UNUserNotificationCenter = .current()) {
self.center = center
}
func send(
title: String,
body: String,
link: String,
onlyWhenAppNotActive: Bool = true
) async throws -> Bool {
if onlyWhenAppNotActive, UIApplication.shared.applicationState == .active {
return false
}
try await ensureNotificationPermission()
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.userInfo = notificationUserInfo(link: link)
let request = UNNotificationRequest(
identifier: "doublefeel.local.\(UUID().uuidString)",
content: content,
trigger: UNTimeIntervalNotificationTrigger(timeInterval: 1, repeats: false)
)
try await center.add(request)
return true
}
private func ensureNotificationPermission() async throws {
let settings = await center.notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return
case .notDetermined:
let granted = try await center.requestAuthorization(options: [.alert, .sound, .badge])
if !granted {
throw LocalNotificationSenderError.notificationsDenied
}
case .denied:
throw LocalNotificationSenderError.notificationsDenied
@unknown default:
throw LocalNotificationSenderError.notificationsUnsupportedStatus(
settings.authorizationStatus.rawValue
)
}
}
private func notificationUserInfo(link: String) -> [String: Any] {
guard !link.isEmpty else { return [:] }
return [
"url": link,
"link": link,
]
}
}
... ...
... ... @@ -409,6 +409,8 @@ protocol PlatformHostApi {
/// maxKB: 压缩大小
/// width.height 切图后输出的大小
func performCropImage(imageUrl: String, maxKB: Int64?, width: Int64?, height: Int64?, completion: @escaping (Result<String?, Error>) -> Void)
/// 发起本地推送
func sendLocalNotification(title: String, content: String, link: String, completion: @escaping (Result<Bool, Error>) -> Void)
}
/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`.
... ... @@ -718,5 +720,25 @@ class PlatformHostApiSetup {
} else {
performCropImageChannel.setMessageHandler(nil)
}
/// 发起本地推送
let sendLocalNotificationChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
if let api = api {
sendLocalNotificationChannel.setMessageHandler { message, reply in
let args = message as! [Any?]
let titleArg = args[0] as! String
let contentArg = args[1] as! String
let linkArg = args[2] as! String
api.sendLocalNotification(title: titleArg, content: contentArg, link: linkArg) { result in
switch result {
case .success(let res):
reply(wrapResult(res))
case .failure(let error):
reply(wrapError(error))
}
}
}
} else {
sendLocalNotificationChannel.setMessageHandler(nil)
}
}
}
... ...
... ... @@ -293,6 +293,22 @@ final class PlatformHostApiImpl: PlatformHostApi {
print("[PlatformHostApiImpl.getFullUserAgent] return: \(userAgent)")
return userAgent
}
func sendLocalNotification(title: String, content: String, link: String, completion: @escaping (Result<Bool, any Error>) -> Void) {
Task {
do {
let sent = try await LocalNotificationSender.shared.send(
title: title,
body: content,
link: link
)
completion(.success(sent))
} catch {
completion(.failure(error))
}
}
}
}
//MARK: - 分享
... ...
... ... @@ -9,11 +9,13 @@ import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
import '../../data/models/enums/app_enums.dart';
import '../../l10n/l10n_extensions.dart';
import '../../pigeon/health_kit_api.g.dart';
import '../../pigeon/health_kit_raw_data_api.g.dart';
import '../config/app_environment_config.dart';
import '../logging/app_logger.dart';
import '../util/app_toast.dart';
import 'health_raw_local_notification.dart';
import 'health_raw_stress_calculator.dart';
import 'health_sleep_calculator.dart';
... ... @@ -28,12 +30,15 @@ class HealthRawDataCoreService {
AppEnvironmentConfig? environmentConfig,
int Function()? userIdProvider,
bool uploadResultsAfterCalculation = true,
HealthRawLocalNotificationDispatcher? localNotificationDispatcher,
}) : _healthApi = healthApi ?? HealthKitHostApi(),
_rawDataApi = rawDataApi ?? HealthKitRawDataHostApi(),
_localStore = localStore ?? HealthRawStressLocalStore(),
_environmentConfig = environmentConfig,
_userIdProvider = userIdProvider,
_uploadResultsAfterCalculation = uploadResultsAfterCalculation;
_uploadResultsAfterCalculation = uploadResultsAfterCalculation,
_localNotificationDispatcher = localNotificationDispatcher ??
HealthRawLocalNotificationDispatcher();
final HealthKitHostApi _healthApi;
final HealthKitRawDataHostApi _rawDataApi;
... ... @@ -41,6 +46,7 @@ class HealthRawDataCoreService {
final AppEnvironmentConfig? _environmentConfig;
final int Function()? _userIdProvider;
final bool _uploadResultsAfterCalculation;
final HealthRawLocalNotificationDispatcher _localNotificationDispatcher;
final StreamController<HealthRawDataUpdatedEvent>
_healthDataUpdatedController =
StreamController<HealthRawDataUpdatedEvent>.broadcast();
... ... @@ -374,6 +380,10 @@ class HealthRawDataCoreService {
dailyStressPoints: dailyStressPoints,
sleepResults: sleepResults,
);
await _sendLocalNotificationsAfterCalculation(
result: storedResult,
previousHrvRawEndTime: latestHrvRawEndTime,
);
if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) {
AppToast.show('首次计算完成');
}
... ... @@ -384,6 +394,49 @@ class HealthRawDataCoreService {
return storedResult;
}
Future<void> _sendLocalNotificationsAfterCalculation({
required HealthRawStressCalculationResult result,
required int? previousHrvRawEndTime,
}) async {
if (result.hrvStressPoints.isEmpty &&
result.realtimeStressPoints.isEmpty &&
result.sleepResults.isEmpty) {
return;
}
try {
final latestRealtimeStressPoint = result.realtimeStressPoints.isEmpty
? null
: result.realtimeStressPoints.reduce(
(a, b) => a.rawEndTime >= b.rawEndTime ? a : b,
);
final realtimeWindow = latestRealtimeStressPoint == null
? const <HealthRawRealtimeStressPoint>[]
: await _localStore.queryRealtimeStressPoints(
userId: result.userId,
startTime: latestRealtimeStressPoint.rawEndTime -
Duration.secondsPerHour +
1,
endTime: latestRealtimeStressPoint.rawEndTime,
);
final record = await _localNotificationDispatcher.readRecord(
result.userId,
);
final notifications = HealthRawLocalNotificationBuilder(l10n).build(
result: result,
previousHrvRawEndTime: previousHrvRawEndTime,
realtimeWindow: realtimeWindow,
record: record,
);
if (notifications.isEmpty) return;
await _localNotificationDispatcher.sendAll(
userId: result.userId,
notifications: notifications,
);
} catch (error, stackTrace) {
_logError('send local health notifications failed', error, stackTrace);
}
}
Future<bool> _hasHealthReadAuthorization() async {
final authorization = await _healthApi.checkHealthAppAuthorization();
return authorization.status == 1;
... ...
import 'dart:convert';
import 'dart:io';
import 'dart:math' as math;
import 'package:path_provider/path_provider.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../pigeon/platform_api.g.dart';
import 'health_raw_data_core_service.dart';
const healthRawTodayLink = 'doublefeel://flutter/home?tab=today';
const healthRawHrvChangeLink =
'doublefeel://flutter/home?tab=today&is_hrv_change=1';
class HealthRawLocalNotification {
const HealthRawLocalNotification({
required this.title,
required this.content,
required this.link,
required this.recordType,
required this.recordTime,
});
final String title;
final String content;
final String link;
final HealthRawLocalNotificationRecordType recordType;
final int recordTime;
}
enum HealthRawLocalNotificationRecordType {
sleep,
hrv,
realtimeStress,
}
class HealthRawLocalNotificationBuilder {
const HealthRawLocalNotificationBuilder(this.l10n);
static const int _hrvMinIntervalSeconds = 2 * 60 * 60;
static const int _realtimeWindowSeconds = 60 * 60;
static const int _realtimeMinPointCount = 10;
final AppLocalizations l10n;
List<HealthRawLocalNotification> build({
required HealthRawStressCalculationResult result,
required int? previousHrvRawEndTime,
required List<HealthRawRealtimeStressPoint> realtimeWindow,
required HealthRawLocalNotificationRecord? record,
}) {
return [
if (_sleepNotification(result.sleepResults) case final notification?)
notification,
if (_hrvNotification(
result.hrvStressPoints,
previousHrvRawEndTime,
record,
)
case final notification?)
notification,
if (_realtimeStressNotification(realtimeWindow, record)
case final notification?)
notification,
];
}
HealthRawLocalNotification? _sleepNotification(
List<HealthRawSleepResult> sleepResults,
) {
if (sleepResults.isEmpty) return null;
final latest = [...sleepResults]..sort((a, b) => a.date.compareTo(b.date));
final sleep = latest.last;
final state = _sleepStateLabel(sleep.sleepState);
if (state == null || sleep.sleepMinutes <= 0) return null;
final duration = _sleepDurationText(sleep.sleepMinutes);
return HealthRawLocalNotification(
title: l10n.healthLocalNotificationSleepTitle(duration, state),
content: l10n.healthLocalNotificationSleepContent,
link: healthRawTodayLink,
recordType: HealthRawLocalNotificationRecordType.sleep,
recordTime: sleep.date,
);
}
HealthRawLocalNotification? _hrvNotification(
List<HealthRawHrvStressPoint> hrvPoints,
int? previousHrvRawEndTime,
HealthRawLocalNotificationRecord? record,
) {
if (hrvPoints.isEmpty) return null;
final sorted = [...hrvPoints]
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
final latest = sorted.last;
final previousTime = sorted.length >= 2
? sorted[sorted.length - 2].rawEndTime
: previousHrvRawEndTime;
if (previousTime == null ||
latest.rawEndTime - previousTime < _hrvMinIntervalSeconds) {
return null;
}
if (record?.lastHrvTime == latest.rawEndTime) return null;
return HealthRawLocalNotification(
title: l10n.healthLocalNotificationHrvTitle(
latest.result.floor(),
_stressStateLabel(latest.state),
_timeText(latest.rawEndTime),
),
content: _hrvContent(latest),
link: healthRawHrvChangeLink,
recordType: HealthRawLocalNotificationRecordType.hrv,
recordTime: latest.rawEndTime,
);
}
HealthRawLocalNotification? _realtimeStressNotification(
List<HealthRawRealtimeStressPoint> realtimeWindow,
HealthRawLocalNotificationRecord? record,
) {
final valid = realtimeWindow
.where((e) => e.result >= 1 && e.result <= 100)
.toList()
..sort((a, b) => a.rawEndTime.compareTo(b.rawEndTime));
if (valid.length < _realtimeMinPointCount) return null;
final latest = valid.last;
if (latest.isSleepLikely) return null;
if (record?.lastRealtimeStressTime case final lastPushTime?) {
if (latest.rawEndTime - lastPushTime < _realtimeWindowSeconds) {
return null;
}
}
final windowStart = math.max(
valid.first.rawEndTime,
latest.rawEndTime - _realtimeWindowSeconds + 1,
);
final average =
valid.map((e) => e.result).reduce((a, b) => a + b) / valid.length;
final state = healthRawRealtimeStressState(average);
return HealthRawLocalNotification(
title: l10n.healthLocalNotificationRealtimeStressTitle(
_stressStateLabel(state),
_timeText(windowStart),
_timeText(latest.rawEndTime),
),
content: _realtimeStressContent(state),
link: healthRawTodayLink,
recordType: HealthRawLocalNotificationRecordType.realtimeStress,
recordTime: latest.rawEndTime,
);
}
String _sleepDurationText(int minutes) {
final hours = minutes ~/ 60;
final remainingMinutes = minutes % 60;
return l10n.healthLocalNotificationSleepDuration(hours, remainingMinutes);
}
String? _sleepStateLabel(int value) {
return switch (value) {
1 => l10n.sleepQualityGreat,
2 => l10n.sleepQualityGood,
3 => l10n.sleepQualityPoor,
_ => null,
};
}
String _stressStateLabel(HealthRawStressState state) {
return switch (state) {
HealthRawStressState.excellent => l10n.inExcellentCondition,
HealthRawStressState.normal => l10n.statusNormal,
HealthRawStressState.attention => l10n.beMindfulOfStress,
HealthRawStressState.overload => l10n.pressureOverload,
};
}
String _hrvContent(HealthRawHrvStressPoint latest) {
final isAboveBaseline = latest.result >= latest.baselineHrv;
return switch (latest.state) {
HealthRawStressState.excellent => isAboveBaseline
? l10n.latestHrvTipExcellentAboveBaseline
: l10n.latestHrvTipExcellentBelowBaseline,
HealthRawStressState.normal => isAboveBaseline
? l10n.latestHrvTipNormalAboveBaseline
: l10n.latestHrvTipNormalBelowBaseline,
HealthRawStressState.attention => isAboveBaseline
? l10n.latestHrvTipAttentionAboveBaseline
: l10n.latestHrvTipAttentionBelowBaseline,
HealthRawStressState.overload => isAboveBaseline
? l10n.latestHrvTipOverloadAboveBaseline
: l10n.latestHrvTipOverloadBelowBaseline,
};
}
String _realtimeStressContent(HealthRawStressState state) {
return switch (state) {
HealthRawStressState.excellent =>
l10n.healthLocalNotificationRealtimeStressExcellentContent,
HealthRawStressState.normal =>
l10n.healthLocalNotificationRealtimeStressNormalContent,
HealthRawStressState.attention =>
l10n.healthLocalNotificationRealtimeStressAttentionContent,
HealthRawStressState.overload =>
l10n.healthLocalNotificationRealtimeStressOverloadContent,
};
}
String _timeText(int seconds) {
final time = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return '${_twoDigits(time.hour)}:${_twoDigits(time.minute)}';
}
String _twoDigits(int value) => value.toString().padLeft(2, '0');
}
class HealthRawLocalNotificationDispatcher {
HealthRawLocalNotificationDispatcher({
PlatformHostApi? platformApi,
HealthRawLocalNotificationRecordStore? recordStore,
}) : _platformApi = platformApi ?? PlatformHostApi(),
_recordStore = recordStore ?? HealthRawLocalNotificationRecordStore();
final PlatformHostApi _platformApi;
final HealthRawLocalNotificationRecordStore _recordStore;
Future<void> sendAll({
required int userId,
required Iterable<HealthRawLocalNotification> notifications,
}) async {
var record = await _recordStore.read(userId);
for (final notification in notifications) {
final sent = await _platformApi.sendLocalNotification(
notification.title,
notification.content,
notification.link,
);
if (!sent) continue;
record = record.withNotification(notification);
await _recordStore.write(userId, record);
}
}
Future<HealthRawLocalNotificationRecord> readRecord(int userId) {
return _recordStore.read(userId);
}
}
class HealthRawLocalNotificationRecord {
const HealthRawLocalNotificationRecord({
this.lastSleepTime,
this.lastHrvTime,
this.lastRealtimeStressTime,
});
final int? lastSleepTime;
final int? lastHrvTime;
final int? lastRealtimeStressTime;
factory HealthRawLocalNotificationRecord.fromJson(Map<String, Object?> json) {
return HealthRawLocalNotificationRecord(
lastSleepTime: (json['last_sleep_time'] as num?)?.toInt(),
lastHrvTime: (json['last_hrv_time'] as num?)?.toInt(),
lastRealtimeStressTime:
(json['last_realtime_stress_time'] as num?)?.toInt(),
);
}
Map<String, Object?> toJson() {
return <String, Object?>{
if (lastSleepTime != null) 'last_sleep_time': lastSleepTime,
if (lastHrvTime != null) 'last_hrv_time': lastHrvTime,
if (lastRealtimeStressTime != null)
'last_realtime_stress_time': lastRealtimeStressTime,
};
}
HealthRawLocalNotificationRecord withNotification(
HealthRawLocalNotification notification,
) {
return switch (notification.recordType) {
HealthRawLocalNotificationRecordType.sleep => copyWith(
lastSleepTime: notification.recordTime,
),
HealthRawLocalNotificationRecordType.hrv => copyWith(
lastHrvTime: notification.recordTime,
),
HealthRawLocalNotificationRecordType.realtimeStress => copyWith(
lastRealtimeStressTime: notification.recordTime,
),
};
}
HealthRawLocalNotificationRecord copyWith({
int? lastSleepTime,
int? lastHrvTime,
int? lastRealtimeStressTime,
}) {
return HealthRawLocalNotificationRecord(
lastSleepTime: lastSleepTime ?? this.lastSleepTime,
lastHrvTime: lastHrvTime ?? this.lastHrvTime,
lastRealtimeStressTime:
lastRealtimeStressTime ?? this.lastRealtimeStressTime,
);
}
}
class HealthRawLocalNotificationRecordStore {
HealthRawLocalNotificationRecordStore({Directory? rootDirectory})
: _rootDirectory = rootDirectory;
static const _fileName = 'health_raw_local_notification_records.json';
final Directory? _rootDirectory;
Future<HealthRawLocalNotificationRecord> read(int userId) async {
final records = await _readRecords();
final userRecord = records[userId.toString()];
if (userRecord is Map<String, Object?>) {
return HealthRawLocalNotificationRecord.fromJson(userRecord);
}
if (userRecord is Map) {
return HealthRawLocalNotificationRecord.fromJson(
userRecord.cast<String, Object?>(),
);
}
return const HealthRawLocalNotificationRecord();
}
Future<void> write(
int userId,
HealthRawLocalNotificationRecord record,
) async {
final records = await _readRecords();
records[userId.toString()] = record.toJson();
final file = await _file();
await file.writeAsString(jsonEncode(records), flush: true);
}
Future<Map<String, Object?>> _readRecords() async {
final file = await _file();
if (!await file.exists()) return <String, Object?>{};
final content = await file.readAsString();
if (content.trim().isEmpty) return <String, Object?>{};
final decoded = jsonDecode(content);
if (decoded is Map<String, Object?>) return decoded;
if (decoded is Map) return decoded.cast<String, Object?>();
return <String, Object?>{};
}
Future<File> _file() async {
final dir = _rootDirectory ?? await getApplicationDocumentsDirectory();
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return File('${dir.path}/$_fileName');
}
}
... ...
... ... @@ -711,6 +711,65 @@
"latestHrvTipAttentionBelowBaseline": "Your HRV is clearly below your usual level. Recent stress may be elevated, so try to rest and adjust your state.",
"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.",
"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.",
"healthLocalNotificationSleepDuration": "{hours}h {minutes}m",
"@healthLocalNotificationSleepDuration": {
"description": "Sleep duration text in local notifications",
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"healthLocalNotificationSleepTitle": "Sleep {duration} · {state}",
"@healthLocalNotificationSleepTitle": {
"description": "Local notification title after sleep analysis completes",
"placeholders": {
"duration": {
"type": "String"
},
"state": {
"type": "String"
}
}
},
"healthLocalNotificationSleepContent": "Today's sleep report is ready. Tap to view your detailed sleep data.",
"healthLocalNotificationHrvTitle": "HRV {hrv}ms · {state} · {time}",
"@healthLocalNotificationHrvTitle": {
"description": "Local notification title for new HRV data",
"placeholders": {
"hrv": {
"type": "int"
},
"state": {
"type": "String"
},
"time": {
"type": "String"
}
}
},
"healthLocalNotificationRealtimeStressTitle": "{state} · {startTime}-{endTime}",
"@healthLocalNotificationRealtimeStressTitle": {
"description": "Local notification title for 60-minute realtime stress summary",
"placeholders": {
"state": {
"type": "String"
},
"startTime": {
"type": "String"
},
"endTime": {
"type": "String"
}
}
},
"healthLocalNotificationRealtimeStressExcellentContent": "Your realtime stress stayed low over the past 60 minutes. You seem relaxed overall. Keep your current rhythm.",
"healthLocalNotificationRealtimeStressNormalContent": "Your stress state was stable over the past 60 minutes. Your current rhythm looks normal.",
"healthLocalNotificationRealtimeStressAttentionContent": "Your stress was elevated over the past 60 minutes. Consider relaxing and making time for rest and recovery.",
"healthLocalNotificationRealtimeStressOverloadContent": "You stayed in a high-stress state over the past 60 minutes. Reduce exertion and prioritize rest and sleep.",
"turnOnNotifications": "Turn on Notifications",
"stayUpToDateOnChangesInYourOwnAndYourFriendsHealth": "Stay up to date on changes in your own and your friends' health"
}
\ No newline at end of file
}
... ...
... ... @@ -1104,6 +1104,65 @@
"latestHrvTipAttentionBelowBaseline": "你的 HRV 明显低于日常水平,近期可能压力偏高,建议尽量休息与调整状态。",
"latestHrvTipOverloadAboveBaseline": "你的 HRV 处于较低水平,身体可能正在承受较高压力(刚运动完 HRV 降低则属于正常情况),建议及时休息与恢复。",
"latestHrvTipOverloadBelowBaseline": "你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。",
"healthLocalNotificationSleepDuration": "{hours}小时{minutes}分钟",
"@healthLocalNotificationSleepDuration": {
"description": "本地通知中的睡眠时长文本",
"placeholders": {
"hours": {
"type": "int"
},
"minutes": {
"type": "int"
}
}
},
"healthLocalNotificationSleepTitle": "睡眠时长{duration}·{state}",
"@healthLocalNotificationSleepTitle": {
"description": "睡眠计算完成后的本地通知标题",
"placeholders": {
"duration": {
"type": "String"
},
"state": {
"type": "String"
}
}
},
"healthLocalNotificationSleepContent": "你今天的睡眠报告已出炉,点击查看详细睡眠数据。",
"healthLocalNotificationHrvTitle": "HRV {hrv}ms · {state} · {time}",
"@healthLocalNotificationHrvTitle": {
"description": "HRV 新数据本地通知标题",
"placeholders": {
"hrv": {
"type": "int"
},
"state": {
"type": "String"
},
"time": {
"type": "String"
}
}
},
"healthLocalNotificationRealtimeStressTitle": "{state} · {startTime}-{endTime}",
"@healthLocalNotificationRealtimeStressTitle": {
"description": "实时压力 60 分钟总结本地通知标题",
"placeholders": {
"state": {
"type": "String"
},
"startTime": {
"type": "String"
},
"endTime": {
"type": "String"
}
}
},
"healthLocalNotificationRealtimeStressExcellentContent": "你过去60分钟的实时压力较低,整体状态较放松,继续保持当前节奏。",
"healthLocalNotificationRealtimeStressNormalContent": "你过去60分钟的压力状态整体稳定,当前节奏正常。",
"healthLocalNotificationRealtimeStressAttentionContent": "你过去60分钟压力偏高,建议适当放松,并注意休息与恢复。",
"healthLocalNotificationRealtimeStressOverloadContent": "你过去60分钟持续处于高压力状态,建议减少消耗,并优先保证休息与睡眠。",
"turnOnNotifications": "开启通知",
"stayUpToDateOnChangesInYourOwnAndYourFriendsHealth": "及时了解自己和好友的健康波动"
}
\ No newline at end of file
}
... ...
... ... @@ -4083,6 +4083,61 @@ abstract class AppLocalizations {
/// **'你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。'**
String get latestHrvTipOverloadBelowBaseline;
/// 本地通知中的睡眠时长文本
///
/// In zh, this message translates to:
/// **'{hours}小时{minutes}分钟'**
String healthLocalNotificationSleepDuration(int hours, int minutes);
/// 睡眠计算完成后的本地通知标题
///
/// In zh, this message translates to:
/// **'睡眠时长{duration}·{state}'**
String healthLocalNotificationSleepTitle(String duration, String state);
/// No description provided for @healthLocalNotificationSleepContent.
///
/// In zh, this message translates to:
/// **'你今天的睡眠报告已出炉,点击查看详细睡眠数据。'**
String get healthLocalNotificationSleepContent;
/// HRV 新数据本地通知标题
///
/// In zh, this message translates to:
/// **'HRV {hrv}ms · {state} · {time}'**
String healthLocalNotificationHrvTitle(int hrv, String state, String time);
/// 实时压力 60 分钟总结本地通知标题
///
/// In zh, this message translates to:
/// **'{state} · {startTime}-{endTime}'**
String healthLocalNotificationRealtimeStressTitle(
String state, String startTime, String endTime);
/// No description provided for @healthLocalNotificationRealtimeStressExcellentContent.
///
/// In zh, this message translates to:
/// **'你过去60分钟的实时压力较低,整体状态较放松,继续保持当前节奏。'**
String get healthLocalNotificationRealtimeStressExcellentContent;
/// No description provided for @healthLocalNotificationRealtimeStressNormalContent.
///
/// In zh, this message translates to:
/// **'你过去60分钟的压力状态整体稳定,当前节奏正常。'**
String get healthLocalNotificationRealtimeStressNormalContent;
/// No description provided for @healthLocalNotificationRealtimeStressAttentionContent.
///
/// In zh, this message translates to:
/// **'你过去60分钟压力偏高,建议适当放松,并注意休息与恢复。'**
String get healthLocalNotificationRealtimeStressAttentionContent;
/// No description provided for @healthLocalNotificationRealtimeStressOverloadContent.
///
/// In zh, this message translates to:
/// **'你过去60分钟持续处于高压力状态,建议减少消耗,并优先保证休息与睡眠。'**
String get healthLocalNotificationRealtimeStressOverloadContent;
/// No description provided for @turnOnNotifications.
///
/// In zh, this message translates to:
... ...
... ... @@ -2290,6 +2290,47 @@ class AppLocalizationsEn extends AppLocalizations {
'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.';
@override
String healthLocalNotificationSleepDuration(int hours, int minutes) {
return '${hours}h ${minutes}m';
}
@override
String healthLocalNotificationSleepTitle(String duration, String state) {
return 'Sleep $duration · $state';
}
@override
String get healthLocalNotificationSleepContent =>
'Today\'s sleep report is ready. Tap to view your detailed sleep data.';
@override
String healthLocalNotificationHrvTitle(int hrv, String state, String time) {
return 'HRV ${hrv}ms · $state · $time';
}
@override
String healthLocalNotificationRealtimeStressTitle(
String state, String startTime, String endTime) {
return '$state · $startTime-$endTime';
}
@override
String get healthLocalNotificationRealtimeStressExcellentContent =>
'Your realtime stress stayed low over the past 60 minutes. You seem relaxed overall. Keep your current rhythm.';
@override
String get healthLocalNotificationRealtimeStressNormalContent =>
'Your stress state was stable over the past 60 minutes. Your current rhythm looks normal.';
@override
String get healthLocalNotificationRealtimeStressAttentionContent =>
'Your stress was elevated over the past 60 minutes. Consider relaxing and making time for rest and recovery.';
@override
String get healthLocalNotificationRealtimeStressOverloadContent =>
'You stayed in a high-stress state over the past 60 minutes. Reduce exertion and prioritize rest and sleep.';
@override
String get turnOnNotifications => 'Turn on Notifications';
@override
... ...
... ... @@ -2187,6 +2187,46 @@ class AppLocalizationsZh extends AppLocalizations {
'你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。';
@override
String healthLocalNotificationSleepDuration(int hours, int minutes) {
return '$hours小时$minutes分钟';
}
@override
String healthLocalNotificationSleepTitle(String duration, String state) {
return '睡眠时长$duration·$state';
}
@override
String get healthLocalNotificationSleepContent => '你今天的睡眠报告已出炉,点击查看详细睡眠数据。';
@override
String healthLocalNotificationHrvTitle(int hrv, String state, String time) {
return 'HRV ${hrv}ms · $state · $time';
}
@override
String healthLocalNotificationRealtimeStressTitle(
String state, String startTime, String endTime) {
return '$state · $startTime-$endTime';
}
@override
String get healthLocalNotificationRealtimeStressExcellentContent =>
'你过去60分钟的实时压力较低,整体状态较放松,继续保持当前节奏。';
@override
String get healthLocalNotificationRealtimeStressNormalContent =>
'你过去60分钟的压力状态整体稳定,当前节奏正常。';
@override
String get healthLocalNotificationRealtimeStressAttentionContent =>
'你过去60分钟压力偏高,建议适当放松,并注意休息与恢复。';
@override
String get healthLocalNotificationRealtimeStressOverloadContent =>
'你过去60分钟持续处于高压力状态,建议减少消耗,并优先保证休息与睡眠。';
@override
String get turnOnNotifications => '开启通知';
@override
... ...
... ... @@ -842,4 +842,33 @@ class PlatformHostApi {
return (pigeonVar_replyList[0] as String?);
}
}
/// 发起本地推送
Future<bool> sendLocalNotification(String title, String content, String link) async {
final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.PlatformHostApi.sendLocalNotification$pigeonVar_messageChannelSuffix';
final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[title, content, link]);
final List<Object?>? pigeonVar_replyList =
await pigeonVar_sendFuture as List<Object?>?;
if (pigeonVar_replyList == null) {
throw _createConnectionError(pigeonVar_channelName);
} else if (pigeonVar_replyList.length > 1) {
throw PlatformException(
code: pigeonVar_replyList[0]! as String,
message: pigeonVar_replyList[1] as String?,
details: pigeonVar_replyList[2],
);
} else if (pigeonVar_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (pigeonVar_replyList[0] as bool?)!;
}
}
}
... ...
... ... @@ -182,4 +182,8 @@ abstract class PlatformHostApi {
@async
String? performCropImage(
String imageUrl, int? maxKB, int? width, int? height);
/// 发起本地推送
@async
bool sendLocalNotification(String title, String content, String link);
}
... ...
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/services/health_raw_local_notification.dart';
import 'package:doublefeel_flutter/l10n/gen/app_localizations_zh.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final builder = HealthRawLocalNotificationBuilder(AppLocalizationsZh());
test('builds sleep notification after new sleep result', () {
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: const [],
dailyStressPoints: const [],
sleepResults: const [
HealthRawSleepResult(
userId: 1,
date: 1000,
startDate: 100,
sleepScore: 80,
sleepState: 2,
inBedMinutes: 430,
awakMinutes: 25,
sleepMinutes: 405,
),
],
),
previousHrvRawEndTime: null,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, hasLength(1));
expect(notifications.single.title, '睡眠时长6小时45分钟·睡得不错');
expect(notifications.single.content, '你今天的睡眠报告已出炉,点击查看详细睡眠数据。');
expect(notifications.single.link, healthRawTodayLink);
});
test('builds hrv notification when previous hrv is at least two hours away',
() {
final latestTime = _seconds(DateTime(2026, 1, 1, 10));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: [
_hrvPoint(latestTime),
],
realtimeStressPoints: const [],
dailyStressPoints: const [],
),
previousHrvRawEndTime: latestTime - Duration.secondsPerHour * 2,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, hasLength(1));
expect(notifications.single.title, 'HRV 32ms · 状态优秀 · 10:00');
expect(notifications.single.link, healthRawHrvChangeLink);
});
test('skips hrv notification when previous hrv is too close', () {
final latestTime = _seconds(DateTime(2026, 1, 1, 10));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: [
_hrvPoint(latestTime),
],
realtimeStressPoints: const [],
dailyStressPoints: const [],
),
previousHrvRawEndTime: latestTime - Duration.secondsPerHour * 2 + 1,
realtimeWindow: const [],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, isEmpty);
});
test('builds realtime stress notification from latest 60 minute window', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: [_realtimePoint(base + 9 * 300, 70)],
dailyStressPoints: const [],
),
previousHrvRawEndTime: null,
realtimeWindow: [
for (var i = 0; i < 10; i++) _realtimePoint(base + i * 300, 70),
],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, hasLength(1));
expect(notifications.single.title, '注意压力 · 09:00-09:45');
expect(notifications.single.content, '你过去60分钟压力偏高,建议适当放松,并注意休息与恢复。');
expect(notifications.single.link, healthRawTodayLink);
});
test('skips realtime stress notification during likely sleep', () {
final base = _seconds(DateTime(2026, 1, 1, 9));
final notifications = builder.build(
result: HealthRawStressCalculationResult(
userId: 1,
hrvStressPoints: const [],
realtimeStressPoints: [
_realtimePoint(base + 9 * 300, 70, isSleepLikely: true),
],
dailyStressPoints: const [],
),
previousHrvRawEndTime: null,
realtimeWindow: [
for (var i = 0; i < 9; i++) _realtimePoint(base + i * 300, 70),
_realtimePoint(base + 9 * 300, 70, isSleepLikely: true),
],
record: const HealthRawLocalNotificationRecord(),
);
expect(notifications, isEmpty);
});
}
int _seconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000;
HealthRawHrvStressPoint _hrvPoint(int rawEndTime) {
return HealthRawHrvStressPoint(
userId: 1,
rawEndTime: rawEndTime,
rawHrv: 35,
result: 32,
sourceStartTime: rawEndTime,
sourceEndTime: rawEndTime,
state: HealthRawStressState.excellent,
baselineHrv: 30,
baselineAwakeHrv: 30,
baselineSleepHrv: null,
baselineRestingHr: 60,
);
}
HealthRawRealtimeStressPoint _realtimePoint(
int rawEndTime,
double result, {
bool isSleepLikely = false,
}) {
return HealthRawRealtimeStressPoint(
userId: 1,
rawEndTime: rawEndTime,
rawHr: 70,
result: result,
sourceStartTime: rawEndTime,
sourceEndTime: rawEndTime,
flags: HealthRawPointFlags(
isSleepLikely: isSleepLikely,
isWorkout: false,
isWorkoutRecovery: false,
isSuspectedActivity: false,
),
);
}
... ...