Commit da69a8df1abc9da5066d5ca13d8422c84d408cb9

Authored by 权海
1 parent 356bf734

feat(ui):趋势显示压力综合值

... ... @@ -16,6 +16,7 @@ const _sleepTypeAsleepDeep = 4;
const _sleepTypeAsleepRem = 5;
const _sleepGoalMinutes = 8 * 60.0;
const _sleepContinuityToleranceSeconds = 1;
const _stressConfirmedActivityBufferSeconds = 12 * 60;
class LocalHealthDataConvert {
const LocalHealthDataConvert._();
... ... @@ -38,6 +39,7 @@ class LocalHealthDataConvert {
final previousStart = switch (dateRangeType) {
0 => start.subtract(const Duration(days: 7)),
1 => DateTime(start.year, start.month - 1),
2 => DateTime(start.year - 1),
_ => null,
};
if (previousStart == null) return const <DateTime>[];
... ... @@ -277,15 +279,23 @@ class LocalHealthDataConvert {
static HrvStatisticsDataV2 hrvStatistics({
required int dateRangeType,
required List<DateTime> days,
required List<DateTime> previousDays,
required List<HealthRawHrvStressPoint> hrvPoints,
required List<HealthRawRealtimeStressPoint> realtimePoints,
}) {
final daily = [
for (final day in days) _hrvDaySummary(day, hrvPoints, realtimePoints),
];
final daily = <_HrvDaySummary>[];
for (final day in days) {
final summary = _hrvDaySummary(day, hrvPoints, realtimePoints);
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);
if (summary.hasData) previousDaily.add(summary);
}
final trendList = dateRangeType == 2
? _monthlyHrvTrend(validDaily)
? _monthlyHrvTrend(daily)
: [
for (final item in daily)
HrvTrendList(
... ... @@ -296,7 +306,8 @@ class LocalHealthDataConvert {
),
];
final distribution = _hrvDistribution(validDaily);
final distribution = _stressStateDistribution(daily);
final previousDistribution = _stressStateDistribution(previousDaily);
final minDay = _extremeHrv(validDaily, min: true);
final maxDay = _extremeHrv(validDaily, min: false);
... ... @@ -309,13 +320,23 @@ class LocalHealthDataConvert {
dayCounts: entry.value,
),
],
dailyDistributionList: [
for (final item in validDaily)
DailyDistributionList(
date: dateKey(item.day),
hrvLevel: item.state,
qoqHrvDistributionList: [
for (final entry in previousDistribution.entries)
QoqHrvDistributionList(
stressState: entry.key,
dayCounts: entry.value,
),
],
dailyDistributionList: dateRangeType == 2
? [
for (final item in daily)
if (item.state != null)
DailyDistributionList(
date: dateKey(item.day),
hrvLevel: item.state,
),
]
: null,
hrvMin: minDay == null
? null
: HrvMin(
... ... @@ -451,18 +472,82 @@ class LocalHealthDataConvert {
}
static V2StressScore v2StressScore(
List<HealthRawRealtimeStressPoint> realtimePoints,
) {
final averageStress = _averageOrNull(
realtimePoints.map((e) => e.result).toList(),
);
final score = averageStress?.round();
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();
return V2StressScore(
state: score == null ? 0 : healthRawRealtimeStressState(score).value,
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,
... ... @@ -480,7 +565,11 @@ class LocalHealthDataConvert {
point.endTime > interval.startTime &&
point.endTime <= interval.endTime))
.toList();
final sleepDuration = windowEnd - windowStart;
final nullableScore = score == 0 ? null : score;
final sleepEvaluate = _sleepEvaluate(score);
final sleepDuration = nullableScore == null || sleepEvaluate == null
? 0
: windowEnd - windowStart;
return _SleepDaySummary(
day: day,
... ... @@ -490,8 +579,8 @@ class LocalHealthDataConvert {
asleepTime: intervals.isEmpty
? null
: intervals.map((e) => e.startTime).reduce(math.min),
score: score == 0 ? null : score,
evaluate: _sleepEvaluate(score),
score: nullableScore,
evaluate: sleepEvaluate,
sleepHr: sleepHr,
);
}
... ... @@ -571,12 +660,17 @@ class LocalHealthDataConvert {
.where((e) => e.rawEndTime >= start && e.rawEndTime < end)
.toList();
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: hrvAverage == null ? null : _hrvStateFromValue(hrvAverage),
state: stressState,
);
}
... ... @@ -600,7 +694,7 @@ class LocalHealthDataConvert {
];
}
static Map<int, int> _hrvDistribution(List<_HrvDaySummary> daily) {
static Map<int, int> _stressStateDistribution(List<_HrvDaySummary> daily) {
final result = <int, int>{};
for (final item in daily) {
final state = item.state;
... ... @@ -610,6 +704,28 @@ 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,
... ... @@ -790,13 +906,6 @@ class LocalHealthDataConvert {
return 3;
}
static int _hrvStateFromValue(num value) {
if (value >= 30) return 4;
if (value >= 21) return 3;
if (value >= 17) return 2;
return 1;
}
static int? _modeState(Iterable<int> states) {
final counts = <int, int>{};
for (final state in states) {
... ... @@ -836,6 +945,16 @@ 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);
... ... @@ -845,6 +964,15 @@ 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 {
... ... @@ -933,4 +1061,6 @@ class _HrvDaySummary {
final double? hrvAverage;
final double? hrAverage;
final int? state;
bool get hasData => hrvAverage != null || hrAverage != null || state != null;
}
... ...
... ... @@ -113,13 +113,22 @@ class LocalHealthDataSource implements HealthDataSource {
final days = LocalHealthDataConvert.rangeDays(dateRangeType, startDate);
if (days.isEmpty) return AppSuccess(HrvStatisticsDataV2());
final queryStart = LocalHealthDataConvert.unixSeconds(days.first);
final queryEnd = LocalHealthDataConvert.unixSeconds(
days.last.add(const Duration(days: 1)),
final previousDays = LocalHealthDataConvert.previousRangeDays(
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: queryStart,
endTime: queryEnd,
startTime: LocalHealthDataConvert.unixSeconds(days.first),
endTime: LocalHealthDataConvert.unixSeconds(
days.last.add(const Duration(days: 1)),
),
);
final realtimePoints = await coreService.queryRealtimeStressPoints(
startTime: queryStart,
... ... @@ -130,6 +139,7 @@ class LocalHealthDataSource implements HealthDataSource {
LocalHealthDataConvert.hrvStatistics(
dateRangeType: dateRangeType,
days: days,
previousDays: previousDays,
hrvPoints: hrvPoints,
realtimePoints: realtimePoints,
),
... ... @@ -280,10 +290,16 @@ class LocalHealthDataSource implements HealthDataSource {
try {
final (startTime, endTime) = _dayRange(intDate);
final realtimePoints = await coreService.queryRealtimeStressPoints(
startTime: startTime,
endTime: endTime,
startTime: startTime - 12 * 60,
endTime: endTime + 12 * 60,
);
return AppSuccess(
LocalHealthDataConvert.v2StressScore(
realtimePoints,
startTime: startTime,
endTime: endTime,
),
);
return AppSuccess(LocalHealthDataConvert.v2StressScore(realtimePoints));
} catch (error) {
return AppFailure(AppUnknownError(error));
}
... ...
... ... @@ -38,10 +38,13 @@ class HrvStatisticsDataV2 {
}
List<HrvTrendList>? hrvTrendList;
/// 当前区间,每日综合压力
List<HrvDistributionList>? hrvDistributionList;
/// 环比(上周上月上年)区间,每日综合压力
List<QoqHrvDistributionList>? qoqHrvDistributionList;
HrvMin? hrvMin;
HrvMax? hrvMax;
/// 年,每日综合压力, 只有年需要塞这个数据
List<DailyDistributionList>? dailyDistributionList;
Map<String, dynamic> toJson() {
... ... @@ -199,6 +202,7 @@ class HrvTrendList {
Object? timeKey;
double? hrvAverage;
double? hrAverage;
/// 当天综合压力
int? state;
Map<String, dynamic> toJson() {
... ...
import 'package:doublefeel_flutter/core/services/health_raw_data_core_service.dart';
import 'package:doublefeel_flutter/data/datasource/health/health_local_data_convert.dart';
import 'package:doublefeel_flutter/pigeon/health_kit_raw_data_api.g.dart';
import 'package:flutter_test/flutter_test.dart';
... ... @@ -56,5 +57,234 @@ void main() {
const Duration(hours: 7, minutes: 4).inSeconds,
);
});
test('sets sleep duration to zero when sleep score is unavailable', () {
final day = DateTime(2026, 7, 16);
final awakeStart = DateTime(2026, 7, 16, 1);
final awakeEnd = DateTime(2026, 7, 16, 2);
final statistics = LocalHealthDataConvert.sleepStatistics(
dateRangeType: 3,
days: [day],
previousDays: const [],
sleepIntervals: [
HealthKitRawDataPoint(
dataType: 2,
startTime: LocalHealthDataConvert.unixSeconds(awakeStart),
endTime: LocalHealthDataConvert.unixSeconds(awakeEnd),
),
],
heartRate: const [],
);
expect(statistics.sleepTrendList?.single.totalTime, 0);
expect(statistics.sleepTrendList?.single.score, isNull);
expect(statistics.sleepTrendList?.single.sleepEvaluate, isNull);
expect(statistics.avgSleepDuration, isNull);
});
});
group('LocalHealthDataConvert v2StressScore', () {
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,
isWorkoutRecovery: false,
isSleepLikely: false,
isSuspectedActivity: false,
);
const suspectedFlags = HealthRawPointFlags(
isWorkout: false,
isWorkoutRecovery: false,
isSleepLikely: false,
isSuspectedActivity: true,
);
HealthRawRealtimeStressPoint point(
int offset,
double value, {
HealthRawPointFlags flags = flagsNone,
}) {
return HealthRawRealtimeStressPoint(
userId: userId,
rawEndTime: dayStart + offset,
result: value,
sourceStartTime: dayStart + offset,
sourceEndTime: dayStart + offset,
flags: flags,
);
}
final score = LocalHealthDataConvert.v2StressScore(
[
point(600, 10),
point(1200, 20, flags: suspectedFlags),
point(1800, 30),
point(2000, 40),
point(3000, 90, flags: workoutFlags),
point(3300, 100),
point(4200, 80),
point(4800, 110),
],
startTime: dayStart,
endTime: dayEnd,
);
expect(score.comprehensiveScore, 34);
expect(score.state, HealthRawStressState.normal.value);
});
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,
isSleepLikely: false,
isSuspectedActivity: false,
);
final score = LocalHealthDataConvert.v2StressScore(
[
HealthRawRealtimeStressPoint(
userId: userId,
rawEndTime: dayStart - 300,
result: 80,
sourceStartTime: dayStart - 300,
sourceEndTime: dayStart - 300,
flags: workoutFlags,
),
HealthRawRealtimeStressPoint(
userId: userId,
rawEndTime: dayStart + 100,
result: 90,
sourceStartTime: dayStart + 100,
sourceEndTime: dayStart + 100,
),
HealthRawRealtimeStressPoint(
userId: userId,
rawEndTime: dayStart + 1000,
result: 20,
sourceStartTime: dayStart + 1000,
sourceEndTime: dayStart + 1000,
),
],
startTime: dayStart,
endTime: dayEnd,
);
expect(score.comprehensiveScore, 20);
expect(score.state, HealthRawStressState.excellent.value);
});
});
group('LocalHealthDataConvert hrvStatistics', () {
test('uses daily comprehensive stress for trend and distributions', () {
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(
userId: 1,
rawEndTime: time,
rawHrv: value,
result: value,
sourceStartTime: time,
sourceEndTime: time,
state: HealthRawStressState.excellent,
baselineHrv: 50,
baselineAwakeHrv: 50,
baselineSleepHrv: null,
baselineRestingHr: 65,
);
}
HealthRawRealtimeStressPoint stressPoint(int time, double value) {
return HealthRawRealtimeStressPoint(
userId: 1,
rawEndTime: time,
result: value,
sourceStartTime: time,
sourceEndTime: time,
);
}
final statistics = LocalHealthDataConvert.hrvStatistics(
dateRangeType: 0,
days: [currentDay],
previousDays: [previousDay],
hrvPoints: [
hrvPoint(currentStart + 600, 80),
],
realtimePoints: [
stressPoint(currentStart + 600, 10),
stressPoint(currentStart + 1200, 80),
stressPoint(previousStart + 600, 90),
],
);
expect(statistics.hrvTrendList?.single.hrvAverage, 80);
expect(
statistics.hrvTrendList?.single.state,
HealthRawStressState.normal.value,
);
expect(statistics.hrvDistributionList?.single.stressId,
HealthRawStressState.normal.value);
expect(statistics.hrvDistributionList?.single.dayCounts, 1);
expect(statistics.qoqHrvDistributionList?.single.stressState,
HealthRawStressState.overload.value);
expect(statistics.qoqHrvDistributionList?.single.dayCounts, 1);
expect(statistics.dailyDistributionList, isNull);
});
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(
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,
),
],
);
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.state,
HealthRawStressState.attention.value,
);
expect(statistics.dailyDistributionList, hasLength(1));
expect(statistics.dailyDistributionList?.single.date, 20260512);
expect(
statistics.dailyDistributionList?.map((e) => e.date),
isNot(contains(20260101)),
);
expect(statistics.hrvDistributionList?.single.stressId,
HealthRawStressState.attention.value);
});
});
}
... ...