Commit 3a10895f8bd156a0f1323278906f8178ece48900

Authored by 权海
1 parent 26c97b6e

feat(ui):首页HRV tip计算

@@ -42,11 +42,27 @@ final class HealthRawStressSQLiteUploader { @@ -42,11 +42,27 @@ final class HealthRawStressSQLiteUploader {
42 42
43 private let session: URLSession 43 private let session: URLSession
44 private let batchSize = 200 44 private let batchSize = 200
  45 + private(set) var hrDataPath: String?
  46 + private(set) var hrvDataPath: String?
  47 + private(set) var sleepDataPath: String?
  48 + private(set) var avgRealtimeStressDataPath: String?
45 49
46 init(session: URLSession = .shared) { 50 init(session: URLSession = .shared) {
47 self.session = session 51 self.session = session
48 } 52 }
49 53
  54 + func syncDatabase(
  55 + hrDataPath: String,
  56 + hrvDataPath: String,
  57 + sleepDataPath: String,
  58 + avgRealtimeStressDataPath: String
  59 + ) {
  60 + self.hrDataPath = hrDataPath
  61 + self.hrvDataPath = hrvDataPath
  62 + self.sleepDataPath = sleepDataPath
  63 + self.avgRealtimeStressDataPath = avgRealtimeStressDataPath
  64 + }
  65 +
50 func uploadHrv(sqliteFilePath: String) async throws -> Int64 { 66 func uploadHrv(sqliteFilePath: String) async throws -> Int64 {
51 let rows = try queryRows( 67 let rows = try queryRows(
52 sqliteFilePath: sqliteFilePath, 68 sqliteFilePath: sqliteFilePath,
@@ -370,6 +370,8 @@ protocol HealthKitRawDataHostApi { @@ -370,6 +370,8 @@ protocol HealthKitRawDataHostApi {
370 func getHealthKitRawData(dataType: Int64, startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthKitRawDataPoint], Error>) -> Void) 370 func getHealthKitRawData(dataType: Int64, startTime: Int64, endTime: Int64, completion: @escaping (Result<[HealthKitRawDataPoint], Error>) -> Void)
371 /// 原生数据上传 371 /// 原生数据上传
372 func performHealthDataUpload(completion: @escaping (Result<Bool, Error>) -> Void) 372 func performHealthDataUpload(completion: @escaping (Result<Bool, Error>) -> Void)
  373 + /// 向原生同步数据库路径
  374 + func syncDatabase(hrDataPath: String, hrvDataPath: String, sleepDataPath: String, avgRealtimeStressDataPath: String, completion: @escaping (Result<Bool, Error>) -> Void)
373 /// 上传心率 - 实时压力数据, 返回上传截止的时间戳, 方便flutter修改数据库 375 /// 上传心率 - 实时压力数据, 返回上传截止的时间戳, 方便flutter修改数据库
374 func performHRDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void) 376 func performHRDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, Error>) -> Void)
375 /// 上传HRV - HRV压力数据, 返回上传截止的时间戳,方便flutter 修改数据库 377 /// 上传HRV - HRV压力数据, 返回上传截止的时间戳,方便flutter 修改数据库
@@ -444,6 +446,27 @@ class HealthKitRawDataHostApiSetup { @@ -444,6 +446,27 @@ class HealthKitRawDataHostApiSetup {
444 } else { 446 } else {
445 performHealthDataUploadChannel.setMessageHandler(nil) 447 performHealthDataUploadChannel.setMessageHandler(nil)
446 } 448 }
  449 + /// 向原生同步数据库路径
  450 + let syncDatabaseChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.syncDatabase\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
  451 + if let api = api {
  452 + syncDatabaseChannel.setMessageHandler { message, reply in
  453 + let args = message as! [Any?]
  454 + let hrDataPathArg = args[0] as! String
  455 + let hrvDataPathArg = args[1] as! String
  456 + let sleepDataPathArg = args[2] as! String
  457 + let avgRealtimeStressDataPathArg = args[3] as! String
  458 + api.syncDatabase(hrDataPath: hrDataPathArg, hrvDataPath: hrvDataPathArg, sleepDataPath: sleepDataPathArg, avgRealtimeStressDataPath: avgRealtimeStressDataPathArg) { result in
  459 + switch result {
  460 + case .success(let res):
  461 + reply(wrapResult(res))
  462 + case .failure(let error):
  463 + reply(wrapError(error))
  464 + }
  465 + }
  466 + }
  467 + } else {
  468 + syncDatabaseChannel.setMessageHandler(nil)
  469 + }
447 /// 上传心率 - 实时压力数据, 返回上传截止的时间戳, 方便flutter修改数据库 470 /// 上传心率 - 实时压力数据, 返回上传截止的时间戳, 方便flutter修改数据库
448 let performHRDataUploadChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performHRDataUpload\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) 471 let performHRDataUploadChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performHRDataUpload\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec)
449 if let api = api { 472 if let api = api {
@@ -22,6 +22,17 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi { @@ -22,6 +22,17 @@ final class HealthKitRawDataHostApiImpl: HealthKitRawDataHostApi {
22 } 22 }
23 } 23 }
24 24
  25 + func syncDatabase(hrDataPath: String, hrvDataPath: String, sleepDataPath: String, avgRealtimeStressDataPath: String, completion: @escaping (Result<Bool, any Error>) -> Void) {
  26 + HealthRawStressSQLiteUploader.shared.syncDatabase(
  27 + hrDataPath: hrDataPath,
  28 + hrvDataPath: hrvDataPath,
  29 + sleepDataPath: sleepDataPath,
  30 + avgRealtimeStressDataPath: avgRealtimeStressDataPath
  31 + )
  32 + completion(.success(true))
  33 + }
  34 +
  35 +
25 func performHRDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) { 36 func performHRDataUpload(sqliteFilePath: String, completion: @escaping (Result<Int64, any Error>) -> Void) {
26 print("trigger HRDataUpload") 37 print("trigger HRDataUpload")
27 Task { 38 Task {
@@ -201,8 +201,7 @@ class TodayController extends GetMaterialController { @@ -201,8 +201,7 @@ class TodayController extends GetMaterialController {
201 showPartnerAdBanner.value = false; 201 showPartnerAdBanner.value = false;
202 } else { 202 } else {
203 _refreshFriendList(onFriendListUpdated: () { 203 _refreshFriendList(onFriendListUpdated: () {
204 - if (friendsList.isNotEmpty ||  
205 - friendsList.value.length >= friendsListLimit) { 204 + if (friendsList.isNotEmpty || friendsList.length >= friendsListLimit) {
206 showPartnerAdBanner.value = false; 205 showPartnerAdBanner.value = false;
207 return; 206 return;
208 } 207 }
@@ -241,10 +240,12 @@ class TodayController extends GetMaterialController { @@ -241,10 +240,12 @@ class TodayController extends GetMaterialController {
241 240
242 DateTime _clampDate(DateTime date) { 241 DateTime _clampDate(DateTime date) {
243 final normalizedDate = DateUtils.dateOnly(date); 242 final normalizedDate = DateUtils.dateOnly(date);
244 - if (normalizedDate.isBefore(firstSelectableDay.value)) 243 + if (normalizedDate.isBefore(firstSelectableDay.value)) {
245 return firstSelectableDay.value; 244 return firstSelectableDay.value;
246 - if (normalizedDate.isAfter(lastSelectableDay.value)) 245 + }
  246 + if (normalizedDate.isAfter(lastSelectableDay.value)) {
247 return lastSelectableDay.value; 247 return lastSelectableDay.value;
  248 + }
248 return normalizedDate; 249 return normalizedDate;
249 } 250 }
250 251
@@ -275,7 +276,7 @@ class TodayController extends GetMaterialController { @@ -275,7 +276,7 @@ class TodayController extends GetMaterialController {
275 AppLogger.d("requestHealthClientAuthorization : $success"); 276 AppLogger.d("requestHealthClientAuthorization : $success");
276 if (success) { 277 if (success) {
277 // await LoadingService.instance.run(() async { 278 // await LoadingService.instance.run(() async {
278 - _rawDataApi.performHealthDataUpload(); 279 + _performHealthDataUpload();
279 _startCoreCaculate(refreshAfterComplete: true); 280 _startCoreCaculate(refreshAfterComplete: true);
280 // }); 281 // });
281 // result.status = 1; 282 // result.status = 1;
@@ -291,7 +292,7 @@ class TodayController extends GetMaterialController { @@ -291,7 +292,7 @@ class TodayController extends GetMaterialController {
291 return; 292 return;
292 } else if (result.status == 1) { 293 } else if (result.status == 1) {
293 // await LoadingService.instance.run(() async { 294 // await LoadingService.instance.run(() async {
294 - _rawDataApi.performHealthDataUpload(); 295 + _performHealthDataUpload();
295 _startCoreCaculate(refreshAfterComplete: true); 296 _startCoreCaculate(refreshAfterComplete: true);
296 // }); 297 // });
297 if (isFromNoPermissionPage) { 298 if (isFromNoPermissionPage) {
@@ -352,17 +353,42 @@ class TodayController extends GetMaterialController { @@ -352,17 +353,42 @@ class TodayController extends GetMaterialController {
352 unawaited(loadDataForDate(selectedDate.value)); 353 unawaited(loadDataForDate(selectedDate.value));
353 showHealthDataAuthCardStatus.value = result.status; 354 showHealthDataAuthCardStatus.value = result.status;
354 } 355 }
355 - } catch (e) {} 356 + } catch (error, stackTrace) {
  357 + AppLogger.e(
  358 + 'TodayController.checkHealthDataAuthCardVisible failed',
  359 + error,
  360 + stackTrace,
  361 + );
  362 + }
  363 + }
  364 + }
  365 +
  366 + Future<void> checkUploadedStatus(
  367 + DateTime firstTime, HealthAuthorization result) async {
  368 + bool hasUploaded = false;
  369 + switch (await _healthApi.getHealthDataEverUploaded()) {
  370 + case AppSuccess(:final data):
  371 + hasUploaded = data.flag ?? false;
  372 + if (hasUploaded && data.startTime != null && data.startTime! > 0) {
  373 + final firstDayTime = DateUtils.dateOnly(
  374 + DateTime.fromMillisecondsSinceEpoch(data.startTime! * 1000),
  375 + );
  376 + if (firstDayTime.isBefore(firstTime)) {
  377 + firstSelectableDay.value = firstDayTime;
  378 + }
  379 + }
  380 + case AppFailure():
356 } 381 }
  382 + showHealthDataAuthCardStatus.value = !hasUploaded ? -1 : result.status;
357 } 383 }
358 384
359 - void _calculateHealthDataWithoutLoading({VoidCallback? uploaded}) { 385 + void _calculateHealthDataWithoutLoading({Future<void> Function()? uploaded}) {
360 unawaited( 386 unawaited(
361 (() async { 387 (() async {
362 try { 388 try {
363 await _healthRawDataCoreService.startCoreCaculate(); 389 await _healthRawDataCoreService.startCoreCaculate();
364 - unawaited(loadDataForDate(selectedDate.value));  
365 - uploaded?.call(); 390 + await loadDataForDate(selectedDate.value);
  391 + await uploaded?.call();
366 } catch (error, stackTrace) { 392 } catch (error, stackTrace) {
367 AppLogger.e('Apple Health calculate failed', error, stackTrace); 393 AppLogger.e('Apple Health calculate failed', error, stackTrace);
368 } 394 }
@@ -385,6 +411,17 @@ class TodayController extends GetMaterialController { @@ -385,6 +411,17 @@ class TodayController extends GetMaterialController {
385 ); 411 );
386 } 412 }
387 413
  414 + void _performHealthDataUpload() {
  415 + unawaited(
  416 + _rawDataApi
  417 + .performHealthDataUpload()
  418 + .catchError((Object error, StackTrace stackTrace) {
  419 + AppLogger.e('Apple Health raw upload failed', error, stackTrace);
  420 + return false;
  421 + }),
  422 + );
  423 + }
  424 +
388 Future<void> checkNotificationCardVisible() async { 425 Future<void> checkNotificationCardVisible() async {
389 try { 426 try {
390 Permission.notification.status.then((status) { 427 Permission.notification.status.then((status) {
@@ -563,7 +600,9 @@ class TodayController extends GetMaterialController { @@ -563,7 +600,9 @@ class TodayController extends GetMaterialController {
563 try { 600 try {
564 final vipPrefs = UserPreferencesVipInfo.fromVipInfo(vip); 601 final vipPrefs = UserPreferencesVipInfo.fromVipInfo(vip);
565 await Get.find<UserPreferencesStorage>().updateVipInfo(vipPrefs); 602 await Get.find<UserPreferencesStorage>().updateVipInfo(vipPrefs);
566 - } on Exception catch (e) {} 603 + } on Exception catch (error, stackTrace) {
  604 + AppLogger.e('Update VIP info failed', error, stackTrace);
  605 + }
567 } 606 }
568 } 607 }
569 608
@@ -574,7 +613,9 @@ class TodayController extends GetMaterialController { @@ -574,7 +613,9 @@ class TodayController extends GetMaterialController {
574 try { 613 try {
575 final vipPrefs = UserPreferencesVipInfo.fromVipInfo(vip); 614 final vipPrefs = UserPreferencesVipInfo.fromVipInfo(vip);
576 await Get.find<UserPreferencesStorage>().updateVipInfo(vipPrefs); 615 await Get.find<UserPreferencesStorage>().updateVipInfo(vipPrefs);
577 - } on Exception catch (e) {} 616 + } on Exception catch (error, stackTrace) {
  617 + AppLogger.e('Update VIP info failed', error, stackTrace);
  618 + }
578 } 619 }
579 } 620 }
580 621
@@ -595,7 +595,7 @@ class _LatestHrvCard extends StatelessWidget { @@ -595,7 +595,7 @@ class _LatestHrvCard extends StatelessWidget {
595 const SizedBox(height: 8), 595 const SizedBox(height: 8),
596 Text( 596 Text(
597 tip, 597 tip,
598 - maxLines: 2, 598 + maxLines: 10,
599 overflow: TextOverflow.ellipsis, 599 overflow: TextOverflow.ellipsis,
600 style: TextStyle( 600 style: TextStyle(
601 color: colors.textSecondary, 601 color: colors.textSecondary,
@@ -7,6 +7,7 @@ import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_sta @@ -7,6 +7,7 @@ import 'package:doublefeel_flutter/data/models/health/activity/activity_burn_sta
7 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; 7 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
8 import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart'; 8 import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart';
9 import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart'; 9 import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart';
  10 +import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
10 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; 11 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
11 12
12 class LocalHealthDataConvert { 13 class LocalHealthDataConvert {
@@ -520,13 +521,23 @@ class LocalHealthDataConvert { @@ -520,13 +521,23 @@ class LocalHealthDataConvert {
520 } 521 }
521 522
522 static V2LatestHrvData v2LatestHrvData( 523 static V2LatestHrvData v2LatestHrvData(
523 - List<HealthRawHrvStressPoint> hrvPoints,  
524 - ) { 524 + List<HealthRawHrvStressPoint> hrvPoints, {
  525 + List<HealthRawHrvStressPoint> previousHrvPoints =
  526 + const <HealthRawHrvStressPoint>[],
  527 + }) {
525 final latest = _latestHrvByRawEndTime(hrvPoints); 528 final latest = _latestHrvByRawEndTime(hrvPoints);
526 return V2LatestHrvData( 529 return V2LatestHrvData(
527 latestHrv: (latest?.result ?? 0).floor().toDouble(), 530 latestHrv: (latest?.result ?? 0).floor().toDouble(),
528 latestDataTime: latest?.rawEndTime, 531 latestDataTime: latest?.rawEndTime,
529 state: latest?.state.value, 532 state: latest?.state.value,
  533 + tip: _latestHrvTip(
  534 + latest: latest,
  535 + previous: _previousHrvPoint(
  536 + latest: latest,
  537 + hrvPoints: hrvPoints,
  538 + previousHrvPoints: previousHrvPoints,
  539 + ),
  540 + ),
530 ); 541 );
531 } 542 }
532 543
@@ -598,6 +609,47 @@ class LocalHealthDataConvert { @@ -598,6 +609,47 @@ class LocalHealthDataConvert {
598 ); 609 );
599 } 610 }
600 611
  612 + static HealthRawHrvStressPoint? _previousHrvPoint({
  613 + required HealthRawHrvStressPoint? latest,
  614 + required List<HealthRawHrvStressPoint> hrvPoints,
  615 + required List<HealthRawHrvStressPoint> previousHrvPoints,
  616 + }) {
  617 + if (latest == null) return null;
  618 + return [...previousHrvPoints, ...hrvPoints]
  619 + .where((item) => item.rawEndTime < latest.rawEndTime)
  620 + .fold<HealthRawHrvStressPoint?>(
  621 + null,
  622 + (previous, item) =>
  623 + previous == null || item.rawEndTime > previous.rawEndTime
  624 + ? item
  625 + : previous,
  626 + );
  627 + }
  628 +
  629 + static String? _latestHrvTip({
  630 + required HealthRawHrvStressPoint? latest,
  631 + required HealthRawHrvStressPoint? previous,
  632 + }) {
  633 + if (latest == null || previous == null) return null;
  634 + if (latest.rawEndTime - previous.rawEndTime < 2 * 60 * 60) return null;
  635 +
  636 + final isAboveBaseline = latest.result >= latest.baselineHrv;
  637 + return switch (latest.state) {
  638 + HealthRawStressState.excellent => isAboveBaseline
  639 + ? l10n.latestHrvTipExcellentAboveBaseline
  640 + : l10n.latestHrvTipExcellentBelowBaseline,
  641 + HealthRawStressState.normal => isAboveBaseline
  642 + ? l10n.latestHrvTipNormalAboveBaseline
  643 + : l10n.latestHrvTipNormalBelowBaseline,
  644 + HealthRawStressState.attention => isAboveBaseline
  645 + ? l10n.latestHrvTipAttentionAboveBaseline
  646 + : l10n.latestHrvTipAttentionBelowBaseline,
  647 + HealthRawStressState.overload => isAboveBaseline
  648 + ? l10n.latestHrvTipOverloadAboveBaseline
  649 + : l10n.latestHrvTipOverloadBelowBaseline,
  650 + };
  651 + }
  652 +
601 static _ActivityDaySummary? _latestActivitySummaryWithGoal( 653 static _ActivityDaySummary? _latestActivitySummaryWithGoal(
602 List<_ActivityDaySummary> daily, 654 List<_ActivityDaySummary> daily,
603 ) { 655 ) {
@@ -249,11 +249,22 @@ class LocalHealthDataSource implements HealthDataSource { @@ -249,11 +249,22 @@ class LocalHealthDataSource implements HealthDataSource {
249 int? queryUserId, int intDate) async { 249 int? queryUserId, int intDate) async {
250 try { 250 try {
251 final (startTime, endTime) = _dayRange(intDate); 251 final (startTime, endTime) = _dayRange(intDate);
  252 + final previousStartTime = startTime -
  253 + HealthRawDataCoreService.defaultLookbackDays * Duration.secondsPerDay;
  254 + final previousHrvPoints = await coreService.queryHrvStressPoints(
  255 + startTime: previousStartTime,
  256 + endTime: startTime - 1,
  257 + );
252 final hrvPoints = await coreService.queryHrvStressPoints( 258 final hrvPoints = await coreService.queryHrvStressPoints(
253 startTime: startTime, 259 startTime: startTime,
254 endTime: endTime, 260 endTime: endTime,
255 ); 261 );
256 - return AppSuccess(LocalHealthDataConvert.v2LatestHrvData(hrvPoints)); 262 + return AppSuccess(
  263 + LocalHealthDataConvert.v2LatestHrvData(
  264 + hrvPoints,
  265 + previousHrvPoints: previousHrvPoints,
  266 + ),
  267 + );
257 } catch (error) { 268 } catch (error) {
258 return AppFailure(AppUnknownError(error)); 269 return AppFailure(AppUnknownError(error));
259 } 270 }
1 import 'dart:convert'; 1 import 'dart:convert';
2 2
3 import 'package:doublefeel_flutter/core/config/app_environment_config.dart'; 3 import 'package:doublefeel_flutter/core/config/app_environment_config.dart';
  4 +import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
  5 +import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
4 import 'package:doublefeel_flutter/pigeon/platform_api.g.dart'; 6 import 'package:doublefeel_flutter/pigeon/platform_api.g.dart';
5 import 'package:get/get.dart'; 7 import 'package:get/get.dart';
6 import 'package:shared_preferences/shared_preferences.dart'; 8 import 'package:shared_preferences/shared_preferences.dart';
@@ -52,7 +54,9 @@ class UserPreferencesStorage { @@ -52,7 +54,9 @@ class UserPreferencesStorage {
52 if (Get.isRegistered<AppEnvironmentConfig>()) { 54 if (Get.isRegistered<AppEnvironmentConfig>()) {
53 await PlatformHostApi().logout(); 55 await PlatformHostApi().logout();
54 } 56 }
55 - } on Exception catch (e) {} 57 + } on Exception {
  58 + return;
  59 + }
56 return; 60 return;
57 } 61 }
58 try { 62 try {
@@ -60,8 +64,23 @@ class UserPreferencesStorage { @@ -60,8 +64,23 @@ class UserPreferencesStorage {
60 var env = Get.find<AppEnvironmentConfig>(); 64 var env = Get.find<AppEnvironmentConfig>();
61 await PlatformHostApi() 65 await PlatformHostApi()
62 .updateLoginInfo(jsonEncode(userInfo), env.serverBaseUrl); 66 .updateLoginInfo(jsonEncode(userInfo), env.serverBaseUrl);
  67 + await _syncHealthRawDatabaseToNative();
63 } 68 }
64 - } on Exception catch (e) {} 69 + } on Exception {
  70 + return;
  71 + }
  72 + }
  73 +
  74 + Future<void> _syncHealthRawDatabaseToNative() async {
  75 + if (!Get.isRegistered<HealthRawDataCoreService>()) return;
  76 + final databasePath =
  77 + await Get.find<HealthRawDataCoreService>().databaseFilePath();
  78 + await HealthKitRawDataHostApi().syncDatabase(
  79 + hrDataPath: databasePath,
  80 + hrvDataPath: databasePath,
  81 + sleepDataPath: databasePath,
  82 + avgRealtimeStressDataPath: databasePath,
  83 + );
65 } 84 }
66 85
67 Future<void> _persist(UserPreferences value) async { 86 Future<void> _persist(UserPreferences value) async {
@@ -113,14 +132,14 @@ class UserPreferencesStorage { @@ -113,14 +132,14 @@ class UserPreferencesStorage {
113 UserInfoResponse? partner, 132 UserInfoResponse? partner,
114 VipInfo? vip, 133 VipInfo? vip,
115 }) async { 134 }) async {
116 - await _persist(  
117 - UserPreferences(  
118 - accessToken: accessToken,  
119 - meUserInfo: me,  
120 - partnerUserInfo: partner,  
121 - vipInfo: vip != null ? UserPreferencesVipInfo.fromVipInfo(vip) : null,  
122 - ), 135 + final userInfo = UserPreferences(
  136 + accessToken: accessToken,
  137 + meUserInfo: me,
  138 + partnerUserInfo: partner,
  139 + vipInfo: vip != null ? UserPreferencesVipInfo.fromVipInfo(vip) : null,
123 ); 140 );
  141 + await _persist(userInfo);
  142 + await _syncLoginInfoToNative(userInfo: userInfo);
124 } 143 }
125 144
126 Future<void> clearPartnerUserInfo() async { 145 Future<void> clearPartnerUserInfo() async {
@@ -701,5 +701,13 @@ @@ -701,5 +701,13 @@
701 "feedbackSubmitSuccessTitle": "Feedback submitted successfully", 701 "feedbackSubmitSuccessTitle": "Feedback submitted successfully",
702 "feedbackSubmitSuccessMessage": "Thank you for your feedback. If further communication is needed, we will contact you via the email address you left as soon as possible. Please keep an eye on your inbox.", 702 "feedbackSubmitSuccessMessage": "Thank you for your feedback. If further communication is needed, we will contact you via the email address you left as soon as possible. Please keep an eye on your inbox.",
703 "feedbackSubmitSuccessConfirm": "OK", 703 "feedbackSubmitSuccessConfirm": "OK",
704 - "frequentMovement": "Frequent movement" 704 + "frequentMovement": "Frequent movement",
  705 + "latestHrvTipExcellentAboveBaseline": "Your HRV is above your usual level. Your body appears relaxed and your stress state looks good. Keep your current rhythm.",
  706 + "latestHrvTipExcellentBelowBaseline": "Your HRV is in an excellent range, but slightly lower than usual. Keep a regular routine and make time for recovery.",
  707 + "latestHrvTipNormalAboveBaseline": "Your HRV is within the normal range and your current stress state is stable. Keep maintaining healthy rest habits.",
  708 + "latestHrvTipNormalBelowBaseline": "Your HRV is within the normal range, but below your usual level. Consider relaxing and resting appropriately.",
  709 + "latestHrvTipAttentionAboveBaseline": "Your HRV is on the low side. Consider relaxing, keeping regular rest, and paying attention to nutrition and recovery.",
  710 + "latestHrvTipAttentionBelowBaseline": "Your HRV is clearly below your usual level. Recent stress may be elevated, so try to rest and adjust your state.",
  711 + "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 + "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."
705 } 713 }
@@ -1094,5 +1094,13 @@ @@ -1094,5 +1094,13 @@
1094 "feedbackSubmitSuccessTitle": "反馈提交成功", 1094 "feedbackSubmitSuccessTitle": "反馈提交成功",
1095 "feedbackSubmitSuccessMessage": "谢谢您的反馈。如需进一步沟通,我们会尽快通过您留下的邮箱地址与您联系,请留意查收邮件。", 1095 "feedbackSubmitSuccessMessage": "谢谢您的反馈。如需进一步沟通,我们会尽快通过您留下的邮箱地址与您联系,请留意查收邮件。",
1096 "feedbackSubmitSuccessConfirm": "好的", 1096 "feedbackSubmitSuccessConfirm": "好的",
1097 - "frequentMovement": "频繁移动" 1097 + "frequentMovement": "频繁移动",
  1098 + "latestHrvTipExcellentAboveBaseline": "你的 HRV 高于日常水平,身体较为放松,压力状态良好,继续保持当前节奏。",
  1099 + "latestHrvTipExcellentBelowBaseline": "你的 HRV 处于优秀水平,但相比平时略低,注意保持规律作息与恢复。",
  1100 + "latestHrvTipNormalAboveBaseline": "你的 HRV 处于正常范围内,当前压力状态稳定,记得继续保持健康作息。",
  1101 + "latestHrvTipNormalBelowBaseline": "你的 HRV 处于正常范围,但低于平时水平,建议适当放松与休息。",
  1102 + "latestHrvTipAttentionAboveBaseline": "你的 HRV 偏低,建议适当放松、规律休息,并注意饮食与恢复。",
  1103 + "latestHrvTipAttentionBelowBaseline": "你的 HRV 明显低于日常水平,近期可能压力偏高,建议尽量休息与调整状态。",
  1104 + "latestHrvTipOverloadAboveBaseline": "你的 HRV 处于较低水平,身体可能正在承受较高压力(刚运动完 HRV 降低则属于正常情况),建议及时休息与恢复。",
  1105 + "latestHrvTipOverloadBelowBaseline": "你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。"
1098 } 1106 }
@@ -4028,6 +4028,54 @@ abstract class AppLocalizations { @@ -4028,6 +4028,54 @@ abstract class AppLocalizations {
4028 /// In zh, this message translates to: 4028 /// In zh, this message translates to:
4029 /// **'频繁移动'** 4029 /// **'频繁移动'**
4030 String get frequentMovement; 4030 String get frequentMovement;
  4031 +
  4032 + /// No description provided for @latestHrvTipExcellentAboveBaseline.
  4033 + ///
  4034 + /// In zh, this message translates to:
  4035 + /// **'你的 HRV 高于日常水平,身体较为放松,压力状态良好,继续保持当前节奏。'**
  4036 + String get latestHrvTipExcellentAboveBaseline;
  4037 +
  4038 + /// No description provided for @latestHrvTipExcellentBelowBaseline.
  4039 + ///
  4040 + /// In zh, this message translates to:
  4041 + /// **'你的 HRV 处于优秀水平,但相比平时略低,注意保持规律作息与恢复。'**
  4042 + String get latestHrvTipExcellentBelowBaseline;
  4043 +
  4044 + /// No description provided for @latestHrvTipNormalAboveBaseline.
  4045 + ///
  4046 + /// In zh, this message translates to:
  4047 + /// **'你的 HRV 处于正常范围内,当前压力状态稳定,记得继续保持健康作息。'**
  4048 + String get latestHrvTipNormalAboveBaseline;
  4049 +
  4050 + /// No description provided for @latestHrvTipNormalBelowBaseline.
  4051 + ///
  4052 + /// In zh, this message translates to:
  4053 + /// **'你的 HRV 处于正常范围,但低于平时水平,建议适当放松与休息。'**
  4054 + String get latestHrvTipNormalBelowBaseline;
  4055 +
  4056 + /// No description provided for @latestHrvTipAttentionAboveBaseline.
  4057 + ///
  4058 + /// In zh, this message translates to:
  4059 + /// **'你的 HRV 偏低,建议适当放松、规律休息,并注意饮食与恢复。'**
  4060 + String get latestHrvTipAttentionAboveBaseline;
  4061 +
  4062 + /// No description provided for @latestHrvTipAttentionBelowBaseline.
  4063 + ///
  4064 + /// In zh, this message translates to:
  4065 + /// **'你的 HRV 明显低于日常水平,近期可能压力偏高,建议尽量休息与调整状态。'**
  4066 + String get latestHrvTipAttentionBelowBaseline;
  4067 +
  4068 + /// No description provided for @latestHrvTipOverloadAboveBaseline.
  4069 + ///
  4070 + /// In zh, this message translates to:
  4071 + /// **'你的 HRV 处于较低水平,身体可能正在承受较高压力(刚运动完 HRV 降低则属于正常情况),建议及时休息与恢复。'**
  4072 + String get latestHrvTipOverloadAboveBaseline;
  4073 +
  4074 + /// No description provided for @latestHrvTipOverloadBelowBaseline.
  4075 + ///
  4076 + /// In zh, this message translates to:
  4077 + /// **'你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。'**
  4078 + String get latestHrvTipOverloadBelowBaseline;
4031 } 4079 }
4032 4080
4033 class _AppLocalizationsDelegate 4081 class _AppLocalizationsDelegate
@@ -2252,4 +2252,36 @@ class AppLocalizationsEn extends AppLocalizations { @@ -2252,4 +2252,36 @@ class AppLocalizationsEn extends AppLocalizations {
2252 2252
2253 @override 2253 @override
2254 String get frequentMovement => 'Frequent movement'; 2254 String get frequentMovement => 'Frequent movement';
  2255 +
  2256 + @override
  2257 + String get latestHrvTipExcellentAboveBaseline =>
  2258 + 'Your HRV is above your usual level. Your body appears relaxed and your stress state looks good. Keep your current rhythm.';
  2259 +
  2260 + @override
  2261 + String get latestHrvTipExcellentBelowBaseline =>
  2262 + 'Your HRV is in an excellent range, but slightly lower than usual. Keep a regular routine and make time for recovery.';
  2263 +
  2264 + @override
  2265 + String get latestHrvTipNormalAboveBaseline =>
  2266 + 'Your HRV is within the normal range and your current stress state is stable. Keep maintaining healthy rest habits.';
  2267 +
  2268 + @override
  2269 + String get latestHrvTipNormalBelowBaseline =>
  2270 + 'Your HRV is within the normal range, but below your usual level. Consider relaxing and resting appropriately.';
  2271 +
  2272 + @override
  2273 + String get latestHrvTipAttentionAboveBaseline =>
  2274 + 'Your HRV is on the low side. Consider relaxing, keeping regular rest, and paying attention to nutrition and recovery.';
  2275 +
  2276 + @override
  2277 + String get latestHrvTipAttentionBelowBaseline =>
  2278 + 'Your HRV is clearly below your usual level. Recent stress may be elevated, so try to rest and adjust your state.';
  2279 +
  2280 + @override
  2281 + String get latestHrvTipOverloadAboveBaseline =>
  2282 + '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.';
  2283 +
  2284 + @override
  2285 + String get latestHrvTipOverloadBelowBaseline =>
  2286 + '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.';
2255 } 2287 }
@@ -2149,4 +2149,36 @@ class AppLocalizationsZh extends AppLocalizations { @@ -2149,4 +2149,36 @@ class AppLocalizationsZh extends AppLocalizations {
2149 2149
2150 @override 2150 @override
2151 String get frequentMovement => '频繁移动'; 2151 String get frequentMovement => '频繁移动';
  2152 +
  2153 + @override
  2154 + String get latestHrvTipExcellentAboveBaseline =>
  2155 + '你的 HRV 高于日常水平,身体较为放松,压力状态良好,继续保持当前节奏。';
  2156 +
  2157 + @override
  2158 + String get latestHrvTipExcellentBelowBaseline =>
  2159 + '你的 HRV 处于优秀水平,但相比平时略低,注意保持规律作息与恢复。';
  2160 +
  2161 + @override
  2162 + String get latestHrvTipNormalAboveBaseline =>
  2163 + '你的 HRV 处于正常范围内,当前压力状态稳定,记得继续保持健康作息。';
  2164 +
  2165 + @override
  2166 + String get latestHrvTipNormalBelowBaseline =>
  2167 + '你的 HRV 处于正常范围,但低于平时水平,建议适当放松与休息。';
  2168 +
  2169 + @override
  2170 + String get latestHrvTipAttentionAboveBaseline =>
  2171 + '你的 HRV 偏低,建议适当放松、规律休息,并注意饮食与恢复。';
  2172 +
  2173 + @override
  2174 + String get latestHrvTipAttentionBelowBaseline =>
  2175 + '你的 HRV 明显低于日常水平,近期可能压力偏高,建议尽量休息与调整状态。';
  2176 +
  2177 + @override
  2178 + String get latestHrvTipOverloadAboveBaseline =>
  2179 + '你的 HRV 处于较低水平,身体可能正在承受较高压力(刚运动完 HRV 降低则属于正常情况),建议及时休息与恢复。';
  2180 +
  2181 + @override
  2182 + String get latestHrvTipOverloadBelowBaseline =>
  2183 + '你的 HRV 明显低于平时水平,身体可能处于高压力状态(刚运动完 HRV 降低则属于正常情况),建议减少消耗、及时休息,并保证睡眠恢复。';
2152 } 2184 }
@@ -14,7 +14,15 @@ extension AppLocalizationsX on BuildContext { @@ -14,7 +14,15 @@ extension AppLocalizationsX on BuildContext {
14 14
15 /// 在 Controller / 无 context 场景中全局访问:l10n.appName 15 /// 在 Controller / 无 context 场景中全局访问:l10n.appName
16 AppLocalizations get l10n { 16 AppLocalizations get l10n {
17 - final context = Get.context; 17 + final context = _safeGetContext();
18 final localizations = context != null ? AppLocalizations.of(context) : null; 18 final localizations = context != null ? AppLocalizations.of(context) : null;
19 return localizations ?? AppLocalizationsZh(); 19 return localizations ?? AppLocalizationsZh();
20 } 20 }
  21 +
  22 +BuildContext? _safeGetContext() {
  23 + try {
  24 + return Get.context;
  25 + } catch (_) {
  26 + return null;
  27 + }
  28 +}
@@ -467,6 +467,35 @@ class HealthKitRawDataHostApi { @@ -467,6 +467,35 @@ class HealthKitRawDataHostApi {
467 } 467 }
468 } 468 }
469 469
  470 + /// 向原生同步数据库路径
  471 + Future<bool> syncDatabase({required String hrDataPath, required String hrvDataPath, required String sleepDataPath, required String avgRealtimeStressDataPath, }) async {
  472 + final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.syncDatabase$pigeonVar_messageChannelSuffix';
  473 + final BasicMessageChannel<Object?> pigeonVar_channel = BasicMessageChannel<Object?>(
  474 + pigeonVar_channelName,
  475 + pigeonChannelCodec,
  476 + binaryMessenger: pigeonVar_binaryMessenger,
  477 + );
  478 + final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[hrDataPath, hrvDataPath, sleepDataPath, avgRealtimeStressDataPath]);
  479 + final List<Object?>? pigeonVar_replyList =
  480 + await pigeonVar_sendFuture as List<Object?>?;
  481 + if (pigeonVar_replyList == null) {
  482 + throw _createConnectionError(pigeonVar_channelName);
  483 + } else if (pigeonVar_replyList.length > 1) {
  484 + throw PlatformException(
  485 + code: pigeonVar_replyList[0]! as String,
  486 + message: pigeonVar_replyList[1] as String?,
  487 + details: pigeonVar_replyList[2],
  488 + );
  489 + } else if (pigeonVar_replyList[0] == null) {
  490 + throw PlatformException(
  491 + code: 'null-error',
  492 + message: 'Host platform returned null value for non-null return value.',
  493 + );
  494 + } else {
  495 + return (pigeonVar_replyList[0] as bool?)!;
  496 + }
  497 + }
  498 +
470 /// 上传心率 - 实时压力数据, 返回上传截止的时间戳, 方便flutter修改数据库 499 /// 上传心率 - 实时压力数据, 返回上传截止的时间戳, 方便flutter修改数据库
471 Future<int> performHRDataUpload({required String sqliteFilePath}) async { 500 Future<int> performHRDataUpload({required String sqliteFilePath}) async {
472 final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performHRDataUpload$pigeonVar_messageChannelSuffix'; 501 final String pigeonVar_channelName = 'dev.flutter.pigeon.doublefeel_flutter.HealthKitRawDataHostApi.performHRDataUpload$pigeonVar_messageChannelSuffix';
@@ -102,6 +102,14 @@ abstract class HealthKitRawDataHostApi { @@ -102,6 +102,14 @@ abstract class HealthKitRawDataHostApi {
102 @async 102 @async
103 bool performHealthDataUpload(); 103 bool performHealthDataUpload();
104 104
  105 + /// 向原生同步数据库路径
  106 + @async
  107 + bool syncDatabase(
  108 + {required String hrDataPath,
  109 + required String hrvDataPath,
  110 + required String sleepDataPath,
  111 + required String avgRealtimeStressDataPath});
  112 +
105 /// 上传心率 - 实时压力数据, 返回上传截止的时间戳, 方便flutter修改数据库 113 /// 上传心率 - 实时压力数据, 返回上传截止的时间戳, 方便flutter修改数据库
106 @async 114 @async
107 int performHRDataUpload({required String sqliteFilePath}); 115 int performHRDataUpload({required String sqliteFilePath});
@@ -488,4 +488,94 @@ void main() { @@ -488,4 +488,94 @@ void main() {
488 expect(statistics.activityTargetInfo?.stand, 12); 488 expect(statistics.activityTargetInfo?.stand, 12);
489 }); 489 });
490 }); 490 });
  491 +
  492 + group('LocalHealthDataConvert latest HRV tip', () {
  493 + test('returns localized tip when previous hrv is at least two hours away',
  494 + () {
  495 + const latestTime = 1800000000;
  496 + final data = LocalHealthDataConvert.v2LatestHrvData(
  497 + [
  498 + _hrvStressPoint(
  499 + rawEndTime: latestTime,
  500 + result: 35,
  501 + baselineHrv: 30,
  502 + state: HealthRawStressState.excellent,
  503 + ),
  504 + ],
  505 + previousHrvPoints: [
  506 + _hrvStressPoint(
  507 + rawEndTime: latestTime - 2 * 60 * 60,
  508 + result: 28,
  509 + baselineHrv: 30,
  510 + state: HealthRawStressState.normal,
  511 + ),
  512 + ],
  513 + );
  514 +
  515 + expect(data.latestHrv, 35);
  516 + expect(data.state, HealthRawStressState.excellent.value);
  517 + expect(data.tip, contains('高于日常水平'));
  518 + });
  519 +
  520 + test('uses below-baseline copy for the latest hrv point', () {
  521 + const latestTime = 1800000000;
  522 + final data = LocalHealthDataConvert.v2LatestHrvData(
  523 + [
  524 + _hrvStressPoint(
  525 + rawEndTime: latestTime,
  526 + result: 65,
  527 + baselineHrv: 70,
  528 + state: HealthRawStressState.attention,
  529 + ),
  530 + ],
  531 + previousHrvPoints: [
  532 + _hrvStressPoint(rawEndTime: latestTime - 3 * 60 * 60),
  533 + ],
  534 + );
  535 +
  536 + expect(data.tip, contains('明显低于日常水平'));
  537 + });
  538 +
  539 + test('does not return tip when previous hrv is less than two hours away',
  540 + () {
  541 + const latestTime = 1800000000;
  542 + final data = LocalHealthDataConvert.v2LatestHrvData(
  543 + [
  544 + _hrvStressPoint(
  545 + rawEndTime: latestTime,
  546 + result: 35,
  547 + baselineHrv: 30,
  548 + state: HealthRawStressState.excellent,
  549 + ),
  550 + ],
  551 + previousHrvPoints: [
  552 + _hrvStressPoint(rawEndTime: latestTime - 60 * 60),
  553 + ],
  554 + );
  555 +
  556 + expect(data.tip, isNull);
  557 + });
  558 + });
  559 +}
  560 +
  561 +HealthRawHrvStressPoint _hrvStressPoint({
  562 + int rawEndTime = 1800000000,
  563 + double rawHrv = 30,
  564 + double result = 30,
  565 + double baselineHrv = 30,
  566 + HealthRawStressState state = HealthRawStressState.normal,
  567 +}) {
  568 + return HealthRawHrvStressPoint(
  569 + userId: 1,
  570 + rawEndTime: rawEndTime,
  571 + rawHrv: rawHrv,
  572 + result: result,
  573 + sourceStartTime: rawEndTime,
  574 + sourceEndTime: rawEndTime,
  575 + state: state,
  576 + baselineHrv: baselineHrv,
  577 + baselineAwakeHrv: baselineHrv,
  578 + baselineSleepHrv: null,
  579 + baselineRestingHr: 60,
  580 + );
491 } 581 }