Commit 3e301e6eb54f2e17b84ee1216ad4be4d8f73622b

Authored by 权海
1 parent d089a4f7

feat(ui):增加同步、计算耗时提示

@@ -191,6 +191,10 @@ class OhosHealthRawDataSource implements HealthRawDataSource { @@ -191,6 +191,10 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
191 ); 191 );
192 } 192 }
193 193
  194 + Future<int?> latestDataTime({required int dataType}) {
  195 + return _syncService.latestDataTime(dataType: dataType);
  196 + }
  197 +
194 @override 198 @override
195 Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) { 199 Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) {
196 return _syncService.getActivityGoal(refresh: refresh); 200 return _syncService.getActivityGoal(refresh: refresh);
@@ -243,11 +243,17 @@ class OHOSHealthRawDataCoreService { @@ -243,11 +243,17 @@ class OHOSHealthRawDataCoreService {
243 throw ArgumentError.value(endTime, 'endTime'); 243 throw ArgumentError.value(endTime, 'endTime');
244 } 244 }
245 final hasAuthorization = await _hasHealthReadAuthorizationSafely(); 245 final hasAuthorization = await _hasHealthReadAuthorizationSafely();
  246 + final willSyncRawData =
  247 + hasAuthorization && _rawDataSource is OhosHealthRawDataSource;
  248 + final syncStartTime = willSyncRawData ? DateTime.now() : null;
246 final syncResults = await _syncCalculationRawDataSafely( 249 final syncResults = await _syncCalculationRawDataSafely(
247 hasAuthorization: hasAuthorization, 250 hasAuthorization: hasAuthorization,
248 startTime: forceStartTime, 251 startTime: forceStartTime,
249 endTime: effectiveEndTime, 252 endTime: effectiveEndTime,
250 ); 253 );
  254 + final syncElapsed = syncStartTime == null
  255 + ? Duration.zero
  256 + : DateTime.now().difference(syncStartTime);
251 final syncedRawStartTime = _earliestStoredTime(syncResults); 257 final syncedRawStartTime = _earliestStoredTime(syncResults);
252 _logInfo( 258 _logInfo(
253 'calculate_sync_finish startTime=$requestedStartTime ' 259 'calculate_sync_finish startTime=$requestedStartTime '
@@ -255,6 +261,7 @@ class OHOSHealthRawDataCoreService { @@ -255,6 +261,7 @@ class OHOSHealthRawDataCoreService {
255 'syncResults=$syncResults', 261 'syncResults=$syncResults',
256 ); 262 );
257 263
  264 + final calculationStartTime = DateTime.now();
258 final storedResult = await _calculateAndStoreSafely( 265 final storedResult = await _calculateAndStoreSafely(
259 userId: userId, 266 userId: userId,
260 requestedStartTime: requestedStartTime, 267 requestedStartTime: requestedStartTime,
@@ -263,12 +270,17 @@ class OHOSHealthRawDataCoreService { @@ -263,12 +270,17 @@ class OHOSHealthRawDataCoreService {
263 syncedRawStartTime: syncedRawStartTime, 270 syncedRawStartTime: syncedRawStartTime,
264 readChunkDays: readChunkDays, 271 readChunkDays: readChunkDays,
265 ); 272 );
  273 + final calculationElapsed = DateTime.now().difference(calculationStartTime);
266 _scheduleResultUpload(); 274 _scheduleResultUpload();
267 await _sendLocalNotificationsAfterCalculationSafely( 275 await _sendLocalNotificationsAfterCalculationSafely(
268 result: storedResult.result, 276 result: storedResult.result,
269 hasExistingHrv: storedResult.hasExistingHrv, 277 hasExistingHrv: storedResult.hasExistingHrv,
270 hasExistingSleep: storedResult.hasExistingSleep, 278 hasExistingSleep: storedResult.hasExistingSleep,
271 ); 279 );
  280 + _showDebugTimingToast(
  281 + syncElapsed: syncElapsed,
  282 + calculationElapsed: calculationElapsed,
  283 + );
272 return storedResult.result; 284 return storedResult.result;
273 } 285 }
274 286
@@ -293,6 +305,15 @@ class OHOSHealthRawDataCoreService { @@ -293,6 +305,15 @@ class OHOSHealthRawDataCoreService {
293 final latestSleepResultTime = await _localStore.latestSleepResultTime( 305 final latestSleepResultTime = await _localStore.latestSleepResultTime(
294 userId, 306 userId,
295 ); 307 );
  308 + final latestRawHrvDataTime = await _latestOhosRawDataTime(
  309 + HealthDataUploadType.hrv.type,
  310 + );
  311 + final latestRawHrDataTime = await _latestOhosRawDataTime(
  312 + HealthDataUploadType.heartRate.type,
  313 + );
  314 + final latestRawSleepDataTime = await _latestOhosRawDataTime(
  315 + OhosHealthRawDataType.sleepAnalysis,
  316 + );
296 hasExistingHrv = latestHrvRawEndTime != null; 317 hasExistingHrv = latestHrvRawEndTime != null;
297 hasExistingSleep = latestSleepResultTime != null; 318 hasExistingSleep = latestSleepResultTime != null;
298 319
@@ -301,13 +322,30 @@ class OHOSHealthRawDataCoreService { @@ -301,13 +322,30 @@ class OHOSHealthRawDataCoreService {
301 requestedStartTime, 322 requestedStartTime,
302 earliestStartTime, 323 earliestStartTime,
303 ); 324 );
  325 + final hrvRawAnchorStart = _rawBackfillStartTime(latestRawHrvDataTime);
  326 + final realtimeRawAnchorStart = _rawBackfillStartTime(
  327 + latestRawHrDataTime,
  328 + );
  329 + final sleepRawAnchorStart = _rawBackfillStartTime(
  330 + latestRawSleepDataTime,
  331 + );
304 final hrvStartTime = math.max( 332 final hrvStartTime = math.max(
305 - _minNullable(hrvContextStart, calculationStartTime) ?? 333 + _minNullableValues([
  334 + hrvContextStart,
  335 + hrvRawAnchorStart,
  336 + syncedRawStartTime,
  337 + requestedStartTime,
  338 + ]) ??
306 calculationStartTime, 339 calculationStartTime,
307 earliestStartTime, 340 earliestStartTime,
308 ); 341 );
309 final realtimeStartTime = math.max( 342 final realtimeStartTime = math.max(
310 - _minNullable(realtimeContextStart, calculationStartTime) ?? 343 + _minNullableValues([
  344 + realtimeContextStart,
  345 + realtimeRawAnchorStart,
  346 + syncedRawStartTime,
  347 + requestedStartTime,
  348 + ]) ??
311 calculationStartTime, 349 calculationStartTime,
312 earliestStartTime, 350 earliestStartTime,
313 ); 351 );
@@ -319,7 +357,21 @@ class OHOSHealthRawDataCoreService { @@ -319,7 +357,21 @@ class OHOSHealthRawDataCoreService {
319 ? calculationStartTime 357 ? calculationStartTime
320 : latestSleepResultTime - Duration.secondsPerDay; 358 : latestSleepResultTime - Duration.secondsPerDay;
321 final sleepStartTime = math.max( 359 final sleepStartTime = math.max(
322 - _minNullable(sleepAnchorTime, syncedRawStartTime) ?? sleepAnchorTime, 360 + _minNullableValues([
  361 + sleepAnchorTime,
  362 + sleepRawAnchorStart,
  363 + syncedRawStartTime,
  364 + requestedStartTime,
  365 + ]) ??
  366 + sleepAnchorTime,
  367 + earliestStartTime,
  368 + );
  369 + final hrvRecomputeStartTime = _boundedNullableStartTime(
  370 + _minNullable(syncedRawStartTime, hrvRawAnchorStart),
  371 + earliestStartTime,
  372 + );
  373 + final realtimeRecomputeStartTime = _boundedNullableStartTime(
  374 + _minNullable(syncedRawStartTime, realtimeRawAnchorStart),
323 earliestStartTime, 375 earliestStartTime,
324 ); 376 );
325 _logInfo( 377 _logInfo(
@@ -331,8 +383,18 @@ class OHOSHealthRawDataCoreService { @@ -331,8 +383,18 @@ class OHOSHealthRawDataCoreService {
331 'latestHrvRawEndTime=$latestHrvRawEndTime ' 383 'latestHrvRawEndTime=$latestHrvRawEndTime '
332 'latestRealtimeRawEndTime=$latestRealtimeRawEndTime ' 384 'latestRealtimeRawEndTime=$latestRealtimeRawEndTime '
333 'latestSleepResultTime=$latestSleepResultTime ' 385 'latestSleepResultTime=$latestSleepResultTime '
334 - 'hrvStartTime=$hrvStartTime heartRateStartTime=$heartRateStartTime '  
335 - 'sleepStartTime=$sleepStartTime', 386 + 'latestRawHrvDataTime=$latestRawHrvDataTime '
  387 + 'latestRawHrDataTime=$latestRawHrDataTime '
  388 + 'latestRawSleepDataTime=$latestRawSleepDataTime '
  389 + 'hrvRawAnchorStart=$hrvRawAnchorStart '
  390 + 'realtimeRawAnchorStart=$realtimeRawAnchorStart '
  391 + 'sleepRawAnchorStart=$sleepRawAnchorStart '
  392 + 'hrvStartTime=$hrvStartTime '
  393 + 'realtimeStartTime=$realtimeStartTime '
  394 + 'heartRateStartTime=$heartRateStartTime '
  395 + 'sleepStartTime=$sleepStartTime '
  396 + 'hrvRecomputeStartTime=$hrvRecomputeStartTime '
  397 + 'realtimeRecomputeStartTime=$realtimeRecomputeStartTime',
336 ); 398 );
337 399
338 final hrvPoints = await _fetchRawDataInChunks( 400 final hrvPoints = await _fetchRawDataInChunks(
@@ -402,12 +464,12 @@ class OHOSHealthRawDataCoreService { @@ -402,12 +464,12 @@ class OHOSHealthRawDataCoreService {
402 hrvStressPoints: _filterNewHrvStressPoints( 464 hrvStressPoints: _filterNewHrvStressPoints(
403 result.hrvStressPoints, 465 result.hrvStressPoints,
404 latestHrvRawEndTime, 466 latestHrvRawEndTime,
405 - recomputeStartTime: syncedRawStartTime, 467 + recomputeStartTime: hrvRecomputeStartTime,
406 ), 468 ),
407 realtimeStressPoints: _filterNewRealtimeStressPoints( 469 realtimeStressPoints: _filterNewRealtimeStressPoints(
408 result.realtimeStressPoints, 470 result.realtimeStressPoints,
409 latestRealtimeRawEndTime, 471 latestRealtimeRawEndTime,
410 - recomputeStartTime: syncedRawStartTime, 472 + recomputeStartTime: realtimeRecomputeStartTime,
411 ), 473 ),
412 ); 474 );
413 _logInfo( 475 _logInfo(
@@ -947,6 +1009,30 @@ class OHOSHealthRawDataCoreService { @@ -947,6 +1009,30 @@ class OHOSHealthRawDataCoreService {
947 } 1009 }
948 } 1010 }
949 1011
  1012 + Future<int?> _latestOhosRawDataTime(int dataType) async {
  1013 + final rawDataSource = _rawDataSource;
  1014 + if (rawDataSource is! OhosHealthRawDataSource) {
  1015 + return null;
  1016 + }
  1017 + return rawDataSource.latestDataTime(dataType: dataType);
  1018 + }
  1019 +
  1020 + int? _rawBackfillStartTime(int? latestDataTime) {
  1021 + if (latestDataTime == null) return null;
  1022 + final backfillTime = math.max(
  1023 + 0,
  1024 + latestDataTime -
  1025 + OhosHealthRawDataSyncService.incrementalBackfillDays *
  1026 + Duration.secondsPerDay,
  1027 + );
  1028 + return _localDay(backfillTime).millisecondsSinceEpoch ~/ 1000;
  1029 + }
  1030 +
  1031 + int? _boundedNullableStartTime(int? startTime, int earliestStartTime) {
  1032 + if (startTime == null) return null;
  1033 + return math.max(startTime, earliestStartTime);
  1034 + }
  1035 +
950 Future<bool> _hasHealthReadAuthorizationSafely() async { 1036 Future<bool> _hasHealthReadAuthorizationSafely() async {
951 try { 1037 try {
952 return await _hasHealthReadAuthorization(); 1038 return await _hasHealthReadAuthorization();
@@ -1258,6 +1344,30 @@ class OHOSHealthRawDataCoreService { @@ -1258,6 +1344,30 @@ class OHOSHealthRawDataCoreService {
1258 return math.min(a, b); 1344 return math.min(a, b);
1259 } 1345 }
1260 1346
  1347 + static int? _minNullableValues(Iterable<int?> values) {
  1348 + int? minValue;
  1349 + for (final value in values) {
  1350 + if (value == null) continue;
  1351 + minValue = minValue == null ? value : math.min(minValue, value);
  1352 + }
  1353 + return minValue;
  1354 + }
  1355 +
  1356 + void _showDebugTimingToast({
  1357 + required Duration syncElapsed,
  1358 + required Duration calculationElapsed,
  1359 + }) {
  1360 + if (!_isDebug) return;
  1361 + _toastSink?.call(
  1362 + '【测试】本轮同步耗时${_elapsedSecondsText(syncElapsed)} s、'
  1363 + '计算耗时${_elapsedSecondsText(calculationElapsed)} s',
  1364 + );
  1365 + }
  1366 +
  1367 + String _elapsedSecondsText(Duration elapsed) {
  1368 + return (elapsed.inMilliseconds / 1000).toStringAsFixed(1);
  1369 + }
  1370 +
1261 static int _dateKeyFromUnixSeconds(int seconds) { 1371 static int _dateKeyFromUnixSeconds(int seconds) {
1262 final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000); 1372 final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
1263 return _dateKeyFromDateTime(date); 1373 return _dateKeyFromDateTime(date);
@@ -50,22 +50,24 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore { @@ -50,22 +50,24 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore {
50 if (dataType == OhosHealthRawDataType.sleepAnalysis) { 50 if (dataType == OhosHealthRawDataType.sleepAnalysis) {
51 final rows = await db.query( 51 final rows = await db.query(
52 sleepDataTable, 52 sleepDataTable,
53 - columns: ['from_time'],  
54 - orderBy: 'from_time DESC', 53 + columns: ['to_time'],
  54 + where: 'to_time > 0',
  55 + orderBy: 'to_time DESC',
55 limit: 1, 56 limit: 1,
56 ); 57 );
57 - return rows.isEmpty ? null : rows.first['from_time'] as int; 58 + return rows.isEmpty ? null : rows.first['to_time'] as int;
58 } 59 }
59 if (dataType == OhosHealthRawDataType.workout) { 60 if (dataType == OhosHealthRawDataType.workout) {
60 final table = _rawDataTable(dataType); 61 final table = _rawDataTable(dataType);
61 await _createRawIntervalDataTypeTable(db, table); 62 await _createRawIntervalDataTypeTable(db, table);
62 final rows = await db.query( 63 final rows = await db.query(
63 table, 64 table,
64 - columns: ['from_time'],  
65 - orderBy: 'from_time DESC', 65 + columns: ['to_time'],
  66 + where: 'to_time > 0',
  67 + orderBy: 'to_time DESC',
66 limit: 1, 68 limit: 1,
67 ); 69 );
68 - return rows.isEmpty ? null : rows.first['from_time'] as int; 70 + return rows.isEmpty ? null : rows.first['to_time'] as int;
69 } 71 }
70 72
71 final storedDataType = _storedHealthDataType(dataType); 73 final storedDataType = _storedHealthDataType(dataType);
@@ -74,6 +76,7 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore { @@ -74,6 +76,7 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore {
74 final rows = await db.query( 76 final rows = await db.query(
75 table, 77 table,
76 columns: ['time'], 78 columns: ['time'],
  79 + where: 'time > 0 AND value IS NOT NULL',
77 orderBy: 'time DESC', 80 orderBy: 'time DESC',
78 limit: 1, 81 limit: 1,
79 ); 82 );
@@ -99,6 +99,10 @@ class OhosHealthRawDataSyncService { @@ -99,6 +99,10 @@ class OhosHealthRawDataSyncService {
99 } 99 }
100 } 100 }
101 101
  102 + Future<int?> latestDataTime({required int dataType}) {
  103 + return _localStore.latestDataTime(dataType: dataType);
  104 + }
  105 +
102 Future<List<OhosHealthRawDataSyncResult>> syncCalculationRawData({ 106 Future<List<OhosHealthRawDataSyncResult>> syncCalculationRawData({
103 int? startTime, 107 int? startTime,
104 int? endTime, 108 int? endTime,
@@ -221,18 +221,18 @@ packages: @@ -221,18 +221,18 @@ packages:
221 dependency: "direct main" 221 dependency: "direct main"
222 description: 222 description:
223 name: dio 223 name: dio
224 - sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" 224 + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
225 url: "https://pub.flutter-io.cn" 225 url: "https://pub.flutter-io.cn"
226 source: hosted 226 source: hosted
227 - version: "5.11.0" 227 + version: "5.11.1"
228 dio_web_adapter: 228 dio_web_adapter:
229 dependency: transitive 229 dependency: transitive
230 description: 230 description:
231 name: dio_web_adapter 231 name: dio_web_adapter
232 - sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" 232 + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
233 url: "https://pub.flutter-io.cn" 233 url: "https://pub.flutter-io.cn"
234 source: hosted 234 source: hosted
235 - version: "2.2.1" 235 + version: "2.2.2"
236 equatable: 236 equatable:
237 dependency: transitive 237 dependency: transitive
238 description: 238 description:
@@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_mod @@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_mod
3 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_data_core_service.dart'; 3 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_data_core_service.dart';
4 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_core_service.dart'; 4 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_core_service.dart';
5 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_sync_service.dart'; 5 import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_sync_service.dart';
  6 +import 'package:doublefeel_flutter/data/models/enums/app_enums.dart';
6 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; 7 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
7 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart'; 8 import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
8 import 'package:flutter_test/flutter_test.dart'; 9 import 'package:flutter_test/flutter_test.dart';
@@ -248,6 +249,116 @@ void main() { @@ -248,6 +249,116 @@ void main() {
248 store.dailyStressPoints.map((point) => point.date), contains(20260830)); 249 store.dailyStressPoints.map((point) => point.date), contains(20260830));
249 }); 250 });
250 251
  252 + test('OHOS core backfills missing daily stress from local raw data anchor',
  253 + () async {
  254 + final backfillDay = DateTime(2026, 8, 30);
  255 + final backfillBase =
  256 + backfillDay.add(const Duration(hours: 10)).millisecondsSinceEpoch ~/
  257 + 1000;
  258 + final latestBase = DateTime(2026, 9, 3, 10).millisecondsSinceEpoch ~/ 1000;
  259 + final rawLocalStore = _FakeOhosHealthRawDataLocalStore(
  260 + latestDataTimeByType: {
  261 + HealthDataUploadType.hrv.type: latestBase,
  262 + HealthDataUploadType.heartRate.type: latestBase,
  263 + },
  264 + itemsByDataType: {
  265 + HealthDataUploadType.hrv.type: [
  266 + _rawItem(HealthDataUploadType.hrv.type, backfillBase, 45),
  267 + _rawItem(HealthDataUploadType.hrv.type, latestBase, 50),
  268 + ],
  269 + HealthDataUploadType.heartRate.type: [
  270 + for (var index = 0; index < 10; index++)
  271 + _rawItem(
  272 + HealthDataUploadType.heartRate.type,
  273 + backfillBase + index * 6 * 60,
  274 + 100,
  275 + ),
  276 + _rawItem(HealthDataUploadType.heartRate.type, latestBase, 80),
  277 + ],
  278 + },
  279 + );
  280 + final remote = _HistoricalBackfillRemoteDataSource(
  281 + hrvItems: const <OhosHealthRawDataItem>[],
  282 + heartRateItems: const <OhosHealthRawDataItem>[],
  283 + );
  284 + final store = _FakeHealthRawStressLocalStore()
  285 + ..hrvStressPoints.add(_existingHrvResult(latestBase))
  286 + ..realtimeStressPoints.add(_existingRealtimeResult(latestBase));
  287 + final service = OHOSHealthRawDataCoreService(
  288 + rawDataSource: OhosHealthRawDataSource(
  289 + syncService: OhosHealthRawDataSyncService(
  290 + remoteDataSource: remote,
  291 + localStore: rawLocalStore,
  292 + ),
  293 + ),
  294 + localStore: store,
  295 + userIdProvider: () => 42,
  296 + uploadResultsAfterCalculation: false,
  297 + healthReadAuthorizationChecker: () async => true,
  298 + );
  299 +
  300 + final result = await service.startCoreCaculate(
  301 + endTime: latestBase + Duration.secondsPerHour,
  302 + readChunkDays: 1,
  303 + );
  304 +
  305 + expect(
  306 + remote.calls
  307 + .where((call) =>
  308 + call.dataType == HealthDataUploadType.hrv.type ||
  309 + call.dataType == HealthDataUploadType.heartRate.type)
  310 + .map((call) => _dateKey(call.startTime)),
  311 + everyElement(lessThanOrEqualTo(20260827)),
  312 + );
  313 + expect(
  314 + store.hrvStressPoints.any((point) => point.rawEndTime == backfillBase),
  315 + isTrue,
  316 + );
  317 + expect(
  318 + store.realtimeStressPoints.any(
  319 + (point) => _dateKey(point.rawEndTime) == 20260830,
  320 + ),
  321 + isTrue,
  322 + );
  323 + expect(result.dailyStressPoints.map((point) => point.date),
  324 + contains(20260830));
  325 + });
  326 +
  327 + test('OHOS core shows debug timing toast after sync and calculation',
  328 + () async {
  329 + final base = DateTime.now()
  330 + .subtract(const Duration(days: 2))
  331 + .millisecondsSinceEpoch ~/
  332 + 1000;
  333 + final toasts = <String>[];
  334 + final service = OHOSHealthRawDataCoreService(
  335 + rawDataSource: _FakeHealthRawDataSource(
  336 + pointsByDataType: {
  337 + HealthDataUploadType.hrv.type: [_point(1, base, 60)],
  338 + HealthDataUploadType.heartRate.type: [_point(2, base, 80)],
  339 + },
  340 + ),
  341 + localStore: _FakeHealthRawStressLocalStore(),
  342 + userIdProvider: () => 42,
  343 + uploadResultsAfterCalculation: false,
  344 + healthReadAuthorizationChecker: () async => false,
  345 + debugModeProvider: () => true,
  346 + toastSink: toasts.add,
  347 + );
  348 +
  349 + await service.syncAndStore(
  350 + startTime: base - Duration.secondsPerHour,
  351 + endTime: base + Duration.secondsPerHour,
  352 + readChunkDays: 1,
  353 + );
  354 +
  355 + expect(toasts, hasLength(1));
  356 + expect(
  357 + toasts.single,
  358 + matches(RegExp(r'^【测试】本轮同步耗时0\.0 s、计算耗时\d+\.\d s$')),
  359 + );
  360 + });
  361 +
251 test('OHOS core aborts calculation and shows debug toast when sync fails', 362 test('OHOS core aborts calculation and shows debug toast when sync fails',
252 () async { 363 () async {
253 final store = _FakeHealthRawStressLocalStore(); 364 final store = _FakeHealthRawStressLocalStore();
@@ -352,15 +463,23 @@ class _RemoteRawCall { @@ -352,15 +463,23 @@ class _RemoteRawCall {
352 } 463 }
353 464
354 class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { 465 class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
355 - _FakeOhosHealthRawDataLocalStore({int? latestDataTime})  
356 - : _latestDataTime = latestDataTime; 466 + _FakeOhosHealthRawDataLocalStore({
  467 + int? latestDataTime,
  468 + Map<int, int>? latestDataTimeByType,
  469 + Map<int, List<OhosHealthRawDataItem>>? itemsByDataType,
  470 + }) : _latestDataTime = latestDataTime,
  471 + _latestDataTimeByType = latestDataTimeByType ?? const <int, int>{},
  472 + _itemsByDataType =
  473 + itemsByDataType ?? const <int, List<OhosHealthRawDataItem>>{};
357 474
358 final int? _latestDataTime; 475 final int? _latestDataTime;
  476 + final Map<int, int> _latestDataTimeByType;
  477 + final Map<int, List<OhosHealthRawDataItem>> _itemsByDataType;
359 final storedBatches = <List<OhosHealthRawDataItem>>[]; 478 final storedBatches = <List<OhosHealthRawDataItem>>[];
360 479
361 @override 480 @override
362 Future<int?> latestDataTime({required int dataType}) async { 481 Future<int?> latestDataTime({required int dataType}) async {
363 - return _latestDataTime; 482 + return _latestDataTimeByType[dataType] ?? _latestDataTime;
364 } 483 }
365 484
366 @override 485 @override
@@ -369,8 +488,10 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { @@ -369,8 +488,10 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
369 required int startTime, 488 required int startTime,
370 required int endTime, 489 required int endTime,
371 }) async { 490 }) async {
372 - return storedBatches  
373 - .expand((batch) => batch) 491 + return [
  492 + ...(_itemsByDataType[dataType] ?? const <OhosHealthRawDataItem>[]),
  493 + ...storedBatches.expand((batch) => batch),
  494 + ]
374 .where( 495 .where(
375 (item) => 496 (item) =>
376 item.dataType == dataType && 497 item.dataType == dataType &&
@@ -77,6 +77,45 @@ void main() { @@ -77,6 +77,45 @@ void main() {
77 expect(result.earliestStoredTime, 1788019232); 77 expect(result.earliestStoredTime, 1788019232);
78 }); 78 });
79 79
  80 + test('syncRawData uses data type specific latest anchors', () async {
  81 + final latestHrv = _unixSeconds(DateTime(2026, 9, 3, 10));
  82 + final latestSleep = _unixSeconds(DateTime(2026, 9, 2, 23));
  83 + final latestWorkout = _unixSeconds(DateTime(2026, 9, 1, 8));
  84 + final remote = _FakeOhosHealthRawDataRemoteDataSource([
  85 + const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]),
  86 + const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]),
  87 + const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]),
  88 + ]);
  89 + final local = _FakeOhosHealthRawDataLocalStore(
  90 + latestDataTimeByType: {
  91 + HuaweiHealthDataType.hrv.dataType: latestHrv,
  92 + OhosHealthRawDataType.sleepAnalysis: latestSleep,
  93 + OhosHealthRawDataType.workout: latestWorkout,
  94 + },
  95 + );
  96 + final service = OhosHealthRawDataSyncService(
  97 + remoteDataSource: remote,
  98 + localStore: local,
  99 + );
  100 +
  101 + await service.syncRawData(
  102 + dataType: HuaweiHealthDataType.hrv.dataType,
  103 + endTime: _unixSeconds(DateTime(2026, 9, 4)),
  104 + );
  105 + await service.syncRawData(
  106 + dataType: OhosHealthRawDataType.sleepAnalysis,
  107 + endTime: _unixSeconds(DateTime(2026, 9, 4)),
  108 + );
  109 + await service.syncRawData(
  110 + dataType: OhosHealthRawDataType.workout,
  111 + endTime: _unixSeconds(DateTime(2026, 9, 4)),
  112 + );
  113 +
  114 + expect(_dateKey(remote.calls[0].startTime), 20260827);
  115 + expect(_dateKey(remote.calls[1].startTime), 20260826);
  116 + expect(_dateKey(remote.calls[2].startTime), 20260825);
  117 + });
  118 +
80 test('syncRawData falls back to half-year lookback when local data is empty', 119 test('syncRawData falls back to half-year lookback when local data is empty',
81 () async { 120 () async {
82 final now = DateTime(2026, 8, 13, 12); 121 final now = DateTime(2026, 8, 13, 12);
@@ -668,17 +707,21 @@ class _FakeOhosHealthRawDataRemoteDataSource @@ -668,17 +707,21 @@ class _FakeOhosHealthRawDataRemoteDataSource
668 } 707 }
669 708
670 class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore { 709 class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
671 - _FakeOhosHealthRawDataLocalStore({int? latestDataTime})  
672 - : _latestDataTime = latestDataTime; 710 + _FakeOhosHealthRawDataLocalStore({
  711 + int? latestDataTime,
  712 + Map<int, int>? latestDataTimeByType,
  713 + }) : _latestDataTime = latestDataTime,
  714 + _latestDataTimeByType = latestDataTimeByType ?? const <int, int>{};
673 715
674 final int? _latestDataTime; 716 final int? _latestDataTime;
  717 + final Map<int, int> _latestDataTimeByType;
675 final List<List<OhosHealthRawDataItem>> storedBatches = 718 final List<List<OhosHealthRawDataItem>> storedBatches =
676 <List<OhosHealthRawDataItem>>[]; 719 <List<OhosHealthRawDataItem>>[];
677 V2ActivityTarget? activityGoal; 720 V2ActivityTarget? activityGoal;
678 721
679 @override 722 @override
680 Future<int?> latestDataTime({required int dataType}) async { 723 Future<int?> latestDataTime({required int dataType}) async {
681 - return _latestDataTime; 724 + return _latestDataTimeByType[dataType] ?? _latestDataTime;
682 } 725 }
683 726
684 @override 727 @override