Showing
5 changed files
with
80 additions
and
10 deletions
| @@ -533,9 +533,12 @@ final class WatchHealthObserverUploader { | @@ -533,9 +533,12 @@ final class WatchHealthObserverUploader { | ||
| 533 | } | 533 | } |
| 534 | 534 | ||
| 535 | private func uploadActivityTarget(_ summary: HKActivitySummary) async throws { | 535 | private func uploadActivityTarget(_ summary: HKActivitySummary) async throws { |
| 536 | + let exerciseGoal = Int(summary.appleExerciseTimeGoal.doubleValue(for: .second())) | ||
| 536 | var body: [String: Any] = [ | 537 | var body: [String: Any] = [ |
| 537 | "move": Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())), | 538 | "move": Int(summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie())), |
| 538 | "stand": Int(summary.appleStandHoursGoal.doubleValue(for: .count())), | 539 | "stand": Int(summary.appleStandHoursGoal.doubleValue(for: .count())), |
| 540 | + "exercise": exerciseGoal, | ||
| 541 | + "exercise_time_goal": exerciseGoal, | ||
| 539 | "active_energy_burned": summary.activeEnergyBurned.doubleValue(for: .kilocalorie()), | 542 | "active_energy_burned": summary.activeEnergyBurned.doubleValue(for: .kilocalorie()), |
| 540 | "active_energy_burned_goal": summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()), | 543 | "active_energy_burned_goal": summary.activeEnergyBurnedGoal.doubleValue(for: .kilocalorie()), |
| 541 | "apple_exercise_time": summary.appleExerciseTime.doubleValue(for: .second()), | 544 | "apple_exercise_time": summary.appleExerciseTime.doubleValue(for: .second()), |
| @@ -546,7 +549,6 @@ final class WatchHealthObserverUploader { | @@ -546,7 +549,6 @@ final class WatchHealthObserverUploader { | ||
| 546 | body["activity_move_mode"] = summary.activityMoveMode.rawValue | 549 | body["activity_move_mode"] = summary.activityMoveMode.rawValue |
| 547 | body["apple_move_time"] = summary.appleMoveTime.doubleValue(for: .second()) | 550 | body["apple_move_time"] = summary.appleMoveTime.doubleValue(for: .second()) |
| 548 | body["apple_move_time_goal"] = summary.appleMoveTimeGoal.doubleValue(for: .second()) | 551 | body["apple_move_time_goal"] = summary.appleMoveTimeGoal.doubleValue(for: .second()) |
| 549 | - body["exercise_time_goal"] = summary.exerciseTimeGoal?.doubleValue(for: .second()) | ||
| 550 | body["stand_hours_goal"] = summary.standHoursGoal?.doubleValue(for: .count()) | 552 | body["stand_hours_goal"] = summary.standHoursGoal?.doubleValue(for: .count()) |
| 551 | _ = try await healthService.uploadActivityTarget(data: body) | 553 | _ = try await healthService.uploadActivityTarget(data: body) |
| 552 | } | 554 | } |
| @@ -422,6 +422,19 @@ class HealthApi { | @@ -422,6 +422,19 @@ class HealthApi { | ||
| 422 | ); | 422 | ); |
| 423 | } | 423 | } |
| 424 | 424 | ||
| 425 | + Future<AppResult<void>> uploadV2ActivityTarget( | ||
| 426 | + V2ActivityTarget target, | ||
| 427 | + ) { | ||
| 428 | + return safeCall( | ||
| 429 | + call: () async { | ||
| 430 | + await _dioClient.dio.post( | ||
| 431 | + ApiPaths.v2ActivityTarget, | ||
| 432 | + data: target.toJson(), | ||
| 433 | + ); | ||
| 434 | + }, | ||
| 435 | + ); | ||
| 436 | + } | ||
| 437 | + | ||
| 425 | Future<AppResult<V2HealthEverUploaded>> getHealthDataEverUploaded() { | 438 | Future<AppResult<V2HealthEverUploaded>> getHealthDataEverUploaded() { |
| 426 | return safeCall( | 439 | return safeCall( |
| 427 | call: () async { | 440 | call: () async { |
| 1 | +import 'dart:async'; | ||
| 2 | + | ||
| 1 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; | 3 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; |
| 4 | +import 'package:doublefeel_flutter/core/network/api/health_api.dart'; | ||
| 2 | import 'package:doublefeel_flutter/core/network/api/harmony_api.dart'; | 5 | import 'package:doublefeel_flutter/core/network/api/harmony_api.dart'; |
| 3 | import 'package:doublefeel_flutter/core/platform/pigeon_api_facade.dart'; | 6 | import 'package:doublefeel_flutter/core/platform/pigeon_api_facade.dart'; |
| 4 | import 'package:doublefeel_flutter/core/result/app_result.dart'; | 7 | import 'package:doublefeel_flutter/core/result/app_result.dart'; |
| @@ -31,15 +34,24 @@ OhosHealthRawDataSyncService createDefaultOhosHealthRawDataSyncService({ | @@ -31,15 +34,24 @@ OhosHealthRawDataSyncService createDefaultOhosHealthRawDataSyncService({ | ||
| 31 | return OhosHealthRawDataSyncService( | 34 | return OhosHealthRawDataSyncService( |
| 32 | localStore: localStore, | 35 | localStore: localStore, |
| 33 | remoteDataSource: OhosHarmonyHealthRawDataRemoteDataSource( | 36 | remoteDataSource: OhosHarmonyHealthRawDataRemoteDataSource( |
| 34 | - HarmonyApiOhosRawDataClient(Get.find<HarmonyApi>()), | 37 | + HarmonyApiOhosRawDataClient( |
| 38 | + Get.find<HarmonyApi>(), | ||
| 39 | + healthApi: Get.isRegistered<HealthApi>() ? Get.find<HealthApi>() : null, | ||
| 40 | + ), | ||
| 35 | ), | 41 | ), |
| 36 | ); | 42 | ); |
| 37 | } | 43 | } |
| 38 | 44 | ||
| 39 | class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | 45 | class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { |
| 40 | - const HarmonyApiOhosRawDataClient(this._harmonyApi, [this._healthKitApi]); | 46 | + const HarmonyApiOhosRawDataClient( |
| 47 | + this._harmonyApi, { | ||
| 48 | + HealthApi? healthApi, | ||
| 49 | + AppHealthKitHostApi? healthKitApi, | ||
| 50 | + }) : _healthApi = healthApi, | ||
| 51 | + _healthKitApi = healthKitApi; | ||
| 41 | 52 | ||
| 42 | final HarmonyApi _harmonyApi; | 53 | final HarmonyApi _harmonyApi; |
| 54 | + final HealthApi? _healthApi; | ||
| 43 | final AppHealthKitHostApi? _healthKitApi; | 55 | final AppHealthKitHostApi? _healthKitApi; |
| 44 | 56 | ||
| 45 | bool get _preferNativeHealthData => | 57 | bool get _preferNativeHealthData => |
| @@ -118,7 +130,8 @@ class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | @@ -118,7 +130,8 @@ class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | ||
| 118 | 130 | ||
| 119 | @override | 131 | @override |
| 120 | Future<AppResult<V2ActivityTarget>> getActivityGoal() async { | 132 | Future<AppResult<V2ActivityTarget>> getActivityGoal() async { |
| 121 | - if (!_preferNativeHealthData) { | 133 | + final useRemoteData = false; |
| 134 | + if (!_preferNativeHealthData || useRemoteData) { | ||
| 122 | AppLogger.i('[OHOS_ACTIVITY_GOAL] source=remote native_enabled=false'); | 135 | AppLogger.i('[OHOS_ACTIVITY_GOAL] source=remote native_enabled=false'); |
| 123 | return _harmonyApi.getActivityGoal(); | 136 | return _harmonyApi.getActivityGoal(); |
| 124 | } | 137 | } |
| @@ -138,6 +151,10 @@ class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | @@ -138,6 +151,10 @@ class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | ||
| 138 | '[OHOS_ACTIVITY_GOAL] ' | 151 | '[OHOS_ACTIVITY_GOAL] ' |
| 139 | 'source=native value=${_goalLogValue(nativeGoal)}', | 152 | 'source=native value=${_goalLogValue(nativeGoal)}', |
| 140 | ); | 153 | ); |
| 154 | + if (useRemoteData || _preferNativeHealthData) { | ||
| 155 | + // 上传目标值到服务器 | ||
| 156 | + unawaited(_uploadNativeActivityGoal(nativeGoal)); | ||
| 157 | + } | ||
| 141 | return AppSuccess(nativeGoal); | 158 | return AppSuccess(nativeGoal); |
| 142 | } | 159 | } |
| 143 | } | 160 | } |
| @@ -163,6 +180,29 @@ class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | @@ -163,6 +180,29 @@ class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | ||
| 163 | return remoteResult; | 180 | return remoteResult; |
| 164 | } | 181 | } |
| 165 | 182 | ||
| 183 | + Future<void> _uploadNativeActivityGoal(V2ActivityTarget goal) async { | ||
| 184 | + final healthApi = _healthApi; | ||
| 185 | + if (healthApi == null) { | ||
| 186 | + AppLogger.i( | ||
| 187 | + '[OHOS_ACTIVITY_GOAL] upload_skipped reason=health_api_missing'); | ||
| 188 | + return; | ||
| 189 | + } | ||
| 190 | + | ||
| 191 | + final result = await healthApi.uploadV2ActivityTarget(goal); | ||
| 192 | + switch (result) { | ||
| 193 | + case AppSuccess(): | ||
| 194 | + AppLogger.i( | ||
| 195 | + '[OHOS_ACTIVITY_GOAL] ' | ||
| 196 | + 'upload_success value=${_goalLogValue(goal)}', | ||
| 197 | + ); | ||
| 198 | + case AppFailure(:final error): | ||
| 199 | + AppLogger.i( | ||
| 200 | + '[OHOS_ACTIVITY_GOAL] ' | ||
| 201 | + 'upload_failed error=$error value=${_goalLogValue(goal)}', | ||
| 202 | + ); | ||
| 203 | + } | ||
| 204 | + } | ||
| 205 | + | ||
| 166 | static bool _hasGoal(V2ActivityTarget goal) { | 206 | static bool _hasGoal(V2ActivityTarget goal) { |
| 167 | return goal.move != null || | 207 | return goal.move != null || |
| 168 | goal.step != null || | 208 | goal.step != null || |
| @@ -404,7 +404,8 @@ class V2RealtimeStressData { | @@ -404,7 +404,8 @@ class V2RealtimeStressData { | ||
| 404 | 404 | ||
| 405 | /// v2/activity_target/ response | 405 | /// v2/activity_target/ response |
| 406 | /// {"id":0,"create_time":0,"update_time":0,"user_id":131, | 406 | /// {"id":0,"create_time":0,"update_time":0,"user_id":131, |
| 407 | -/// "move":300,"step":8000,"stand":12,"exercise":3600} | 407 | +/// "move":300,"step":8000,"stand":12,"exercise":3600, |
| 408 | +/// "exercise_time_goal":3600} | ||
| 408 | class V2ActivityTarget { | 409 | class V2ActivityTarget { |
| 409 | const V2ActivityTarget({ | 410 | const V2ActivityTarget({ |
| 410 | this.id, | 411 | this.id, |
| @@ -430,7 +431,8 @@ class V2ActivityTarget { | @@ -430,7 +431,8 @@ class V2ActivityTarget { | ||
| 430 | /// 站立小时目标 | 431 | /// 站立小时目标 |
| 431 | final int? stand; | 432 | final int? stand; |
| 432 | 433 | ||
| 433 | - /// 锻炼时长目标(秒) | 434 | + /// 锻炼时长目标(秒)。上传时也会同步写入同义字段 |
| 435 | + /// [exercise_time_goal],两者始终使用同一个值。 | ||
| 434 | final int? exercise; | 436 | final int? exercise; |
| 435 | 437 | ||
| 436 | final int? createTime; | 438 | final int? createTime; |
| @@ -444,7 +446,8 @@ class V2ActivityTarget { | @@ -444,7 +446,8 @@ class V2ActivityTarget { | ||
| 444 | move: _parseInt(json['move']), | 446 | move: _parseInt(json['move']), |
| 445 | step: _parseInt(json['step']), | 447 | step: _parseInt(json['step']), |
| 446 | stand: _parseInt(json['stand']), | 448 | stand: _parseInt(json['stand']), |
| 447 | - exercise: _parseInt(json['exercise']), | 449 | + exercise: |
| 450 | + _parseInt(json['exercise']) ?? _parseInt(json['exercise_time_goal']), | ||
| 448 | createTime: _parseInt(json['create_time']), | 451 | createTime: _parseInt(json['create_time']), |
| 449 | updateTime: _parseInt(json['update_time']), | 452 | updateTime: _parseInt(json['update_time']), |
| 450 | sleepTargetDuration: _parseInt(json['sleep_target_duration']), | 453 | sleepTargetDuration: _parseInt(json['sleep_target_duration']), |
| @@ -458,7 +461,10 @@ class V2ActivityTarget { | @@ -458,7 +461,10 @@ class V2ActivityTarget { | ||
| 458 | if (move != null) val['move'] = move; | 461 | if (move != null) val['move'] = move; |
| 459 | if (step != null) val['step'] = step; | 462 | if (step != null) val['step'] = step; |
| 460 | if (stand != null) val['stand'] = stand; | 463 | if (stand != null) val['stand'] = stand; |
| 461 | - if (exercise != null) val['exercise'] = exercise; | 464 | + if (exercise != null) { |
| 465 | + val['exercise'] = exercise; | ||
| 466 | + val['exercise_time_goal'] = exercise; | ||
| 467 | + } | ||
| 462 | if (createTime != null) val['create_time'] = createTime; | 468 | if (createTime != null) val['create_time'] = createTime; |
| 463 | if (updateTime != null) val['update_time'] = updateTime; | 469 | if (updateTime != null) val['update_time'] = updateTime; |
| 464 | if (sleepTargetDuration != null) { | 470 | if (sleepTargetDuration != null) { |
| @@ -99,7 +99,7 @@ export class HealthKitHostApiImpl extends HealthKitHostApi { | @@ -99,7 +99,7 @@ export class HealthKitHostApiImpl extends HealthKitHostApi { | ||
| 99 | result.success(new HealthActivityGoal( | 99 | result.success(new HealthActivityGoal( |
| 100 | this.activeCaloriesToMoveGoal(report.activeCaloriesGoal), | 100 | this.activeCaloriesToMoveGoal(report.activeCaloriesGoal), |
| 101 | report.stepsGoal, | 101 | report.stepsGoal, |
| 102 | - report.activeHoursGoal, | 102 | + this.activeHoursToStandGoal(report.activeHoursGoal), |
| 103 | this.exerciseMinutesToSeconds(report.exerciseGoal), | 103 | this.exerciseMinutesToSeconds(report.exerciseGoal), |
| 104 | )); | 104 | )); |
| 105 | }) | 105 | }) |
| @@ -141,13 +141,22 @@ export class HealthKitHostApiImpl extends HealthKitHostApi { | @@ -141,13 +141,22 @@ export class HealthKitHostApiImpl extends HealthKitHostApi { | ||
| 141 | } | 141 | } |
| 142 | 142 | ||
| 143 | private exerciseMinutesToSeconds(value: number | undefined): number | undefined { | 143 | private exerciseMinutesToSeconds(value: number | undefined): number | undefined { |
| 144 | - return value === undefined ? undefined : Math.round(value * 60); | 144 | + // Health Service Kit reports the goal in minutes; the activity-target API |
| 145 | + // uses seconds. `Int(Double)` on iOS truncates, so use the same rule here. | ||
| 146 | + return value === undefined ? undefined : Math.floor(value * 60); | ||
| 145 | } | 147 | } |
| 146 | 148 | ||
| 147 | private activeCaloriesToMoveGoal(value: number | undefined): number | undefined { | 149 | private activeCaloriesToMoveGoal(value: number | undefined): number | undefined { |
| 150 | + // Health Service Kit uses calories, while the shared activity-target API | ||
| 151 | + // (and HealthKit) use kilocalories. | ||
| 148 | return value === undefined ? undefined : Math.floor(value / 1000); | 152 | return value === undefined ? undefined : Math.floor(value / 1000); |
| 149 | } | 153 | } |
| 150 | 154 | ||
| 155 | + private activeHoursToStandGoal(value: number | undefined): number | undefined { | ||
| 156 | + // Both platforms upload an integral count of stand hours. | ||
| 157 | + return value === undefined ? undefined : Math.floor(value); | ||
| 158 | + } | ||
| 159 | + | ||
| 151 | private async requestAuthorizations(): Promise<healthStore.AuthorizationResponse> { | 160 | private async requestAuthorizations(): Promise<healthStore.AuthorizationResponse> { |
| 152 | await this.ensureInitialized(); | 161 | await this.ensureInitialized(); |
| 153 | return healthStore.requestAuthorizations(this.context, this.authorizationRequest); | 162 | return healthStore.requestAuthorizations(this.context, this.authorizationRequest); |
-
mentioned in commit fd20342d
-
Please register or login to post a comment