Showing
17 changed files
with
464 additions
and
28 deletions
| @@ -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 | } |
-
Please register or login to post a comment