Commit 60476ea8d1a8026a843837cf2c41b50c257982aa

Authored by 权海
1 parent a7f33c69

feat(ui):增加综合压力表

... ... @@ -139,7 +139,7 @@ final class HealthKitQueryReader {
return try await fetchRawQuantitySamples(
identifier: .appleStandTime,
dataType: .stand,
unit: .second(),
unit: .hour(),
startDate: startDate,
endDate: endDate
)
... ... @@ -216,8 +216,8 @@ final class HealthKitQueryReader {
appleMoveTimeGoal: Self.appleMoveTimeGoalValue(from: summary),
appleExerciseTime: summary.appleExerciseTime.doubleValue(for: .second()),
exerciseTimeGoal: Self.exerciseTimeGoalValue(from: summary),
appleStandHours: Self.standSeconds(from: summary.appleStandHours),
standHoursGoal: Self.standHoursGoalValue(from: summary)
appleStandHours: summary.appleStandHours.doubleValue(for: .count()),
standHoursGoal: summary.standHoursGoal?.doubleValue(for: .count()),
)
}
... ... @@ -249,17 +249,6 @@ final class HealthKitQueryReader {
return nil
}
private static func standHoursGoalValue(from summary: HKActivitySummary) -> Double? {
if #available(iOS 16.0, *) {
return summary.standHoursGoal.map { Self.standSeconds(from: $0) }
}
return nil
}
private static func standSeconds(from quantity: HKQuantity) -> Double {
quantity.doubleValue(for: .count()) * 60 * 60
}
private func fetchHeartRateFamily(startDate: Date, endDate: Date) async throws -> [NativeHealthDataPoint] {
async let heartRate = fetchQuantitySamples(
identifier: .heartRate,
... ...
... ... @@ -139,7 +139,7 @@ struct NativeActivityTarget {
// 公里
let appleMoveTime: Double?
let appleMoveTimeGoal: Double?
// 分钟
//
let appleExerciseTime: Double?
let exerciseTimeGoal: Double?
//小时
... ...
... ... @@ -262,7 +262,7 @@ class TodayController extends GetMaterialController {
if (success) {
// await LoadingService.instance.run(() async {
_rawDataApi.performHealthDataUpload();
_startCoreCaculate();
_startCoreCaculate(refreshAfterComplete: true);
// });
// result.status = 1;
// AppToast.show('刷新完成');
... ... @@ -278,7 +278,7 @@ class TodayController extends GetMaterialController {
} else if (result.status == 1) {
// await LoadingService.instance.run(() async {
_rawDataApi.performHealthDataUpload();
_startCoreCaculate();
_startCoreCaculate(refreshAfterComplete: true);
// });
if (isFromNoPermissionPage) {
Get.back();
... ... @@ -376,11 +376,14 @@ class TodayController extends GetMaterialController {
);
}
void _startCoreCaculate() {
void _startCoreCaculate({bool refreshAfterComplete = false}) {
unawaited(
(() async {
try {
await _healthRawDataCoreService.startCoreCaculate();
if (refreshAfterComplete) {
await loadDataForDate(selectedDate.value);
}
} catch (error, stackTrace) {
AppLogger.e('Apple Health calculate failed', error, stackTrace);
}
... ... @@ -450,13 +453,11 @@ class TodayController extends GetMaterialController {
case AppFailure():
stressChartData.clear();
}
if (v2ActivityTarget.value == null) {
switch (await _healthApi.getV2ActivityTarget(friendUserId)) {
case AppSuccess(:final data):
v2ActivityTarget.value = data;
case AppFailure():
v2ActivityTarget.value = null;
}
switch (await _healthApi.getV2ActivityTarget(friendUserId)) {
case AppSuccess(:final data):
v2ActivityTarget.value = data;
case AppFailure():
v2ActivityTarget.value = null;
}
}
... ...
... ... @@ -225,7 +225,7 @@ class TodayActivityCard extends StatelessWidget {
);
}
// stand 单位为秒,转换为小时+分钟
// stand 单位为小时
Widget standWidget;
if (stand > 0 && (activityTarget?.stand ?? 0) > 0) {
standWidget = Text.rich(
... ...
... ... @@ -180,11 +180,14 @@ class HealthRawDataCoreService {
userId: userId,
hrvStressPoints: const <HealthRawHrvStressPoint>[],
realtimeStressPoints: const <HealthRawRealtimeStressPoint>[],
dailyStressPoints: const <HealthRawDailyStressPoint>[],
);
}
final totalWatch = Stopwatch()..start();
final calculateStartedAt = DateTime.now();
_logInfo(
'calculateStartAt=${_formatDateTimeMilliseconds(calculateStartedAt)}',
);
await _localStore.ensureReadable(userId);
final readWatch = Stopwatch()..start();
final effectiveEndTime =
endTime ?? DateTime.now().millisecondsSinceEpoch ~/ 1000;
final earliestStartTime = DateTime.now()
... ... @@ -215,8 +218,6 @@ class HealthRawDataCoreService {
earliestStartTime,
);
final restingHeartRateStartTime = heartRateStartTime;
final readStartTime =
math.min(math.min(hrvStartTime, heartRateStartTime), effectiveEndTime);
final hrvPoints = await _fetchRawDataInChunks(
HealthDataUploadType.hrv.type,
... ... @@ -246,11 +247,7 @@ class HealthRawDataCoreService {
effectiveEndTime,
readChunkDays: readChunkDays,
);
readWatch.stop();
final calculateAndStoreWatch = Stopwatch()..start();
final calculateStartedAt = DateTime.now();
final calculateWatch = Stopwatch()..start();
final result = calculate(
hrvPoints: hrvPoints,
heartRatePoints: heartRatePoints,
... ... @@ -260,38 +257,21 @@ class HealthRawDataCoreService {
startTime: math.min(hrvStartTime, heartRateStartTime),
endTime: effectiveEndTime,
);
calculateWatch.stop();
final calculateFinishedAt = DateTime.now();
await _localStore.upsertResult(result);
calculateAndStoreWatch.stop();
totalWatch.stop();
final dailyStressPoints = await _calculateAndStoreDailyStressPoints(
userId: userId,
realtimePoints: result.realtimeStressPoints,
nowSeconds: effectiveEndTime,
);
final storedResult = result.copyWith(dailyStressPoints: dailyStressPoints);
if (isFirstCalculation && (_environmentConfig?.isDebug ?? false)) {
AppToast.show(
'首次计算完成, 总耗时${_formatDurationMinutesSeconds(totalWatch.elapsed)}',
);
AppToast.show('首次计算完成');
}
final calculateFinishedAt = DateTime.now();
_logInfo(
'timing userId=$userId '
'rangeStart=$readStartTime rangeStartAt=${_formatUnixSeconds(readStartTime)} '
'rangeEnd=$effectiveEndTime rangeEndAt=${_formatUnixSeconds(effectiveEndTime)} '
'cursorHrvSourceStart=$hrvContextStart '
'cursorRealtimeSourceStart=$realtimeContextStart '
'hrvStart=$hrvStartTime realtimeStart=$realtimeStartTime '
'hrStart=$heartRateStartTime restingHrStart=$restingHeartRateStartTime '
'calculateStartAt=${calculateStartedAt.toIso8601String()} '
'calculateEndAt=${calculateFinishedAt.toIso8601String()} '
'raw(hrv=${hrvPoints.length}, hr=${heartRatePoints.length}, '
'restingHr=${restingHeartRatePoints.length}, '
'sleep=${sleepIntervals.length}, workout=${workoutIntervals.length}) '
'result(hrv=${result.hrvStressPoints.length}, '
'realtime=${result.realtimeStressPoints.length}) '
'readMs=${readWatch.elapsedMilliseconds} '
'calculateMs=${calculateWatch.elapsedMilliseconds} '
'calculateAndStoreMs=${calculateAndStoreWatch.elapsedMilliseconds} '
'totalMs=${totalWatch.elapsedMilliseconds}',
'calculateEndAt=${_formatDateTimeMilliseconds(calculateFinishedAt)}',
);
return result;
return storedResult;
}
Future<bool> _hasHealthReadAuthorization() async {
... ... @@ -392,6 +372,17 @@ class HealthRawDataCoreService {
);
}
Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({
required int startDate,
required int endDate,
}) {
return _localStore.queryDailyStressPoints(
userId: _userId,
startDate: startDate,
endDate: endDate,
);
}
Future<List<HealthKitRawDataPoint>> queryRawDataPoints({
required int dataType,
required int startTime,
... ... @@ -481,6 +472,57 @@ class HealthRawDataCoreService {
);
}
Future<List<HealthRawDailyStressPoint>> _calculateAndStoreDailyStressPoints({
required int userId,
required List<HealthRawRealtimeStressPoint> realtimePoints,
required int nowSeconds,
}) async {
final todayDate = _dateKeyFromUnixSeconds(nowSeconds);
final affectedDates = <int>{
todayDate,
for (final point in realtimePoints)
_dateKeyFromUnixSeconds(point.rawEndTime),
}.toList()
..sort();
final existingDates = await _localStore.existingDailyStressDates(
userId: userId,
dates: affectedDates,
);
final dailyStressPoints = <HealthRawDailyStressPoint>[];
final emptyDates = <int>[];
for (final date in affectedDates) {
if (date != todayDate && existingDates.contains(date)) {
continue;
}
final (startTime, endTime) = _dayRangeFromDateKey(date);
final dayRealtimePoints = await _localStore.queryRealtimeStressPoints(
userId: userId,
startTime: startTime,
endTime: endTime,
);
final point = HealthRawDailyStressCalculator.calculate(
userId: userId,
date: date,
realtimePoints: dayRealtimePoints,
dataTime: date == todayDate ? nowSeconds : startTime,
);
if (point == null) {
emptyDates.add(date);
continue;
}
dailyStressPoints.add(point);
}
await _localStore.upsertDailyStressPoints(
userId: userId,
points: dailyStressPoints,
);
await _localStore.deleteDailyStressDates(
userId: userId,
dates: emptyDates,
);
return dailyStressPoints;
}
Future<List<HealthKitRawDataPoint>> _fetchRawDataInChunks(
int dataType,
int startTime,
... ... @@ -540,26 +582,37 @@ class HealthRawDataCoreService {
return math.min(a, b);
}
static String _formatUnixSeconds(int seconds) {
return DateTime.fromMillisecondsSinceEpoch(seconds * 1000)
.toIso8601String();
static int _dateKeyFromUnixSeconds(int seconds) {
final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000);
return date.year * 10000 + date.month * 100 + date.day;
}
static String _formatDurationMinutesSeconds(Duration duration) {
final minutes = duration.inMinutes;
final seconds = duration.inSeconds.remainder(60);
return '$minutes分钟$seconds秒';
static (int startTime, int endTime) _dayRangeFromDateKey(int dateKey) {
final year = dateKey ~/ 10000;
final month = (dateKey ~/ 100) % 100;
final day = dateKey % 100;
final start = DateTime(year, month, day);
return (
start.millisecondsSinceEpoch ~/ 1000,
start.add(const Duration(days: 1)).millisecondsSinceEpoch ~/ 1000 - 1,
);
}
static String _formatDateTimeMilliseconds(DateTime dateTime) {
String two(int value) => value.toString().padLeft(2, '0');
String three(int value) => value.toString().padLeft(3, '0');
return '${dateTime.year.toString().padLeft(4, '0')}-'
'${two(dateTime.month)}-'
'${two(dateTime.day)}T'
'${two(dateTime.hour)}:'
'${two(dateTime.minute)}:'
'${two(dateTime.second)}.'
'${three(dateTime.millisecond)}';
}
static void _logInfo(String message) {
final tagged = 'HealthRawDataCoreService $message';
developer.log(tagged, name: 'HealthRawDataCoreService');
debugPrint(tagged);
try {
AppLogger.i(tagged);
} catch (_) {
// Logger may be unavailable in isolated unit tests.
}
}
static void _logError(
... ... @@ -588,11 +641,26 @@ class HealthRawStressCalculationResult {
required this.userId,
required this.hrvStressPoints,
required this.realtimeStressPoints,
required this.dailyStressPoints,
});
final int userId;
final List<HealthRawHrvStressPoint> hrvStressPoints;
final List<HealthRawRealtimeStressPoint> realtimeStressPoints;
final List<HealthRawDailyStressPoint> dailyStressPoints;
HealthRawStressCalculationResult copyWith({
List<HealthRawHrvStressPoint>? hrvStressPoints,
List<HealthRawRealtimeStressPoint>? realtimeStressPoints,
List<HealthRawDailyStressPoint>? dailyStressPoints,
}) {
return HealthRawStressCalculationResult(
userId: userId,
hrvStressPoints: hrvStressPoints ?? this.hrvStressPoints,
realtimeStressPoints: realtimeStressPoints ?? this.realtimeStressPoints,
dailyStressPoints: dailyStressPoints ?? this.dailyStressPoints,
);
}
}
class HealthRawDataUpdatedEvent {
... ... @@ -799,6 +867,140 @@ class HealthRawRealtimeStressPoint {
}
}
class HealthRawDailyStressPoint {
const HealthRawDailyStressPoint({
required this.userId,
required this.date,
required this.stressValue,
required this.stressScore,
required this.state,
required this.dataTime,
this.uploaded = false,
});
final int userId;
final int date;
final double stressValue;
final int stressScore;
final HealthRawStressState state;
final int dataTime;
final bool uploaded;
factory HealthRawDailyStressPoint.fromDb(Map<String, Object?> row) {
final stressScore = row['stress_score'] as int;
return HealthRawDailyStressPoint(
userId: row['user_id'] as int,
date: row['date'] as int,
stressValue: (row['stress_value'] as num).toDouble(),
stressScore: stressScore,
state: _stressStateFromDb(row['state']) ??
healthRawRealtimeStressState(stressScore),
dataTime: row['data_time'] as int,
uploaded: (row['uploaded'] as int) == 1,
);
}
}
class HealthRawDailyStressCalculator {
const HealthRawDailyStressCalculator._();
static const int _confirmedActivityBufferSeconds = 12 * 60;
static HealthRawDailyStressPoint? calculate({
required int userId,
required int date,
required List<HealthRawRealtimeStressPoint> realtimePoints,
required int dataTime,
}) {
final validValuePoints =
realtimePoints.where((e) => _isValidStressValue(e.result)).toList();
final activityBuffers = _confirmedActivityBuffers(realtimePoints);
final values = validValuePoints
.where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))
.map((e) => e.result)
.toList()
..sort();
if (values.isEmpty) return null;
final stressValue = _average(values);
final stressScore = _dailyStressScore(values).round().clamp(1, 100).toInt();
return HealthRawDailyStressPoint(
userId: userId,
date: date,
stressValue: stressValue,
stressScore: stressScore,
state: healthRawRealtimeStressState(stressScore),
dataTime: dataTime,
);
}
static double _dailyStressScore(List<double> sortedValues) {
final median = _median(sortedValues);
final p75 = _percentile75(sortedValues);
return _round(_clamp(median * 0.60 + p75 * 0.40, 1, 100));
}
static List<({int start, int end})> _confirmedActivityBuffers(
List<HealthRawRealtimeStressPoint> realtimePoints,
) {
final ranges = realtimePoints
.where((e) =>
_isValidStressValue(e.result) &&
(e.isWorkout || e.isWorkoutRecovery))
.map((e) => (
start: e.rawEndTime - _confirmedActivityBufferSeconds,
end: e.rawEndTime + _confirmedActivityBufferSeconds,
))
.toList()
..sort((a, b) => a.start.compareTo(b.start));
if (ranges.isEmpty) return const <({int start, int end})>[];
final merged = <({int start, int end})>[];
var current = ranges.first;
for (final range in ranges.skip(1)) {
if (range.start <= current.end) {
current = (
start: current.start,
end: math.max(current.end, range.end),
);
} else {
merged.add(current);
current = range;
}
}
merged.add(current);
return merged;
}
static bool _isInTimeRanges(int time, List<({int start, int end})> ranges) {
return ranges.any((e) => time >= e.start && time <= e.end);
}
static bool _isValidStressValue(num value) => value >= 1 && value <= 100;
static double _average(List<double> values) =>
values.reduce((a, b) => a + b) / values.length;
static double _median(List<double> sortedValues) {
final length = sortedValues.length;
final middle = length ~/ 2;
if (length.isOdd) return sortedValues[middle];
return (sortedValues[middle - 1] + sortedValues[middle]) / 2;
}
static double _percentile75(List<double> sortedValues) {
final rank = (sortedValues.length * 0.75).ceil().clamp(
1,
sortedValues.length,
);
return sortedValues[rank - 1];
}
static double _clamp(num value, num min, num max) =>
math.max(min.toDouble(), math.min(max.toDouble(), value.toDouble()));
static double _round(double value) => (value * 10).round() / 10;
}
HealthRawStressState healthRawRealtimeStressState(num value) {
if (value >= 81) return HealthRawStressState.overload;
if (value >= 61) return HealthRawStressState.attention;
... ... @@ -834,10 +1036,12 @@ class HealthRawStressDbRows {
const HealthRawStressDbRows({
required this.hrvRows,
required this.realtimeRows,
required this.dailyStressRows,
});
final List<Map<String, Object?>> hrvRows;
final List<Map<String, Object?>> realtimeRows;
final List<Map<String, Object?>> dailyStressRows;
}
class HealthRawStressLocalStore {
... ... @@ -849,6 +1053,7 @@ class HealthRawStressLocalStore {
static const hrvResultsTable = 'hrv_results';
static const realtimeStressResultsTable = 'realtime_stress_results';
static const dailyStressResultsTable = 'daily_stress_results';
final Directory? _rootDirectory;
final DatabaseFactory? _databaseFactory;
... ... @@ -874,6 +1079,48 @@ class HealthRawStressLocalStore {
});
}
Future<void> upsertDailyStressPoints({
required int userId,
required Iterable<HealthRawDailyStressPoint> points,
}) async {
final db = await _database(userId);
await db.transaction((txn) async {
for (final point in points) {
await _upsertDailyStressResettingUploaded(txn, _dailyStressRow(point));
}
});
}
Future<Set<int>> existingDailyStressDates({
required int userId,
required Iterable<int> dates,
}) async {
final dateList = dates.toSet().toList();
if (dateList.isEmpty) return const <int>{};
final db = await _database(userId);
final rows = await db.query(
dailyStressResultsTable,
columns: ['date'],
where: 'date IN (${List.filled(dateList.length, '?').join(',')})',
whereArgs: dateList,
);
return rows.map((row) => row['date'] as int).toSet();
}
Future<void> deleteDailyStressDates({
required int userId,
required Iterable<int> dates,
}) async {
final dateList = dates.toSet().toList();
if (dateList.isEmpty) return;
final db = await _database(userId);
await db.delete(
dailyStressResultsTable,
where: 'date IN (${List.filled(dateList.length, '?').join(',')})',
whereArgs: dateList,
);
}
Future<void> ensureReadable(int userId) async {
final db = await _database(userId);
await db.query(
... ... @@ -929,6 +1176,21 @@ class HealthRawStressLocalStore {
return rows.map(HealthRawRealtimeStressPoint.fromDb).toList();
}
Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({
required int userId,
required int startDate,
required int endDate,
}) async {
final db = await _database(userId);
final rows = await db.query(
dailyStressResultsTable,
where: 'date >= ? AND date <= ?',
whereArgs: [startDate, endDate],
orderBy: 'date ASC',
);
return rows.map(HealthRawDailyStressPoint.fromDb).toList();
}
Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async {
final db = await _database(userId);
final hrvRows = await db.query(
... ... @@ -939,9 +1201,14 @@ class HealthRawStressLocalStore {
realtimeStressResultsTable,
orderBy: 'raw_end_time ASC',
);
final dailyStressRows = await db.query(
dailyStressResultsTable,
orderBy: 'date ASC',
);
return HealthRawStressDbRows(
hrvRows: hrvRows,
realtimeRows: realtimeRows,
dailyStressRows: dailyStressRows,
);
}
... ... @@ -1015,7 +1282,7 @@ class HealthRawStressLocalStore {
final db = await factory.openDatabase(
path,
options: OpenDatabaseOptions(
version: 4,
version: 5,
onCreate: (db, version) async {
await _createTables(db);
},
... ... @@ -1030,6 +1297,9 @@ class HealthRawStressLocalStore {
if (oldVersion < 4) {
await _addHrvRawAndStateColumns(db);
}
if (oldVersion < 5) {
await _createDailyStressTable(db);
}
},
),
);
... ... @@ -1072,6 +1342,21 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
uploaded INTEGER NOT NULL DEFAULT 0
)
''');
await _createDailyStressTable(db);
}
Future<void> _createDailyStressTable(DatabaseExecutor db) async {
await db.execute('''
CREATE TABLE IF NOT EXISTS $dailyStressResultsTable (
date INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
stress_value REAL NOT NULL,
stress_score INTEGER NOT NULL,
state INTEGER NOT NULL,
data_time INTEGER NOT NULL,
uploaded INTEGER NOT NULL DEFAULT 0
)
''');
}
Future<void> _addFlagColumns(DatabaseExecutor db, String table) async {
... ... @@ -1181,6 +1466,18 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
};
}
Map<String, Object?> _dailyStressRow(HealthRawDailyStressPoint point) {
return <String, Object?>{
'date': point.date,
'user_id': point.userId,
'stress_value': point.stressValue,
'stress_score': point.stressScore,
'state': point.state.value,
'data_time': point.dataTime,
'uploaded': point.uploaded ? 1 : 0,
};
}
Map<String, Object?> _flagsRow(HealthRawPointFlags flags) {
return <String, Object?>{
'is_workout': flags.isWorkout ? 1 : 0,
... ... @@ -1227,4 +1524,28 @@ CREATE TABLE IF NOT EXISTS $realtimeStressResultsTable (
whereArgs: [row['raw_end_time']],
);
}
Future<void> _upsertDailyStressResettingUploaded(
DatabaseExecutor db,
Map<String, Object?> row,
) async {
await db.insert(
dailyStressResultsTable,
row,
conflictAlgorithm: ConflictAlgorithm.ignore,
);
await db.update(
dailyStressResultsTable,
<String, Object?>{
'user_id': row['user_id'],
'stress_value': row['stress_value'],
'stress_score': row['stress_score'],
'state': row['state'],
'data_time': row['data_time'],
'uploaded': 0,
},
where: 'date = ?',
whereArgs: [row['date']],
);
}
}
... ...
... ... @@ -59,6 +59,7 @@ class HealthRawStressCalculator {
userId: userId,
hrvStressPoints: hrvStressPoints,
realtimeStressPoints: realtimeStressPoints,
dailyStressPoints: const <HealthRawDailyStressPoint>[],
);
}
... ...
... ... @@ -16,7 +16,6 @@ const _sleepTypeAsleepDeep = 4;
const _sleepTypeAsleepRem = 5;
const _sleepGoalMinutes = 8 * 60.0;
const _sleepContinuityToleranceSeconds = 1;
const _stressConfirmedActivityBufferSeconds = 12 * 60;
class LocalHealthDataConvert {
const LocalHealthDataConvert._();
... ... @@ -230,7 +229,7 @@ class LocalHealthDataConvert {
final data = ActivityBurnStatisticsDataV2(
totalMove: _sum(daily.map((e) => e.move).toList()),
totalSteps: _sum(daily.map((e) => e.steps).toList()),
totalStand: _sum(daily.map((e) => e.standSeconds).toList()),
totalStand: _sum(daily.map((e) => e.standHours).toList()),
totalExercise: _sum(daily.map((e) => e.exerciseSeconds).toList()),
avgMove: _averageOrNull(daily.map((e) => e.move).toList()),
qoqAvgMove: _averageOrNull(previousDaily.map((e) => e.move).toList()),
... ... @@ -248,7 +247,7 @@ class LocalHealthDataConvert {
timeKey: dateKey(item.day),
value: Value(
move: item.move,
stand: item.standSeconds,
stand: item.standHours,
exercise: item.exerciseSeconds,
),
),
... ... @@ -281,17 +280,17 @@ class LocalHealthDataConvert {
required List<DateTime> days,
required List<DateTime> previousDays,
required List<HealthRawHrvStressPoint> hrvPoints,
required List<HealthRawRealtimeStressPoint> realtimePoints,
required List<HealthRawDailyStressPoint> dailyStressPoints,
}) {
final daily = <_HrvDaySummary>[];
for (final day in days) {
final summary = _hrvDaySummary(day, hrvPoints, realtimePoints);
final summary = _hrvDaySummary(day, hrvPoints, dailyStressPoints);
if (summary.hasData) daily.add(summary);
}
final validDaily = daily.where((e) => e.hrvAverage != null).toList();
final previousDaily = <_HrvDaySummary>[];
for (final day in previousDays) {
final summary = _hrvDaySummary(day, hrvPoints, realtimePoints);
final summary = _hrvDaySummary(day, hrvPoints, dailyStressPoints);
if (summary.hasData) previousDaily.add(summary);
}
final trendList = dateRangeType == 2
... ... @@ -414,7 +413,7 @@ class LocalHealthDataConvert {
)?.round(),
move: activitySummary.move.round(),
exercise: activitySummary.exerciseSeconds.round(),
stand: activitySummary.standSeconds.round(),
stand: activitySummary.standHours.round(),
steps: activitySummary.steps.round(),
sleepDuration: sleepSummary.durationSeconds.round(),
sleepScore: sleepSummary.score?.toDouble(),
... ... @@ -471,83 +470,14 @@ class LocalHealthDataConvert {
);
}
static V2StressScore v2StressScore(
List<HealthRawRealtimeStressPoint> realtimePoints, {
required int startTime,
required int endTime,
}) {
final dayPoints = realtimePoints
.where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)
.toList();
final validValuePoints =
dayPoints.where((e) => _isValidStressValue(e.result)).toList();
final activityBuffers = _confirmedActivityBuffers(realtimePoints);
final validStressValues = validValuePoints
.where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))
.map((e) => e.result)
.toList()
..sort();
final dailyStress = _dailyStress(validStressValues);
final score = dailyStress?.round();
static V2StressScore v2StressScore(HealthRawDailyStressPoint? dailyStress) {
final score = dailyStress?.stressScore;
return V2StressScore(
state: score == null ? 0 : healthRawRealtimeStressState(score).value,
state: dailyStress?.state.value ?? 0,
comprehensiveScore: score,
);
}
static double? _dailyStress(List<double> sortedValues) {
if (sortedValues.isEmpty) return null;
final median = _median(sortedValues)!;
final p75 = _percentile75(sortedValues);
return _round(_clamp(median * 0.60 + p75 * 0.40, 1, 100), 1);
}
static List<({int start, int end})> _confirmedActivityBuffers(
List<HealthRawRealtimeStressPoint> realtimePoints,
) {
final ranges = realtimePoints
.where((e) =>
_isValidStressValue(e.result) &&
(e.isWorkout || e.isWorkoutRecovery))
.map((e) => (
start: e.rawEndTime - _stressConfirmedActivityBufferSeconds,
end: e.rawEndTime + _stressConfirmedActivityBufferSeconds,
))
.toList()
..sort((a, b) => a.start.compareTo(b.start));
if (ranges.isEmpty) return const <({int start, int end})>[];
final merged = <({int start, int end})>[];
var current = ranges.first;
for (final range in ranges.skip(1)) {
if (range.start <= current.end) {
current = (
start: current.start,
end: math.max(current.end, range.end),
);
} else {
merged.add(current);
current = range;
}
}
merged.add(current);
return merged;
}
static bool _isInTimeRanges(int time, List<({int start, int end})> ranges) {
return ranges.any((e) => time >= e.start && time <= e.end);
}
static bool _isValidStressValue(num value) => value >= 1 && value <= 100;
static double _percentile75(List<double> sortedValues) {
final rank = (sortedValues.length * 0.75).ceil().clamp(
1,
sortedValues.length,
);
return sortedValues[rank - 1];
}
static _SleepDaySummary _sleepDaySummary(
DateTime day,
List<HealthKitRawDataPoint> sleepIntervals,
... ... @@ -598,7 +528,7 @@ class LocalHealthDataConvert {
day: day,
move: latest?.activeEnergyBurned ?? 0,
steps: latest?.appleMoveTime ?? 0,
standSeconds: latest?.appleStandHours ?? 0,
standHours: latest?.appleStandHours ?? 0,
exerciseSeconds: latest?.appleExerciseTime ?? 0,
moveGoal: latest?.activeEnergyBurnedGoal,
stepGoal: latest?.appleMoveTimeGoal,
... ... @@ -648,30 +578,33 @@ class LocalHealthDataConvert {
static _HrvDaySummary _hrvDaySummary(
DateTime day,
List<HealthRawHrvStressPoint> hrv,
List<HealthRawRealtimeStressPoint> realtime,
List<HealthRawDailyStressPoint> dailyStress,
) {
final start = unixSeconds(day);
final end = unixSeconds(day.add(const Duration(days: 1)));
final dayHrv =
hrv.where((e) => e.rawEndTime >= start && e.rawEndTime < end).toList();
final dayRealtime = realtime
.where((e) => e.rawEndTime >= start && e.rawEndTime < end)
.toList();
final stress = _dailyStressPointForDay(day, dailyStress);
final hrvAverage = _averageOrNull(dayHrv.map((e) => e.result).toList());
final stressState = _dailyStressState(
realtime,
startTime: start,
endTime: end - 1,
);
return _HrvDaySummary(
day: day,
hrvAverage: hrvAverage?.toDouble(),
hrAverage:
_averageOrNull(dayRealtime.map((e) => e.result).toList())?.toDouble(),
state: stressState,
hrAverage: stress?.stressScore.toDouble(),
state: stress?.state.value,
);
}
static HealthRawDailyStressPoint? _dailyStressPointForDay(
DateTime day,
List<HealthRawDailyStressPoint> dailyStress,
) {
final key = dateKey(day);
for (final item in dailyStress) {
if (item.date == key) return item;
}
return null;
}
static List<HrvTrendList> _monthlyHrvTrend(List<_HrvDaySummary> daily) {
final byMonth = <int, List<_HrvDaySummary>>{};
for (final item in daily) {
... ... @@ -702,28 +635,6 @@ class LocalHealthDataConvert {
return result;
}
static int? _dailyStressState(
List<HealthRawRealtimeStressPoint> realtimePoints, {
required int startTime,
required int endTime,
}) {
final dayPoints = realtimePoints
.where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)
.toList();
final validValuePoints =
dayPoints.where((e) => _isValidStressValue(e.result)).toList();
final activityBuffers = _confirmedActivityBuffers(realtimePoints);
final validStressValues = validValuePoints
.where((e) => !_isInTimeRanges(e.rawEndTime, activityBuffers))
.map((e) => e.result)
.toList()
..sort();
final dailyStress = _dailyStress(validStressValues);
return dailyStress == null
? null
: healthRawRealtimeStressState(dailyStress).value;
}
static _HrvDaySummary? _extremeHrv(
List<_HrvDaySummary> daily, {
required bool min,
... ... @@ -943,16 +854,6 @@ class LocalHealthDataConvert {
return _sum(values) / values.length;
}
static double? _median(List<double> values) {
if (values.isEmpty) return null;
final sorted = [...values]..sort();
final middle = sorted.length ~/ 2;
if (sorted.length.isEven) {
return (sorted[middle - 1] + sorted[middle]) / 2;
}
return sorted[middle];
}
static num? _maxOrNull(List<num> values) {
if (values.isEmpty) return null;
return values.reduce(math.max);
... ... @@ -962,15 +863,6 @@ class LocalHealthDataConvert {
if (values.isEmpty) return null;
return values.reduce(math.min);
}
static double _clamp(double value, double lower, double upper) {
return math.min(math.max(value, lower), upper).toDouble();
}
static double _round(double value, int places) {
final scale = math.pow(10, places).toDouble();
return (value * scale).round() / scale;
}
}
class _SleepDaySummary {
... ... @@ -1022,7 +914,7 @@ class _ActivityDaySummary {
required this.day,
required this.move,
required this.steps,
required this.standSeconds,
required this.standHours,
required this.exerciseSeconds,
required this.moveGoal,
required this.stepGoal,
... ... @@ -1033,7 +925,7 @@ class _ActivityDaySummary {
final DateTime day;
final num move;
final num steps;
final num standSeconds;
final num standHours;
final num exerciseSeconds;
final num? moveGoal;
final num? stepGoal;
... ...
... ... @@ -117,22 +117,16 @@ class LocalHealthDataSource implements HealthDataSource {
dateRangeType,
startDate,
);
final allDays = [...previousDays, ...days];
final queryStart =
LocalHealthDataConvert.unixSeconds(allDays.first) - 12 * 60;
final queryEnd = LocalHealthDataConvert.unixSeconds(
days.last.add(const Duration(days: 1)),
) +
12 * 60;
final hrvPoints = await coreService.queryHrvStressPoints(
startTime: LocalHealthDataConvert.unixSeconds(days.first),
endTime: LocalHealthDataConvert.unixSeconds(
days.last.add(const Duration(days: 1)),
),
);
final realtimePoints = await coreService.queryRealtimeStressPoints(
startTime: queryStart,
endTime: queryEnd,
final dailyStressPoints = await coreService.queryDailyStressPoints(
startDate: LocalHealthDataConvert.dateKey(
previousDays.isEmpty ? days.first : previousDays.first),
endDate: LocalHealthDataConvert.dateKey(days.last),
);
return AppSuccess(
... ... @@ -141,7 +135,7 @@ class LocalHealthDataSource implements HealthDataSource {
days: days,
previousDays: previousDays,
hrvPoints: hrvPoints,
realtimePoints: realtimePoints,
dailyStressPoints: dailyStressPoints,
),
);
} catch (error) {
... ... @@ -288,16 +282,13 @@ class LocalHealthDataSource implements HealthDataSource {
Future<AppResult<V2StressScore>> getV2StressScore(
int? queryUserId, int intDate) async {
try {
final (startTime, endTime) = _dayRange(intDate);
final realtimePoints = await coreService.queryRealtimeStressPoints(
startTime: startTime - 12 * 60,
endTime: endTime + 12 * 60,
final dailyStressPoints = await coreService.queryDailyStressPoints(
startDate: intDate,
endDate: intDate,
);
return AppSuccess(
LocalHealthDataConvert.v2StressScore(
realtimePoints,
startTime: startTime,
endTime: endTime,
dailyStressPoints.isEmpty ? null : dailyStressPoints.last,
),
);
} catch (error) {
... ...
... ... @@ -124,6 +124,110 @@ void main() {
expect(rows.hrvRows.first['baseline_hrv'], greaterThan(0));
expect(rows.hrvRows.first['baseline_awake_hrv'], greaterThan(0));
expect(rows.hrvRows.first['baseline_resting_hr'], greaterThan(0));
expect(
store.operationLog.indexOf('upsertResult'),
lessThan(store.operationLog.indexOf('queryRealtimeStressPoints')),
);
expect(
store.operationLog.indexOf('queryRealtimeStressPoints'),
lessThan(store.operationLog.indexOf('upsertDailyStressPoints')),
);
});
test('daily stress keeps existing historical rows and does not recalculate',
() async {
final oldDay = DateTime.now().subtract(const Duration(days: 2));
final oldStart = DateTime(oldDay.year, oldDay.month, oldDay.day)
.millisecondsSinceEpoch ~/
1000;
final oldDate = oldDay.year * 10000 + oldDay.month * 100 + oldDay.day;
final api = _FakeHealthKitRawDataHostApi();
api.setPoints(HealthDataUploadType.heartRate.type, [
_point(oldStart + 60, 68),
_point(oldStart + 120, 70),
_point(oldStart + 180, 72),
]);
api.setPoints(HealthDataUploadType.restingHeartRate.type, [
_point(oldStart + 30, 60),
_point(oldStart + 90, 61),
]);
final store = _MemoryHealthRawStressLocalStore();
store.insertDailyStress(
HealthRawDailyStressPoint(
userId: 42,
date: oldDate,
stressValue: 11,
stressScore: 11,
state: HealthRawStressState.excellent,
dataTime: oldStart,
uploaded: true,
),
);
final service = HealthRawDataCoreService(
healthApi: _FakeHealthKitHostApi(),
rawDataApi: api,
localStore: store,
userIdProvider: () => 42,
);
final result = await service.syncAndStore(
startTime: oldStart,
endTime: oldStart + Duration.secondsPerDay + 300,
readChunkDays: 1,
);
expect(result.realtimeStressPoints, isNotEmpty);
expect(result.dailyStressPoints, isEmpty);
final daily = await service.queryDailyStressPoints(
startDate: oldDate,
endDate: oldDate,
);
expect(daily, hasLength(1));
expect(daily.single.stressScore, 11);
expect(daily.single.uploaded, isTrue);
});
test('daily stress table upsert overwrites by date and resets uploaded',
() async {
final store = _MemoryHealthRawStressLocalStore();
store.insertDailyStress(
const HealthRawDailyStressPoint(
userId: 42,
date: 20260720,
stressValue: 10,
stressScore: 10,
state: HealthRawStressState.excellent,
dataTime: 1000,
uploaded: true,
),
);
await store.upsertDailyStressPoints(
userId: 42,
points: const [
HealthRawDailyStressPoint(
userId: 42,
date: 20260720,
stressValue: 66,
stressScore: 70,
state: HealthRawStressState.attention,
dataTime: 2000,
),
],
);
final daily = await store.queryDailyStressPoints(
userId: 42,
startDate: 20260720,
endDate: 20260720,
);
expect(daily, hasLength(1));
expect(daily.single.stressValue, 66);
expect(daily.single.stressScore, 70);
expect(daily.single.state, HealthRawStressState.attention);
expect(daily.single.dataTime, 2000);
expect(daily.single.uploaded, isFalse);
});
test('startCoreCaculate skips raw reads when health auth is missing',
... ... @@ -266,6 +370,18 @@ class _ReadCall {
class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
final Map<int, List<HealthRawHrvStressPoint>> _hrv = {};
final Map<int, List<HealthRawRealtimeStressPoint>> _realtime = {};
final Map<int, List<HealthRawDailyStressPoint>> _daily = {};
final List<String> operationLog = [];
void insertDailyStress(HealthRawDailyStressPoint point) {
final byDate = <int, HealthRawDailyStressPoint>{
for (final item in _daily[point.userId] ?? <HealthRawDailyStressPoint>[])
item.date: item,
};
byDate[point.date] = point;
_daily[point.userId] = byDate.values.toList()
..sort((a, b) => a.date.compareTo(b.date));
}
@override
Future<void> ensureReadable(int userId) async {}
... ... @@ -275,6 +391,7 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
@override
Future<void> upsertResult(HealthRawStressCalculationResult result) async {
operationLog.add('upsertResult');
final hrvByTime = <int, HealthRawHrvStressPoint>{
for (final point in _hrv[result.userId] ?? <HealthRawHrvStressPoint>[])
point.rawEndTime: point,
... ... @@ -318,6 +435,53 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
}
@override
Future<void> upsertDailyStressPoints({
required int userId,
required Iterable<HealthRawDailyStressPoint> points,
}) async {
operationLog.add('upsertDailyStressPoints');
final byDate = <int, HealthRawDailyStressPoint>{
for (final point in _daily[userId] ?? <HealthRawDailyStressPoint>[])
point.date: point,
};
for (final point in points) {
byDate[point.date] = HealthRawDailyStressPoint(
userId: point.userId,
date: point.date,
stressValue: point.stressValue,
stressScore: point.stressScore,
state: point.state,
dataTime: point.dataTime,
);
}
_daily[userId] = byDate.values.toList()
..sort((a, b) => a.date.compareTo(b.date));
}
@override
Future<Set<int>> existingDailyStressDates({
required int userId,
required Iterable<int> dates,
}) async {
final dateSet = dates.toSet();
return (_daily[userId] ?? <HealthRawDailyStressPoint>[])
.where((e) => dateSet.contains(e.date))
.map((e) => e.date)
.toSet();
}
@override
Future<void> deleteDailyStressDates({
required int userId,
required Iterable<int> dates,
}) async {
final dateSet = dates.toSet();
_daily[userId] = (_daily[userId] ?? <HealthRawDailyStressPoint>[])
.where((e) => !dateSet.contains(e.date))
.toList();
}
@override
Future<List<HealthRawHrvStressPoint>> queryHrvStressPoints({
required int userId,
required int startTime,
... ... @@ -334,12 +498,24 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
required int startTime,
required int endTime,
}) async {
operationLog.add('queryRealtimeStressPoints');
return (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[])
.where((e) => e.rawEndTime >= startTime && e.rawEndTime <= endTime)
.toList();
}
@override
Future<List<HealthRawDailyStressPoint>> queryDailyStressPoints({
required int userId,
required int startDate,
required int endDate,
}) async {
return (_daily[userId] ?? <HealthRawDailyStressPoint>[])
.where((e) => e.date >= startDate && e.date <= endDate)
.toList();
}
@override
Future<HealthRawStressDbRows> debugQueryAllRows(int userId) async {
return HealthRawStressDbRows(
hrvRows:
... ... @@ -347,6 +523,9 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
realtimeRows: (_realtime[userId] ?? <HealthRawRealtimeStressPoint>[])
.map(_realtimeRow)
.toList(),
dailyStressRows: (_daily[userId] ?? <HealthRawDailyStressPoint>[])
.map(_dailyStressRow)
.toList(),
);
}
... ... @@ -446,4 +625,16 @@ class _MemoryHealthRawStressLocalStore extends HealthRawStressLocalStore {
'uploaded': point.uploaded ? 1 : 0,
};
}
Map<String, Object?> _dailyStressRow(HealthRawDailyStressPoint point) {
return <String, Object?>{
'date': point.date,
'user_id': point.userId,
'stress_value': point.stressValue,
'stress_score': point.stressScore,
'state': point.state.value,
'data_time': point.dataTime,
'uploaded': point.uploaded ? 1 : 0,
};
}
}
... ...
... ... @@ -83,11 +83,10 @@ void main() {
});
});
group('LocalHealthDataConvert v2StressScore', () {
group('HealthRawDailyStressCalculator', () {
test('uses median and p75 after excluding confirmed activity buffer', () {
const userId = 1;
const dayStart = 100000;
const dayEnd = dayStart + Duration.secondsPerDay - 1;
const flagsNone = HealthRawPointFlags.none();
const workoutFlags = HealthRawPointFlags(
isWorkout: true,
... ... @@ -117,8 +116,11 @@ void main() {
);
}
final score = LocalHealthDataConvert.v2StressScore(
[
final dailyStress = HealthRawDailyStressCalculator.calculate(
userId: userId,
date: 20260716,
dataTime: dayStart,
realtimePoints: [
point(600, 10),
point(1200, 20, flags: suspectedFlags),
point(1800, 30),
... ... @@ -128,18 +130,17 @@ void main() {
point(4200, 80),
point(4800, 110),
],
startTime: dayStart,
endTime: dayEnd,
);
final score = LocalHealthDataConvert.v2StressScore(dailyStress);
expect(score.comprehensiveScore, 34);
expect(score.state, HealthRawStressState.normal.value);
expect(dailyStress?.stressValue, 36);
});
test('uses activity buffer from adjacent day', () {
const userId = 1;
const dayStart = 100000;
const dayEnd = dayStart + Duration.secondsPerDay - 1;
const workoutFlags = HealthRawPointFlags(
isWorkout: true,
isWorkoutRecovery: false,
... ... @@ -147,8 +148,11 @@ void main() {
isSuspectedActivity: false,
);
final score = LocalHealthDataConvert.v2StressScore(
[
final dailyStress = HealthRawDailyStressCalculator.calculate(
userId: userId,
date: 20260716,
dataTime: dayStart,
realtimePoints: [
HealthRawRealtimeStressPoint(
userId: userId,
rawEndTime: dayStart - 300,
... ... @@ -172,9 +176,8 @@ void main() {
sourceEndTime: dayStart + 1000,
),
],
startTime: dayStart,
endTime: dayEnd,
);
final score = LocalHealthDataConvert.v2StressScore(dailyStress);
expect(score.comprehensiveScore, 20);
expect(score.state, HealthRawStressState.excellent.value);
... ... @@ -186,7 +189,6 @@ void main() {
final currentDay = DateTime(2026, 7, 16);
final previousDay = DateTime(2026, 7, 9);
final currentStart = LocalHealthDataConvert.unixSeconds(currentDay);
final previousStart = LocalHealthDataConvert.unixSeconds(previousDay);
HealthRawHrvStressPoint hrvPoint(int time, double value) {
return HealthRawHrvStressPoint(
... ... @@ -204,13 +206,17 @@ void main() {
);
}
HealthRawRealtimeStressPoint stressPoint(int time, double value) {
return HealthRawRealtimeStressPoint(
HealthRawDailyStressPoint dailyStressPoint(
DateTime day,
int score,
) {
return HealthRawDailyStressPoint(
userId: 1,
rawEndTime: time,
result: value,
sourceStartTime: time,
sourceEndTime: time,
date: LocalHealthDataConvert.dateKey(day),
stressValue: score.toDouble(),
stressScore: score,
state: healthRawRealtimeStressState(score),
dataTime: LocalHealthDataConvert.unixSeconds(day),
);
}
... ... @@ -221,10 +227,9 @@ void main() {
hrvPoints: [
hrvPoint(currentStart + 600, 80),
],
realtimePoints: [
stressPoint(currentStart + 600, 10),
stressPoint(currentStart + 1200, 80),
stressPoint(previousStart + 600, 90),
dailyStressPoints: [
dailyStressPoint(currentDay, 34),
dailyStressPoint(previousDay, 90),
],
);
... ... @@ -244,27 +249,20 @@ void main() {
test('keeps yearly months that only have comprehensive stress data', () {
final mayDay = DateTime(2026, 5, 12);
final mayStart = LocalHealthDataConvert.unixSeconds(mayDay);
final statistics = LocalHealthDataConvert.hrvStatistics(
dateRangeType: 2,
days: LocalHealthDataConvert.rangeDays(2, 20260101),
previousDays: const [],
hrvPoints: const [],
realtimePoints: [
HealthRawRealtimeStressPoint(
dailyStressPoints: [
HealthRawDailyStressPoint(
userId: 1,
rawEndTime: mayStart + 600,
result: 70,
sourceStartTime: mayStart + 600,
sourceEndTime: mayStart + 600,
),
HealthRawRealtimeStressPoint(
userId: 1,
rawEndTime: mayStart + 1200,
result: 80,
sourceStartTime: mayStart + 1200,
sourceEndTime: mayStart + 1200,
date: 20260512,
stressValue: 75,
stressScore: 77,
state: HealthRawStressState.attention,
dataTime: LocalHealthDataConvert.unixSeconds(mayDay),
),
],
);
... ... @@ -272,7 +270,7 @@ void main() {
expect(statistics.hrvTrendList, hasLength(1));
expect(statistics.hrvTrendList?.single.timeKey, 5);
expect(statistics.hrvTrendList?.single.hrvAverage, isNull);
expect(statistics.hrvTrendList?.single.hrAverage, 75);
expect(statistics.hrvTrendList?.single.hrAverage, 77);
expect(
statistics.hrvTrendList?.single.state,
HealthRawStressState.attention.value,
... ... @@ -289,7 +287,8 @@ void main() {
});
group('LocalHealthDataConvert activity unit conversion', () {
test('passes through exercise and stand seconds from raw activity data',
test(
'passes through exercise seconds and stand hours from raw activity data',
() {
final day = DateTime(2026, 7, 16);
final endTime = LocalHealthDataConvert.unixSeconds(
... ... @@ -304,8 +303,8 @@ void main() {
appleMoveTimeGoal: 10000,
appleExerciseTime: 1800,
exerciseTimeGoal: 3600,
appleStandHours: 21600,
standHoursGoal: 43200,
appleStandHours: 6,
standHoursGoal: 12,
),
];
... ... @@ -330,13 +329,13 @@ void main() {
);
expect(healthData.exercise, 1800);
expect(healthData.stand, 21600);
expect(healthData.stand, 6);
expect(target.exercise, 3600);
expect(target.stand, 43200);
expect(target.stand, 12);
expect(statistics.totalExercise, 1800);
expect(statistics.totalStand, 21600);
expect(statistics.totalStand, 6);
expect(statistics.activityTargetInfo?.exercise, 3600);
expect(statistics.activityTargetInfo?.stand, 43200);
expect(statistics.activityTargetInfo?.stand, 12);
});
});
}
... ...