Showing
13 changed files
with
765 additions
and
28 deletions
| @@ -4,6 +4,7 @@ import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; | @@ -4,6 +4,7 @@ import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; | ||
| 4 | import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; | 4 | import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; |
| 5 | import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart'; | 5 | import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart'; |
| 6 | import 'package:doublefeel_flutter/data/models/harmony/privacy_records.dart'; | 6 | import 'package:doublefeel_flutter/data/models/harmony/privacy_records.dart'; |
| 7 | +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | ||
| 7 | 8 | ||
| 8 | import '../../result/app_result.dart'; | 9 | import '../../result/app_result.dart'; |
| 9 | import '../../result/safe_call.dart'; | 10 | import '../../result/safe_call.dart'; |
| @@ -105,4 +106,15 @@ class HarmonyApi { | @@ -105,4 +106,15 @@ class HarmonyApi { | ||
| 105 | }, | 106 | }, |
| 106 | ); | 107 | ); |
| 107 | } | 108 | } |
| 109 | + | ||
| 110 | + Future<AppResult<V2ActivityTarget>> getActivityGoal() { | ||
| 111 | + return safeCall( | ||
| 112 | + call: () async { | ||
| 113 | + final response = await _dioClient.dio.get(ApiPaths.hmActivityGoal); | ||
| 114 | + return V2ActivityTarget.fromJson( | ||
| 115 | + response.data as Map<String, dynamic>, | ||
| 116 | + ); | ||
| 117 | + }, | ||
| 118 | + ); | ||
| 119 | + } | ||
| 108 | } | 120 | } |
| @@ -238,6 +238,58 @@ class HealthApi { | @@ -238,6 +238,58 @@ class HealthApi { | ||
| 238 | ); | 238 | ); |
| 239 | } | 239 | } |
| 240 | 240 | ||
| 241 | + Future<AppResult<void>> uploadV2HrvTrendResults( | ||
| 242 | + List<Map<String, Object?>> dataList, | ||
| 243 | + ) { | ||
| 244 | + return safeCall( | ||
| 245 | + call: () async { | ||
| 246 | + await _dioClient.dio.post( | ||
| 247 | + ApiPaths.v2HrvTrend, | ||
| 248 | + data: {'data_list': dataList}, | ||
| 249 | + ); | ||
| 250 | + }, | ||
| 251 | + ); | ||
| 252 | + } | ||
| 253 | + | ||
| 254 | + Future<AppResult<void>> uploadV2RealtimeStressResults( | ||
| 255 | + List<Map<String, Object?>> dataList, | ||
| 256 | + ) { | ||
| 257 | + return safeCall( | ||
| 258 | + call: () async { | ||
| 259 | + await _dioClient.dio.post( | ||
| 260 | + ApiPaths.v2RealtimeStress, | ||
| 261 | + data: {'data_list': dataList}, | ||
| 262 | + ); | ||
| 263 | + }, | ||
| 264 | + ); | ||
| 265 | + } | ||
| 266 | + | ||
| 267 | + Future<AppResult<void>> uploadV2DailyStressResults( | ||
| 268 | + List<Map<String, Object?>> dataList, | ||
| 269 | + ) { | ||
| 270 | + return safeCall( | ||
| 271 | + call: () async { | ||
| 272 | + await _dioClient.dio.post( | ||
| 273 | + ApiPaths.v2StressScore, | ||
| 274 | + data: {'data_list': dataList}, | ||
| 275 | + ); | ||
| 276 | + }, | ||
| 277 | + ); | ||
| 278 | + } | ||
| 279 | + | ||
| 280 | + Future<AppResult<void>> uploadV2SleepScoreResults( | ||
| 281 | + List<Map<String, Object?>> dataList, | ||
| 282 | + ) { | ||
| 283 | + return safeCall( | ||
| 284 | + call: () async { | ||
| 285 | + await _dioClient.dio.post( | ||
| 286 | + ApiPaths.v2SleepScore, | ||
| 287 | + data: {'data_list': dataList}, | ||
| 288 | + ); | ||
| 289 | + }, | ||
| 290 | + ); | ||
| 291 | + } | ||
| 292 | + | ||
| 241 | Future<AppResult<void>> uploadPulseTypeResult(PulseType pulseType) { | 293 | Future<AppResult<void>> uploadPulseTypeResult(PulseType pulseType) { |
| 242 | return safeCall( | 294 | return safeCall( |
| 243 | call: () async { | 295 | call: () async { |
| @@ -50,6 +50,7 @@ abstract final class ApiPaths { | @@ -50,6 +50,7 @@ abstract final class ApiPaths { | ||
| 50 | static const v2HrvTrend = '/client/doublefeel/health/v2/hrv_trend/'; | 50 | static const v2HrvTrend = '/client/doublefeel/health/v2/hrv_trend/'; |
| 51 | static const v2RealtimeStress = | 51 | static const v2RealtimeStress = |
| 52 | '/client/doublefeel/health/v2/realtime_stress/'; | 52 | '/client/doublefeel/health/v2/realtime_stress/'; |
| 53 | + static const v2SleepScore = '/client/doublefeel/health/v2/sleep_score/'; | ||
| 53 | static const v2RealtimeStressPush = | 54 | static const v2RealtimeStressPush = |
| 54 | '/client/doublefeel/health/v2/realtime_stress/push/'; | 55 | '/client/doublefeel/health/v2/realtime_stress/push/'; |
| 55 | static const v2ActivityTarget = | 56 | static const v2ActivityTarget = |
| @@ -96,6 +97,7 @@ abstract final class ApiPaths { | @@ -96,6 +97,7 @@ abstract final class ApiPaths { | ||
| 96 | static const hmHealthData = '/client/doublefeel/huawei_hm/health_data/'; | 97 | static const hmHealthData = '/client/doublefeel/huawei_hm/health_data/'; |
| 97 | static const hmSleepData = '/client/doublefeel/huawei_hm/sleep_data/'; | 98 | static const hmSleepData = '/client/doublefeel/huawei_hm/sleep_data/'; |
| 98 | static const hmActivityTime = '/client/doublefeel/huawei_hm/activity_time/'; | 99 | static const hmActivityTime = '/client/doublefeel/huawei_hm/activity_time/'; |
| 100 | + static const hmActivityGoal = '/client/doublefeel/huawei_hm/activity_goal/'; | ||
| 99 | static const hmPrivacyRecords = | 101 | static const hmPrivacyRecords = |
| 100 | '/client/doublefeel/huawei_hm/privacy_records/'; | 102 | '/client/doublefeel/huawei_hm/privacy_records/'; |
| 101 | } | 103 | } |
| @@ -3,6 +3,7 @@ import 'dart:async'; | @@ -3,6 +3,7 @@ import 'dart:async'; | ||
| 3 | import 'package:flutter/foundation.dart'; | 3 | import 'package:flutter/foundation.dart'; |
| 4 | 4 | ||
| 5 | import '../../../pigeon/health_kit_raw_data_api.g.dart'; | 5 | import '../../../pigeon/health_kit_raw_data_api.g.dart'; |
| 6 | +import '../../../data/models/health/health_v2_models.dart'; | ||
| 6 | import '../../config/app_environment_config.dart'; | 7 | import '../../config/app_environment_config.dart'; |
| 7 | import '../../network/api/health_api.dart'; | 8 | import '../../network/api/health_api.dart'; |
| 8 | import 'health_raw_models.dart'; | 9 | import 'health_raw_models.dart'; |
| @@ -255,6 +256,16 @@ class HealthRawDataCoreService { | @@ -255,6 +256,16 @@ class HealthRawDataCoreService { | ||
| 255 | ); | 256 | ); |
| 256 | } | 257 | } |
| 257 | 258 | ||
| 259 | + Future<V2ActivityTarget?> queryOhosActivityGoal({ | ||
| 260 | + bool refresh = true, | ||
| 261 | + }) { | ||
| 262 | + final platform = _targetPlatform ?? defaultTargetPlatform; | ||
| 263 | + if (platform == TargetPlatform.iOS) { | ||
| 264 | + return Future<V2ActivityTarget?>.value(); | ||
| 265 | + } | ||
| 266 | + return _ohosService.queryActivityGoal(refresh: refresh); | ||
| 267 | + } | ||
| 268 | + | ||
| 258 | Future<void> markHrvStressUploaded({ | 269 | Future<void> markHrvStressUploaded({ |
| 259 | required Iterable<int> rawEndTimes, | 270 | required Iterable<int> rawEndTimes, |
| 260 | }) { | 271 | }) { |
| 1 | import 'package:flutter/foundation.dart'; | 1 | import 'package:flutter/foundation.dart'; |
| 2 | 2 | ||
| 3 | +import '../../../data/models/health/health_v2_models.dart'; | ||
| 3 | import '../../platform/pigeon_api_facade.dart'; | 4 | import '../../platform/pigeon_api_facade.dart'; |
| 4 | import 'platform_ohos/huawei_health_data_type.dart'; | 5 | import 'platform_ohos/huawei_health_data_type.dart'; |
| 5 | import 'platform_ohos/ohos_harmony_health_raw_data_sync_service_factory.dart'; | 6 | import 'platform_ohos/ohos_harmony_health_raw_data_sync_service_factory.dart'; |
| @@ -28,6 +29,8 @@ abstract class HealthRawDataSource { | @@ -28,6 +29,8 @@ abstract class HealthRawDataSource { | ||
| 28 | int startTime, | 29 | int startTime, |
| 29 | int endTime, | 30 | int endTime, |
| 30 | ); | 31 | ); |
| 32 | + | ||
| 33 | + Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}); | ||
| 31 | } | 34 | } |
| 32 | 35 | ||
| 33 | class PlatformHealthRawDataSource implements HealthRawDataSource { | 36 | class PlatformHealthRawDataSource implements HealthRawDataSource { |
| @@ -85,6 +88,11 @@ class PlatformHealthRawDataSource implements HealthRawDataSource { | @@ -85,6 +88,11 @@ class PlatformHealthRawDataSource implements HealthRawDataSource { | ||
| 85 | ) { | 88 | ) { |
| 86 | return _currentDataSource.getRawWorkoutData(startTime, endTime); | 89 | return _currentDataSource.getRawWorkoutData(startTime, endTime); |
| 87 | } | 90 | } |
| 91 | + | ||
| 92 | + @override | ||
| 93 | + Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) { | ||
| 94 | + return _currentDataSource.getActivityGoal(refresh: refresh); | ||
| 95 | + } | ||
| 88 | } | 96 | } |
| 89 | 97 | ||
| 90 | // IOS | 98 | // IOS |
| @@ -132,6 +140,11 @@ class AppleHealthRawDataSource implements HealthRawDataSource { | @@ -132,6 +140,11 @@ class AppleHealthRawDataSource implements HealthRawDataSource { | ||
| 132 | ) { | 140 | ) { |
| 133 | return _rawDataApi.getHealthKitRawWorkoutData(startTime, endTime); | 141 | return _rawDataApi.getHealthKitRawWorkoutData(startTime, endTime); |
| 134 | } | 142 | } |
| 143 | + | ||
| 144 | + @override | ||
| 145 | + Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) async { | ||
| 146 | + return null; | ||
| 147 | + } | ||
| 135 | } | 148 | } |
| 136 | 149 | ||
| 137 | // ohos | 150 | // ohos |
| @@ -149,6 +162,11 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | @@ -149,6 +162,11 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | ||
| 149 | final OhosHealthRawDataSyncService _syncService; | 162 | final OhosHealthRawDataSyncService _syncService; |
| 150 | final bool _syncBeforeRead; | 163 | final bool _syncBeforeRead; |
| 151 | 164 | ||
| 165 | + static const String _activityLogMarker = '[OHOS_HEALTH_ACTIVITY_SOURCE]'; | ||
| 166 | + static const double _defaultActiveEnergyBurnedGoal = 300.0; | ||
| 167 | + static const double _defaultExerciseTimeGoalSeconds = 1800.0; | ||
| 168 | + static const double _defaultStandHoursGoal = 12.0; | ||
| 169 | + | ||
| 152 | Future<List<OhosHealthRawDataSyncResult>> syncCalculationRawData({ | 170 | Future<List<OhosHealthRawDataSyncResult>> syncCalculationRawData({ |
| 153 | int? startTime, | 171 | int? startTime, |
| 154 | int? endTime, | 172 | int? endTime, |
| @@ -174,6 +192,11 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | @@ -174,6 +192,11 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | ||
| 174 | } | 192 | } |
| 175 | 193 | ||
| 176 | @override | 194 | @override |
| 195 | + Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) { | ||
| 196 | + return _syncService.getActivityGoal(refresh: refresh); | ||
| 197 | + } | ||
| 198 | + | ||
| 199 | + @override | ||
| 177 | Future<bool> hasHealthData() async { | 200 | Future<bool> hasHealthData() async { |
| 178 | // TODO: Query the OHOS local raw-data database when its schema is ready. | 201 | // TODO: Query the OHOS local raw-data database when its schema is ready. |
| 179 | return false; | 202 | return false; |
| @@ -243,7 +266,7 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | @@ -243,7 +266,7 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | ||
| 243 | if (_syncBeforeRead) { | 266 | if (_syncBeforeRead) { |
| 244 | await Future.wait( | 267 | await Future.wait( |
| 245 | <int>[ | 268 | <int>[ |
| 246 | - OhosHealthRawDataType.activitySummary, | 269 | + HuaweiHealthDataType.activity.dataType, |
| 247 | HuaweiHealthDataType.exerciseDuration.dataType, | 270 | HuaweiHealthDataType.exerciseDuration.dataType, |
| 248 | HuaweiHealthDataType.standingDuration.dataType, | 271 | HuaweiHealthDataType.standingDuration.dataType, |
| 249 | HuaweiHealthDataType.stepCount.dataType, | 272 | HuaweiHealthDataType.stepCount.dataType, |
| @@ -258,7 +281,7 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | @@ -258,7 +281,7 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | ||
| 258 | ); | 281 | ); |
| 259 | } | 282 | } |
| 260 | final activity = await _syncService.queryRawData( | 283 | final activity = await _syncService.queryRawData( |
| 261 | - dataType: OhosHealthRawDataType.activitySummary, | 284 | + dataType: HuaweiHealthDataType.activity.dataType, |
| 262 | startTime: startTime, | 285 | startTime: startTime, |
| 263 | endTime: endTime, | 286 | endTime: endTime, |
| 264 | ); | 287 | ); |
| @@ -274,28 +297,27 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | @@ -274,28 +297,27 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | ||
| 274 | ); | 297 | ); |
| 275 | final byTime = <int, HealthKitRawActivityDataPoint>{}; | 298 | final byTime = <int, HealthKitRawActivityDataPoint>{}; |
| 276 | for (final item in activity) { | 299 | for (final item in activity) { |
| 277 | - final point = byTime.putIfAbsent( | ||
| 278 | - item.dataTime, | ||
| 279 | - () => HealthKitRawActivityDataPoint(endTime: item.dataTime), | ||
| 280 | - ); | 300 | + final point = _activityPoint(byTime, item); |
| 281 | point.activeEnergyBurned = _doublePayload(item.payload, 'value'); | 301 | point.activeEnergyBurned = _doublePayload(item.payload, 'value'); |
| 282 | } | 302 | } |
| 283 | for (final item in exercise) { | 303 | for (final item in exercise) { |
| 284 | - final point = byTime.putIfAbsent( | ||
| 285 | - item.dataTime, | ||
| 286 | - () => HealthKitRawActivityDataPoint(endTime: item.dataTime), | ||
| 287 | - ); | ||
| 288 | - point.appleExerciseTime = _doublePayload(item.payload, 'value'); | 304 | + final point = _activityPoint(byTime, item); |
| 305 | + final minutes = _doublePayload(item.payload, 'value'); | ||
| 306 | + point.appleExerciseTime = | ||
| 307 | + minutes == null ? null : minutes * Duration.secondsPerMinute; | ||
| 289 | } | 308 | } |
| 290 | for (final item in standing) { | 309 | for (final item in standing) { |
| 291 | - final point = byTime.putIfAbsent( | ||
| 292 | - item.dataTime, | ||
| 293 | - () => HealthKitRawActivityDataPoint(endTime: item.dataTime), | ||
| 294 | - ); | 310 | + final point = _activityPoint(byTime, item); |
| 295 | point.appleStandHours = _doublePayload(item.payload, 'value'); | 311 | point.appleStandHours = _doublePayload(item.payload, 'value'); |
| 296 | } | 312 | } |
| 297 | - return byTime.values.toList(growable: false) | 313 | + final points = byTime.values.toList(growable: false) |
| 298 | ..sort((a, b) => a.endTime.compareTo(b.endTime)); | 314 | ..sort((a, b) => a.endTime.compareTo(b.endTime)); |
| 315 | + debugPrint( | ||
| 316 | + '$_activityLogMarker query startTime=$startTime endTime=$endTime ' | ||
| 317 | + 'activity=${activity.length} exercise=${exercise.length} ' | ||
| 318 | + 'standing=${standing.length} points=${points.length}', | ||
| 319 | + ); | ||
| 320 | + return points; | ||
| 299 | } | 321 | } |
| 300 | 322 | ||
| 301 | @override | 323 | @override |
| @@ -337,6 +359,25 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | @@ -337,6 +359,25 @@ class OhosHealthRawDataSource implements HealthRawDataSource { | ||
| 337 | ); | 359 | ); |
| 338 | } | 360 | } |
| 339 | 361 | ||
| 362 | + HealthKitRawActivityDataPoint _activityPoint( | ||
| 363 | + Map<int, HealthKitRawActivityDataPoint> byTime, | ||
| 364 | + OhosHealthRawDataItem item, | ||
| 365 | + ) { | ||
| 366 | + final point = byTime.putIfAbsent( | ||
| 367 | + item.dataTime, | ||
| 368 | + () => HealthKitRawActivityDataPoint( | ||
| 369 | + endTime: item.dataTime, | ||
| 370 | + activeEnergyBurnedGoal: _defaultActiveEnergyBurnedGoal, | ||
| 371 | + exerciseTimeGoal: _defaultExerciseTimeGoalSeconds, | ||
| 372 | + standHoursGoal: _defaultStandHoursGoal, | ||
| 373 | + ), | ||
| 374 | + ); | ||
| 375 | + point.activeEnergyBurnedGoal ??= _defaultActiveEnergyBurnedGoal; | ||
| 376 | + point.exerciseTimeGoal ??= _defaultExerciseTimeGoalSeconds; | ||
| 377 | + point.standHoursGoal ??= _defaultStandHoursGoal; | ||
| 378 | + return point; | ||
| 379 | + } | ||
| 380 | + | ||
| 340 | int _appleSleepType(int ohosSleepType) { | 381 | int _appleSleepType(int ohosSleepType) { |
| 341 | return switch (ohosSleepType) { | 382 | return switch (ohosSleepType) { |
| 342 | 1 => 4, // deep | 383 | 1 => 4, // deep |
| @@ -1820,6 +1820,20 @@ class HealthRawStressLocalStore { | @@ -1820,6 +1820,20 @@ class HealthRawStressLocalStore { | ||
| 1820 | return rows.map(HealthRawHrvStressPoint.fromDb).toList(); | 1820 | return rows.map(HealthRawHrvStressPoint.fromDb).toList(); |
| 1821 | } | 1821 | } |
| 1822 | 1822 | ||
| 1823 | + Future<List<HealthRawHrvStressPoint>> queryPendingHrvStressPoints({ | ||
| 1824 | + required int userId, | ||
| 1825 | + int limit = 200, | ||
| 1826 | + }) async { | ||
| 1827 | + final db = await _database(userId); | ||
| 1828 | + final rows = await db.query( | ||
| 1829 | + hrvResultsTable, | ||
| 1830 | + where: 'uploaded IS NULL OR uploaded != 1', | ||
| 1831 | + orderBy: 'raw_end_time ASC', | ||
| 1832 | + limit: limit, | ||
| 1833 | + ); | ||
| 1834 | + return rows.map(HealthRawHrvStressPoint.fromDb).toList(); | ||
| 1835 | + } | ||
| 1836 | + | ||
| 1823 | Future<List<HealthRawRealtimeStressPoint>> queryRealtimeStressPoints({ | 1837 | Future<List<HealthRawRealtimeStressPoint>> queryRealtimeStressPoints({ |
| 1824 | required int userId, | 1838 | required int userId, |
| 1825 | required int startTime, | 1839 | required int startTime, |
| @@ -1835,6 +1849,20 @@ class HealthRawStressLocalStore { | @@ -1835,6 +1849,20 @@ class HealthRawStressLocalStore { | ||
| 1835 | return rows.map(HealthRawRealtimeStressPoint.fromDb).toList(); | 1849 | return rows.map(HealthRawRealtimeStressPoint.fromDb).toList(); |
| 1836 | } | 1850 | } |
| 1837 | 1851 | ||
| 1852 | + Future<List<HealthRawRealtimeStressPoint>> queryPendingRealtimeStressPoints({ | ||
| 1853 | + required int userId, | ||
| 1854 | + int limit = 200, | ||
| 1855 | + }) async { | ||
| 1856 | + final db = await _database(userId); | ||
| 1857 | + final rows = await db.query( | ||
| 1858 | + realtimeStressResultsTable, | ||
| 1859 | + where: 'uploaded IS NULL OR uploaded != 1', | ||
| 1860 | + orderBy: 'raw_end_time ASC', | ||
| 1861 | + limit: limit, | ||
| 1862 | + ); | ||
| 1863 | + return rows.map(HealthRawRealtimeStressPoint.fromDb).toList(); | ||
| 1864 | + } | ||
| 1865 | + | ||
| 1838 | Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({ | 1866 | Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({ |
| 1839 | required int userId, | 1867 | required int userId, |
| 1840 | required int startDate, | 1868 | required int startDate, |
| @@ -1850,6 +1878,20 @@ class HealthRawStressLocalStore { | @@ -1850,6 +1878,20 @@ class HealthRawStressLocalStore { | ||
| 1850 | return rows.map(HealthRawDailyStressPoint.fromDb).toList(); | 1878 | return rows.map(HealthRawDailyStressPoint.fromDb).toList(); |
| 1851 | } | 1879 | } |
| 1852 | 1880 | ||
| 1881 | + Future<List<HealthRawDailyStressPoint>> queryPendingDailyStressPoints({ | ||
| 1882 | + required int userId, | ||
| 1883 | + int limit = 200, | ||
| 1884 | + }) async { | ||
| 1885 | + final db = await _database(userId); | ||
| 1886 | + final rows = await db.query( | ||
| 1887 | + dailyStressResultsTable, | ||
| 1888 | + where: 'uploaded IS NULL OR uploaded != 1', | ||
| 1889 | + orderBy: 'date ASC', | ||
| 1890 | + limit: limit, | ||
| 1891 | + ); | ||
| 1892 | + return rows.map(HealthRawDailyStressPoint.fromDb).toList(); | ||
| 1893 | + } | ||
| 1894 | + | ||
| 1853 | Future<List<HealthRawSleepResult>> querySleepResults({ | 1895 | Future<List<HealthRawSleepResult>> querySleepResults({ |
| 1854 | required int userId, | 1896 | required int userId, |
| 1855 | required int startTime, | 1897 | required int startTime, |
| @@ -1865,6 +1907,20 @@ class HealthRawStressLocalStore { | @@ -1865,6 +1907,20 @@ class HealthRawStressLocalStore { | ||
| 1865 | return rows.map(HealthRawSleepResult.fromDb).toList(); | 1907 | return rows.map(HealthRawSleepResult.fromDb).toList(); |
| 1866 | } | 1908 | } |
| 1867 | 1909 | ||
| 1910 | + Future<List<HealthRawSleepResult>> queryPendingSleepResults({ | ||
| 1911 | + required int userId, | ||
| 1912 | + int limit = 200, | ||
| 1913 | + }) async { | ||
| 1914 | + final db = await _database(userId); | ||
| 1915 | + final rows = await db.query( | ||
| 1916 | + sleepResultsTable, | ||
| 1917 | + where: 'uploaded IS NULL OR uploaded != 1', | ||
| 1918 | + orderBy: 'date ASC', | ||
| 1919 | + limit: limit, | ||
| 1920 | + ); | ||
| 1921 | + return rows.map(HealthRawSleepResult.fromDb).toList(); | ||
| 1922 | + } | ||
| 1923 | + | ||
| 1868 | Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { | 1924 | Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async { |
| 1869 | final db = await _database(userId); | 1925 | final db = await _database(userId); |
| 1870 | final hrvRows = await db.query( | 1926 | final hrvRows = await db.query( |
| @@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/core/result/app_result.dart'; | @@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/core/result/app_result.dart'; | ||
| 3 | import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; | 3 | import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; |
| 4 | import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; | 4 | import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; |
| 5 | import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart'; | 5 | import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart'; |
| 6 | +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | ||
| 6 | import 'package:get/get.dart'; | 7 | import 'package:get/get.dart'; |
| 7 | 8 | ||
| 8 | import 'huawei_health_data_type.dart'; | 9 | import 'huawei_health_data_type.dart'; |
| @@ -49,4 +50,9 @@ class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | @@ -49,4 +50,9 @@ class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient { | ||
| 49 | Future<AppResult<HmWorkoutData>> getWorkoutData(int startTime, int endTime) { | 50 | Future<AppResult<HmWorkoutData>> getWorkoutData(int startTime, int endTime) { |
| 50 | return _harmonyApi.getWorkoutData(startTime, endTime); | 51 | return _harmonyApi.getWorkoutData(startTime, endTime); |
| 51 | } | 52 | } |
| 53 | + | ||
| 54 | + @override | ||
| 55 | + Future<AppResult<V2ActivityTarget>> getActivityGoal() { | ||
| 56 | + return _harmonyApi.getActivityGoal(); | ||
| 57 | + } | ||
| 52 | } | 58 | } |
| @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; | @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart'; | ||
| 8 | import 'package:get/get.dart'; | 8 | import 'package:get/get.dart'; |
| 9 | 9 | ||
| 10 | import '../../../../data/models/enums/app_enums.dart'; | 10 | import '../../../../data/models/enums/app_enums.dart'; |
| 11 | +import '../../../../data/models/health/health_v2_models.dart'; | ||
| 11 | import '../../../../pigeon/health_kit_raw_data_api.g.dart'; | 12 | import '../../../../pigeon/health_kit_raw_data_api.g.dart'; |
| 12 | import '../../../config/app_environment_config.dart'; | 13 | import '../../../config/app_environment_config.dart'; |
| 13 | import '../../../logging/app_logger.dart'; | 14 | import '../../../logging/app_logger.dart'; |
| @@ -20,6 +21,7 @@ import '../health_raw_stress_calculator.dart'; | @@ -20,6 +21,7 @@ import '../health_raw_stress_calculator.dart'; | ||
| 20 | import '../health_sleep_calculator.dart'; | 21 | import '../health_sleep_calculator.dart'; |
| 21 | import '../platform_ios/apple_health_raw_data_core_service.dart'; | 22 | import '../platform_ios/apple_health_raw_data_core_service.dart'; |
| 22 | import 'ohos_health_raw_data_sync_service.dart'; | 23 | import 'ohos_health_raw_data_sync_service.dart'; |
| 24 | +import 'ohos_health_raw_result_upload_service.dart'; | ||
| 23 | 25 | ||
| 24 | class OHOSHealthRawDataCoreService { | 26 | class OHOSHealthRawDataCoreService { |
| 25 | static const int defaultLookbackDays = 183; | 27 | static const int defaultLookbackDays = 183; |
| @@ -34,6 +36,7 @@ class OHOSHealthRawDataCoreService { | @@ -34,6 +36,7 @@ class OHOSHealthRawDataCoreService { | ||
| 34 | HealthApi? serverHealthApi, | 36 | HealthApi? serverHealthApi, |
| 35 | HarmonyApi? harmonyApi, | 37 | HarmonyApi? harmonyApi, |
| 36 | Future<bool> Function()? healthReadAuthorizationChecker, | 38 | Future<bool> Function()? healthReadAuthorizationChecker, |
| 39 | + OhosHealthRawResultUploadService? resultUploadService, | ||
| 37 | }) : _rawDataSource = rawDataSource ?? | 40 | }) : _rawDataSource = rawDataSource ?? |
| 38 | OhosHealthRawDataSource( | 41 | OhosHealthRawDataSource( |
| 39 | userIdProvider: userIdProvider, | 42 | userIdProvider: userIdProvider, |
| @@ -47,7 +50,8 @@ class OHOSHealthRawDataCoreService { | @@ -47,7 +50,8 @@ class OHOSHealthRawDataCoreService { | ||
| 47 | _serverHealthApi = serverHealthApi, | 50 | _serverHealthApi = serverHealthApi, |
| 48 | _harmonyApi = harmonyApi ?? | 51 | _harmonyApi = harmonyApi ?? |
| 49 | (Get.isRegistered<HarmonyApi>() ? Get.find<HarmonyApi>() : null), | 52 | (Get.isRegistered<HarmonyApi>() ? Get.find<HarmonyApi>() : null), |
| 50 | - _healthReadAuthorizationChecker = healthReadAuthorizationChecker; | 53 | + _healthReadAuthorizationChecker = healthReadAuthorizationChecker, |
| 54 | + _resultUploadService = resultUploadService; | ||
| 51 | 55 | ||
| 52 | final HealthRawDataSource _rawDataSource; | 56 | final HealthRawDataSource _rawDataSource; |
| 53 | final HealthRawStressLocalStore _localStore; | 57 | final HealthRawStressLocalStore _localStore; |
| @@ -57,6 +61,7 @@ class OHOSHealthRawDataCoreService { | @@ -57,6 +61,7 @@ class OHOSHealthRawDataCoreService { | ||
| 57 | final HealthApi? _serverHealthApi; | 61 | final HealthApi? _serverHealthApi; |
| 58 | final HarmonyApi? _harmonyApi; | 62 | final HarmonyApi? _harmonyApi; |
| 59 | final Future<bool> Function()? _healthReadAuthorizationChecker; | 63 | final Future<bool> Function()? _healthReadAuthorizationChecker; |
| 64 | + OhosHealthRawResultUploadService? _resultUploadService; | ||
| 60 | final StreamController<HealthRawDataUpdatedEvent> | 65 | final StreamController<HealthRawDataUpdatedEvent> |
| 61 | _healthDataUpdatedController = | 66 | _healthDataUpdatedController = |
| 62 | StreamController<HealthRawDataUpdatedEvent>.broadcast(); | 67 | StreamController<HealthRawDataUpdatedEvent>.broadcast(); |
| @@ -84,11 +89,12 @@ class OHOSHealthRawDataCoreService { | @@ -84,11 +89,12 @@ class OHOSHealthRawDataCoreService { | ||
| 84 | return _rawDataSource.hasHealthData(); | 89 | return _rawDataSource.hasHealthData(); |
| 85 | } | 90 | } |
| 86 | 91 | ||
| 87 | - Future<bool> performHealthDataUpload() async { | ||
| 88 | - // TODO: Trigger OHOS raw-data upload when the OHOS upload API is ready. | ||
| 89 | - return false; | 92 | + Future<V2ActivityTarget?> queryActivityGoal({bool refresh = true}) { |
| 93 | + return _rawDataSource.getActivityGoal(refresh: refresh); | ||
| 90 | } | 94 | } |
| 91 | 95 | ||
| 96 | + Future<bool> performHealthDataUpload() => _uploadResults(); | ||
| 97 | + | ||
| 92 | Future<bool> syncDatabaseToNativeIfNeeded() async { | 98 | Future<bool> syncDatabaseToNativeIfNeeded() async { |
| 93 | // OHOS must not call the Apple Pigeon database sync path. | 99 | // OHOS must not call the Apple Pigeon database sync path. |
| 94 | return false; | 100 | return false; |
| @@ -547,10 +553,24 @@ class OHOSHealthRawDataCoreService { | @@ -547,10 +553,24 @@ class OHOSHealthRawDataCoreService { | ||
| 547 | ); | 553 | ); |
| 548 | } | 554 | } |
| 549 | 555 | ||
| 550 | - Future<void> _uploadResults() async { | ||
| 551 | - // TODO: Upload OHOS result rows through OHOS/backend APIs and mark uploaded | ||
| 552 | - // rows in _localStore after the contract is available. | ||
| 553 | - if (_serverHealthApi == null) return; | 556 | + Future<bool> _uploadResults() async { |
| 557 | + final serverHealthApi = _serverHealthApi; | ||
| 558 | + if (serverHealthApi == null) { | ||
| 559 | + _logInfo('upload results skipped: HealthApi missing'); | ||
| 560 | + return false; | ||
| 561 | + } | ||
| 562 | + final uploader = _resultUploadService ??= OhosHealthRawResultUploadService( | ||
| 563 | + healthApi: serverHealthApi, | ||
| 564 | + localStore: _localStore, | ||
| 565 | + ); | ||
| 566 | + try { | ||
| 567 | + final summary = await uploader.uploadPendingResults(userId: _userId); | ||
| 568 | + _logInfo('upload results finish: $summary'); | ||
| 569 | + return summary.success; | ||
| 570 | + } catch (error, stackTrace) { | ||
| 571 | + _logError('upload results failed', error, stackTrace); | ||
| 572 | + return false; | ||
| 573 | + } | ||
| 554 | } | 574 | } |
| 555 | 575 | ||
| 556 | Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataIfNeeded({ | 576 | Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataIfNeeded({ |
| 1 | import 'dart:io'; | 1 | import 'dart:io'; |
| 2 | 2 | ||
| 3 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; | 3 | import 'package:doublefeel_flutter/core/logging/app_logger.dart'; |
| 4 | +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | ||
| 4 | import 'package:flutter/foundation.dart'; | 5 | import 'package:flutter/foundation.dart'; |
| 5 | import 'package:path_provider/path_provider.dart'; | 6 | import 'package:path_provider/path_provider.dart'; |
| 6 | import 'package:sqflite/sqflite.dart'; | 7 | import 'package:sqflite/sqflite.dart'; |
| @@ -21,6 +22,7 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore { | @@ -21,6 +22,7 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore { | ||
| 21 | 22 | ||
| 22 | static const rawDataTable = 'ohos_raw_data'; | 23 | static const rawDataTable = 'ohos_raw_data'; |
| 23 | static const sleepDataTable = 'ohos_sleep_data'; | 24 | static const sleepDataTable = 'ohos_sleep_data'; |
| 25 | + static const activityGoalTable = 'ohos_activity_goal'; | ||
| 24 | static const logMarker = '[OHOS_RAW_DATA_DB]'; | 26 | static const logMarker = '[OHOS_RAW_DATA_DB]'; |
| 25 | 27 | ||
| 26 | final int Function()? _userIdProvider; | 28 | final int Function()? _userIdProvider; |
| @@ -203,6 +205,54 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore { | @@ -203,6 +205,54 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore { | ||
| 203 | .toList(growable: false); | 205 | .toList(growable: false); |
| 204 | } | 206 | } |
| 205 | 207 | ||
| 208 | + @override | ||
| 209 | + Future<void> upsertActivityGoal(V2ActivityTarget goal) async { | ||
| 210 | + final db = await _database(_userId); | ||
| 211 | + await _createActivityGoalTable(db); | ||
| 212 | + final now = _nowProvider().millisecondsSinceEpoch ~/ 1000; | ||
| 213 | + await db.insert( | ||
| 214 | + activityGoalTable, | ||
| 215 | + <String, Object?>{ | ||
| 216 | + 'id': 1, | ||
| 217 | + 'move': goal.move, | ||
| 218 | + 'step': goal.step, | ||
| 219 | + 'exercise': goal.exercise, | ||
| 220 | + 'stand': goal.stand, | ||
| 221 | + 'sleep_target_duration': goal.sleepTargetDuration, | ||
| 222 | + 'create_time': goal.createTime ?? now, | ||
| 223 | + 'update_time': now, | ||
| 224 | + }, | ||
| 225 | + conflictAlgorithm: ConflictAlgorithm.replace, | ||
| 226 | + ); | ||
| 227 | + _log( | ||
| 228 | + 'activity_goal_stored move=${goal.move} step=${goal.step} ' | ||
| 229 | + 'exercise=${goal.exercise} stand=${goal.stand}', | ||
| 230 | + ); | ||
| 231 | + } | ||
| 232 | + | ||
| 233 | + @override | ||
| 234 | + Future<V2ActivityTarget?> queryActivityGoal() async { | ||
| 235 | + final db = await _database(_userId); | ||
| 236 | + await _createActivityGoalTable(db); | ||
| 237 | + final rows = await db.query( | ||
| 238 | + activityGoalTable, | ||
| 239 | + orderBy: 'update_time DESC', | ||
| 240 | + limit: 1, | ||
| 241 | + ); | ||
| 242 | + if (rows.isEmpty) return null; | ||
| 243 | + final row = rows.first; | ||
| 244 | + return V2ActivityTarget( | ||
| 245 | + userId: _userId, | ||
| 246 | + move: row['move'] as int?, | ||
| 247 | + step: row['step'] as int?, | ||
| 248 | + stand: row['stand'] as int?, | ||
| 249 | + exercise: row['exercise'] as int?, | ||
| 250 | + createTime: row['create_time'] as int?, | ||
| 251 | + updateTime: row['update_time'] as int?, | ||
| 252 | + sleepTargetDuration: row['sleep_target_duration'] as int?, | ||
| 253 | + ); | ||
| 254 | + } | ||
| 255 | + | ||
| 206 | Future<void> close({int? userId}) async { | 256 | Future<void> close({int? userId}) async { |
| 207 | if (userId != null) { | 257 | if (userId != null) { |
| 208 | final db = _opened.remove(userId); | 258 | final db = _opened.remove(userId); |
| @@ -264,6 +314,22 @@ CREATE TABLE IF NOT EXISTS $sleepDataTable ( | @@ -264,6 +314,22 @@ CREATE TABLE IF NOT EXISTS $sleepDataTable ( | ||
| 264 | 'CREATE INDEX IF NOT EXISTS idx_ohos_sleep_data_time ' | 314 | 'CREATE INDEX IF NOT EXISTS idx_ohos_sleep_data_time ' |
| 265 | 'ON $sleepDataTable(to_time)', | 315 | 'ON $sleepDataTable(to_time)', |
| 266 | ); | 316 | ); |
| 317 | + await _createActivityGoalTable(db); | ||
| 318 | + } | ||
| 319 | + | ||
| 320 | + Future<void> _createActivityGoalTable(DatabaseExecutor db) async { | ||
| 321 | + await db.execute(''' | ||
| 322 | +CREATE TABLE IF NOT EXISTS $activityGoalTable ( | ||
| 323 | + id INTEGER PRIMARY KEY, | ||
| 324 | + move INTEGER, | ||
| 325 | + step INTEGER, | ||
| 326 | + exercise INTEGER, | ||
| 327 | + stand INTEGER, | ||
| 328 | + sleep_target_duration INTEGER, | ||
| 329 | + create_time INTEGER NOT NULL, | ||
| 330 | + update_time INTEGER NOT NULL | ||
| 331 | +) | ||
| 332 | +'''); | ||
| 267 | } | 333 | } |
| 268 | 334 | ||
| 269 | Future<void> _createRawDataTypeTable( | 335 | Future<void> _createRawDataTypeTable( |
| @@ -4,6 +4,7 @@ import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; | @@ -4,6 +4,7 @@ import 'package:doublefeel_flutter/data/models/enums/app_enums.dart'; | ||
| 4 | import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; | 4 | import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; |
| 5 | import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; | 5 | import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; |
| 6 | import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart'; | 6 | import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart'; |
| 7 | +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | ||
| 7 | import 'package:flutter/foundation.dart'; | 8 | import 'package:flutter/foundation.dart'; |
| 8 | 9 | ||
| 9 | import 'huawei_health_data_type.dart'; | 10 | import 'huawei_health_data_type.dart'; |
| @@ -153,6 +154,34 @@ class OhosHealthRawDataSyncService { | @@ -153,6 +154,34 @@ class OhosHealthRawDataSyncService { | ||
| 153 | ); | 154 | ); |
| 154 | } | 155 | } |
| 155 | 156 | ||
| 157 | + Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) async { | ||
| 158 | + if (refresh) { | ||
| 159 | + try { | ||
| 160 | + final remoteGoal = await _remoteDataSource.fetchActivityGoal(); | ||
| 161 | + if (remoteGoal != null) { | ||
| 162 | + await _localStore.upsertActivityGoal(remoteGoal); | ||
| 163 | + _log( | ||
| 164 | + 'activity_goal_remote_success ' | ||
| 165 | + 'move=${remoteGoal.move} step=${remoteGoal.step} ' | ||
| 166 | + 'exercise=${remoteGoal.exercise} stand=${remoteGoal.stand}', | ||
| 167 | + ); | ||
| 168 | + return remoteGoal; | ||
| 169 | + } | ||
| 170 | + } catch (error, stackTrace) { | ||
| 171 | + _log( | ||
| 172 | + 'activity_goal_remote_failed error=$error stackTrace=$stackTrace', | ||
| 173 | + ); | ||
| 174 | + } | ||
| 175 | + } | ||
| 176 | + final localGoal = await _localStore.queryActivityGoal(); | ||
| 177 | + _log( | ||
| 178 | + 'activity_goal_local_fallback found=${localGoal != null} ' | ||
| 179 | + 'move=${localGoal?.move} step=${localGoal?.step} ' | ||
| 180 | + 'exercise=${localGoal?.exercise} stand=${localGoal?.stand}', | ||
| 181 | + ); | ||
| 182 | + return localGoal; | ||
| 183 | + } | ||
| 184 | + | ||
| 156 | Future<OhosHealthRawDataSyncResult> _syncResolvedRawData({ | 185 | Future<OhosHealthRawDataSyncResult> _syncResolvedRawData({ |
| 157 | required int dataType, | 186 | required int dataType, |
| 158 | required int startTime, | 187 | required int startTime, |
| @@ -334,6 +363,8 @@ abstract class OhosHealthRawDataRemoteDataSource { | @@ -334,6 +363,8 @@ abstract class OhosHealthRawDataRemoteDataSource { | ||
| 334 | required int endTime, | 363 | required int endTime, |
| 335 | String? pageToken, | 364 | String? pageToken, |
| 336 | }); | 365 | }); |
| 366 | + | ||
| 367 | + Future<V2ActivityTarget?> fetchActivityGoal(); | ||
| 337 | } | 368 | } |
| 338 | 369 | ||
| 339 | abstract class OhosHealthRawDataLocalStore { | 370 | abstract class OhosHealthRawDataLocalStore { |
| @@ -349,6 +380,10 @@ abstract class OhosHealthRawDataLocalStore { | @@ -349,6 +380,10 @@ abstract class OhosHealthRawDataLocalStore { | ||
| 349 | required int startTime, | 380 | required int startTime, |
| 350 | required int endTime, | 381 | required int endTime, |
| 351 | }); | 382 | }); |
| 383 | + | ||
| 384 | + Future<void> upsertActivityGoal(V2ActivityTarget goal); | ||
| 385 | + | ||
| 386 | + Future<V2ActivityTarget?> queryActivityGoal(); | ||
| 352 | } | 387 | } |
| 353 | 388 | ||
| 354 | abstract class OhosHarmonyRawDataClient { | 389 | abstract class OhosHarmonyRawDataClient { |
| @@ -367,6 +402,8 @@ abstract class OhosHarmonyRawDataClient { | @@ -367,6 +402,8 @@ abstract class OhosHarmonyRawDataClient { | ||
| 367 | int startTime, | 402 | int startTime, |
| 368 | int endTime, | 403 | int endTime, |
| 369 | ); | 404 | ); |
| 405 | + | ||
| 406 | + Future<AppResult<V2ActivityTarget>> getActivityGoal(); | ||
| 370 | } | 407 | } |
| 371 | 408 | ||
| 372 | class OhosHarmonyHealthRawDataRemoteDataSource | 409 | class OhosHarmonyHealthRawDataRemoteDataSource |
| @@ -435,6 +472,17 @@ class OhosHarmonyHealthRawDataRemoteDataSource | @@ -435,6 +472,17 @@ class OhosHarmonyHealthRawDataRemoteDataSource | ||
| 435 | }; | 472 | }; |
| 436 | } | 473 | } |
| 437 | 474 | ||
| 475 | + @override | ||
| 476 | + Future<V2ActivityTarget?> fetchActivityGoal() async { | ||
| 477 | + final result = await _client.getActivityGoal(); | ||
| 478 | + return switch (result) { | ||
| 479 | + AppSuccess<V2ActivityTarget>(:final data) => data, | ||
| 480 | + AppFailure<V2ActivityTarget>(:final error) => throw StateError( | ||
| 481 | + 'OHOS activity goal fetch failed: $error', | ||
| 482 | + ), | ||
| 483 | + }; | ||
| 484 | + } | ||
| 485 | + | ||
| 438 | HuaweiHealthDataType _resolveHuaweiDataType(int dataType) { | 486 | HuaweiHealthDataType _resolveHuaweiDataType(int dataType) { |
| 439 | if (dataType == OhosHealthRawDataType.activitySummary) { | 487 | if (dataType == OhosHealthRawDataType.activitySummary) { |
| 440 | return HuaweiHealthDataType.activity; | 488 | return HuaweiHealthDataType.activity; |
| @@ -521,6 +569,12 @@ class TodoOhosHealthRawDataRemoteDataSource | @@ -521,6 +569,12 @@ class TodoOhosHealthRawDataRemoteDataSource | ||
| 521 | // are available. | 569 | // are available. |
| 522 | return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]); | 570 | return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]); |
| 523 | } | 571 | } |
| 572 | + | ||
| 573 | + @override | ||
| 574 | + Future<V2ActivityTarget?> fetchActivityGoal() async { | ||
| 575 | + // TODO: Call the OHOS activity-goal API after HarmonyApi is registered. | ||
| 576 | + return null; | ||
| 577 | + } | ||
| 524 | } | 578 | } |
| 525 | 579 | ||
| 526 | class TodoOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | 580 | class TodoOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { |
| @@ -549,6 +603,16 @@ class TodoOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -549,6 +603,16 @@ class TodoOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 549 | }) async { | 603 | }) async { |
| 550 | return const <OhosHealthRawDataItem>[]; | 604 | return const <OhosHealthRawDataItem>[]; |
| 551 | } | 605 | } |
| 606 | + | ||
| 607 | + @override | ||
| 608 | + Future<void> upsertActivityGoal(V2ActivityTarget goal) async { | ||
| 609 | + // TODO: Store OHOS activity goal into local database. | ||
| 610 | + } | ||
| 611 | + | ||
| 612 | + @override | ||
| 613 | + Future<V2ActivityTarget?> queryActivityGoal() async { | ||
| 614 | + return null; | ||
| 615 | + } | ||
| 552 | } | 616 | } |
| 553 | 617 | ||
| 554 | class OhosHealthRawDataPage { | 618 | class OhosHealthRawDataPage { |
lib/core/services/raw_data_service/platform_ohos/ohos_health_raw_result_upload_service.dart
0 → 100644
| 1 | +import 'package:flutter/foundation.dart'; | ||
| 2 | + | ||
| 3 | +import '../../../logging/app_logger.dart'; | ||
| 4 | +import '../../../network/api/health_api.dart'; | ||
| 5 | +import '../../../result/app_result.dart'; | ||
| 6 | +import '../health_raw_models.dart'; | ||
| 7 | +import '../platform_ios/apple_health_raw_data_core_service.dart'; | ||
| 8 | + | ||
| 9 | +class OhosHealthRawResultUploadService { | ||
| 10 | + OhosHealthRawResultUploadService({ | ||
| 11 | + required HealthApi healthApi, | ||
| 12 | + required HealthRawStressLocalStore localStore, | ||
| 13 | + int batchSize = 200, | ||
| 14 | + }) : _healthApi = healthApi, | ||
| 15 | + _localStore = localStore, | ||
| 16 | + _batchSize = batchSize; | ||
| 17 | + | ||
| 18 | + static const logMarker = '[OHOS_HEALTH_RESULT_UPLOAD]'; | ||
| 19 | + | ||
| 20 | + final HealthApi _healthApi; | ||
| 21 | + final HealthRawStressLocalStore _localStore; | ||
| 22 | + final int _batchSize; | ||
| 23 | + Future<OhosHealthRawResultUploadSummary>? _runningUpload; | ||
| 24 | + | ||
| 25 | + Future<OhosHealthRawResultUploadSummary> uploadPendingResults({ | ||
| 26 | + required int userId, | ||
| 27 | + }) async { | ||
| 28 | + final running = _runningUpload; | ||
| 29 | + if (running != null) { | ||
| 30 | + _log('upload_duplicate_join userId=$userId'); | ||
| 31 | + return running; | ||
| 32 | + } | ||
| 33 | + final task = _uploadPendingResults(userId: userId); | ||
| 34 | + _runningUpload = task; | ||
| 35 | + try { | ||
| 36 | + return await task; | ||
| 37 | + } finally { | ||
| 38 | + if (identical(_runningUpload, task)) { | ||
| 39 | + _runningUpload = null; | ||
| 40 | + } | ||
| 41 | + } | ||
| 42 | + } | ||
| 43 | + | ||
| 44 | + Future<OhosHealthRawResultUploadSummary> _uploadPendingResults({ | ||
| 45 | + required int userId, | ||
| 46 | + }) async { | ||
| 47 | + _log('upload_start userId=$userId'); | ||
| 48 | + final summary = OhosHealthRawResultUploadSummary(); | ||
| 49 | + | ||
| 50 | + await _uploadKind( | ||
| 51 | + kind: 'hrv', | ||
| 52 | + summary: summary, | ||
| 53 | + query: () => _localStore.queryPendingHrvStressPoints( | ||
| 54 | + userId: userId, | ||
| 55 | + limit: _batchSize, | ||
| 56 | + ), | ||
| 57 | + toBody: (points) => points.map(_hrvBody).toList(growable: false), | ||
| 58 | + upload: _healthApi.uploadV2HrvTrendResults, | ||
| 59 | + markUploadedUntil: (points) => _localStore.markHrvStressUploadedUntil( | ||
| 60 | + userId: userId, | ||
| 61 | + rawEndTime: points.last.rawEndTime, | ||
| 62 | + ), | ||
| 63 | + latestTime: (points) => points.last.rawEndTime, | ||
| 64 | + ); | ||
| 65 | + await _uploadKind( | ||
| 66 | + kind: 'realtimeStress', | ||
| 67 | + summary: summary, | ||
| 68 | + query: () => _localStore.queryPendingRealtimeStressPoints( | ||
| 69 | + userId: userId, | ||
| 70 | + limit: _batchSize, | ||
| 71 | + ), | ||
| 72 | + toBody: (points) => points.map(_realtimeBody).toList(growable: false), | ||
| 73 | + upload: _healthApi.uploadV2RealtimeStressResults, | ||
| 74 | + markUploadedUntil: (points) => | ||
| 75 | + _localStore.markRealtimeStressUploadedUntil( | ||
| 76 | + userId: userId, | ||
| 77 | + rawEndTime: points.last.rawEndTime, | ||
| 78 | + ), | ||
| 79 | + latestTime: (points) => points.last.rawEndTime, | ||
| 80 | + ); | ||
| 81 | + await _uploadKind( | ||
| 82 | + kind: 'dailyStress', | ||
| 83 | + summary: summary, | ||
| 84 | + query: () => _localStore.queryPendingDailyStressPoints( | ||
| 85 | + userId: userId, | ||
| 86 | + limit: _batchSize, | ||
| 87 | + ), | ||
| 88 | + toBody: (points) => points.map(_dailyStressBody).toList(growable: false), | ||
| 89 | + upload: _healthApi.uploadV2DailyStressResults, | ||
| 90 | + markUploadedUntil: (points) => _localStore.markDailyStressUploadedUntil( | ||
| 91 | + userId: userId, | ||
| 92 | + date: points.last.date, | ||
| 93 | + ), | ||
| 94 | + latestTime: (points) => points.last.date, | ||
| 95 | + ); | ||
| 96 | + await _uploadKind( | ||
| 97 | + kind: 'sleepScore', | ||
| 98 | + summary: summary, | ||
| 99 | + query: () => _localStore.queryPendingSleepResults( | ||
| 100 | + userId: userId, | ||
| 101 | + limit: _batchSize, | ||
| 102 | + ), | ||
| 103 | + toBody: (points) => points.map(_sleepBody).toList(growable: false), | ||
| 104 | + upload: _healthApi.uploadV2SleepScoreResults, | ||
| 105 | + markUploadedUntil: (points) => _localStore.markSleepResultsUploadedUntil( | ||
| 106 | + userId: userId, | ||
| 107 | + date: points.last.date, | ||
| 108 | + ), | ||
| 109 | + latestTime: (points) => points.last.date, | ||
| 110 | + ); | ||
| 111 | + | ||
| 112 | + _log( | ||
| 113 | + 'upload_finish userId=$userId success=${summary.success} ' | ||
| 114 | + 'hrv=${summary.hrvCount} realtime=${summary.realtimeStressCount} ' | ||
| 115 | + 'daily=${summary.dailyStressCount} sleep=${summary.sleepScoreCount} ' | ||
| 116 | + 'errors=${summary.errors.length}', | ||
| 117 | + ); | ||
| 118 | + return summary; | ||
| 119 | + } | ||
| 120 | + | ||
| 121 | + Future<void> _uploadKind<T>({ | ||
| 122 | + required String kind, | ||
| 123 | + required OhosHealthRawResultUploadSummary summary, | ||
| 124 | + required Future<List<T>> Function() query, | ||
| 125 | + required List<Map<String, Object?>> Function(List<T>) toBody, | ||
| 126 | + required Future<AppResult<void>> Function(List<Map<String, Object?>>) | ||
| 127 | + upload, | ||
| 128 | + required Future<void> Function(List<T>) markUploadedUntil, | ||
| 129 | + required int Function(List<T>) latestTime, | ||
| 130 | + }) async { | ||
| 131 | + while (true) { | ||
| 132 | + final points = await query(); | ||
| 133 | + if (points.isEmpty) { | ||
| 134 | + _log('upload_skip kind=$kind reason=no_pending'); | ||
| 135 | + return; | ||
| 136 | + } | ||
| 137 | + final body = toBody(points); | ||
| 138 | + final until = latestTime(points); | ||
| 139 | + _log('upload_batch_start kind=$kind count=${points.length} until=$until'); | ||
| 140 | + final result = await upload(body); | ||
| 141 | + switch (result) { | ||
| 142 | + case AppSuccess(): | ||
| 143 | + await markUploadedUntil(points); | ||
| 144 | + summary.add(kind, points.length); | ||
| 145 | + _log( | ||
| 146 | + 'upload_batch_success kind=$kind count=${points.length} ' | ||
| 147 | + 'until=$until', | ||
| 148 | + ); | ||
| 149 | + case AppFailure(:final error): | ||
| 150 | + summary.addError(kind, error.toString()); | ||
| 151 | + _log( | ||
| 152 | + 'upload_batch_failed kind=$kind count=${points.length} ' | ||
| 153 | + 'until=$until error=$error', | ||
| 154 | + ); | ||
| 155 | + return; | ||
| 156 | + } | ||
| 157 | + } | ||
| 158 | + } | ||
| 159 | + | ||
| 160 | + Map<String, Object?> _hrvBody(HealthRawHrvStressPoint point) { | ||
| 161 | + return <String, Object?>{ | ||
| 162 | + 'data_time': point.rawEndTime, | ||
| 163 | + 'raw_hrv': point.rawHrv, | ||
| 164 | + 'trend_hrv': point.result, | ||
| 165 | + 'state': point.state.value, | ||
| 166 | + 'hr_baseline': point.baselineRestingHr, | ||
| 167 | + 'hrv_baseline': point.baselineHrv, | ||
| 168 | + 'is_asleep': point.flags.isSleepLikely ? 1 : 0, | ||
| 169 | + 'is_workout': point.flags.isWorkout ? 1 : 0, | ||
| 170 | + 'is_workout_recovery': point.flags.isWorkoutRecovery ? 1 : 0, | ||
| 171 | + 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, | ||
| 172 | + }; | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + Map<String, Object?> _realtimeBody(HealthRawRealtimeStressPoint point) { | ||
| 176 | + return <String, Object?>{ | ||
| 177 | + 'data_time': point.rawEndTime, | ||
| 178 | + 'hr_value': point.rawHr, | ||
| 179 | + 'stress_value': point.result, | ||
| 180 | + 'state': point.state.value, | ||
| 181 | + 'is_asleep': point.flags.isSleepLikely ? 1 : 0, | ||
| 182 | + 'is_workout': point.flags.isWorkout ? 1 : 0, | ||
| 183 | + 'is_workout_recovery': point.flags.isWorkoutRecovery ? 1 : 0, | ||
| 184 | + 'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0, | ||
| 185 | + }; | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + Map<String, Object?> _dailyStressBody(HealthRawDailyStressPoint point) { | ||
| 189 | + return <String, Object?>{ | ||
| 190 | + 'stress_value': point.stressValue, | ||
| 191 | + 'stress_score': point.stressScore, | ||
| 192 | + 'state': point.state.value, | ||
| 193 | + 'data_time': point.dataTime, | ||
| 194 | + }; | ||
| 195 | + } | ||
| 196 | + | ||
| 197 | + Map<String, Object?> _sleepBody(HealthRawSleepResult result) { | ||
| 198 | + return <String, Object?>{ | ||
| 199 | + 'date': result.date, | ||
| 200 | + 'sleep_score': result.sleepScore, | ||
| 201 | + 'sleep_state': result.sleepState, | ||
| 202 | + 'in_bed_minutes': result.inBedMinutes, | ||
| 203 | + 'awak_minutes': result.awakMinutes, | ||
| 204 | + 'sleep_minutes': result.sleepMinutes, | ||
| 205 | + }; | ||
| 206 | + } | ||
| 207 | + | ||
| 208 | + void _log(String message) { | ||
| 209 | + final taggedMessage = '$logMarker $message'; | ||
| 210 | + debugPrint(taggedMessage); | ||
| 211 | + try { | ||
| 212 | + AppLogger.i(taggedMessage); | ||
| 213 | + } catch (_) { | ||
| 214 | + // AppLogger may be unavailable in early bootstrap/test contexts. | ||
| 215 | + } | ||
| 216 | + } | ||
| 217 | +} | ||
| 218 | + | ||
| 219 | +class OhosHealthRawResultUploadSummary { | ||
| 220 | + int hrvCount = 0; | ||
| 221 | + int realtimeStressCount = 0; | ||
| 222 | + int dailyStressCount = 0; | ||
| 223 | + int sleepScoreCount = 0; | ||
| 224 | + final Map<String, String> errors = <String, String>{}; | ||
| 225 | + | ||
| 226 | + bool get success => errors.isEmpty; | ||
| 227 | + | ||
| 228 | + void add(String kind, int count) { | ||
| 229 | + switch (kind) { | ||
| 230 | + case 'hrv': | ||
| 231 | + hrvCount += count; | ||
| 232 | + case 'realtimeStress': | ||
| 233 | + realtimeStressCount += count; | ||
| 234 | + case 'dailyStress': | ||
| 235 | + dailyStressCount += count; | ||
| 236 | + case 'sleepScore': | ||
| 237 | + sleepScoreCount += count; | ||
| 238 | + } | ||
| 239 | + } | ||
| 240 | + | ||
| 241 | + void addError(String kind, String error) { | ||
| 242 | + errors[kind] = error; | ||
| 243 | + } | ||
| 244 | + | ||
| 245 | + @override | ||
| 246 | + String toString() { | ||
| 247 | + return 'OhosHealthRawResultUploadSummary(' | ||
| 248 | + 'success=$success, ' | ||
| 249 | + 'hrvCount=$hrvCount, ' | ||
| 250 | + 'realtimeStressCount=$realtimeStressCount, ' | ||
| 251 | + 'dailyStressCount=$dailyStressCount, ' | ||
| 252 | + 'sleepScoreCount=$sleepScoreCount, ' | ||
| 253 | + 'errors=$errors' | ||
| 254 | + ')'; | ||
| 255 | + } | ||
| 256 | +} |
| @@ -8,6 +8,7 @@ import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | @@ -8,6 +8,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/core/platform/pigeon_api_facade.dart'; | 10 | import 'package:doublefeel_flutter/core/platform/pigeon_api_facade.dart'; |
| 11 | +import 'package:flutter/foundation.dart'; | ||
| 11 | 12 | ||
| 12 | import 'health_datasource.dart'; | 13 | import 'health_datasource.dart'; |
| 13 | import 'health_local_data_convert.dart'; | 14 | import 'health_local_data_convert.dart'; |
| @@ -17,6 +18,12 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -17,6 +18,12 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 17 | 18 | ||
| 18 | final HealthRawDataCoreService coreService; | 19 | final HealthRawDataCoreService coreService; |
| 19 | 20 | ||
| 21 | + static const int _ohosDefaultMoveGoal = 300; | ||
| 22 | + static const int _ohosDefaultStepGoal = 8000; | ||
| 23 | + static const int _ohosDefaultStandGoal = 12; | ||
| 24 | + static const int _ohosDefaultExerciseGoalSeconds = 30 * 60; | ||
| 25 | + static const int _ohosDefaultSleepTargetSeconds = 8 * 60 * 60; | ||
| 26 | + | ||
| 20 | @override | 27 | @override |
| 21 | Future<AppResult<SleepStatisticsData>> getSleepStatistics( | 28 | Future<AppResult<SleepStatisticsData>> getSleepStatistics( |
| 22 | int dateRangeType, | 29 | int dateRangeType, |
| @@ -84,6 +91,11 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -84,6 +91,11 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 84 | startTime: queryStart, | 91 | startTime: queryStart, |
| 85 | endTime: queryEnd, | 92 | endTime: queryEnd, |
| 86 | ); | 93 | ); |
| 94 | + final resolvedActivity = await _resolveOhosActivityGoalsIfNeeded( | ||
| 95 | + activity: activity, | ||
| 96 | + userId: queryUserId ?? 0, | ||
| 97 | + fallbackUpdateTime: queryEnd, | ||
| 98 | + ); | ||
| 87 | final heartRate = await coreService.queryRawDataPoints( | 99 | final heartRate = await coreService.queryRawDataPoints( |
| 88 | dataType: HealthDataUploadType.heartRate.type, | 100 | dataType: HealthDataUploadType.heartRate.type, |
| 89 | startTime: queryStart, | 101 | startTime: queryStart, |
| @@ -94,7 +106,7 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -94,7 +106,7 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 94 | LocalHealthDataConvert.activityBurnStatistics( | 106 | LocalHealthDataConvert.activityBurnStatistics( |
| 95 | days: days, | 107 | days: days, |
| 96 | previousDays: previousDays, | 108 | previousDays: previousDays, |
| 97 | - activity: activity, | 109 | + activity: resolvedActivity, |
| 98 | heartRate: heartRate, | 110 | heartRate: heartRate, |
| 99 | sleepIntervals: [], | 111 | sleepIntervals: [], |
| 100 | ), | 112 | ), |
| @@ -181,6 +193,17 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -181,6 +193,17 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 181 | endTime: queryEnd, | 193 | endTime: queryEnd, |
| 182 | ); | 194 | ); |
| 183 | final resolvedActivity = _resolveActivityTarget(activity); | 195 | final resolvedActivity = _resolveActivityTarget(activity); |
| 196 | + if (_isOhosPlatform) { | ||
| 197 | + final activityGoal = await coreService.queryOhosActivityGoal(); | ||
| 198 | + return AppSuccess( | ||
| 199 | + _ohosActivityTarget( | ||
| 200 | + userId: queryUserId ?? 0, | ||
| 201 | + remoteOrCachedTarget: activityGoal, | ||
| 202 | + activity: resolvedActivity, | ||
| 203 | + fallbackUpdateTime: queryEnd, | ||
| 204 | + ), | ||
| 205 | + ); | ||
| 206 | + } | ||
| 184 | return AppSuccess( | 207 | return AppSuccess( |
| 185 | LocalHealthDataConvert.v2ActivityTarget( | 208 | LocalHealthDataConvert.v2ActivityTarget( |
| 186 | userId: queryUserId ?? 0, | 209 | userId: queryUserId ?? 0, |
| @@ -212,6 +235,11 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -212,6 +235,11 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 212 | startTime: dayStart, | 235 | startTime: dayStart, |
| 213 | endTime: dayEnd, | 236 | endTime: dayEnd, |
| 214 | ); | 237 | ); |
| 238 | + final resolvedActivity = await _resolveOhosActivityGoalsIfNeeded( | ||
| 239 | + activity: activity, | ||
| 240 | + userId: queryUserId ?? 0, | ||
| 241 | + fallbackUpdateTime: dayEnd, | ||
| 242 | + ); | ||
| 215 | final sleepResults = await coreService.querySleepResults( | 243 | final sleepResults = await coreService.querySleepResults( |
| 216 | startTime: dayStart, | 244 | startTime: dayStart, |
| 217 | endTime: dayEnd, | 245 | endTime: dayEnd, |
| @@ -242,7 +270,7 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -242,7 +270,7 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 242 | LocalHealthDataConvert.v2HealthDataFromSleepResult( | 270 | LocalHealthDataConvert.v2HealthDataFromSleepResult( |
| 243 | day: day, | 271 | day: day, |
| 244 | hrvPoints: hrvPoints, | 272 | hrvPoints: hrvPoints, |
| 245 | - activity: activity, | 273 | + activity: resolvedActivity, |
| 246 | sleepingHeartRate: sleepingHeartRate, | 274 | sleepingHeartRate: sleepingHeartRate, |
| 247 | restingHeartRate: restingHeartRate, | 275 | restingHeartRate: restingHeartRate, |
| 248 | sleepResult: sleepResult, | 276 | sleepResult: sleepResult, |
| @@ -392,4 +420,76 @@ class LocalHealthDataSource implements HealthDataSource { | @@ -392,4 +420,76 @@ class LocalHealthDataSource implements HealthDataSource { | ||
| 392 | } | 420 | } |
| 393 | 421 | ||
| 394 | bool _isPositive(num? value) => value != null && value > 0; | 422 | bool _isPositive(num? value) => value != null && value > 0; |
| 423 | + | ||
| 424 | + bool get _isOhosPlatform => defaultTargetPlatform.name == 'ohos'; | ||
| 425 | + | ||
| 426 | + Future<List<HealthKitRawActivityDataPoint>> | ||
| 427 | + _resolveOhosActivityGoalsIfNeeded({ | ||
| 428 | + required List<HealthKitRawActivityDataPoint> activity, | ||
| 429 | + required int userId, | ||
| 430 | + required int fallbackUpdateTime, | ||
| 431 | + }) async { | ||
| 432 | + if (!_isOhosPlatform) return activity; | ||
| 433 | + final target = _ohosActivityTarget( | ||
| 434 | + userId: userId, | ||
| 435 | + remoteOrCachedTarget: await coreService.queryOhosActivityGoal(), | ||
| 436 | + activity: _resolveActivityTarget(activity), | ||
| 437 | + fallbackUpdateTime: fallbackUpdateTime, | ||
| 438 | + ); | ||
| 439 | + if (activity.isEmpty) { | ||
| 440 | + return [ | ||
| 441 | + _activityPointWithTarget( | ||
| 442 | + HealthKitRawActivityDataPoint(endTime: fallbackUpdateTime), | ||
| 443 | + target, | ||
| 444 | + ), | ||
| 445 | + ]; | ||
| 446 | + } | ||
| 447 | + return activity | ||
| 448 | + .map((item) => _activityPointWithTarget(item, target)) | ||
| 449 | + .toList(growable: false); | ||
| 450 | + } | ||
| 451 | + | ||
| 452 | + HealthKitRawActivityDataPoint _activityPointWithTarget( | ||
| 453 | + HealthKitRawActivityDataPoint item, | ||
| 454 | + V2ActivityTarget target, | ||
| 455 | + ) { | ||
| 456 | + return HealthKitRawActivityDataPoint( | ||
| 457 | + endTime: item.endTime, | ||
| 458 | + activityMoveMode: item.activityMoveMode, | ||
| 459 | + activeEnergyBurned: item.activeEnergyBurned, | ||
| 460 | + activeEnergyBurnedGoal: target.move?.toDouble(), | ||
| 461 | + appleMoveTime: item.appleMoveTime, | ||
| 462 | + appleMoveTimeGoal: item.appleMoveTimeGoal, | ||
| 463 | + appleExerciseTime: item.appleExerciseTime, | ||
| 464 | + exerciseTimeGoal: target.exercise?.toDouble(), | ||
| 465 | + appleStandHours: item.appleStandHours, | ||
| 466 | + standHoursGoal: target.stand?.toDouble(), | ||
| 467 | + ); | ||
| 468 | + } | ||
| 469 | + | ||
| 470 | + V2ActivityTarget _ohosActivityTarget({ | ||
| 471 | + required int userId, | ||
| 472 | + required V2ActivityTarget? remoteOrCachedTarget, | ||
| 473 | + required HealthKitRawActivityDataPoint? activity, | ||
| 474 | + required int fallbackUpdateTime, | ||
| 475 | + }) { | ||
| 476 | + return V2ActivityTarget( | ||
| 477 | + userId: userId, | ||
| 478 | + move: remoteOrCachedTarget?.move ?? | ||
| 479 | + activity?.activeEnergyBurnedGoal?.ceil() ?? | ||
| 480 | + _ohosDefaultMoveGoal, | ||
| 481 | + step: remoteOrCachedTarget?.step ?? _ohosDefaultStepGoal, | ||
| 482 | + stand: remoteOrCachedTarget?.stand ?? | ||
| 483 | + activity?.standHoursGoal?.round() ?? | ||
| 484 | + _ohosDefaultStandGoal, | ||
| 485 | + exercise: remoteOrCachedTarget?.exercise ?? | ||
| 486 | + activity?.exerciseTimeGoal?.round() ?? | ||
| 487 | + _ohosDefaultExerciseGoalSeconds, | ||
| 488 | + updateTime: remoteOrCachedTarget?.updateTime ?? | ||
| 489 | + activity?.endTime ?? | ||
| 490 | + fallbackUpdateTime, | ||
| 491 | + sleepTargetDuration: remoteOrCachedTarget?.sleepTargetDuration ?? | ||
| 492 | + _ohosDefaultSleepTargetSeconds, | ||
| 493 | + ); | ||
| 494 | + } | ||
| 395 | } | 495 | } |
| @@ -6,6 +6,7 @@ import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ | @@ -6,6 +6,7 @@ import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ | ||
| 6 | import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; | 6 | import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart'; |
| 7 | import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; | 7 | import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart'; |
| 8 | import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart'; | 8 | import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart'; |
| 9 | +import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; | ||
| 9 | import 'package:flutter_test/flutter_test.dart'; | 10 | import 'package:flutter_test/flutter_test.dart'; |
| 10 | 11 | ||
| 11 | void main() { | 12 | void main() { |
| @@ -438,6 +439,29 @@ void main() { | @@ -438,6 +439,29 @@ void main() { | ||
| 438 | expect(page.items.single.payload['end_time'], 1787870000); | 439 | expect(page.items.single.payload['end_time'], 1787870000); |
| 439 | expect(page.items.single.payload['activity_type'], 90); | 440 | expect(page.items.single.payload['activity_type'], 90); |
| 440 | }); | 441 | }); |
| 442 | + | ||
| 443 | + test('getActivityGoal fetches remote goal and stores local cache', () async { | ||
| 444 | + const goal = V2ActivityTarget( | ||
| 445 | + move: 400, | ||
| 446 | + step: 9000, | ||
| 447 | + exercise: 1800, | ||
| 448 | + stand: 12, | ||
| 449 | + ); | ||
| 450 | + final remote = _FakeOhosHealthRawDataRemoteDataSource( | ||
| 451 | + const <OhosHealthRawDataPage>[], | ||
| 452 | + activityGoal: goal, | ||
| 453 | + ); | ||
| 454 | + final local = _FakeOhosHealthRawDataLocalStore(); | ||
| 455 | + final service = OhosHealthRawDataSyncService( | ||
| 456 | + remoteDataSource: remote, | ||
| 457 | + localStore: local, | ||
| 458 | + ); | ||
| 459 | + | ||
| 460 | + final result = await service.getActivityGoal(); | ||
| 461 | + | ||
| 462 | + expect(result, same(goal)); | ||
| 463 | + expect(local.activityGoal, same(goal)); | ||
| 464 | + }); | ||
| 441 | } | 465 | } |
| 442 | 466 | ||
| 443 | class _FakeOhosHealthRawDataRemoteDataSource | 467 | class _FakeOhosHealthRawDataRemoteDataSource |
| @@ -446,12 +470,15 @@ class _FakeOhosHealthRawDataRemoteDataSource | @@ -446,12 +470,15 @@ class _FakeOhosHealthRawDataRemoteDataSource | ||
| 446 | this._pages, { | 470 | this._pages, { |
| 447 | Completer<void>? gate, | 471 | Completer<void>? gate, |
| 448 | Set<int> failingDataTypes = const <int>{}, | 472 | Set<int> failingDataTypes = const <int>{}, |
| 473 | + V2ActivityTarget? activityGoal, | ||
| 449 | }) : _gate = gate, | 474 | }) : _gate = gate, |
| 450 | - _failingDataTypes = failingDataTypes; | 475 | + _failingDataTypes = failingDataTypes, |
| 476 | + _activityGoal = activityGoal; | ||
| 451 | 477 | ||
| 452 | final List<OhosHealthRawDataPage> _pages; | 478 | final List<OhosHealthRawDataPage> _pages; |
| 453 | final Completer<void>? _gate; | 479 | final Completer<void>? _gate; |
| 454 | final Set<int> _failingDataTypes; | 480 | final Set<int> _failingDataTypes; |
| 481 | + final V2ActivityTarget? _activityGoal; | ||
| 455 | final List<_FetchCall> calls = <_FetchCall>[]; | 482 | final List<_FetchCall> calls = <_FetchCall>[]; |
| 456 | 483 | ||
| 457 | @override | 484 | @override |
| @@ -481,6 +508,11 @@ class _FakeOhosHealthRawDataRemoteDataSource | @@ -481,6 +508,11 @@ class _FakeOhosHealthRawDataRemoteDataSource | ||
| 481 | } | 508 | } |
| 482 | return _pages[pageIndex]; | 509 | return _pages[pageIndex]; |
| 483 | } | 510 | } |
| 511 | + | ||
| 512 | + @override | ||
| 513 | + Future<V2ActivityTarget?> fetchActivityGoal() async { | ||
| 514 | + return _activityGoal; | ||
| 515 | + } | ||
| 484 | } | 516 | } |
| 485 | 517 | ||
| 486 | class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | 518 | class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { |
| @@ -490,6 +522,7 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -490,6 +522,7 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 490 | final int? _latestDataTime; | 522 | final int? _latestDataTime; |
| 491 | final List<List<OhosHealthRawDataItem>> storedBatches = | 523 | final List<List<OhosHealthRawDataItem>> storedBatches = |
| 492 | <List<OhosHealthRawDataItem>>[]; | 524 | <List<OhosHealthRawDataItem>>[]; |
| 525 | + V2ActivityTarget? activityGoal; | ||
| 493 | 526 | ||
| 494 | @override | 527 | @override |
| 495 | Future<int?> latestDataTime({required int dataType}) async { | 528 | Future<int?> latestDataTime({required int dataType}) async { |
| @@ -521,6 +554,16 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | @@ -521,6 +554,16 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { | ||
| 521 | ) | 554 | ) |
| 522 | .toList(growable: false); | 555 | .toList(growable: false); |
| 523 | } | 556 | } |
| 557 | + | ||
| 558 | + @override | ||
| 559 | + Future<void> upsertActivityGoal(V2ActivityTarget goal) async { | ||
| 560 | + activityGoal = goal; | ||
| 561 | + } | ||
| 562 | + | ||
| 563 | + @override | ||
| 564 | + Future<V2ActivityTarget?> queryActivityGoal() async { | ||
| 565 | + return activityGoal; | ||
| 566 | + } | ||
| 524 | } | 567 | } |
| 525 | 568 | ||
| 526 | class _FetchCall { | 569 | class _FetchCall { |
| @@ -542,13 +585,16 @@ class _FakeOhosHarmonyRawDataClient implements OhosHarmonyRawDataClient { | @@ -542,13 +585,16 @@ class _FakeOhosHarmonyRawDataClient implements OhosHarmonyRawDataClient { | ||
| 542 | HmHealthData? healthData, | 585 | HmHealthData? healthData, |
| 543 | HmSleepData? sleepData, | 586 | HmSleepData? sleepData, |
| 544 | HmWorkoutData? workoutData, | 587 | HmWorkoutData? workoutData, |
| 588 | + V2ActivityTarget? activityGoal, | ||
| 545 | }) : _healthData = healthData ?? HmHealthData(), | 589 | }) : _healthData = healthData ?? HmHealthData(), |
| 546 | _sleepData = sleepData ?? HmSleepData(), | 590 | _sleepData = sleepData ?? HmSleepData(), |
| 547 | - _workoutData = workoutData ?? HmWorkoutData(); | 591 | + _workoutData = workoutData ?? HmWorkoutData(), |
| 592 | + _activityGoal = activityGoal ?? const V2ActivityTarget(); | ||
| 548 | 593 | ||
| 549 | final HmHealthData _healthData; | 594 | final HmHealthData _healthData; |
| 550 | final HmSleepData _sleepData; | 595 | final HmSleepData _sleepData; |
| 551 | final HmWorkoutData _workoutData; | 596 | final HmWorkoutData _workoutData; |
| 597 | + final V2ActivityTarget _activityGoal; | ||
| 552 | final List<_HealthCall> healthCalls = <_HealthCall>[]; | 598 | final List<_HealthCall> healthCalls = <_HealthCall>[]; |
| 553 | final List<_SleepCall> sleepCalls = <_SleepCall>[]; | 599 | final List<_SleepCall> sleepCalls = <_SleepCall>[]; |
| 554 | final List<_SleepCall> workoutCalls = <_SleepCall>[]; | 600 | final List<_SleepCall> workoutCalls = <_SleepCall>[]; |
| @@ -596,6 +642,11 @@ class _FakeOhosHarmonyRawDataClient implements OhosHarmonyRawDataClient { | @@ -596,6 +642,11 @@ class _FakeOhosHarmonyRawDataClient implements OhosHarmonyRawDataClient { | ||
| 596 | ); | 642 | ); |
| 597 | return AppSuccess(_workoutData); | 643 | return AppSuccess(_workoutData); |
| 598 | } | 644 | } |
| 645 | + | ||
| 646 | + @override | ||
| 647 | + Future<AppResult<V2ActivityTarget>> getActivityGoal() async { | ||
| 648 | + return AppSuccess(_activityGoal); | ||
| 649 | + } | ||
| 599 | } | 650 | } |
| 600 | 651 | ||
| 601 | class _HealthCall { | 652 | class _HealthCall { |
-
Please register or login to post a comment