Commit 1d2a0dd3d3049b12e172e0620a5c8dfe6bf69d47

Authored by 权海
1 parent a209a9f5

feat(ui):增加ohos活动目标

... ... @@ -4,6 +4,7 @@ import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart';
import 'package:doublefeel_flutter/data/models/harmony/privacy_records.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import '../../result/app_result.dart';
import '../../result/safe_call.dart';
... ... @@ -105,4 +106,15 @@ class HarmonyApi {
},
);
}
Future<AppResult<V2ActivityTarget>> getActivityGoal() {
return safeCall(
call: () async {
final response = await _dioClient.dio.get(ApiPaths.hmActivityGoal);
return V2ActivityTarget.fromJson(
response.data as Map<String, dynamic>,
);
},
);
}
}
... ...
... ... @@ -238,6 +238,58 @@ class HealthApi {
);
}
Future<AppResult<void>> uploadV2HrvTrendResults(
List<Map<String, Object?>> dataList,
) {
return safeCall(
call: () async {
await _dioClient.dio.post(
ApiPaths.v2HrvTrend,
data: {'data_list': dataList},
);
},
);
}
Future<AppResult<void>> uploadV2RealtimeStressResults(
List<Map<String, Object?>> dataList,
) {
return safeCall(
call: () async {
await _dioClient.dio.post(
ApiPaths.v2RealtimeStress,
data: {'data_list': dataList},
);
},
);
}
Future<AppResult<void>> uploadV2DailyStressResults(
List<Map<String, Object?>> dataList,
) {
return safeCall(
call: () async {
await _dioClient.dio.post(
ApiPaths.v2StressScore,
data: {'data_list': dataList},
);
},
);
}
Future<AppResult<void>> uploadV2SleepScoreResults(
List<Map<String, Object?>> dataList,
) {
return safeCall(
call: () async {
await _dioClient.dio.post(
ApiPaths.v2SleepScore,
data: {'data_list': dataList},
);
},
);
}
Future<AppResult<void>> uploadPulseTypeResult(PulseType pulseType) {
return safeCall(
call: () async {
... ...
... ... @@ -50,6 +50,7 @@ abstract final class ApiPaths {
static const v2HrvTrend = '/client/doublefeel/health/v2/hrv_trend/';
static const v2RealtimeStress =
'/client/doublefeel/health/v2/realtime_stress/';
static const v2SleepScore = '/client/doublefeel/health/v2/sleep_score/';
static const v2RealtimeStressPush =
'/client/doublefeel/health/v2/realtime_stress/push/';
static const v2ActivityTarget =
... ... @@ -96,6 +97,7 @@ abstract final class ApiPaths {
static const hmHealthData = '/client/doublefeel/huawei_hm/health_data/';
static const hmSleepData = '/client/doublefeel/huawei_hm/sleep_data/';
static const hmActivityTime = '/client/doublefeel/huawei_hm/activity_time/';
static const hmActivityGoal = '/client/doublefeel/huawei_hm/activity_goal/';
static const hmPrivacyRecords =
'/client/doublefeel/huawei_hm/privacy_records/';
}
... ...
... ... @@ -3,6 +3,7 @@ import 'dart:async';
import 'package:flutter/foundation.dart';
import '../../../pigeon/health_kit_raw_data_api.g.dart';
import '../../../data/models/health/health_v2_models.dart';
import '../../config/app_environment_config.dart';
import '../../network/api/health_api.dart';
import 'health_raw_models.dart';
... ... @@ -255,6 +256,16 @@ class HealthRawDataCoreService {
);
}
Future<V2ActivityTarget?> queryOhosActivityGoal({
bool refresh = true,
}) {
final platform = _targetPlatform ?? defaultTargetPlatform;
if (platform == TargetPlatform.iOS) {
return Future<V2ActivityTarget?>.value();
}
return _ohosService.queryActivityGoal(refresh: refresh);
}
Future<void> markHrvStressUploaded({
required Iterable<int> rawEndTimes,
}) {
... ...
import 'package:flutter/foundation.dart';
import '../../../data/models/health/health_v2_models.dart';
import '../../platform/pigeon_api_facade.dart';
import 'platform_ohos/huawei_health_data_type.dart';
import 'platform_ohos/ohos_harmony_health_raw_data_sync_service_factory.dart';
... ... @@ -28,6 +29,8 @@ abstract class HealthRawDataSource {
int startTime,
int endTime,
);
Future<V2ActivityTarget?> getActivityGoal({bool refresh = true});
}
class PlatformHealthRawDataSource implements HealthRawDataSource {
... ... @@ -85,6 +88,11 @@ class PlatformHealthRawDataSource implements HealthRawDataSource {
) {
return _currentDataSource.getRawWorkoutData(startTime, endTime);
}
@override
Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) {
return _currentDataSource.getActivityGoal(refresh: refresh);
}
}
// IOS
... ... @@ -132,6 +140,11 @@ class AppleHealthRawDataSource implements HealthRawDataSource {
) {
return _rawDataApi.getHealthKitRawWorkoutData(startTime, endTime);
}
@override
Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) async {
return null;
}
}
// ohos
... ... @@ -149,6 +162,11 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
final OhosHealthRawDataSyncService _syncService;
final bool _syncBeforeRead;
static const String _activityLogMarker = '[OHOS_HEALTH_ACTIVITY_SOURCE]';
static const double _defaultActiveEnergyBurnedGoal = 300.0;
static const double _defaultExerciseTimeGoalSeconds = 1800.0;
static const double _defaultStandHoursGoal = 12.0;
Future<List<OhosHealthRawDataSyncResult>> syncCalculationRawData({
int? startTime,
int? endTime,
... ... @@ -174,6 +192,11 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
}
@override
Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) {
return _syncService.getActivityGoal(refresh: refresh);
}
@override
Future<bool> hasHealthData() async {
// TODO: Query the OHOS local raw-data database when its schema is ready.
return false;
... ... @@ -243,7 +266,7 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
if (_syncBeforeRead) {
await Future.wait(
<int>[
OhosHealthRawDataType.activitySummary,
HuaweiHealthDataType.activity.dataType,
HuaweiHealthDataType.exerciseDuration.dataType,
HuaweiHealthDataType.standingDuration.dataType,
HuaweiHealthDataType.stepCount.dataType,
... ... @@ -258,7 +281,7 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
);
}
final activity = await _syncService.queryRawData(
dataType: OhosHealthRawDataType.activitySummary,
dataType: HuaweiHealthDataType.activity.dataType,
startTime: startTime,
endTime: endTime,
);
... ... @@ -274,28 +297,27 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
);
final byTime = <int, HealthKitRawActivityDataPoint>{};
for (final item in activity) {
final point = byTime.putIfAbsent(
item.dataTime,
() => HealthKitRawActivityDataPoint(endTime: item.dataTime),
);
final point = _activityPoint(byTime, item);
point.activeEnergyBurned = _doublePayload(item.payload, 'value');
}
for (final item in exercise) {
final point = byTime.putIfAbsent(
item.dataTime,
() => HealthKitRawActivityDataPoint(endTime: item.dataTime),
);
point.appleExerciseTime = _doublePayload(item.payload, 'value');
final point = _activityPoint(byTime, item);
final minutes = _doublePayload(item.payload, 'value');
point.appleExerciseTime =
minutes == null ? null : minutes * Duration.secondsPerMinute;
}
for (final item in standing) {
final point = byTime.putIfAbsent(
item.dataTime,
() => HealthKitRawActivityDataPoint(endTime: item.dataTime),
);
final point = _activityPoint(byTime, item);
point.appleStandHours = _doublePayload(item.payload, 'value');
}
return byTime.values.toList(growable: false)
final points = byTime.values.toList(growable: false)
..sort((a, b) => a.endTime.compareTo(b.endTime));
debugPrint(
'$_activityLogMarker query startTime=$startTime endTime=$endTime '
'activity=${activity.length} exercise=${exercise.length} '
'standing=${standing.length} points=${points.length}',
);
return points;
}
@override
... ... @@ -337,6 +359,25 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
);
}
HealthKitRawActivityDataPoint _activityPoint(
Map<int, HealthKitRawActivityDataPoint> byTime,
OhosHealthRawDataItem item,
) {
final point = byTime.putIfAbsent(
item.dataTime,
() => HealthKitRawActivityDataPoint(
endTime: item.dataTime,
activeEnergyBurnedGoal: _defaultActiveEnergyBurnedGoal,
exerciseTimeGoal: _defaultExerciseTimeGoalSeconds,
standHoursGoal: _defaultStandHoursGoal,
),
);
point.activeEnergyBurnedGoal ??= _defaultActiveEnergyBurnedGoal;
point.exerciseTimeGoal ??= _defaultExerciseTimeGoalSeconds;
point.standHoursGoal ??= _defaultStandHoursGoal;
return point;
}
int _appleSleepType(int ohosSleepType) {
return switch (ohosSleepType) {
1 => 4, // deep
... ...
... ... @@ -1820,6 +1820,20 @@ class HealthRawStressLocalStore {
return rows.map(HealthRawHrvStressPoint.fromDb).toList();
}
Future<List<HealthRawHrvStressPoint>> queryPendingHrvStressPoints({
required int userId,
int limit = 200,
}) async {
final db = await _database(userId);
final rows = await db.query(
hrvResultsTable,
where: 'uploaded IS NULL OR uploaded != 1',
orderBy: 'raw_end_time ASC',
limit: limit,
);
return rows.map(HealthRawHrvStressPoint.fromDb).toList();
}
Future<List<HealthRawRealtimeStressPoint>> queryRealtimeStressPoints({
required int userId,
required int startTime,
... ... @@ -1835,6 +1849,20 @@ class HealthRawStressLocalStore {
return rows.map(HealthRawRealtimeStressPoint.fromDb).toList();
}
Future<List<HealthRawRealtimeStressPoint>> queryPendingRealtimeStressPoints({
required int userId,
int limit = 200,
}) async {
final db = await _database(userId);
final rows = await db.query(
realtimeStressResultsTable,
where: 'uploaded IS NULL OR uploaded != 1',
orderBy: 'raw_end_time ASC',
limit: limit,
);
return rows.map(HealthRawRealtimeStressPoint.fromDb).toList();
}
Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({
required int userId,
required int startDate,
... ... @@ -1850,6 +1878,20 @@ class HealthRawStressLocalStore {
return rows.map(HealthRawDailyStressPoint.fromDb).toList();
}
Future<List<HealthRawDailyStressPoint>> queryPendingDailyStressPoints({
required int userId,
int limit = 200,
}) async {
final db = await _database(userId);
final rows = await db.query(
dailyStressResultsTable,
where: 'uploaded IS NULL OR uploaded != 1',
orderBy: 'date ASC',
limit: limit,
);
return rows.map(HealthRawDailyStressPoint.fromDb).toList();
}
Future<List<HealthRawSleepResult>> querySleepResults({
required int userId,
required int startTime,
... ... @@ -1865,6 +1907,20 @@ class HealthRawStressLocalStore {
return rows.map(HealthRawSleepResult.fromDb).toList();
}
Future<List<HealthRawSleepResult>> queryPendingSleepResults({
required int userId,
int limit = 200,
}) async {
final db = await _database(userId);
final rows = await db.query(
sleepResultsTable,
where: 'uploaded IS NULL OR uploaded != 1',
orderBy: 'date ASC',
limit: limit,
);
return rows.map(HealthRawSleepResult.fromDb).toList();
}
Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async {
final db = await _database(userId);
final hrvRows = await db.query(
... ...
... ... @@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:get/get.dart';
import 'huawei_health_data_type.dart';
... ... @@ -49,4 +50,9 @@ class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient {
Future<AppResult<HmWorkoutData>> getWorkoutData(int startTime, int endTime) {
return _harmonyApi.getWorkoutData(startTime, endTime);
}
@override
Future<AppResult<V2ActivityTarget>> getActivityGoal() {
return _harmonyApi.getActivityGoal();
}
}
... ...
... ... @@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
import 'package:get/get.dart';
import '../../../../data/models/enums/app_enums.dart';
import '../../../../data/models/health/health_v2_models.dart';
import '../../../../pigeon/health_kit_raw_data_api.g.dart';
import '../../../config/app_environment_config.dart';
import '../../../logging/app_logger.dart';
... ... @@ -20,6 +21,7 @@ import '../health_raw_stress_calculator.dart';
import '../health_sleep_calculator.dart';
import '../platform_ios/apple_health_raw_data_core_service.dart';
import 'ohos_health_raw_data_sync_service.dart';
import 'ohos_health_raw_result_upload_service.dart';
class OHOSHealthRawDataCoreService {
static const int defaultLookbackDays = 183;
... ... @@ -34,6 +36,7 @@ class OHOSHealthRawDataCoreService {
HealthApi? serverHealthApi,
HarmonyApi? harmonyApi,
Future<bool> Function()? healthReadAuthorizationChecker,
OhosHealthRawResultUploadService? resultUploadService,
}) : _rawDataSource = rawDataSource ??
OhosHealthRawDataSource(
userIdProvider: userIdProvider,
... ... @@ -47,7 +50,8 @@ class OHOSHealthRawDataCoreService {
_serverHealthApi = serverHealthApi,
_harmonyApi = harmonyApi ??
(Get.isRegistered<HarmonyApi>() ? Get.find<HarmonyApi>() : null),
_healthReadAuthorizationChecker = healthReadAuthorizationChecker;
_healthReadAuthorizationChecker = healthReadAuthorizationChecker,
_resultUploadService = resultUploadService;
final HealthRawDataSource _rawDataSource;
final HealthRawStressLocalStore _localStore;
... ... @@ -57,6 +61,7 @@ class OHOSHealthRawDataCoreService {
final HealthApi? _serverHealthApi;
final HarmonyApi? _harmonyApi;
final Future<bool> Function()? _healthReadAuthorizationChecker;
OhosHealthRawResultUploadService? _resultUploadService;
final StreamController<HealthRawDataUpdatedEvent>
_healthDataUpdatedController =
StreamController<HealthRawDataUpdatedEvent>.broadcast();
... ... @@ -84,11 +89,12 @@ class OHOSHealthRawDataCoreService {
return _rawDataSource.hasHealthData();
}
Future<bool> performHealthDataUpload() async {
// TODO: Trigger OHOS raw-data upload when the OHOS upload API is ready.
return false;
Future<V2ActivityTarget?> queryActivityGoal({bool refresh = true}) {
return _rawDataSource.getActivityGoal(refresh: refresh);
}
Future<bool> performHealthDataUpload() => _uploadResults();
Future<bool> syncDatabaseToNativeIfNeeded() async {
// OHOS must not call the Apple Pigeon database sync path.
return false;
... ... @@ -547,10 +553,24 @@ class OHOSHealthRawDataCoreService {
);
}
Future<void> _uploadResults() async {
// TODO: Upload OHOS result rows through OHOS/backend APIs and mark uploaded
// rows in _localStore after the contract is available.
if (_serverHealthApi == null) return;
Future<bool> _uploadResults() async {
final serverHealthApi = _serverHealthApi;
if (serverHealthApi == null) {
_logInfo('upload results skipped: HealthApi missing');
return false;
}
final uploader = _resultUploadService ??= OhosHealthRawResultUploadService(
healthApi: serverHealthApi,
localStore: _localStore,
);
try {
final summary = await uploader.uploadPendingResults(userId: _userId);
_logInfo('upload results finish: $summary');
return summary.success;
} catch (error, stackTrace) {
_logError('upload results failed', error, stackTrace);
return false;
}
}
Future<List<OhosHealthRawDataSyncResult>> _syncCalculationRawDataIfNeeded({
... ...
import 'dart:io';
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
... ... @@ -21,6 +22,7 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore {
static const rawDataTable = 'ohos_raw_data';
static const sleepDataTable = 'ohos_sleep_data';
static const activityGoalTable = 'ohos_activity_goal';
static const logMarker = '[OHOS_RAW_DATA_DB]';
final int Function()? _userIdProvider;
... ... @@ -203,6 +205,54 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore {
.toList(growable: false);
}
@override
Future<void> upsertActivityGoal(V2ActivityTarget goal) async {
final db = await _database(_userId);
await _createActivityGoalTable(db);
final now = _nowProvider().millisecondsSinceEpoch ~/ 1000;
await db.insert(
activityGoalTable,
<String, Object?>{
'id': 1,
'move': goal.move,
'step': goal.step,
'exercise': goal.exercise,
'stand': goal.stand,
'sleep_target_duration': goal.sleepTargetDuration,
'create_time': goal.createTime ?? now,
'update_time': now,
},
conflictAlgorithm: ConflictAlgorithm.replace,
);
_log(
'activity_goal_stored move=${goal.move} step=${goal.step} '
'exercise=${goal.exercise} stand=${goal.stand}',
);
}
@override
Future<V2ActivityTarget?> queryActivityGoal() async {
final db = await _database(_userId);
await _createActivityGoalTable(db);
final rows = await db.query(
activityGoalTable,
orderBy: 'update_time DESC',
limit: 1,
);
if (rows.isEmpty) return null;
final row = rows.first;
return V2ActivityTarget(
userId: _userId,
move: row['move'] as int?,
step: row['step'] as int?,
stand: row['stand'] as int?,
exercise: row['exercise'] as int?,
createTime: row['create_time'] as int?,
updateTime: row['update_time'] as int?,
sleepTargetDuration: row['sleep_target_duration'] as int?,
);
}
Future<void> close({int? userId}) async {
if (userId != null) {
final db = _opened.remove(userId);
... ... @@ -264,6 +314,22 @@ CREATE TABLE IF NOT EXISTS $sleepDataTable (
'CREATE INDEX IF NOT EXISTS idx_ohos_sleep_data_time '
'ON $sleepDataTable(to_time)',
);
await _createActivityGoalTable(db);
}
Future<void> _createActivityGoalTable(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $activityGoalTable (
id INTEGER PRIMARY KEY,
move INTEGER,
step INTEGER,
exercise INTEGER,
stand INTEGER,
sleep_target_duration INTEGER,
create_time INTEGER NOT NULL,
update_time INTEGER NOT NULL
)
''');
}
Future<void> _createRawDataTypeTable(
... ...
... ... @@ -4,6 +4,7 @@ import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:flutter/foundation.dart';
import 'huawei_health_data_type.dart';
... ... @@ -153,6 +154,34 @@ class OhosHealthRawDataSyncService {
);
}
Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) async {
if (refresh) {
try {
final remoteGoal = await _remoteDataSource.fetchActivityGoal();
if (remoteGoal != null) {
await _localStore.upsertActivityGoal(remoteGoal);
_log(
'activity_goal_remote_success '
'move=${remoteGoal.move} step=${remoteGoal.step} '
'exercise=${remoteGoal.exercise} stand=${remoteGoal.stand}',
);
return remoteGoal;
}
} catch (error, stackTrace) {
_log(
'activity_goal_remote_failed error=$error stackTrace=$stackTrace',
);
}
}
final localGoal = await _localStore.queryActivityGoal();
_log(
'activity_goal_local_fallback found=${localGoal != null} '
'move=${localGoal?.move} step=${localGoal?.step} '
'exercise=${localGoal?.exercise} stand=${localGoal?.stand}',
);
return localGoal;
}
Future<OhosHealthRawDataSyncResult> _syncResolvedRawData({
required int dataType,
required int startTime,
... ... @@ -334,6 +363,8 @@ abstract class OhosHealthRawDataRemoteDataSource {
required int endTime,
String? pageToken,
});
Future<V2ActivityTarget?> fetchActivityGoal();
}
abstract class OhosHealthRawDataLocalStore {
... ... @@ -349,6 +380,10 @@ abstract class OhosHealthRawDataLocalStore {
required int startTime,
required int endTime,
});
Future<void> upsertActivityGoal(V2ActivityTarget goal);
Future<V2ActivityTarget?> queryActivityGoal();
}
abstract class OhosHarmonyRawDataClient {
... ... @@ -367,6 +402,8 @@ abstract class OhosHarmonyRawDataClient {
int startTime,
int endTime,
);
Future<AppResult<V2ActivityTarget>> getActivityGoal();
}
class OhosHarmonyHealthRawDataRemoteDataSource
... ... @@ -435,6 +472,17 @@ class OhosHarmonyHealthRawDataRemoteDataSource
};
}
@override
Future<V2ActivityTarget?> fetchActivityGoal() async {
final result = await _client.getActivityGoal();
return switch (result) {
AppSuccess<V2ActivityTarget>(:final data) => data,
AppFailure<V2ActivityTarget>(:final error) => throw StateError(
'OHOS activity goal fetch failed: $error',
),
};
}
HuaweiHealthDataType _resolveHuaweiDataType(int dataType) {
if (dataType == OhosHealthRawDataType.activitySummary) {
return HuaweiHealthDataType.activity;
... ... @@ -521,6 +569,12 @@ class TodoOhosHealthRawDataRemoteDataSource
// are available.
return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]);
}
@override
Future<V2ActivityTarget?> fetchActivityGoal() async {
// TODO: Call the OHOS activity-goal API after HarmonyApi is registered.
return null;
}
}
class TodoOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
... ... @@ -549,6 +603,16 @@ class TodoOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
}) async {
return const <OhosHealthRawDataItem>[];
}
@override
Future<void> upsertActivityGoal(V2ActivityTarget goal) async {
// TODO: Store OHOS activity goal into local database.
}
@override
Future<V2ActivityTarget?> queryActivityGoal() async {
return null;
}
}
class OhosHealthRawDataPage {
... ...
import 'package:flutter/foundation.dart';
import '../../../logging/app_logger.dart';
import '../../../network/api/health_api.dart';
import '../../../result/app_result.dart';
import '../health_raw_models.dart';
import '../platform_ios/apple_health_raw_data_core_service.dart';
class OhosHealthRawResultUploadService {
OhosHealthRawResultUploadService({
required HealthApi healthApi,
required HealthRawStressLocalStore localStore,
int batchSize = 200,
}) : _healthApi = healthApi,
_localStore = localStore,
_batchSize = batchSize;
static const logMarker = '[OHOS_HEALTH_RESULT_UPLOAD]';
final HealthApi _healthApi;
final HealthRawStressLocalStore _localStore;
final int _batchSize;
Future<OhosHealthRawResultUploadSummary>? _runningUpload;
Future<OhosHealthRawResultUploadSummary> uploadPendingResults({
required int userId,
}) async {
final running = _runningUpload;
if (running != null) {
_log('upload_duplicate_join userId=$userId');
return running;
}
final task = _uploadPendingResults(userId: userId);
_runningUpload = task;
try {
return await task;
} finally {
if (identical(_runningUpload, task)) {
_runningUpload = null;
}
}
}
Future<OhosHealthRawResultUploadSummary> _uploadPendingResults({
required int userId,
}) async {
_log('upload_start userId=$userId');
final summary = OhosHealthRawResultUploadSummary();
await _uploadKind(
kind: 'hrv',
summary: summary,
query: () => _localStore.queryPendingHrvStressPoints(
userId: userId,
limit: _batchSize,
),
toBody: (points) => points.map(_hrvBody).toList(growable: false),
upload: _healthApi.uploadV2HrvTrendResults,
markUploadedUntil: (points) => _localStore.markHrvStressUploadedUntil(
userId: userId,
rawEndTime: points.last.rawEndTime,
),
latestTime: (points) => points.last.rawEndTime,
);
await _uploadKind(
kind: 'realtimeStress',
summary: summary,
query: () => _localStore.queryPendingRealtimeStressPoints(
userId: userId,
limit: _batchSize,
),
toBody: (points) => points.map(_realtimeBody).toList(growable: false),
upload: _healthApi.uploadV2RealtimeStressResults,
markUploadedUntil: (points) =>
_localStore.markRealtimeStressUploadedUntil(
userId: userId,
rawEndTime: points.last.rawEndTime,
),
latestTime: (points) => points.last.rawEndTime,
);
await _uploadKind(
kind: 'dailyStress',
summary: summary,
query: () => _localStore.queryPendingDailyStressPoints(
userId: userId,
limit: _batchSize,
),
toBody: (points) => points.map(_dailyStressBody).toList(growable: false),
upload: _healthApi.uploadV2DailyStressResults,
markUploadedUntil: (points) => _localStore.markDailyStressUploadedUntil(
userId: userId,
date: points.last.date,
),
latestTime: (points) => points.last.date,
);
await _uploadKind(
kind: 'sleepScore',
summary: summary,
query: () => _localStore.queryPendingSleepResults(
userId: userId,
limit: _batchSize,
),
toBody: (points) => points.map(_sleepBody).toList(growable: false),
upload: _healthApi.uploadV2SleepScoreResults,
markUploadedUntil: (points) => _localStore.markSleepResultsUploadedUntil(
userId: userId,
date: points.last.date,
),
latestTime: (points) => points.last.date,
);
_log(
'upload_finish userId=$userId success=${summary.success} '
'hrv=${summary.hrvCount} realtime=${summary.realtimeStressCount} '
'daily=${summary.dailyStressCount} sleep=${summary.sleepScoreCount} '
'errors=${summary.errors.length}',
);
return summary;
}
Future<void> _uploadKind<T>({
required String kind,
required OhosHealthRawResultUploadSummary summary,
required Future<List<T>> Function() query,
required List<Map<String, Object?>> Function(List<T>) toBody,
required Future<AppResult<void>> Function(List<Map<String, Object?>>)
upload,
required Future<void> Function(List<T>) markUploadedUntil,
required int Function(List<T>) latestTime,
}) async {
while (true) {
final points = await query();
if (points.isEmpty) {
_log('upload_skip kind=$kind reason=no_pending');
return;
}
final body = toBody(points);
final until = latestTime(points);
_log('upload_batch_start kind=$kind count=${points.length} until=$until');
final result = await upload(body);
switch (result) {
case AppSuccess():
await markUploadedUntil(points);
summary.add(kind, points.length);
_log(
'upload_batch_success kind=$kind count=${points.length} '
'until=$until',
);
case AppFailure(:final error):
summary.addError(kind, error.toString());
_log(
'upload_batch_failed kind=$kind count=${points.length} '
'until=$until error=$error',
);
return;
}
}
}
Map<String, Object?> _hrvBody(HealthRawHrvStressPoint point) {
return <String, Object?>{
'data_time': point.rawEndTime,
'raw_hrv': point.rawHrv,
'trend_hrv': point.result,
'state': point.state.value,
'hr_baseline': point.baselineRestingHr,
'hrv_baseline': point.baselineHrv,
'is_asleep': point.flags.isSleepLikely ? 1 : 0,
'is_workout': point.flags.isWorkout ? 1 : 0,
'is_workout_recovery': point.flags.isWorkoutRecovery ? 1 : 0,
'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0,
};
}
Map<String, Object?> _realtimeBody(HealthRawRealtimeStressPoint point) {
return <String, Object?>{
'data_time': point.rawEndTime,
'hr_value': point.rawHr,
'stress_value': point.result,
'state': point.state.value,
'is_asleep': point.flags.isSleepLikely ? 1 : 0,
'is_workout': point.flags.isWorkout ? 1 : 0,
'is_workout_recovery': point.flags.isWorkoutRecovery ? 1 : 0,
'is_suspected_activity': point.flags.isSuspectedActivity ? 1 : 0,
};
}
Map<String, Object?> _dailyStressBody(HealthRawDailyStressPoint point) {
return <String, Object?>{
'stress_value': point.stressValue,
'stress_score': point.stressScore,
'state': point.state.value,
'data_time': point.dataTime,
};
}
Map<String, Object?> _sleepBody(HealthRawSleepResult result) {
return <String, Object?>{
'date': result.date,
'sleep_score': result.sleepScore,
'sleep_state': result.sleepState,
'in_bed_minutes': result.inBedMinutes,
'awak_minutes': result.awakMinutes,
'sleep_minutes': result.sleepMinutes,
};
}
void _log(String message) {
final taggedMessage = '$logMarker $message';
debugPrint(taggedMessage);
try {
AppLogger.i(taggedMessage);
} catch (_) {
// AppLogger may be unavailable in early bootstrap/test contexts.
}
}
}
class OhosHealthRawResultUploadSummary {
int hrvCount = 0;
int realtimeStressCount = 0;
int dailyStressCount = 0;
int sleepScoreCount = 0;
final Map<String, String> errors = <String, String>{};
bool get success => errors.isEmpty;
void add(String kind, int count) {
switch (kind) {
case 'hrv':
hrvCount += count;
case 'realtimeStress':
realtimeStressCount += count;
case 'dailyStress':
dailyStressCount += count;
case 'sleepScore':
sleepScoreCount += count;
}
}
void addError(String kind, String error) {
errors[kind] = error;
}
@override
String toString() {
return 'OhosHealthRawResultUploadSummary('
'success=$success, '
'hrvCount=$hrvCount, '
'realtimeStressCount=$realtimeStressCount, '
'dailyStressCount=$dailyStressCount, '
'sleepScoreCount=$sleepScoreCount, '
'errors=$errors'
')';
}
}
... ...
... ... @@ -8,6 +8,7 @@ import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/data/models/health/hrv/hrv_statistics_data.dart';
import 'package:doublefeel_flutter/data/models/health/sleep/sleep_statistics_data.dart';
import 'package:doublefeel_flutter/core/platform/pigeon_api_facade.dart';
import 'package:flutter/foundation.dart';
import 'health_datasource.dart';
import 'health_local_data_convert.dart';
... ... @@ -17,6 +18,12 @@ class LocalHealthDataSource implements HealthDataSource {
final HealthRawDataCoreService coreService;
static const int _ohosDefaultMoveGoal = 300;
static const int _ohosDefaultStepGoal = 8000;
static const int _ohosDefaultStandGoal = 12;
static const int _ohosDefaultExerciseGoalSeconds = 30 * 60;
static const int _ohosDefaultSleepTargetSeconds = 8 * 60 * 60;
@override
Future<AppResult<SleepStatisticsData>> getSleepStatistics(
int dateRangeType,
... ... @@ -84,6 +91,11 @@ class LocalHealthDataSource implements HealthDataSource {
startTime: queryStart,
endTime: queryEnd,
);
final resolvedActivity = await _resolveOhosActivityGoalsIfNeeded(
activity: activity,
userId: queryUserId ?? 0,
fallbackUpdateTime: queryEnd,
);
final heartRate = await coreService.queryRawDataPoints(
dataType: HealthDataUploadType.heartRate.type,
startTime: queryStart,
... ... @@ -94,7 +106,7 @@ class LocalHealthDataSource implements HealthDataSource {
LocalHealthDataConvert.activityBurnStatistics(
days: days,
previousDays: previousDays,
activity: activity,
activity: resolvedActivity,
heartRate: heartRate,
sleepIntervals: [],
),
... ... @@ -181,6 +193,17 @@ class LocalHealthDataSource implements HealthDataSource {
endTime: queryEnd,
);
final resolvedActivity = _resolveActivityTarget(activity);
if (_isOhosPlatform) {
final activityGoal = await coreService.queryOhosActivityGoal();
return AppSuccess(
_ohosActivityTarget(
userId: queryUserId ?? 0,
remoteOrCachedTarget: activityGoal,
activity: resolvedActivity,
fallbackUpdateTime: queryEnd,
),
);
}
return AppSuccess(
LocalHealthDataConvert.v2ActivityTarget(
userId: queryUserId ?? 0,
... ... @@ -212,6 +235,11 @@ class LocalHealthDataSource implements HealthDataSource {
startTime: dayStart,
endTime: dayEnd,
);
final resolvedActivity = await _resolveOhosActivityGoalsIfNeeded(
activity: activity,
userId: queryUserId ?? 0,
fallbackUpdateTime: dayEnd,
);
final sleepResults = await coreService.querySleepResults(
startTime: dayStart,
endTime: dayEnd,
... ... @@ -242,7 +270,7 @@ class LocalHealthDataSource implements HealthDataSource {
LocalHealthDataConvert.v2HealthDataFromSleepResult(
day: day,
hrvPoints: hrvPoints,
activity: activity,
activity: resolvedActivity,
sleepingHeartRate: sleepingHeartRate,
restingHeartRate: restingHeartRate,
sleepResult: sleepResult,
... ... @@ -392,4 +420,76 @@ class LocalHealthDataSource implements HealthDataSource {
}
bool _isPositive(num? value) => value != null && value > 0;
bool get _isOhosPlatform => defaultTargetPlatform.name == 'ohos';
Future<List<HealthKitRawActivityDataPoint>>
_resolveOhosActivityGoalsIfNeeded({
required List<HealthKitRawActivityDataPoint> activity,
required int userId,
required int fallbackUpdateTime,
}) async {
if (!_isOhosPlatform) return activity;
final target = _ohosActivityTarget(
userId: userId,
remoteOrCachedTarget: await coreService.queryOhosActivityGoal(),
activity: _resolveActivityTarget(activity),
fallbackUpdateTime: fallbackUpdateTime,
);
if (activity.isEmpty) {
return [
_activityPointWithTarget(
HealthKitRawActivityDataPoint(endTime: fallbackUpdateTime),
target,
),
];
}
return activity
.map((item) => _activityPointWithTarget(item, target))
.toList(growable: false);
}
HealthKitRawActivityDataPoint _activityPointWithTarget(
HealthKitRawActivityDataPoint item,
V2ActivityTarget target,
) {
return HealthKitRawActivityDataPoint(
endTime: item.endTime,
activityMoveMode: item.activityMoveMode,
activeEnergyBurned: item.activeEnergyBurned,
activeEnergyBurnedGoal: target.move?.toDouble(),
appleMoveTime: item.appleMoveTime,
appleMoveTimeGoal: item.appleMoveTimeGoal,
appleExerciseTime: item.appleExerciseTime,
exerciseTimeGoal: target.exercise?.toDouble(),
appleStandHours: item.appleStandHours,
standHoursGoal: target.stand?.toDouble(),
);
}
V2ActivityTarget _ohosActivityTarget({
required int userId,
required V2ActivityTarget? remoteOrCachedTarget,
required HealthKitRawActivityDataPoint? activity,
required int fallbackUpdateTime,
}) {
return V2ActivityTarget(
userId: userId,
move: remoteOrCachedTarget?.move ??
activity?.activeEnergyBurnedGoal?.ceil() ??
_ohosDefaultMoveGoal,
step: remoteOrCachedTarget?.step ?? _ohosDefaultStepGoal,
stand: remoteOrCachedTarget?.stand ??
activity?.standHoursGoal?.round() ??
_ohosDefaultStandGoal,
exercise: remoteOrCachedTarget?.exercise ??
activity?.exerciseTimeGoal?.round() ??
_ohosDefaultExerciseGoalSeconds,
updateTime: remoteOrCachedTarget?.updateTime ??
activity?.endTime ??
fallbackUpdateTime,
sleepTargetDuration: remoteOrCachedTarget?.sleepTargetDuration ??
_ohosDefaultSleepTargetSeconds,
);
}
}
... ...
... ... @@ -6,6 +6,7 @@ import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/
import 'package:doublefeel_flutter/data/models/harmony/hm_health_data.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_sleep_data.dart';
import 'package:doublefeel_flutter/data/models/harmony/hm_workout_data.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
... ... @@ -438,6 +439,29 @@ void main() {
expect(page.items.single.payload['end_time'], 1787870000);
expect(page.items.single.payload['activity_type'], 90);
});
test('getActivityGoal fetches remote goal and stores local cache', () async {
const goal = V2ActivityTarget(
move: 400,
step: 9000,
exercise: 1800,
stand: 12,
);
final remote = _FakeOhosHealthRawDataRemoteDataSource(
const <OhosHealthRawDataPage>[],
activityGoal: goal,
);
final local = _FakeOhosHealthRawDataLocalStore();
final service = OhosHealthRawDataSyncService(
remoteDataSource: remote,
localStore: local,
);
final result = await service.getActivityGoal();
expect(result, same(goal));
expect(local.activityGoal, same(goal));
});
}
class _FakeOhosHealthRawDataRemoteDataSource
... ... @@ -446,12 +470,15 @@ class _FakeOhosHealthRawDataRemoteDataSource
this._pages, {
Completer<void>? gate,
Set<int> failingDataTypes = const <int>{},
V2ActivityTarget? activityGoal,
}) : _gate = gate,
_failingDataTypes = failingDataTypes;
_failingDataTypes = failingDataTypes,
_activityGoal = activityGoal;
final List<OhosHealthRawDataPage> _pages;
final Completer<void>? _gate;
final Set<int> _failingDataTypes;
final V2ActivityTarget? _activityGoal;
final List<_FetchCall> calls = <_FetchCall>[];
@override
... ... @@ -481,6 +508,11 @@ class _FakeOhosHealthRawDataRemoteDataSource
}
return _pages[pageIndex];
}
@override
Future<V2ActivityTarget?> fetchActivityGoal() async {
return _activityGoal;
}
}
class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
... ... @@ -490,6 +522,7 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
final int? _latestDataTime;
final List<List<OhosHealthRawDataItem>> storedBatches =
<List<OhosHealthRawDataItem>>[];
V2ActivityTarget? activityGoal;
@override
Future<int?> latestDataTime({required int dataType}) async {
... ... @@ -521,6 +554,16 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
)
.toList(growable: false);
}
@override
Future<void> upsertActivityGoal(V2ActivityTarget goal) async {
activityGoal = goal;
}
@override
Future<V2ActivityTarget?> queryActivityGoal() async {
return activityGoal;
}
}
class _FetchCall {
... ... @@ -542,13 +585,16 @@ class _FakeOhosHarmonyRawDataClient implements OhosHarmonyRawDataClient {
HmHealthData? healthData,
HmSleepData? sleepData,
HmWorkoutData? workoutData,
V2ActivityTarget? activityGoal,
}) : _healthData = healthData ?? HmHealthData(),
_sleepData = sleepData ?? HmSleepData(),
_workoutData = workoutData ?? HmWorkoutData();
_workoutData = workoutData ?? HmWorkoutData(),
_activityGoal = activityGoal ?? const V2ActivityTarget();
final HmHealthData _healthData;
final HmSleepData _sleepData;
final HmWorkoutData _workoutData;
final V2ActivityTarget _activityGoal;
final List<_HealthCall> healthCalls = <_HealthCall>[];
final List<_SleepCall> sleepCalls = <_SleepCall>[];
final List<_SleepCall> workoutCalls = <_SleepCall>[];
... ... @@ -596,6 +642,11 @@ class _FakeOhosHarmonyRawDataClient implements OhosHarmonyRawDataClient {
);
return AppSuccess(_workoutData);
}
@override
Future<AppResult<V2ActivityTarget>> getActivityGoal() async {
return AppSuccess(_activityGoal);
}
}
class _HealthCall {
... ...