Commit 3e301e6eb54f2e17b84ee1216ad4be4d8f73622b

Authored by 权海
1 parent d089a4f7

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

... ... @@ -191,6 +191,10 @@ class OhosHealthRawDataSource implements HealthRawDataSource {
);
}
Future<int?> latestDataTime({required int dataType}) {
return _syncService.latestDataTime(dataType: dataType);
}
@override
Future<V2ActivityTarget?> getActivityGoal({bool refresh = true}) {
return _syncService.getActivityGoal(refresh: refresh);
... ...
... ... @@ -243,11 +243,17 @@ class OHOSHealthRawDataCoreService {
throw ArgumentError.value(endTime, 'endTime');
}
final hasAuthorization = await _hasHealthReadAuthorizationSafely();
final willSyncRawData =
hasAuthorization && _rawDataSource is OhosHealthRawDataSource;
final syncStartTime = willSyncRawData ? DateTime.now() : null;
final syncResults = await _syncCalculationRawDataSafely(
hasAuthorization: hasAuthorization,
startTime: forceStartTime,
endTime: effectiveEndTime,
);
final syncElapsed = syncStartTime == null
? Duration.zero
: DateTime.now().difference(syncStartTime);
final syncedRawStartTime = _earliestStoredTime(syncResults);
_logInfo(
'calculate_sync_finish startTime=$requestedStartTime '
... ... @@ -255,6 +261,7 @@ class OHOSHealthRawDataCoreService {
'syncResults=$syncResults',
);
final calculationStartTime = DateTime.now();
final storedResult = await _calculateAndStoreSafely(
userId: userId,
requestedStartTime: requestedStartTime,
... ... @@ -263,12 +270,17 @@ class OHOSHealthRawDataCoreService {
syncedRawStartTime: syncedRawStartTime,
readChunkDays: readChunkDays,
);
final calculationElapsed = DateTime.now().difference(calculationStartTime);
_scheduleResultUpload();
await _sendLocalNotificationsAfterCalculationSafely(
result: storedResult.result,
hasExistingHrv: storedResult.hasExistingHrv,
hasExistingSleep: storedResult.hasExistingSleep,
);
_showDebugTimingToast(
syncElapsed: syncElapsed,
calculationElapsed: calculationElapsed,
);
return storedResult.result;
}
... ... @@ -293,6 +305,15 @@ class OHOSHealthRawDataCoreService {
final latestSleepResultTime = await _localStore.latestSleepResultTime(
userId,
);
final latestRawHrvDataTime = await _latestOhosRawDataTime(
HealthDataUploadType.hrv.type,
);
final latestRawHrDataTime = await _latestOhosRawDataTime(
HealthDataUploadType.heartRate.type,
);
final latestRawSleepDataTime = await _latestOhosRawDataTime(
OhosHealthRawDataType.sleepAnalysis,
);
hasExistingHrv = latestHrvRawEndTime != null;
hasExistingSleep = latestSleepResultTime != null;
... ... @@ -301,13 +322,30 @@ class OHOSHealthRawDataCoreService {
requestedStartTime,
earliestStartTime,
);
final hrvRawAnchorStart = _rawBackfillStartTime(latestRawHrvDataTime);
final realtimeRawAnchorStart = _rawBackfillStartTime(
latestRawHrDataTime,
);
final sleepRawAnchorStart = _rawBackfillStartTime(
latestRawSleepDataTime,
);
final hrvStartTime = math.max(
_minNullable(hrvContextStart, calculationStartTime) ??
_minNullableValues([
hrvContextStart,
hrvRawAnchorStart,
syncedRawStartTime,
requestedStartTime,
]) ??
calculationStartTime,
earliestStartTime,
);
final realtimeStartTime = math.max(
_minNullable(realtimeContextStart, calculationStartTime) ??
_minNullableValues([
realtimeContextStart,
realtimeRawAnchorStart,
syncedRawStartTime,
requestedStartTime,
]) ??
calculationStartTime,
earliestStartTime,
);
... ... @@ -319,7 +357,21 @@ class OHOSHealthRawDataCoreService {
? calculationStartTime
: latestSleepResultTime - Duration.secondsPerDay;
final sleepStartTime = math.max(
_minNullable(sleepAnchorTime, syncedRawStartTime) ?? sleepAnchorTime,
_minNullableValues([
sleepAnchorTime,
sleepRawAnchorStart,
syncedRawStartTime,
requestedStartTime,
]) ??
sleepAnchorTime,
earliestStartTime,
);
final hrvRecomputeStartTime = _boundedNullableStartTime(
_minNullable(syncedRawStartTime, hrvRawAnchorStart),
earliestStartTime,
);
final realtimeRecomputeStartTime = _boundedNullableStartTime(
_minNullable(syncedRawStartTime, realtimeRawAnchorStart),
earliestStartTime,
);
_logInfo(
... ... @@ -331,8 +383,18 @@ class OHOSHealthRawDataCoreService {
'latestHrvRawEndTime=$latestHrvRawEndTime '
'latestRealtimeRawEndTime=$latestRealtimeRawEndTime '
'latestSleepResultTime=$latestSleepResultTime '
'hrvStartTime=$hrvStartTime heartRateStartTime=$heartRateStartTime '
'sleepStartTime=$sleepStartTime',
'latestRawHrvDataTime=$latestRawHrvDataTime '
'latestRawHrDataTime=$latestRawHrDataTime '
'latestRawSleepDataTime=$latestRawSleepDataTime '
'hrvRawAnchorStart=$hrvRawAnchorStart '
'realtimeRawAnchorStart=$realtimeRawAnchorStart '
'sleepRawAnchorStart=$sleepRawAnchorStart '
'hrvStartTime=$hrvStartTime '
'realtimeStartTime=$realtimeStartTime '
'heartRateStartTime=$heartRateStartTime '
'sleepStartTime=$sleepStartTime '
'hrvRecomputeStartTime=$hrvRecomputeStartTime '
'realtimeRecomputeStartTime=$realtimeRecomputeStartTime',
);
final hrvPoints = await _fetchRawDataInChunks(
... ... @@ -402,12 +464,12 @@ class OHOSHealthRawDataCoreService {
hrvStressPoints: _filterNewHrvStressPoints(
result.hrvStressPoints,
latestHrvRawEndTime,
recomputeStartTime: syncedRawStartTime,
recomputeStartTime: hrvRecomputeStartTime,
),
realtimeStressPoints: _filterNewRealtimeStressPoints(
result.realtimeStressPoints,
latestRealtimeRawEndTime,
recomputeStartTime: syncedRawStartTime,
recomputeStartTime: realtimeRecomputeStartTime,
),
);
_logInfo(
... ... @@ -947,6 +1009,30 @@ class OHOSHealthRawDataCoreService {
}
}
Future<int?> _latestOhosRawDataTime(int dataType) async {
final rawDataSource = _rawDataSource;
if (rawDataSource is! OhosHealthRawDataSource) {
return null;
}
return rawDataSource.latestDataTime(dataType: dataType);
}
int? _rawBackfillStartTime(int? latestDataTime) {
if (latestDataTime == null) return null;
final backfillTime = math.max(
0,
latestDataTime -
OhosHealthRawDataSyncService.incrementalBackfillDays *
Duration.secondsPerDay,
);
return _localDay(backfillTime).millisecondsSinceEpoch ~/ 1000;
}
int? _boundedNullableStartTime(int? startTime, int earliestStartTime) {
if (startTime == null) return null;
return math.max(startTime, earliestStartTime);
}
Future<bool> _hasHealthReadAuthorizationSafely() async {
try {
return await _hasHealthReadAuthorization();
... ... @@ -1258,6 +1344,30 @@ class OHOSHealthRawDataCoreService {
return math.min(a, b);
}
static int? _minNullableValues(Iterable<int?> values) {
int? minValue;
for (final value in values) {
if (value == null) continue;
minValue = minValue == null ? value : math.min(minValue, value);
}
return minValue;
}
void _showDebugTimingToast({
required Duration syncElapsed,
required Duration calculationElapsed,
}) {
if (!_isDebug) return;
_toastSink?.call(
'【测试】本轮同步耗时${_elapsedSecondsText(syncElapsed)} s、'
'计算耗时${_elapsedSecondsText(calculationElapsed)} s',
);
}
String _elapsedSecondsText(Duration elapsed) {
return (elapsed.inMilliseconds / 1000).toStringAsFixed(1);
}
static int _dateKeyFromUnixSeconds(int seconds) {
final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return _dateKeyFromDateTime(date);
... ...
... ... @@ -50,22 +50,24 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore {
if (dataType == OhosHealthRawDataType.sleepAnalysis) {
final rows = await db.query(
sleepDataTable,
columns: ['from_time'],
orderBy: 'from_time DESC',
columns: ['to_time'],
where: 'to_time > 0',
orderBy: 'to_time DESC',
limit: 1,
);
return rows.isEmpty ? null : rows.first['from_time'] as int;
return rows.isEmpty ? null : rows.first['to_time'] as int;
}
if (dataType == OhosHealthRawDataType.workout) {
final table = _rawDataTable(dataType);
await _createRawIntervalDataTypeTable(db, table);
final rows = await db.query(
table,
columns: ['from_time'],
orderBy: 'from_time DESC',
columns: ['to_time'],
where: 'to_time > 0',
orderBy: 'to_time DESC',
limit: 1,
);
return rows.isEmpty ? null : rows.first['from_time'] as int;
return rows.isEmpty ? null : rows.first['to_time'] as int;
}
final storedDataType = _storedHealthDataType(dataType);
... ... @@ -74,6 +76,7 @@ class OhosHealthRawDataSqliteStore implements OhosHealthRawDataLocalStore {
final rows = await db.query(
table,
columns: ['time'],
where: 'time > 0 AND value IS NOT NULL',
orderBy: 'time DESC',
limit: 1,
);
... ...
... ... @@ -99,6 +99,10 @@ class OhosHealthRawDataSyncService {
}
}
Future<int?> latestDataTime({required int dataType}) {
return _localStore.latestDataTime(dataType: dataType);
}
Future<List<OhosHealthRawDataSyncResult>> syncCalculationRawData({
int? startTime,
int? endTime,
... ...
... ... @@ -221,18 +221,18 @@ packages:
dependency: "direct main"
description:
name: dio
sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c"
sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1"
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.11.0"
version: "5.11.1"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c"
sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.1"
version: "2.2.2"
equatable:
dependency: transitive
description:
... ...
... ... @@ -3,6 +3,7 @@ import 'package:doublefeel_flutter/core/services/raw_data_service/health_raw_mod
import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ios/apple_health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/core/services/raw_data_service/platform_ohos/ohos_health_raw_data_core_service.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/enums/app_enums.dart';
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'package:flutter_test/flutter_test.dart';
... ... @@ -248,6 +249,116 @@ void main() {
store.dailyStressPoints.map((point) => point.date), contains(20260830));
});
test('OHOS core backfills missing daily stress from local raw data anchor',
() async {
final backfillDay = DateTime(2026, 8, 30);
final backfillBase =
backfillDay.add(const Duration(hours: 10)).millisecondsSinceEpoch ~/
1000;
final latestBase = DateTime(2026, 9, 3, 10).millisecondsSinceEpoch ~/ 1000;
final rawLocalStore = _FakeOhosHealthRawDataLocalStore(
latestDataTimeByType: {
HealthDataUploadType.hrv.type: latestBase,
HealthDataUploadType.heartRate.type: latestBase,
},
itemsByDataType: {
HealthDataUploadType.hrv.type: [
_rawItem(HealthDataUploadType.hrv.type, backfillBase, 45),
_rawItem(HealthDataUploadType.hrv.type, latestBase, 50),
],
HealthDataUploadType.heartRate.type: [
for (var index = 0; index < 10; index++)
_rawItem(
HealthDataUploadType.heartRate.type,
backfillBase + index * 6 * 60,
100,
),
_rawItem(HealthDataUploadType.heartRate.type, latestBase, 80),
],
},
);
final remote = _HistoricalBackfillRemoteDataSource(
hrvItems: const <OhosHealthRawDataItem>[],
heartRateItems: const <OhosHealthRawDataItem>[],
);
final store = _FakeHealthRawStressLocalStore()
..hrvStressPoints.add(_existingHrvResult(latestBase))
..realtimeStressPoints.add(_existingRealtimeResult(latestBase));
final service = OHOSHealthRawDataCoreService(
rawDataSource: OhosHealthRawDataSource(
syncService: OhosHealthRawDataSyncService(
remoteDataSource: remote,
localStore: rawLocalStore,
),
),
localStore: store,
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
healthReadAuthorizationChecker: () async => true,
);
final result = await service.startCoreCaculate(
endTime: latestBase + Duration.secondsPerHour,
readChunkDays: 1,
);
expect(
remote.calls
.where((call) =>
call.dataType == HealthDataUploadType.hrv.type ||
call.dataType == HealthDataUploadType.heartRate.type)
.map((call) => _dateKey(call.startTime)),
everyElement(lessThanOrEqualTo(20260827)),
);
expect(
store.hrvStressPoints.any((point) => point.rawEndTime == backfillBase),
isTrue,
);
expect(
store.realtimeStressPoints.any(
(point) => _dateKey(point.rawEndTime) == 20260830,
),
isTrue,
);
expect(result.dailyStressPoints.map((point) => point.date),
contains(20260830));
});
test('OHOS core shows debug timing toast after sync and calculation',
() async {
final base = DateTime.now()
.subtract(const Duration(days: 2))
.millisecondsSinceEpoch ~/
1000;
final toasts = <String>[];
final service = OHOSHealthRawDataCoreService(
rawDataSource: _FakeHealthRawDataSource(
pointsByDataType: {
HealthDataUploadType.hrv.type: [_point(1, base, 60)],
HealthDataUploadType.heartRate.type: [_point(2, base, 80)],
},
),
localStore: _FakeHealthRawStressLocalStore(),
userIdProvider: () => 42,
uploadResultsAfterCalculation: false,
healthReadAuthorizationChecker: () async => false,
debugModeProvider: () => true,
toastSink: toasts.add,
);
await service.syncAndStore(
startTime: base - Duration.secondsPerHour,
endTime: base + Duration.secondsPerHour,
readChunkDays: 1,
);
expect(toasts, hasLength(1));
expect(
toasts.single,
matches(RegExp(r'^【测试】本轮同步耗时0\.0 s、计算耗时\d+\.\d s$')),
);
});
test('OHOS core aborts calculation and shows debug toast when sync fails',
() async {
final store = _FakeHealthRawStressLocalStore();
... ... @@ -352,15 +463,23 @@ class _RemoteRawCall {
}
class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
_FakeOhosHealthRawDataLocalStore({int? latestDataTime})
: _latestDataTime = latestDataTime;
_FakeOhosHealthRawDataLocalStore({
int? latestDataTime,
Map<int, int>? latestDataTimeByType,
Map<int, List<OhosHealthRawDataItem>>? itemsByDataType,
}) : _latestDataTime = latestDataTime,
_latestDataTimeByType = latestDataTimeByType ?? const <int, int>{},
_itemsByDataType =
itemsByDataType ?? const <int, List<OhosHealthRawDataItem>>{};
final int? _latestDataTime;
final Map<int, int> _latestDataTimeByType;
final Map<int, List<OhosHealthRawDataItem>> _itemsByDataType;
final storedBatches = <List<OhosHealthRawDataItem>>[];
@override
Future<int?> latestDataTime({required int dataType}) async {
return _latestDataTime;
return _latestDataTimeByType[dataType] ?? _latestDataTime;
}
@override
... ... @@ -369,8 +488,10 @@ class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
required int startTime,
required int endTime,
}) async {
return storedBatches
.expand((batch) => batch)
return [
...(_itemsByDataType[dataType] ?? const <OhosHealthRawDataItem>[]),
...storedBatches.expand((batch) => batch),
]
.where(
(item) =>
item.dataType == dataType &&
... ...
... ... @@ -77,6 +77,45 @@ void main() {
expect(result.earliestStoredTime, 1788019232);
});
test('syncRawData uses data type specific latest anchors', () async {
final latestHrv = _unixSeconds(DateTime(2026, 9, 3, 10));
final latestSleep = _unixSeconds(DateTime(2026, 9, 2, 23));
final latestWorkout = _unixSeconds(DateTime(2026, 9, 1, 8));
final remote = _FakeOhosHealthRawDataRemoteDataSource([
const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]),
const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]),
const OhosHealthRawDataPage(items: <OhosHealthRawDataItem>[]),
]);
final local = _FakeOhosHealthRawDataLocalStore(
latestDataTimeByType: {
HuaweiHealthDataType.hrv.dataType: latestHrv,
OhosHealthRawDataType.sleepAnalysis: latestSleep,
OhosHealthRawDataType.workout: latestWorkout,
},
);
final service = OhosHealthRawDataSyncService(
remoteDataSource: remote,
localStore: local,
);
await service.syncRawData(
dataType: HuaweiHealthDataType.hrv.dataType,
endTime: _unixSeconds(DateTime(2026, 9, 4)),
);
await service.syncRawData(
dataType: OhosHealthRawDataType.sleepAnalysis,
endTime: _unixSeconds(DateTime(2026, 9, 4)),
);
await service.syncRawData(
dataType: OhosHealthRawDataType.workout,
endTime: _unixSeconds(DateTime(2026, 9, 4)),
);
expect(_dateKey(remote.calls[0].startTime), 20260827);
expect(_dateKey(remote.calls[1].startTime), 20260826);
expect(_dateKey(remote.calls[2].startTime), 20260825);
});
test('syncRawData falls back to half-year lookback when local data is empty',
() async {
final now = DateTime(2026, 8, 13, 12);
... ... @@ -668,17 +707,21 @@ class _FakeOhosHealthRawDataRemoteDataSource
}
class _FakeOhosHealthRawDataLocalStore implements OhosHealthRawDataLocalStore {
_FakeOhosHealthRawDataLocalStore({int? latestDataTime})
: _latestDataTime = latestDataTime;
_FakeOhosHealthRawDataLocalStore({
int? latestDataTime,
Map<int, int>? latestDataTimeByType,
}) : _latestDataTime = latestDataTime,
_latestDataTimeByType = latestDataTimeByType ?? const <int, int>{};
final int? _latestDataTime;
final Map<int, int> _latestDataTimeByType;
final List<List<OhosHealthRawDataItem>> storedBatches =
<List<OhosHealthRawDataItem>>[];
V2ActivityTarget? activityGoal;
@override
Future<int?> latestDataTime({required int dataType}) async {
return _latestDataTime;
return _latestDataTimeByType[dataType] ?? _latestDataTime;
}
@override
... ...