Commit f64340df049e3b51b2d42e82bdf84173542388e5

Authored by 权海
1 parent 8ec3dec7

feat(ui):增加鸿蒙数据获取

import 'package:flutter/foundation.dart';
import '../../platform/pigeon_api_facade.dart';
import 'platform_ohos/ohos_harmony_health_raw_data_sync_service_factory.dart';
import 'platform_ohos/ohos_health_raw_data_sync_service.dart';
abstract class HealthRawDataSource {
... ... @@ -136,7 +137,8 @@ class AppleHealthRawDataSource implements HealthRawDataSource {
class OhosHealthRawDataSource implements HealthRawDataSource {
OhosHealthRawDataSource({
OhosHealthRawDataSyncService? syncService,
}) : _syncService = syncService ?? OhosHealthRawDataSyncService();
}) : _syncService =
syncService ?? createDefaultOhosHealthRawDataSyncService();
final OhosHealthRawDataSyncService _syncService;
... ...
import 'package:doublefeel_flutter/core/network/api/harmony_api.dart';
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:get/get.dart';
import 'huawei_health_data_type.dart';
import 'ohos_health_raw_data_sync_service.dart';
OhosHealthRawDataSyncService createDefaultOhosHealthRawDataSyncService() {
if (!Get.isRegistered<HarmonyApi>()) {
return OhosHealthRawDataSyncService();
}
return OhosHealthRawDataSyncService(
remoteDataSource: OhosHarmonyHealthRawDataRemoteDataSource(
HarmonyApiOhosRawDataClient(Get.find<HarmonyApi>()),
),
);
}
class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient {
const HarmonyApiOhosRawDataClient(this._harmonyApi);
final HarmonyApi _harmonyApi;
@override
Future<AppResult<HmHealthData>> getHealthData(
HuaweiHealthDataType dataType,
int startDate,
int endDate,
) {
return _harmonyApi.getHealthData(dataType, startDate, endDate);
}
@override
Future<AppResult<HmSleepData>> getSleepData(int startDate, int endDate) {
return _harmonyApi.getSleepData(startDate, endDate);
}
}
... ...
import 'package:doublefeel_flutter/core/logging/app_logger.dart';
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:flutter/foundation.dart';
import 'huawei_health_data_type.dart';
class OhosHealthRawDataType {
const OhosHealthRawDataType._();
... ... @@ -11,16 +19,20 @@ class OhosHealthRawDataSyncService {
OhosHealthRawDataRemoteDataSource? remoteDataSource,
OhosHealthRawDataLocalStore? localStore,
DateTime Function()? nowProvider,
void Function(String message)? logSink,
}) : _remoteDataSource =
remoteDataSource ?? const TodoOhosHealthRawDataRemoteDataSource(),
_localStore = localStore ?? const TodoOhosHealthRawDataLocalStore(),
_nowProvider = nowProvider ?? DateTime.now;
_nowProvider = nowProvider ?? DateTime.now,
_logSink = logSink;
static const int defaultLookbackDays = 183;
static const String logMarker = '[OHOS_HEALTH_RAW_SYNC]';
final OhosHealthRawDataRemoteDataSource _remoteDataSource;
final OhosHealthRawDataLocalStore _localStore;
final DateTime Function() _nowProvider;
final void Function(String message)? _logSink;
Future<OhosHealthRawDataSyncResult> syncRawData({
required int dataType,
... ... @@ -38,34 +50,79 @@ class OhosHealthRawDataSyncService {
throw ArgumentError.value(endTime, 'endTime');
}
String? pageToken;
final fetchRanges = _splitIntoFetchRanges(
startTime: resolvedStartTime,
endTime: resolvedEndTime,
);
_log(
'sync_start dataType=$dataType '
'startTime=$resolvedStartTime endTime=$resolvedEndTime '
'rangeCount=${fetchRanges.length}',
);
var pageCount = 0;
var storedCount = 0;
do {
final page = await _remoteDataSource.fetchRawDataPage(
dataType: dataType,
startTime: resolvedStartTime,
endTime: resolvedEndTime,
pageToken: pageToken,
for (var rangeIndex = 0; rangeIndex < fetchRanges.length; rangeIndex++) {
final range = fetchRanges[rangeIndex];
_log(
'segment_start dataType=$dataType '
'segment=${rangeIndex + 1}/${fetchRanges.length} '
'startTime=${range.startTime} endTime=${range.endTime} '
'startDate=${_dateKeyFromUnixSeconds(range.startTime)} '
'endDate=${_dateKeyFromUnixSeconds(range.endTime)}',
);
pageCount += 1;
if (page.items.isNotEmpty) {
await _localStore.upsertRawDataBatch(
String? pageToken;
do {
final page = await _remoteDataSource.fetchRawDataPage(
dataType: dataType,
items: page.items,
startTime: range.startTime,
endTime: range.endTime,
pageToken: pageToken,
);
pageCount += 1;
_log(
'page_fetched dataType=$dataType '
'segment=${rangeIndex + 1}/${fetchRanges.length} '
'page=$pageCount items=${page.items.length} '
'nextPageToken=${page.nextPageToken ?? ''}',
);
storedCount += page.items.length;
}
pageToken = page.nextPageToken;
} while (pageToken != null && pageToken.isNotEmpty);
if (page.items.isNotEmpty) {
await _localStore.upsertRawDataBatch(
dataType: dataType,
items: page.items,
);
storedCount += page.items.length;
_log(
'page_stored dataType=$dataType '
'segment=${rangeIndex + 1}/${fetchRanges.length} '
'storedItems=${page.items.length} totalStored=$storedCount',
);
}
pageToken = page.nextPageToken;
} while (pageToken != null && pageToken.isNotEmpty);
_log(
'segment_finish dataType=$dataType '
'segment=${rangeIndex + 1}/${fetchRanges.length}',
);
}
_log(
'sync_finish dataType=$dataType '
'startTime=$resolvedStartTime endTime=$resolvedEndTime '
'rangeCount=${fetchRanges.length} pageCount=$pageCount '
'storedCount=$storedCount',
);
return OhosHealthRawDataSyncResult(
dataType: dataType,
startTime: resolvedStartTime,
endTime: resolvedEndTime,
segmentCount: fetchRanges.length,
pageCount: pageCount,
storedCount: storedCount,
);
... ... @@ -88,6 +145,46 @@ class OhosHealthRawDataSyncService {
int _unixSeconds(DateTime time) {
return time.millisecondsSinceEpoch ~/ 1000;
}
List<OhosHealthRawDataFetchRange> _splitIntoFetchRanges({
required int startTime,
required int endTime,
}) {
final ranges = <OhosHealthRawDataFetchRange>[];
var cursor = DateTime.fromMillisecondsSinceEpoch(startTime * 1000);
final end = DateTime.fromMillisecondsSinceEpoch(endTime * 1000);
while (!cursor.isAfter(end)) {
final nextMonthStart = DateTime(cursor.year, cursor.month + 1);
final monthEndTime = nextMonthStart.subtract(const Duration(seconds: 1));
final segmentEndDateTime =
monthEndTime.isBefore(end) ? monthEndTime : end;
final segmentStartTime = _unixSeconds(cursor);
final segmentEndTime = _unixSeconds(segmentEndDateTime);
ranges.add(
OhosHealthRawDataFetchRange(
startTime: segmentStartTime,
endTime: segmentEndTime,
),
);
cursor = DateTime.fromMillisecondsSinceEpoch(
(segmentEndTime + 1) * 1000,
);
}
return ranges;
}
void _log(String message) {
final taggedMessage = '$logMarker $message';
debugPrint(taggedMessage);
_logSink?.call(taggedMessage);
try {
AppLogger.i(taggedMessage);
} catch (_) {
// AppLogger may not be initialized in isolated test/bootstrap contexts.
}
}
}
abstract class OhosHealthRawDataRemoteDataSource {
... ... @@ -108,6 +205,111 @@ abstract class OhosHealthRawDataLocalStore {
});
}
abstract class OhosHarmonyRawDataClient {
Future<AppResult<HmHealthData>> getHealthData(
HuaweiHealthDataType dataType,
int startDate,
int endDate,
);
Future<AppResult<HmSleepData>> getSleepData(
int startDate,
int endDate,
);
}
class OhosHarmonyHealthRawDataRemoteDataSource
implements OhosHealthRawDataRemoteDataSource {
const OhosHarmonyHealthRawDataRemoteDataSource(this._client);
final OhosHarmonyRawDataClient _client;
@override
Future<OhosHealthRawDataPage> fetchRawDataPage({
required int dataType,
required int startTime,
required int endTime,
String? pageToken,
}) async {
final startDate = _dateKeyFromUnixSeconds(startTime);
final endDate = _dateKeyFromUnixSeconds(endTime);
if (dataType == OhosHealthRawDataType.sleepAnalysis) {
final result = await _client.getSleepData(startDate, endDate);
return switch (result) {
AppSuccess<HmSleepData>(:final data) => OhosHealthRawDataPage(
items: _sleepItems(data),
),
AppFailure<HmSleepData>(:final error) => throw StateError(
'OHOS sleep raw data fetch failed: $error',
),
};
}
final huaweiDataType = _resolveHuaweiDataType(dataType);
final result = await _client.getHealthData(
huaweiDataType,
startDate,
endDate,
);
return switch (result) {
AppSuccess<HmHealthData>(:final data) => OhosHealthRawDataPage(
items: _healthItems(
dataType: dataType,
data: data,
),
),
AppFailure<HmHealthData>(:final error) => throw StateError(
'OHOS health raw data fetch failed: $error',
),
};
}
HuaweiHealthDataType _resolveHuaweiDataType(int dataType) {
if (dataType == OhosHealthRawDataType.activitySummary) {
return HuaweiHealthDataType.activity;
}
if (dataType == OhosHealthRawDataType.workout) {
return HuaweiHealthDataType.exerciseDuration;
}
for (final value in HuaweiHealthDataType.values) {
if (value.dataType == dataType) {
return value;
}
}
throw ArgumentError.value(dataType, 'dataType');
}
List<OhosHealthRawDataItem> _healthItems({
required int dataType,
required HmHealthData data,
}) {
return (data.list ?? const <HmHealthDataItem>[])
.where((item) => item.time != null)
.map(
(item) => OhosHealthRawDataItem(
dataType: item.dataType?.toInt() ?? dataType,
dataTime: item.time!.toInt(),
payload: Map<String, Object?>.from(item.toJson()),
),
)
.toList(growable: false);
}
List<OhosHealthRawDataItem> _sleepItems(HmSleepData data) {
return (data.list ?? const <HmSleepDataItem>[])
.where((item) => item.toTime != null)
.map(
(item) => OhosHealthRawDataItem(
dataType:
item.dataType?.toInt() ?? OhosHealthRawDataType.sleepAnalysis,
dataTime: item.toTime!.toInt(),
payload: Map<String, Object?>.from(item.toJson()),
),
)
.toList(growable: false);
}
}
class TodoOhosHealthRawDataRemoteDataSource
implements OhosHealthRawDataRemoteDataSource {
const TodoOhosHealthRawDataRemoteDataSource();
... ... @@ -165,11 +367,22 @@ class OhosHealthRawDataItem {
final Map<String, Object?> payload;
}
class OhosHealthRawDataFetchRange {
const OhosHealthRawDataFetchRange({
required this.startTime,
required this.endTime,
});
final int startTime;
final int endTime;
}
class OhosHealthRawDataSyncResult {
const OhosHealthRawDataSyncResult({
required this.dataType,
required this.startTime,
required this.endTime,
this.segmentCount = 1,
required this.pageCount,
required this.storedCount,
});
... ... @@ -177,6 +390,12 @@ class OhosHealthRawDataSyncResult {
final int dataType;
final int startTime;
final int endTime;
final int segmentCount;
final int pageCount;
final int storedCount;
}
int _dateKeyFromUnixSeconds(int seconds) {
final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day;
}
... ...
... ... @@ -5,12 +5,12 @@
"name": "auto",
"type": "HarmonyOS",
"material": {
"certpath": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer",
"certpath": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer",
"keyAlias": "debugKey",
"keyPassword": "00000019084446C3F235D890CFE9AC6015D14270D13D370ACF44FEDD89097E1ABBBDE3810C8FEBFFFD",
"profile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b",
"profile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b",
"signAlg": "SHA256withECDSA",
"storeFile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12",
"storeFile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12",
"storePassword": "00000019D85309732E0C3572D434400BA13D429AE5A182B54C1EA6F2C9D7DB5AE81BD9A64F12ACC681"
}
},
... ... @@ -18,33 +18,33 @@
"name": "release",
"type": "HarmonyOS",
"material": {
"storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12",
"storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12",
"storePassword": "0000001E8F02C32C2D5431F31FC368F020D891F18E6DC45A5913C60CACC1074B11282B3527F3E7D420F00B21B13A",
"keyAlias": "DoubleFeel",
"keyPassword": "0000001ECC963DD8029FC0957D403D334E6A864BA98BD1ABF0F96E41AEB1002C3CBAFE12680A33902C1D5C61E7C9",
"signAlg": "SHA256withECDSA",
"profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b",
"certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer"
"profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b",
"certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer"
}
},
{
"name": "debug",
"type": "HarmonyOS",
"material": {
"storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12",
"storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12",
"storePassword": "0000001E1BE8542E4E9F7969905217A5421D89506648518A33A93BE1045A511094FF10805EC4686172CC000ED9FA",
"keyAlias": "DoubleFeel",
"keyPassword": "0000001EFBAD9B2C408962C5BF2044ED8A57E4E192D171CAC3EBFF8D648684E52A690DFD6B8BDD7DC28AF8D6174C",
"signAlg": "SHA256withECDSA",
"profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b",
"certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer"
"profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b",
"certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer"
}
}
],
"products": [
{
"name": "default",
"signingConfig": "auto",
"signingConfig": "debug",
"targetSdkVersion": "6.0.0(20)",
"compatibleSdkVersion": "6.0.0(20)",
"runtimeOS": "HarmonyOS",
... ...
import 'package:doublefeel_flutter/core/result/app_result.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/huawei_health_data_type.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_sync_service.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:flutter_test/flutter_test.dart';
void main() {
... ... @@ -36,6 +40,7 @@ void main() {
expect(result.startTime, 1000);
expect(result.endTime, 2000);
expect(result.segmentCount, 1);
expect(result.pageCount, 2);
expect(result.storedCount, 2);
expect(remote.calls.map((e) => e.pageToken), [null, 'next']);
... ... @@ -70,10 +75,149 @@ void main() {
expect(result.startTime, expectedStart);
expect(result.endTime, expectedEnd);
expect(remote.calls.single.startTime, expectedStart);
expect(remote.calls.single.endTime, expectedEnd);
expect(result.segmentCount, remote.calls.length);
expect(remote.calls.first.startTime, expectedStart);
expect(remote.calls.last.endTime, expectedEnd);
expect(local.storedBatches, isEmpty);
});
test('syncRawData splits cross-month ranges and writes marked logs',
() async {
final start = _unixSeconds(DateTime(2026, 1, 30, 10));
final end = _unixSeconds(DateTime(2026, 3, 2, 9));
final remote = _FakeOhosHealthRawDataRemoteDataSource([
const OhosHealthRawDataPage(
items: [
OhosHealthRawDataItem(
dataType: 1,
dataTime: 1,
payload: {'value': 45},
),
],
),
const OhosHealthRawDataPage(
items: [
OhosHealthRawDataItem(
dataType: 1,
dataTime: 2,
payload: {'value': 46},
),
],
),
const OhosHealthRawDataPage(
items: [
OhosHealthRawDataItem(
dataType: 1,
dataTime: 3,
payload: {'value': 47},
),
],
),
]);
final local = _FakeOhosHealthRawDataLocalStore();
final logs = <String>[];
final service = OhosHealthRawDataSyncService(
remoteDataSource: remote,
localStore: local,
logSink: logs.add,
);
final result = await service.syncRawData(
dataType: 1,
startTime: start,
endTime: end,
);
expect(result.segmentCount, 3);
expect(result.pageCount, 3);
expect(result.storedCount, 3);
expect(remote.calls.map((e) => _dateKey(e.startTime)), [
20260130,
20260201,
20260301,
]);
expect(remote.calls.map((e) => _dateKey(e.endTime)), [
20260131,
20260228,
20260302,
]);
expect(
logs,
contains(
contains(OhosHealthRawDataSyncService.logMarker),
),
);
expect(logs, contains(contains('segment_start dataType=1 segment=1/3')));
expect(logs, contains(contains('sync_finish dataType=1')));
});
test('Harmony remote data source calls getHealthData and converts items',
() async {
final client = _FakeOhosHarmonyRawDataClient(
healthData: HmHealthData(
list: [
HmHealthDataItem(
time: 1787900000,
dataType: 1,
value: 53,
isAsleep: 0,
),
HmHealthDataItem(
dataType: 1,
value: 54,
),
],
),
);
final remote = OhosHarmonyHealthRawDataRemoteDataSource(client);
final page = await remote.fetchRawDataPage(
dataType: HuaweiHealthDataType.hrv.dataType,
startTime: _unixSeconds(DateTime(2026, 8, 1)),
endTime: _unixSeconds(DateTime(2026, 8, 31, 23, 59, 59)),
);
expect(client.healthCalls.single.dataType, HuaweiHealthDataType.hrv);
expect(client.healthCalls.single.startDate, 20260801);
expect(client.healthCalls.single.endDate, 20260831);
expect(page.nextPageToken, isNull);
expect(page.items, hasLength(1));
expect(page.items.single.dataType, 1);
expect(page.items.single.dataTime, 1787900000);
expect(page.items.single.payload['value'], 53);
});
test('Harmony remote data source calls getSleepData and converts items',
() async {
final client = _FakeOhosHarmonyRawDataClient(
sleepData: HmSleepData(
list: [
HmSleepDataItem(
fromTime: 1787850000,
toTime: 1787870000,
dataType: 100,
),
HmSleepDataItem(
fromTime: 1787880000,
),
],
),
);
final remote = OhosHarmonyHealthRawDataRemoteDataSource(client);
final page = await remote.fetchRawDataPage(
dataType: OhosHealthRawDataType.sleepAnalysis,
startTime: _unixSeconds(DateTime(2026, 8, 1)),
endTime: _unixSeconds(DateTime(2026, 8, 2, 23, 59, 59)),
);
expect(client.sleepCalls.single.startDate, 20260801);
expect(client.sleepCalls.single.endDate, 20260802);
expect(page.items, hasLength(1));
expect(page.items.single.dataType, 100);
expect(page.items.single.dataTime, 1787870000);
expect(page.items.single.payload['from_time'], 1787850000);
});
}
class _FakeOhosHealthRawDataRemoteDataSource
... ... @@ -98,7 +242,11 @@ class _FakeOhosHealthRawDataRemoteDataSource
pageToken: pageToken,
),
);
return _pages[calls.length - 1];
final pageIndex = calls.length - 1;
if (pageIndex >= _pages.length) {
return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]);
}
return _pages[pageIndex];
}
}
... ... @@ -137,3 +285,75 @@ class _FetchCall {
final int endTime;
final String? pageToken;
}
class _FakeOhosHarmonyRawDataClient implements OhosHarmonyRawDataClient {
_FakeOhosHarmonyRawDataClient({
HmHealthData? healthData,
HmSleepData? sleepData,
}) : _healthData = healthData ?? HmHealthData(),
_sleepData = sleepData ?? HmSleepData();
final HmHealthData _healthData;
final HmSleepData _sleepData;
final List<_HealthCall> healthCalls = <_HealthCall>[];
final List<_SleepCall> sleepCalls = <_SleepCall>[];
@override
Future<AppResult<HmHealthData>> getHealthData(
HuaweiHealthDataType dataType,
int startDate,
int endDate,
) async {
healthCalls.add(
_HealthCall(
dataType: dataType,
startDate: startDate,
endDate: endDate,
),
);
return AppSuccess(_healthData);
}
@override
Future<AppResult<HmSleepData>> getSleepData(
int startDate,
int endDate,
) async {
sleepCalls.add(
_SleepCall(
startDate: startDate,
endDate: endDate,
),
);
return AppSuccess(_sleepData);
}
}
class _HealthCall {
const _HealthCall({
required this.dataType,
required this.startDate,
required this.endDate,
});
final HuaweiHealthDataType dataType;
final int startDate;
final int endDate;
}
class _SleepCall {
const _SleepCall({
required this.startDate,
required this.endDate,
});
final int startDate;
final int endDate;
}
int _unixSeconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000;
int _dateKey(int seconds) {
final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day;
}
... ...