Commit cbc1b20105d61b40c1a2d653510890feb4294a7a

Authored by 刘宏哲
1 parent 3e129759

feat(app): hrv report add comprehensive_stress_score

... ... @@ -186,6 +186,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
yearStart.add(Duration(days: i)),
trendsByDate,
levelsByDate,
preferDailyLevel: true,
),
],
distributionCounts:
... ... @@ -198,14 +199,20 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
HrvDayReport _dayReport(
DateTime date,
Map<DateTime, HrvTrendList> trendsByDate,
Map<DateTime, HrvStressLevel> levelsByDate,
) {
Map<DateTime, HrvStressLevel> levelsByDate, {
bool preferDailyLevel = false,
}) {
final trend = trendsByDate[date];
final level = _stressLevelFromApiId(trend?.state) ?? levelsByDate[date];
final trendLevel = _stressLevelFromApiId(trend?.state);
final level = preferDailyLevel
? levelsByDate[date]
: trendLevel ?? levelsByDate[date];
return HrvDayReport(
date: date,
averageHrv: trend?.hrvAverage?.toDouble(),
averageHeartRate: trend?.hrAverage?.round(),
lastRestingHrValue: trend?.lastRestingHrValue,
comprehensiveStressScore: trend?.comprehensiveStressScore?.toDouble(),
level: level,
);
}
... ... @@ -234,6 +241,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
date: date,
averageHrv: value.toDouble(),
averageHeartRate: trend?.hrAverage?.round(),
lastRestingHrValue: trend?.lastRestingHrValue,
level: _stressLevelFromApiId(trend?.state) ?? levelsByDate[date],
);
}
... ...
... ... @@ -36,12 +36,16 @@ class HrvDayReport {
required this.date,
this.averageHrv,
this.averageHeartRate,
this.lastRestingHrValue,
this.comprehensiveStressScore,
this.level,
});
final DateTime date;
final double? averageHrv;
final int? averageHeartRate;
final int? lastRestingHrValue;
final double? comprehensiveStressScore;
final HrvStressLevel? level;
bool get hasData => averageHrv != null;
... ... @@ -384,6 +388,15 @@ class YearlyHrvReport implements HrvPeriodReport {
return values.reduce((sum, value) => sum + value) / values.length;
}
double? averageStressScoreForMonth(int month) {
final values = daysForMonth(month)
.map((day) => day.comprehensiveStressScore)
.whereType<double>()
.toList();
if (values.isEmpty) return null;
return values.reduce((sum, value) => sum + value) / values.length;
}
HrvStressLevel? levelForMonth(int month) {
final levels = daysForMonth(month)
.where((day) => day.averageHrv != null)
... ...
... ... @@ -192,6 +192,7 @@ class _HrvBarChart extends StatefulWidget {
}
class _HrvBarChartState extends State<_HrvBarChart> {
static const _maxStressScore = 100.0;
static const _axisLabelWidth = 23.0;
static const _plotLeft = _axisLabelWidth;
static const _plotRight = 0.0;
... ... @@ -247,7 +248,7 @@ class _HrvBarChartState extends State<_HrvBarChart> {
showingTooltipIndicators: _touchedIndex == i ? [1] : [],
barRods: [
BarChartRodData(
toY: day.averageHrv ?? 0,
toY: _barHeight(day.comprehensiveStressScore),
width: widget.isMonth ? 4 : _weekBarWidth,
color: day.level == null
? Colors.transparent
... ... @@ -279,7 +280,7 @@ class _HrvBarChartState extends State<_HrvBarChart> {
return BarChart(
BarChartData(
minY: 0,
maxY: 110,
maxY: 100,
alignment: widget.isMonth
? BarChartAlignment.spaceAround
: BarChartAlignment.center,
... ... @@ -441,7 +442,7 @@ class _HrvBarChartState extends State<_HrvBarChart> {
),
TextSpan(
text:
'${day.averageHrv?.round() ?? '-'}ms · ${day.averageHeartRate ?? '-'}bpm',
'${day.averageHrv?.round() ?? '-'}ms · ${day.lastRestingHrValue ?? '-'}bpm',
style: const TextStyle(
color: _tooltipMetaColor,
fontSize: 10,
... ... @@ -474,9 +475,14 @@ class _HrvBarChartState extends State<_HrvBarChart> {
double _tooltipBottomForHeight(double height) {
final plotHeight = height - _bottomTitleHeight;
return plotHeight * (1 - _tooltipAnchorY / 110) - _tooltipMargin;
return plotHeight * (1 - _tooltipAnchorY / 100) - _tooltipMargin;
}
double _barHeight(double? comprehensiveStressScore) =>
(_maxStressScore - (comprehensiveStressScore ?? _maxStressScore))
.clamp(0, _maxStressScore)
.toDouble();
int? _indexForTouch(double? dx, double chartWidth) {
if (dx == null || chartWidth <= 0 || widget.report.days.isEmpty) {
return null;
... ...
... ... @@ -106,6 +106,7 @@ class _YearBarChart extends StatefulWidget {
}
class _YearBarChartState extends State<_YearBarChart> {
static const _maxStressScore = 100.0;
static const _axisLabelWidth = 23.0;
static const _plotLeft = _axisLabelWidth;
static const _plotRight = 0.0;
... ... @@ -168,7 +169,8 @@ class _YearBarChartState extends State<_YearBarChart> {
_touchedMonth == month ? [1] : [],
barRods: [
BarChartRodData(
toY: widget.report.averageForMonth(month) ?? 0,
toY: _barHeight(widget.report
.averageStressScoreForMonth(month)),
width: 8,
color: _colorForMonth(month),
borderRadius: const BorderRadius.vertical(
... ... @@ -365,6 +367,11 @@ class _YearBarChartState extends State<_YearBarChart> {
return plotHeight * (1 - _tooltipAnchorY / 100) - _tooltipMargin;
}
double _barHeight(double? comprehensiveStressScore) =>
(_maxStressScore - (comprehensiveStressScore ?? _maxStressScore))
.clamp(0, _maxStressScore)
.toDouble();
int? _monthForTouch(double? dx, double chartWidth) {
if (dx == null || chartWidth <= 0) return null;
return ((dx / chartWidth) * 12).floor().clamp(0, 11).toInt() + 1;
... ...
... ... @@ -9,6 +9,13 @@ DateTime _today() {
return DateTime(now.year, now.month, now.day);
}
class _ReportPeriodAnchor {
const _ReportPeriodAnchor({required this.date, this.sourceMonth});
final DateTime date;
final DateTime? sourceMonth;
}
abstract class ReportPeriodLogic {
ReportPeriodLogic({this.forceRefresh = true});
... ... @@ -16,7 +23,7 @@ abstract class ReportPeriodLogic {
final selectedPeriod = ReportPeriod.day.obs;
final selectedDate = _today().obs;
final isLoading = false.obs;
var _anchorDate = _today();
var _anchor = _ReportPeriodAnchor(date: _today());
List<ReportPeriod> get supportedPeriods => ReportPeriod.values;
... ... @@ -50,7 +57,7 @@ abstract class ReportPeriodLogic {
void initializeQuery(ReportPeriod period, DateTime date) {
final nextPeriod = normalizePeriod(period);
final nextDate = normalizeDate(nextPeriod, date);
_anchorDate = _anchorDateFor(nextPeriod, date);
_setAnchorFor(nextPeriod, date);
selectedPeriod.value = nextPeriod;
selectedDate.value = nextDate;
}
... ... @@ -69,7 +76,7 @@ abstract class ReportPeriodLogic {
return Future.value();
}
if (!sameQuery) {
_anchorDate = _anchorDateFor(nextPeriod, date);
_setAnchorFor(nextPeriod, date);
}
selectedPeriod.value = nextPeriod;
selectedDate.value = nextDate;
... ... @@ -132,9 +139,14 @@ abstract class ReportPeriodLogic {
if (!shouldRefresh && selectedPeriod.value == nextPeriod) {
return Future.value();
}
final nextDate = normalizeDate(nextPeriod, _anchorDate);
final nextDate = _dateForPeriodTransition(nextPeriod);
final sourceMonth = selectedPeriod.value == ReportPeriod.month &&
nextPeriod == ReportPeriod.week
? monthStart
: null;
selectedPeriod.value = nextPeriod;
selectedDate.value = nextDate;
_setAnchorFor(nextPeriod, nextDate, sourceMonth: sourceMonth);
return loadReport();
}
... ... @@ -144,7 +156,7 @@ abstract class ReportPeriodLogic {
}) {
final shouldRefresh = forceRefresh ?? this.forceRefresh;
final nextDate = ReportDateRangeConfig.clampDate(date);
_anchorDate = _anchorDateFor(ReportPeriod.day, nextDate);
_setAnchorFor(ReportPeriod.day, nextDate);
if (!shouldRefresh && selectedDate.value == nextDate) return Future.value();
selectedDate.value = nextDate;
return loadReport();
... ... @@ -160,7 +172,7 @@ abstract class ReportPeriodLogic {
ReportDateRangeConfig.firstWeekStart(),
ReportDateRangeConfig.lastWeekStart(),
);
_anchorDate = _anchorDateFor(ReportPeriod.week, nextWeekStart);
_setAnchorFor(ReportPeriod.week, nextWeekStart);
if (!shouldRefresh && weekStart == nextWeekStart) return Future.value();
selectedDate.value = nextWeekStart;
return loadReport();
... ... @@ -177,7 +189,7 @@ abstract class ReportPeriodLogic {
ReportDateRangeConfig.firstMonth(),
ReportDateRangeConfig.lastMonth(),
);
_anchorDate = _anchorDateFor(ReportPeriod.month, nextMonth);
_setAnchorFor(ReportPeriod.month, nextMonth);
if (!shouldRefresh && monthStart == nextMonth) return Future.value();
selectedDate.value = nextMonth;
return loadReport();
... ... @@ -192,11 +204,11 @@ abstract class ReportPeriodLogic {
ReportDateRangeConfig.firstYear(),
ReportDateRangeConfig.lastYear(),
);
final nextDate = DateTime(nextYear);
_setAnchorFor(ReportPeriod.year, nextDate);
if (!shouldRefresh && selectedDate.value.year == nextYear) {
return Future.value();
}
final nextDate = DateTime(nextYear);
_anchorDate = _anchorDateFor(ReportPeriod.year, nextDate);
selectedDate.value = nextDate;
return loadReport();
}
... ... @@ -224,7 +236,7 @@ abstract class ReportPeriodLogic {
Future<void> previousDay() {
final candidate = _previousCandidate;
if (_isBeforeRange(candidate)) return Future.value();
_anchorDate = _anchorDateFor(selectedPeriod.value, candidate);
_setAnchorFor(selectedPeriod.value, candidate);
selectedDate.value = candidate;
return loadReport();
}
... ... @@ -232,7 +244,7 @@ abstract class ReportPeriodLogic {
Future<void> nextDay() {
final candidate = _nextCandidate;
if (_isAfterRange(candidate)) return Future.value();
_anchorDate = _anchorDateFor(selectedPeriod.value, candidate);
_setAnchorFor(selectedPeriod.value, candidate);
selectedDate.value = candidate;
return loadReport();
}
... ... @@ -241,19 +253,60 @@ abstract class ReportPeriodLogic {
void dispose() {}
DateTime _anchorDateFor(ReportPeriod period, DateTime date) {
void _setAnchorFor(
ReportPeriod period,
DateTime date, {
DateTime? sourceMonth,
}) {
final normalizedPeriod = normalizePeriod(period);
final normalizedDate = normalizeDate(normalizedPeriod, date);
return switch (normalizedPeriod) {
ReportPeriod.day => normalizedDate,
ReportPeriod.week => ReportDateRangeConfig.clampDate(
normalizedDate.add(const Duration(days: 3)),
final weekSourceMonth =
DateTime(normalizedDate.year, normalizedDate.month);
_anchor = switch (normalizedPeriod) {
ReportPeriod.day => _ReportPeriodAnchor(date: normalizedDate),
ReportPeriod.week => _ReportPeriodAnchor(
date: ReportDateRangeConfig.clampDate(
normalizedDate.add(const Duration(days: 6)),
),
sourceMonth: sourceMonth ?? weekSourceMonth,
),
ReportPeriod.month => _ReportPeriodAnchor(
date: ReportDateRangeConfig.clampDate(
_lastDayOfMonth(normalizedDate),
),
),
ReportPeriod.year => _ReportPeriodAnchor(
date: ReportDateRangeConfig.clampDate(
_lastDayOfYear(normalizedDate),
),
),
ReportPeriod.month => normalizedDate,
ReportPeriod.year => normalizedDate,
};
}
DateTime _dateForPeriodTransition(ReportPeriod nextPeriod) {
final currentPeriod = selectedPeriod.value;
if (currentPeriod == ReportPeriod.week &&
nextPeriod == ReportPeriod.month) {
// Preserve the source month when entering from a month. A directly
// selected week uses the month containing its Monday as the source.
return normalizeDate(nextPeriod, _anchor.sourceMonth ?? weekStart);
}
if (currentPeriod == ReportPeriod.week &&
nextPeriod == ReportPeriod.year) {
// A cross-year week belongs to the year containing its Monday.
return normalizeDate(nextPeriod, weekStart);
}
return normalizeDate(nextPeriod, _anchor.date);
}
DateTime _lastDayOfYear(DateTime date) => DateTime(date.year + 1, 1, 0);
DateTime _lastDayOfMonth(DateTime date) =>
DateTime(date.year, date.month + 1, 0);
DateTime get _previousCandidate => switch (selectedPeriod.value) {
ReportPeriod.year => DateTime(selectedDate.value.year - 1),
ReportPeriod.month =>
... ...
... ... @@ -336,6 +336,7 @@ class LocalHealthDataConvert {
timeKey: dateKey(item.day),
hrvAverage: item.hrvAverage,
hrAverage: item.hrAverage,
comprehensiveStressScore: item.comprehensiveStressScore,
state: item.state,
),
];
... ... @@ -706,6 +707,7 @@ class LocalHealthDataConvert {
day: day,
hrvAverage: hrvAverage?.toDouble(),
hrAverage: stress?.stressScore.toDouble(),
comprehensiveStressScore: stress?.stressScore.toDouble(),
state: stress?.state.value,
);
}
... ... @@ -736,6 +738,12 @@ class LocalHealthDataConvert {
hrAverage: _averageOrNull(
entry.value.map((e) => e.hrAverage).whereType<num>().toList(),
)?.toDouble(),
comprehensiveStressScore: _averageOrNull(
entry.value
.map((e) => e.comprehensiveStressScore)
.whereType<num>()
.toList(),
)?.toDouble(),
state: _modeState(entry.value.map((e) => e.state).whereType<int>()),
),
];
... ... @@ -1011,12 +1019,14 @@ class _HrvDaySummary {
required this.day,
required this.hrvAverage,
required this.hrAverage,
required this.comprehensiveStressScore,
required this.state,
});
final DateTime day;
final double? hrvAverage;
final double? hrAverage;
final double? comprehensiveStressScore;
final int? state;
bool get hasData => hrvAverage != null || hrAverage != null || state != null;
... ...
... ... @@ -189,6 +189,8 @@ class HrvTrendList {
this.timeKey,
this.hrvAverage,
this.hrAverage,
this.lastRestingHrValue,
this.comprehensiveStressScore,
this.state,
});
... ... @@ -196,12 +198,17 @@ class HrvTrendList {
timeKey = json['time_key'];
hrvAverage = _parseDouble(json['hrv_average']);
hrAverage = _parseDouble(json['hr_average']);
lastRestingHrValue = _parseInt(json['last_resting_hr_value']);
comprehensiveStressScore =
_parseDouble(json['comprehensive_stress_score']);
state = _parseInt(json['state']);
}
Object? timeKey;
double? hrvAverage;
double? hrAverage;
int? lastRestingHrValue;
double? comprehensiveStressScore;
/// 当天综合压力
int? state;
... ... @@ -210,6 +217,8 @@ class HrvTrendList {
map['time_key'] = timeKey;
map['hrv_average'] = hrvAverage;
map['hr_average'] = hrAverage;
map['last_resting_hr_value'] = lastRestingHrValue;
map['comprehensive_stress_score'] = comprehensiveStressScore;
map['state'] = state;
return map;
}
... ...
import 'package:doublefeel_flutter/app/modules/report_common/config/report_date_range_config.dart';
import 'package:doublefeel_flutter/app/modules/report_common/controllers/report_period_logic.dart';
import 'package:doublefeel_flutter/app/modules/report_common/models/report_period.dart';
import 'package:flutter_test/flutter_test.dart';
class _TestReportPeriodLogic extends ReportPeriodLogic {
_TestReportPeriodLogic() : super(forceRefresh: false);
@override
Future<void> loadReport() async {}
}
class _MonthBoundaryReportPeriodLogic extends _TestReportPeriodLogic {
_MonthBoundaryReportPeriodLogic(this.sourceMonth);
final DateTime sourceMonth;
@override
DateTime normalizeDate(ReportPeriod period, DateTime date) {
if (period == ReportPeriod.week &&
date.year == sourceMonth.year &&
date.month == sourceMonth.month) {
// Simulates a month whose latest selectable week starts in the
// preceding month.
return sourceMonth.subtract(
Duration(days: sourceMonth.weekday - DateTime.monday),
);
}
return super.normalizeDate(period, date);
}
}
void main() {
group('ReportPeriodLogic period transitions', () {
test('year to month selects December for a completed year', () async {
final logic = _TestReportPeriodLogic();
final year = DateTime.now().year - 1;
logic.initializeQuery(ReportPeriod.year, DateTime(year));
await logic.selectPeriod(ReportPeriod.month);
expect(logic.monthStart, DateTime(year, DateTime.december));
});
test('year to month stops at the current month for the current year',
() async {
final logic = _TestReportPeriodLogic();
final now = DateTime.now();
logic.initializeQuery(ReportPeriod.year, now);
await logic.selectPeriod(ReportPeriod.month);
expect(logic.monthStart, DateTime(now.year, now.month));
});
test('year to week selects the week containing the year end', () async {
final logic = _TestReportPeriodLogic();
final year = DateTime.now().year - 1;
final yearEnd = DateTime(year + 1, 1, 0);
logic.initializeQuery(ReportPeriod.year, DateTime(year));
await logic.selectPeriod(ReportPeriod.week);
expect(
logic.weekStart,
yearEnd.subtract(Duration(days: yearEnd.weekday - DateTime.monday)),
);
});
test('week to year uses the year containing the week start', () async {
final logic = _TestReportPeriodLogic();
final year = DateTime.now().year - 1;
logic.initializeQuery(ReportPeriod.week, DateTime(year, 12, 1));
await logic.selectPeriod(ReportPeriod.year);
expect(logic.selectedDate.value, DateTime(year));
});
test('cross-year week to year uses the year containing the week start',
() async {
final nowYear = DateTime.now().year;
final year = [nowYear - 1, nowYear - 2].firstWhere((candidate) {
final yearEnd = DateTime(candidate + 1, 1, 0);
return yearEnd.weekday != DateTime.sunday;
});
final yearEnd = DateTime(year + 1, 1, 0);
final weekStart = yearEnd.subtract(
Duration(days: yearEnd.weekday - DateTime.monday),
);
final logic = _TestReportPeriodLogic();
logic.initializeQuery(ReportPeriod.week, yearEnd);
expect(weekStart.year, year);
expect(weekStart.add(const Duration(days: 6)).year, year + 1);
await logic.selectPeriod(ReportPeriod.year);
expect(logic.selectedDate.value, DateTime(year));
});
test('month to week selects the week containing the month end', () async {
final logic = _TestReportPeriodLogic();
final month = DateTime(DateTime.now().year - 1, 10);
final monthEnd = DateTime(month.year, month.month + 1, 0);
logic.initializeQuery(ReportPeriod.month, month);
await logic.selectPeriod(ReportPeriod.week);
expect(
logic.weekStart,
monthEnd.subtract(Duration(days: monthEnd.weekday - DateTime.monday)),
);
});
test('month to week to month keeps the source month',
() async {
final logic = _TestReportPeriodLogic();
final year = DateTime.now().year - 1;
logic.initializeQuery(ReportPeriod.year, DateTime(year));
await logic.selectPeriod(ReportPeriod.month);
await logic.selectPeriod(ReportPeriod.week);
await logic.selectPeriod(ReportPeriod.month);
expect(logic.monthStart, DateTime(year, DateTime.december));
});
test('clamped cross-month week returns to the original source month',
() async {
final year = DateTime.now().year - 1;
final sourceMonth = List<DateTime>.generate(
12,
(index) => DateTime(year, index + 1),
).firstWhere((month) => month.weekday != DateTime.monday);
final logic = _MonthBoundaryReportPeriodLogic(sourceMonth);
final clampedWeekStart = sourceMonth.subtract(
Duration(days: sourceMonth.weekday - DateTime.monday),
);
logic.initializeQuery(ReportPeriod.month, sourceMonth);
await logic.selectPeriod(ReportPeriod.week);
expect(logic.weekStart, clampedWeekStart);
await logic.selectPeriod(ReportPeriod.month);
expect(logic.monthStart, sourceMonth);
});
test('week to day selects Sunday when the whole week is selectable',
() async {
final logic = _TestReportPeriodLogic();
logic.initializeQuery(
ReportPeriod.week,
DateTime(DateTime.now().year - 1, 10, 20),
);
final expectedDay = logic.weekEnd;
await logic.selectPeriod(ReportPeriod.day);
expect(logic.selectedDate.value, expectedDay);
});
test('week to day clamps a partial latest week to the latest date',
() async {
final logic = _TestReportPeriodLogic();
logic.initializeQuery(
ReportPeriod.week,
ReportDateRangeConfig.lastWeekStart(),
);
await logic.selectPeriod(ReportPeriod.day);
expect(logic.selectedDate.value, ReportDateRangeConfig.lastDate());
});
});
}
... ...
... ... @@ -385,6 +385,10 @@ void main() {
expect(statistics.hrvTrendList?.single.hrvAverage, 80);
expect(
statistics.hrvTrendList?.single.comprehensiveStressScore,
34,
);
expect(
statistics.hrvTrendList?.single.state,
HealthRawStressState.normal.value,
);
... ...