Commit f64340df049e3b51b2d42e82bdf84173542388e5

Authored by 权海
1 parent 8ec3dec7

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

1 import 'package:flutter/foundation.dart'; 1 import 'package:flutter/foundation.dart';
2 2
3 import '../../platform/pigeon_api_facade.dart'; 3 import '../../platform/pigeon_api_facade.dart';
  4 +import 'platform_ohos/ohos_harmony_health_raw_data_sync_service_factory.dart';
4 import 'platform_ohos/ohos_health_raw_data_sync_service.dart'; 5 import 'platform_ohos/ohos_health_raw_data_sync_service.dart';
5 6
6 abstract class HealthRawDataSource { 7 abstract class HealthRawDataSource {
@@ -136,7 +137,8 @@ class AppleHealthRawDataSource implements HealthRawDataSource { @@ -136,7 +137,8 @@ class AppleHealthRawDataSource implements HealthRawDataSource {
136 class OhosHealthRawDataSource implements HealthRawDataSource { 137 class OhosHealthRawDataSource implements HealthRawDataSource {
137 OhosHealthRawDataSource({ 138 OhosHealthRawDataSource({
138 OhosHealthRawDataSyncService? syncService, 139 OhosHealthRawDataSyncService? syncService,
139 - }) : _syncService = syncService ?? OhosHealthRawDataSyncService(); 140 + }) : _syncService =
  141 + syncService ?? createDefaultOhosHealthRawDataSyncService();
140 142
141 final OhosHealthRawDataSyncService _syncService; 143 final OhosHealthRawDataSyncService _syncService;
142 144
  1 +import 'package:doublefeel_flutter/core/network/api/harmony_api.dart';
  2 +import 'package:doublefeel_flutter/core/result/app_result.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';
  5 +import 'package:get/get.dart';
  6 +
  7 +import 'huawei_health_data_type.dart';
  8 +import 'ohos_health_raw_data_sync_service.dart';
  9 +
  10 +OhosHealthRawDataSyncService createDefaultOhosHealthRawDataSyncService() {
  11 + if (!Get.isRegistered<HarmonyApi>()) {
  12 + return OhosHealthRawDataSyncService();
  13 + }
  14 + return OhosHealthRawDataSyncService(
  15 + remoteDataSource: OhosHarmonyHealthRawDataRemoteDataSource(
  16 + HarmonyApiOhosRawDataClient(Get.find<HarmonyApi>()),
  17 + ),
  18 + );
  19 +}
  20 +
  21 +class HarmonyApiOhosRawDataClient implements OhosHarmonyRawDataClient {
  22 + const HarmonyApiOhosRawDataClient(this._harmonyApi);
  23 +
  24 + final HarmonyApi _harmonyApi;
  25 +
  26 + @override
  27 + Future<AppResult<HmHealthData>> getHealthData(
  28 + HuaweiHealthDataType dataType,
  29 + int startDate,
  30 + int endDate,
  31 + ) {
  32 + return _harmonyApi.getHealthData(dataType, startDate, endDate);
  33 + }
  34 +
  35 + @override
  36 + Future<AppResult<HmSleepData>> getSleepData(int startDate, int endDate) {
  37 + return _harmonyApi.getSleepData(startDate, endDate);
  38 + }
  39 +}
  1 +import 'package:doublefeel_flutter/core/logging/app_logger.dart';
  2 +import 'package:doublefeel_flutter/core/result/app_result.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';
  5 +import 'package:flutter/foundation.dart';
  6 +
  7 +import 'huawei_health_data_type.dart';
  8 +
1 class OhosHealthRawDataType { 9 class OhosHealthRawDataType {
2 const OhosHealthRawDataType._(); 10 const OhosHealthRawDataType._();
3 11
@@ -11,16 +19,20 @@ class OhosHealthRawDataSyncService { @@ -11,16 +19,20 @@ class OhosHealthRawDataSyncService {
11 OhosHealthRawDataRemoteDataSource? remoteDataSource, 19 OhosHealthRawDataRemoteDataSource? remoteDataSource,
12 OhosHealthRawDataLocalStore? localStore, 20 OhosHealthRawDataLocalStore? localStore,
13 DateTime Function()? nowProvider, 21 DateTime Function()? nowProvider,
  22 + void Function(String message)? logSink,
14 }) : _remoteDataSource = 23 }) : _remoteDataSource =
15 remoteDataSource ?? const TodoOhosHealthRawDataRemoteDataSource(), 24 remoteDataSource ?? const TodoOhosHealthRawDataRemoteDataSource(),
16 _localStore = localStore ?? const TodoOhosHealthRawDataLocalStore(), 25 _localStore = localStore ?? const TodoOhosHealthRawDataLocalStore(),
17 - _nowProvider = nowProvider ?? DateTime.now; 26 + _nowProvider = nowProvider ?? DateTime.now,
  27 + _logSink = logSink;
18 28
19 static const int defaultLookbackDays = 183; 29 static const int defaultLookbackDays = 183;
  30 + static const String logMarker = '[OHOS_HEALTH_RAW_SYNC]';
20 31
21 final OhosHealthRawDataRemoteDataSource _remoteDataSource; 32 final OhosHealthRawDataRemoteDataSource _remoteDataSource;
22 final OhosHealthRawDataLocalStore _localStore; 33 final OhosHealthRawDataLocalStore _localStore;
23 final DateTime Function() _nowProvider; 34 final DateTime Function() _nowProvider;
  35 + final void Function(String message)? _logSink;
24 36
25 Future<OhosHealthRawDataSyncResult> syncRawData({ 37 Future<OhosHealthRawDataSyncResult> syncRawData({
26 required int dataType, 38 required int dataType,
@@ -38,34 +50,79 @@ class OhosHealthRawDataSyncService { @@ -38,34 +50,79 @@ class OhosHealthRawDataSyncService {
38 throw ArgumentError.value(endTime, 'endTime'); 50 throw ArgumentError.value(endTime, 'endTime');
39 } 51 }
40 52
41 - String? pageToken; 53 + final fetchRanges = _splitIntoFetchRanges(
  54 + startTime: resolvedStartTime,
  55 + endTime: resolvedEndTime,
  56 + );
  57 + _log(
  58 + 'sync_start dataType=$dataType '
  59 + 'startTime=$resolvedStartTime endTime=$resolvedEndTime '
  60 + 'rangeCount=${fetchRanges.length}',
  61 + );
  62 +
42 var pageCount = 0; 63 var pageCount = 0;
43 var storedCount = 0; 64 var storedCount = 0;
44 65
45 - do {  
46 - final page = await _remoteDataSource.fetchRawDataPage(  
47 - dataType: dataType,  
48 - startTime: resolvedStartTime,  
49 - endTime: resolvedEndTime,  
50 - pageToken: pageToken, 66 + for (var rangeIndex = 0; rangeIndex < fetchRanges.length; rangeIndex++) {
  67 + final range = fetchRanges[rangeIndex];
  68 + _log(
  69 + 'segment_start dataType=$dataType '
  70 + 'segment=${rangeIndex + 1}/${fetchRanges.length} '
  71 + 'startTime=${range.startTime} endTime=${range.endTime} '
  72 + 'startDate=${_dateKeyFromUnixSeconds(range.startTime)} '
  73 + 'endDate=${_dateKeyFromUnixSeconds(range.endTime)}',
51 ); 74 );
52 - pageCount += 1;  
53 75
54 - if (page.items.isNotEmpty) {  
55 - await _localStore.upsertRawDataBatch( 76 + String? pageToken;
  77 + do {
  78 + final page = await _remoteDataSource.fetchRawDataPage(
56 dataType: dataType, 79 dataType: dataType,
57 - items: page.items, 80 + startTime: range.startTime,
  81 + endTime: range.endTime,
  82 + pageToken: pageToken,
  83 + );
  84 + pageCount += 1;
  85 + _log(
  86 + 'page_fetched dataType=$dataType '
  87 + 'segment=${rangeIndex + 1}/${fetchRanges.length} '
  88 + 'page=$pageCount items=${page.items.length} '
  89 + 'nextPageToken=${page.nextPageToken ?? ''}',
58 ); 90 );
59 - storedCount += page.items.length;  
60 - }  
61 91
62 - pageToken = page.nextPageToken;  
63 - } while (pageToken != null && pageToken.isNotEmpty); 92 + if (page.items.isNotEmpty) {
  93 + await _localStore.upsertRawDataBatch(
  94 + dataType: dataType,
  95 + items: page.items,
  96 + );
  97 + storedCount += page.items.length;
  98 + _log(
  99 + 'page_stored dataType=$dataType '
  100 + 'segment=${rangeIndex + 1}/${fetchRanges.length} '
  101 + 'storedItems=${page.items.length} totalStored=$storedCount',
  102 + );
  103 + }
  104 +
  105 + pageToken = page.nextPageToken;
  106 + } while (pageToken != null && pageToken.isNotEmpty);
  107 +
  108 + _log(
  109 + 'segment_finish dataType=$dataType '
  110 + 'segment=${rangeIndex + 1}/${fetchRanges.length}',
  111 + );
  112 + }
  113 +
  114 + _log(
  115 + 'sync_finish dataType=$dataType '
  116 + 'startTime=$resolvedStartTime endTime=$resolvedEndTime '
  117 + 'rangeCount=${fetchRanges.length} pageCount=$pageCount '
  118 + 'storedCount=$storedCount',
  119 + );
64 120
65 return OhosHealthRawDataSyncResult( 121 return OhosHealthRawDataSyncResult(
66 dataType: dataType, 122 dataType: dataType,
67 startTime: resolvedStartTime, 123 startTime: resolvedStartTime,
68 endTime: resolvedEndTime, 124 endTime: resolvedEndTime,
  125 + segmentCount: fetchRanges.length,
69 pageCount: pageCount, 126 pageCount: pageCount,
70 storedCount: storedCount, 127 storedCount: storedCount,
71 ); 128 );
@@ -88,6 +145,46 @@ class OhosHealthRawDataSyncService { @@ -88,6 +145,46 @@ class OhosHealthRawDataSyncService {
88 int _unixSeconds(DateTime time) { 145 int _unixSeconds(DateTime time) {
89 return time.millisecondsSinceEpoch ~/ 1000; 146 return time.millisecondsSinceEpoch ~/ 1000;
90 } 147 }
  148 +
  149 + List<OhosHealthRawDataFetchRange> _splitIntoFetchRanges({
  150 + required int startTime,
  151 + required int endTime,
  152 + }) {
  153 + final ranges = <OhosHealthRawDataFetchRange>[];
  154 + var cursor = DateTime.fromMillisecondsSinceEpoch(startTime * 1000);
  155 + final end = DateTime.fromMillisecondsSinceEpoch(endTime * 1000);
  156 +
  157 + while (!cursor.isAfter(end)) {
  158 + final nextMonthStart = DateTime(cursor.year, cursor.month + 1);
  159 + final monthEndTime = nextMonthStart.subtract(const Duration(seconds: 1));
  160 + final segmentEndDateTime =
  161 + monthEndTime.isBefore(end) ? monthEndTime : end;
  162 + final segmentStartTime = _unixSeconds(cursor);
  163 + final segmentEndTime = _unixSeconds(segmentEndDateTime);
  164 + ranges.add(
  165 + OhosHealthRawDataFetchRange(
  166 + startTime: segmentStartTime,
  167 + endTime: segmentEndTime,
  168 + ),
  169 + );
  170 + cursor = DateTime.fromMillisecondsSinceEpoch(
  171 + (segmentEndTime + 1) * 1000,
  172 + );
  173 + }
  174 +
  175 + return ranges;
  176 + }
  177 +
  178 + void _log(String message) {
  179 + final taggedMessage = '$logMarker $message';
  180 + debugPrint(taggedMessage);
  181 + _logSink?.call(taggedMessage);
  182 + try {
  183 + AppLogger.i(taggedMessage);
  184 + } catch (_) {
  185 + // AppLogger may not be initialized in isolated test/bootstrap contexts.
  186 + }
  187 + }
91 } 188 }
92 189
93 abstract class OhosHealthRawDataRemoteDataSource { 190 abstract class OhosHealthRawDataRemoteDataSource {
@@ -108,6 +205,111 @@ abstract class OhosHealthRawDataLocalStore { @@ -108,6 +205,111 @@ abstract class OhosHealthRawDataLocalStore {
108 }); 205 });
109 } 206 }
110 207
  208 +abstract class OhosHarmonyRawDataClient {
  209 + Future<AppResult<HmHealthData>> getHealthData(
  210 + HuaweiHealthDataType dataType,
  211 + int startDate,
  212 + int endDate,
  213 + );
  214 +
  215 + Future<AppResult<HmSleepData>> getSleepData(
  216 + int startDate,
  217 + int endDate,
  218 + );
  219 +}
  220 +
  221 +class OhosHarmonyHealthRawDataRemoteDataSource
  222 + implements OhosHealthRawDataRemoteDataSource {
  223 + const OhosHarmonyHealthRawDataRemoteDataSource(this._client);
  224 +
  225 + final OhosHarmonyRawDataClient _client;
  226 +
  227 + @override
  228 + Future<OhosHealthRawDataPage> fetchRawDataPage({
  229 + required int dataType,
  230 + required int startTime,
  231 + required int endTime,
  232 + String? pageToken,
  233 + }) async {
  234 + final startDate = _dateKeyFromUnixSeconds(startTime);
  235 + final endDate = _dateKeyFromUnixSeconds(endTime);
  236 + if (dataType == OhosHealthRawDataType.sleepAnalysis) {
  237 + final result = await _client.getSleepData(startDate, endDate);
  238 + return switch (result) {
  239 + AppSuccess<HmSleepData>(:final data) => OhosHealthRawDataPage(
  240 + items: _sleepItems(data),
  241 + ),
  242 + AppFailure<HmSleepData>(:final error) => throw StateError(
  243 + 'OHOS sleep raw data fetch failed: $error',
  244 + ),
  245 + };
  246 + }
  247 +
  248 + final huaweiDataType = _resolveHuaweiDataType(dataType);
  249 + final result = await _client.getHealthData(
  250 + huaweiDataType,
  251 + startDate,
  252 + endDate,
  253 + );
  254 + return switch (result) {
  255 + AppSuccess<HmHealthData>(:final data) => OhosHealthRawDataPage(
  256 + items: _healthItems(
  257 + dataType: dataType,
  258 + data: data,
  259 + ),
  260 + ),
  261 + AppFailure<HmHealthData>(:final error) => throw StateError(
  262 + 'OHOS health raw data fetch failed: $error',
  263 + ),
  264 + };
  265 + }
  266 +
  267 + HuaweiHealthDataType _resolveHuaweiDataType(int dataType) {
  268 + if (dataType == OhosHealthRawDataType.activitySummary) {
  269 + return HuaweiHealthDataType.activity;
  270 + }
  271 + if (dataType == OhosHealthRawDataType.workout) {
  272 + return HuaweiHealthDataType.exerciseDuration;
  273 + }
  274 + for (final value in HuaweiHealthDataType.values) {
  275 + if (value.dataType == dataType) {
  276 + return value;
  277 + }
  278 + }
  279 + throw ArgumentError.value(dataType, 'dataType');
  280 + }
  281 +
  282 + List<OhosHealthRawDataItem> _healthItems({
  283 + required int dataType,
  284 + required HmHealthData data,
  285 + }) {
  286 + return (data.list ?? const <HmHealthDataItem>[])
  287 + .where((item) => item.time != null)
  288 + .map(
  289 + (item) => OhosHealthRawDataItem(
  290 + dataType: item.dataType?.toInt() ?? dataType,
  291 + dataTime: item.time!.toInt(),
  292 + payload: Map<String, Object?>.from(item.toJson()),
  293 + ),
  294 + )
  295 + .toList(growable: false);
  296 + }
  297 +
  298 + List<OhosHealthRawDataItem> _sleepItems(HmSleepData data) {
  299 + return (data.list ?? const <HmSleepDataItem>[])
  300 + .where((item) => item.toTime != null)
  301 + .map(
  302 + (item) => OhosHealthRawDataItem(
  303 + dataType:
  304 + item.dataType?.toInt() ?? OhosHealthRawDataType.sleepAnalysis,
  305 + dataTime: item.toTime!.toInt(),
  306 + payload: Map<String, Object?>.from(item.toJson()),
  307 + ),
  308 + )
  309 + .toList(growable: false);
  310 + }
  311 +}
  312 +
111 class TodoOhosHealthRawDataRemoteDataSource 313 class TodoOhosHealthRawDataRemoteDataSource
112 implements OhosHealthRawDataRemoteDataSource { 314 implements OhosHealthRawDataRemoteDataSource {
113 const TodoOhosHealthRawDataRemoteDataSource(); 315 const TodoOhosHealthRawDataRemoteDataSource();
@@ -165,11 +367,22 @@ class OhosHealthRawDataItem { @@ -165,11 +367,22 @@ class OhosHealthRawDataItem {
165 final Map<String, Object?> payload; 367 final Map<String, Object?> payload;
166 } 368 }
167 369
  370 +class OhosHealthRawDataFetchRange {
  371 + const OhosHealthRawDataFetchRange({
  372 + required this.startTime,
  373 + required this.endTime,
  374 + });
  375 +
  376 + final int startTime;
  377 + final int endTime;
  378 +}
  379 +
168 class OhosHealthRawDataSyncResult { 380 class OhosHealthRawDataSyncResult {
169 const OhosHealthRawDataSyncResult({ 381 const OhosHealthRawDataSyncResult({
170 required this.dataType, 382 required this.dataType,
171 required this.startTime, 383 required this.startTime,
172 required this.endTime, 384 required this.endTime,
  385 + this.segmentCount = 1,
173 required this.pageCount, 386 required this.pageCount,
174 required this.storedCount, 387 required this.storedCount,
175 }); 388 });
@@ -177,6 +390,12 @@ class OhosHealthRawDataSyncResult { @@ -177,6 +390,12 @@ class OhosHealthRawDataSyncResult {
177 final int dataType; 390 final int dataType;
178 final int startTime; 391 final int startTime;
179 final int endTime; 392 final int endTime;
  393 + final int segmentCount;
180 final int pageCount; 394 final int pageCount;
181 final int storedCount; 395 final int storedCount;
182 } 396 }
  397 +
  398 +int _dateKeyFromUnixSeconds(int seconds) {
  399 + final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
  400 + return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day;
  401 +}
@@ -5,12 +5,12 @@ @@ -5,12 +5,12 @@
5 "name": "auto", 5 "name": "auto",
6 "type": "HarmonyOS", 6 "type": "HarmonyOS",
7 "material": { 7 "material": {
8 - "certpath": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer", 8 + "certpath": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.cer",
9 "keyAlias": "debugKey", 9 "keyAlias": "debugKey",
10 "keyPassword": "00000019084446C3F235D890CFE9AC6015D14270D13D370ACF44FEDD89097E1ABBBDE3810C8FEBFFFD", 10 "keyPassword": "00000019084446C3F235D890CFE9AC6015D14270D13D370ACF44FEDD89097E1ABBBDE3810C8FEBFFFD",
11 - "profile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b", 11 + "profile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p7b",
12 "signAlg": "SHA256withECDSA", 12 "signAlg": "SHA256withECDSA",
13 - "storeFile": "/Users/liuhongzhe/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12", 13 + "storeFile": "/Users/quanhai/Developer/doublefeel_flutter/.ohos/config/debug_ohos_WWjJA4uV69RmLVhj0o_rA0VEMyuWGcKp7RSWudtmCR0=.p12",
14 "storePassword": "00000019D85309732E0C3572D434400BA13D429AE5A182B54C1EA6F2C9D7DB5AE81BD9A64F12ACC681" 14 "storePassword": "00000019D85309732E0C3572D434400BA13D429AE5A182B54C1EA6F2C9D7DB5AE81BD9A64F12ACC681"
15 } 15 }
16 }, 16 },
@@ -18,33 +18,33 @@ @@ -18,33 +18,33 @@
18 "name": "release", 18 "name": "release",
19 "type": "HarmonyOS", 19 "type": "HarmonyOS",
20 "material": { 20 "material": {
21 - "storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12", 21 + "storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12",
22 "storePassword": "0000001E8F02C32C2D5431F31FC368F020D891F18E6DC45A5913C60CACC1074B11282B3527F3E7D420F00B21B13A", 22 "storePassword": "0000001E8F02C32C2D5431F31FC368F020D891F18E6DC45A5913C60CACC1074B11282B3527F3E7D420F00B21B13A",
23 "keyAlias": "DoubleFeel", 23 "keyAlias": "DoubleFeel",
24 "keyPassword": "0000001ECC963DD8029FC0957D403D334E6A864BA98BD1ABF0F96E41AEB1002C3CBAFE12680A33902C1D5C61E7C9", 24 "keyPassword": "0000001ECC963DD8029FC0957D403D334E6A864BA98BD1ABF0F96E41AEB1002C3CBAFE12680A33902C1D5C61E7C9",
25 "signAlg": "SHA256withECDSA", 25 "signAlg": "SHA256withECDSA",
26 - "profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b",  
27 - "certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer" 26 + "profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeel_hm_releaseRelease.p7b",
  27 + "certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/release/DoubleFeelWatch_Release.cer"
28 } 28 }
29 }, 29 },
30 { 30 {
31 "name": "debug", 31 "name": "debug",
32 "type": "HarmonyOS", 32 "type": "HarmonyOS",
33 "material": { 33 "material": {
34 - "storeFile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/doublefeel.p12", 34 + "storeFile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/doublefeel.p12",
35 "storePassword": "0000001E1BE8542E4E9F7969905217A5421D89506648518A33A93BE1045A511094FF10805EC4686172CC000ED9FA", 35 "storePassword": "0000001E1BE8542E4E9F7969905217A5421D89506648518A33A93BE1045A511094FF10805EC4686172CC000ED9FA",
36 "keyAlias": "DoubleFeel", 36 "keyAlias": "DoubleFeel",
37 "keyPassword": "0000001EFBAD9B2C408962C5BF2044ED8A57E4E192D171CAC3EBFF8D648684E52A690DFD6B8BDD7DC28AF8D6174C", 37 "keyPassword": "0000001EFBAD9B2C408962C5BF2044ED8A57E4E192D171CAC3EBFF8D648684E52A690DFD6B8BDD7DC28AF8D6174C",
38 "signAlg": "SHA256withECDSA", 38 "signAlg": "SHA256withECDSA",
39 - "profile": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b",  
40 - "certpath": "/Users/liuhongzhe/Work/DD/Project/GitLab/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer" 39 + "profile": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debugDebug.p7b",
  40 + "certpath": "/Users/quanhai/Developer/doublefeel_flutter/ohos/keystore/debug/DoubleFeel_hm_debug.cer"
41 } 41 }
42 } 42 }
43 ], 43 ],
44 "products": [ 44 "products": [
45 { 45 {
46 "name": "default", 46 "name": "default",
47 - "signingConfig": "auto", 47 + "signingConfig": "debug",
48 "targetSdkVersion": "6.0.0(20)", 48 "targetSdkVersion": "6.0.0(20)",
49 "compatibleSdkVersion": "6.0.0(20)", 49 "compatibleSdkVersion": "6.0.0(20)",
50 "runtimeOS": "HarmonyOS", 50 "runtimeOS": "HarmonyOS",
  1 +import 'package:doublefeel_flutter/core/result/app_result.dart';
  2 +import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/huawei_health_data_type.dart';
1 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_sync_service.dart'; 3 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_sync_service.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';
2 import 'package:flutter_test/flutter_test.dart'; 6 import 'package:flutter_test/flutter_test.dart';
3 7
4 void main() { 8 void main() {
@@ -36,6 +40,7 @@ void main() { @@ -36,6 +40,7 @@ void main() {
36 40
37 expect(result.startTime, 1000); 41 expect(result.startTime, 1000);
38 expect(result.endTime, 2000); 42 expect(result.endTime, 2000);
  43 + expect(result.segmentCount, 1);
39 expect(result.pageCount, 2); 44 expect(result.pageCount, 2);
40 expect(result.storedCount, 2); 45 expect(result.storedCount, 2);
41 expect(remote.calls.map((e) => e.pageToken), [null, 'next']); 46 expect(remote.calls.map((e) => e.pageToken), [null, 'next']);
@@ -70,10 +75,149 @@ void main() { @@ -70,10 +75,149 @@ void main() {
70 75
71 expect(result.startTime, expectedStart); 76 expect(result.startTime, expectedStart);
72 expect(result.endTime, expectedEnd); 77 expect(result.endTime, expectedEnd);
73 - expect(remote.calls.single.startTime, expectedStart);  
74 - expect(remote.calls.single.endTime, expectedEnd); 78 + expect(result.segmentCount, remote.calls.length);
  79 + expect(remote.calls.first.startTime, expectedStart);
  80 + expect(remote.calls.last.endTime, expectedEnd);
75 expect(local.storedBatches, isEmpty); 81 expect(local.storedBatches, isEmpty);
76 }); 82 });
  83 +
  84 + test('syncRawData splits cross-month ranges and writes marked logs',
  85 + () async {
  86 + final start = _unixSeconds(DateTime(2026, 1, 30, 10));
  87 + final end = _unixSeconds(DateTime(2026, 3, 2, 9));
  88 + final remote = _FakeOhosHealthRawDataRemoteDataSource([
  89 + const OhosHealthRawDataPage(
  90 + items: [
  91 + OhosHealthRawDataItem(
  92 + dataType: 1,
  93 + dataTime: 1,
  94 + payload: {'value': 45},
  95 + ),
  96 + ],
  97 + ),
  98 + const OhosHealthRawDataPage(
  99 + items: [
  100 + OhosHealthRawDataItem(
  101 + dataType: 1,
  102 + dataTime: 2,
  103 + payload: {'value': 46},
  104 + ),
  105 + ],
  106 + ),
  107 + const OhosHealthRawDataPage(
  108 + items: [
  109 + OhosHealthRawDataItem(
  110 + dataType: 1,
  111 + dataTime: 3,
  112 + payload: {'value': 47},
  113 + ),
  114 + ],
  115 + ),
  116 + ]);
  117 + final local = _FakeOhosHealthRawDataLocalStore();
  118 + final logs = <String>[];
  119 + final service = OhosHealthRawDataSyncService(
  120 + remoteDataSource: remote,
  121 + localStore: local,
  122 + logSink: logs.add,
  123 + );
  124 +
  125 + final result = await service.syncRawData(
  126 + dataType: 1,
  127 + startTime: start,
  128 + endTime: end,
  129 + );
  130 +
  131 + expect(result.segmentCount, 3);
  132 + expect(result.pageCount, 3);
  133 + expect(result.storedCount, 3);
  134 + expect(remote.calls.map((e) => _dateKey(e.startTime)), [
  135 + 20260130,
  136 + 20260201,
  137 + 20260301,
  138 + ]);
  139 + expect(remote.calls.map((e) => _dateKey(e.endTime)), [
  140 + 20260131,
  141 + 20260228,
  142 + 20260302,
  143 + ]);
  144 + expect(
  145 + logs,
  146 + contains(
  147 + contains(OhosHealthRawDataSyncService.logMarker),
  148 + ),
  149 + );
  150 + expect(logs, contains(contains('segment_start dataType=1 segment=1/3')));
  151 + expect(logs, contains(contains('sync_finish dataType=1')));
  152 + });
  153 +
  154 + test('Harmony remote data source calls getHealthData and converts items',
  155 + () async {
  156 + final client = _FakeOhosHarmonyRawDataClient(
  157 + healthData: HmHealthData(
  158 + list: [
  159 + HmHealthDataItem(
  160 + time: 1787900000,
  161 + dataType: 1,
  162 + value: 53,
  163 + isAsleep: 0,
  164 + ),
  165 + HmHealthDataItem(
  166 + dataType: 1,
  167 + value: 54,
  168 + ),
  169 + ],
  170 + ),
  171 + );
  172 + final remote = OhosHarmonyHealthRawDataRemoteDataSource(client);
  173 +
  174 + final page = await remote.fetchRawDataPage(
  175 + dataType: HuaweiHealthDataType.hrv.dataType,
  176 + startTime: _unixSeconds(DateTime(2026, 8, 1)),
  177 + endTime: _unixSeconds(DateTime(2026, 8, 31, 23, 59, 59)),
  178 + );
  179 +
  180 + expect(client.healthCalls.single.dataType, HuaweiHealthDataType.hrv);
  181 + expect(client.healthCalls.single.startDate, 20260801);
  182 + expect(client.healthCalls.single.endDate, 20260831);
  183 + expect(page.nextPageToken, isNull);
  184 + expect(page.items, hasLength(1));
  185 + expect(page.items.single.dataType, 1);
  186 + expect(page.items.single.dataTime, 1787900000);
  187 + expect(page.items.single.payload['value'], 53);
  188 + });
  189 +
  190 + test('Harmony remote data source calls getSleepData and converts items',
  191 + () async {
  192 + final client = _FakeOhosHarmonyRawDataClient(
  193 + sleepData: HmSleepData(
  194 + list: [
  195 + HmSleepDataItem(
  196 + fromTime: 1787850000,
  197 + toTime: 1787870000,
  198 + dataType: 100,
  199 + ),
  200 + HmSleepDataItem(
  201 + fromTime: 1787880000,
  202 + ),
  203 + ],
  204 + ),
  205 + );
  206 + final remote = OhosHarmonyHealthRawDataRemoteDataSource(client);
  207 +
  208 + final page = await remote.fetchRawDataPage(
  209 + dataType: OhosHealthRawDataType.sleepAnalysis,
  210 + startTime: _unixSeconds(DateTime(2026, 8, 1)),
  211 + endTime: _unixSeconds(DateTime(2026, 8, 2, 23, 59, 59)),
  212 + );
  213 +
  214 + expect(client.sleepCalls.single.startDate, 20260801);
  215 + expect(client.sleepCalls.single.endDate, 20260802);
  216 + expect(page.items, hasLength(1));
  217 + expect(page.items.single.dataType, 100);
  218 + expect(page.items.single.dataTime, 1787870000);
  219 + expect(page.items.single.payload['from_time'], 1787850000);
  220 + });
77 } 221 }
78 222
79 class _FakeOhosHealthRawDataRemoteDataSource 223 class _FakeOhosHealthRawDataRemoteDataSource
@@ -98,7 +242,11 @@ class _FakeOhosHealthRawDataRemoteDataSource @@ -98,7 +242,11 @@ class _FakeOhosHealthRawDataRemoteDataSource
98 pageToken: pageToken, 242 pageToken: pageToken,
99 ), 243 ),
100 ); 244 );
101 - return _pages[calls.length - 1]; 245 + final pageIndex = calls.length - 1;
  246 + if (pageIndex >= _pages.length) {
  247 + return const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]);
  248 + }
  249 + return _pages[pageIndex];
102 } 250 }
103 } 251 }
104 252
@@ -137,3 +285,75 @@ class _FetchCall { @@ -137,3 +285,75 @@ class _FetchCall {
137 final int endTime; 285 final int endTime;
138 final String? pageToken; 286 final String? pageToken;
139 } 287 }
  288 +
  289 +class _FakeOhosHarmonyRawDataClient implements OhosHarmonyRawDataClient {
  290 + _FakeOhosHarmonyRawDataClient({
  291 + HmHealthData? healthData,
  292 + HmSleepData? sleepData,
  293 + }) : _healthData = healthData ?? HmHealthData(),
  294 + _sleepData = sleepData ?? HmSleepData();
  295 +
  296 + final HmHealthData _healthData;
  297 + final HmSleepData _sleepData;
  298 + final List<_HealthCall> healthCalls = <_HealthCall>[];
  299 + final List<_SleepCall> sleepCalls = <_SleepCall>[];
  300 +
  301 + @override
  302 + Future<AppResult<HmHealthData>> getHealthData(
  303 + HuaweiHealthDataType dataType,
  304 + int startDate,
  305 + int endDate,
  306 + ) async {
  307 + healthCalls.add(
  308 + _HealthCall(
  309 + dataType: dataType,
  310 + startDate: startDate,
  311 + endDate: endDate,
  312 + ),
  313 + );
  314 + return AppSuccess(_healthData);
  315 + }
  316 +
  317 + @override
  318 + Future<AppResult<HmSleepData>> getSleepData(
  319 + int startDate,
  320 + int endDate,
  321 + ) async {
  322 + sleepCalls.add(
  323 + _SleepCall(
  324 + startDate: startDate,
  325 + endDate: endDate,
  326 + ),
  327 + );
  328 + return AppSuccess(_sleepData);
  329 + }
  330 +}
  331 +
  332 +class _HealthCall {
  333 + const _HealthCall({
  334 + required this.dataType,
  335 + required this.startDate,
  336 + required this.endDate,
  337 + });
  338 +
  339 + final HuaweiHealthDataType dataType;
  340 + final int startDate;
  341 + final int endDate;
  342 +}
  343 +
  344 +class _SleepCall {
  345 + const _SleepCall({
  346 + required this.startDate,
  347 + required this.endDate,
  348 + });
  349 +
  350 + final int startDate;
  351 + final int endDate;
  352 +}
  353 +
  354 +int _unixSeconds(DateTime time) => time.millisecondsSinceEpoch ~/ 1000;
  355 +
  356 +int _dateKey(int seconds) {
  357 + final dateTime = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
  358 + return dateTime.year * 10000 + dateTime.month * 100 + dateTime.day;
  359 +}