Commit cbc1b20105d61b40c1a2d653510890feb4294a7a

Authored by 刘宏哲
1 parent 3e129759

feat(app): hrv report add comprehensive_stress_score

@@ -186,6 +186,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource { @@ -186,6 +186,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
186 yearStart.add(Duration(days: i)), 186 yearStart.add(Duration(days: i)),
187 trendsByDate, 187 trendsByDate,
188 levelsByDate, 188 levelsByDate,
  189 + preferDailyLevel: true,
189 ), 190 ),
190 ], 191 ],
191 distributionCounts: 192 distributionCounts:
@@ -198,14 +199,20 @@ class ApiHrvReportDataSource implements HrvReportDataSource { @@ -198,14 +199,20 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
198 HrvDayReport _dayReport( 199 HrvDayReport _dayReport(
199 DateTime date, 200 DateTime date,
200 Map<DateTime, HrvTrendList> trendsByDate, 201 Map<DateTime, HrvTrendList> trendsByDate,
201 - Map<DateTime, HrvStressLevel> levelsByDate,  
202 - ) { 202 + Map<DateTime, HrvStressLevel> levelsByDate, {
  203 + bool preferDailyLevel = false,
  204 + }) {
203 final trend = trendsByDate[date]; 205 final trend = trendsByDate[date];
204 - final level = _stressLevelFromApiId(trend?.state) ?? levelsByDate[date]; 206 + final trendLevel = _stressLevelFromApiId(trend?.state);
  207 + final level = preferDailyLevel
  208 + ? levelsByDate[date]
  209 + : trendLevel ?? levelsByDate[date];
205 return HrvDayReport( 210 return HrvDayReport(
206 date: date, 211 date: date,
207 averageHrv: trend?.hrvAverage?.toDouble(), 212 averageHrv: trend?.hrvAverage?.toDouble(),
208 averageHeartRate: trend?.hrAverage?.round(), 213 averageHeartRate: trend?.hrAverage?.round(),
  214 + lastRestingHrValue: trend?.lastRestingHrValue,
  215 + comprehensiveStressScore: trend?.comprehensiveStressScore?.toDouble(),
209 level: level, 216 level: level,
210 ); 217 );
211 } 218 }
@@ -234,6 +241,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource { @@ -234,6 +241,7 @@ class ApiHrvReportDataSource implements HrvReportDataSource {
234 date: date, 241 date: date,
235 averageHrv: value.toDouble(), 242 averageHrv: value.toDouble(),
236 averageHeartRate: trend?.hrAverage?.round(), 243 averageHeartRate: trend?.hrAverage?.round(),
  244 + lastRestingHrValue: trend?.lastRestingHrValue,
237 level: _stressLevelFromApiId(trend?.state) ?? levelsByDate[date], 245 level: _stressLevelFromApiId(trend?.state) ?? levelsByDate[date],
238 ); 246 );
239 } 247 }
@@ -36,12 +36,16 @@ class HrvDayReport { @@ -36,12 +36,16 @@ class HrvDayReport {
36 required this.date, 36 required this.date,
37 this.averageHrv, 37 this.averageHrv,
38 this.averageHeartRate, 38 this.averageHeartRate,
  39 + this.lastRestingHrValue,
  40 + this.comprehensiveStressScore,
39 this.level, 41 this.level,
40 }); 42 });
41 43
42 final DateTime date; 44 final DateTime date;
43 final double? averageHrv; 45 final double? averageHrv;
44 final int? averageHeartRate; 46 final int? averageHeartRate;
  47 + final int? lastRestingHrValue;
  48 + final double? comprehensiveStressScore;
45 final HrvStressLevel? level; 49 final HrvStressLevel? level;
46 50
47 bool get hasData => averageHrv != null; 51 bool get hasData => averageHrv != null;
@@ -384,6 +388,15 @@ class YearlyHrvReport implements HrvPeriodReport { @@ -384,6 +388,15 @@ class YearlyHrvReport implements HrvPeriodReport {
384 return values.reduce((sum, value) => sum + value) / values.length; 388 return values.reduce((sum, value) => sum + value) / values.length;
385 } 389 }
386 390
  391 + double? averageStressScoreForMonth(int month) {
  392 + final values = daysForMonth(month)
  393 + .map((day) => day.comprehensiveStressScore)
  394 + .whereType<double>()
  395 + .toList();
  396 + if (values.isEmpty) return null;
  397 + return values.reduce((sum, value) => sum + value) / values.length;
  398 + }
  399 +
387 HrvStressLevel? levelForMonth(int month) { 400 HrvStressLevel? levelForMonth(int month) {
388 final levels = daysForMonth(month) 401 final levels = daysForMonth(month)
389 .where((day) => day.averageHrv != null) 402 .where((day) => day.averageHrv != null)
@@ -192,6 +192,7 @@ class _HrvBarChart extends StatefulWidget { @@ -192,6 +192,7 @@ class _HrvBarChart extends StatefulWidget {
192 } 192 }
193 193
194 class _HrvBarChartState extends State<_HrvBarChart> { 194 class _HrvBarChartState extends State<_HrvBarChart> {
  195 + static const _maxStressScore = 100.0;
195 static const _axisLabelWidth = 23.0; 196 static const _axisLabelWidth = 23.0;
196 static const _plotLeft = _axisLabelWidth; 197 static const _plotLeft = _axisLabelWidth;
197 static const _plotRight = 0.0; 198 static const _plotRight = 0.0;
@@ -247,7 +248,7 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -247,7 +248,7 @@ class _HrvBarChartState extends State<_HrvBarChart> {
247 showingTooltipIndicators: _touchedIndex == i ? [1] : [], 248 showingTooltipIndicators: _touchedIndex == i ? [1] : [],
248 barRods: [ 249 barRods: [
249 BarChartRodData( 250 BarChartRodData(
250 - toY: day.averageHrv ?? 0, 251 + toY: _barHeight(day.comprehensiveStressScore),
251 width: widget.isMonth ? 4 : _weekBarWidth, 252 width: widget.isMonth ? 4 : _weekBarWidth,
252 color: day.level == null 253 color: day.level == null
253 ? Colors.transparent 254 ? Colors.transparent
@@ -279,7 +280,7 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -279,7 +280,7 @@ class _HrvBarChartState extends State<_HrvBarChart> {
279 return BarChart( 280 return BarChart(
280 BarChartData( 281 BarChartData(
281 minY: 0, 282 minY: 0,
282 - maxY: 110, 283 + maxY: 100,
283 alignment: widget.isMonth 284 alignment: widget.isMonth
284 ? BarChartAlignment.spaceAround 285 ? BarChartAlignment.spaceAround
285 : BarChartAlignment.center, 286 : BarChartAlignment.center,
@@ -441,7 +442,7 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -441,7 +442,7 @@ class _HrvBarChartState extends State<_HrvBarChart> {
441 ), 442 ),
442 TextSpan( 443 TextSpan(
443 text: 444 text:
444 - '${day.averageHrv?.round() ?? '-'}ms · ${day.averageHeartRate ?? '-'}bpm', 445 + '${day.averageHrv?.round() ?? '-'}ms · ${day.lastRestingHrValue ?? '-'}bpm',
445 style: const TextStyle( 446 style: const TextStyle(
446 color: _tooltipMetaColor, 447 color: _tooltipMetaColor,
447 fontSize: 10, 448 fontSize: 10,
@@ -474,9 +475,14 @@ class _HrvBarChartState extends State<_HrvBarChart> { @@ -474,9 +475,14 @@ class _HrvBarChartState extends State<_HrvBarChart> {
474 475
475 double _tooltipBottomForHeight(double height) { 476 double _tooltipBottomForHeight(double height) {
476 final plotHeight = height - _bottomTitleHeight; 477 final plotHeight = height - _bottomTitleHeight;
477 - return plotHeight * (1 - _tooltipAnchorY / 110) - _tooltipMargin; 478 + return plotHeight * (1 - _tooltipAnchorY / 100) - _tooltipMargin;
478 } 479 }
479 480
  481 + double _barHeight(double? comprehensiveStressScore) =>
  482 + (_maxStressScore - (comprehensiveStressScore ?? _maxStressScore))
  483 + .clamp(0, _maxStressScore)
  484 + .toDouble();
  485 +
480 int? _indexForTouch(double? dx, double chartWidth) { 486 int? _indexForTouch(double? dx, double chartWidth) {
481 if (dx == null || chartWidth <= 0 || widget.report.days.isEmpty) { 487 if (dx == null || chartWidth <= 0 || widget.report.days.isEmpty) {
482 return null; 488 return null;
@@ -106,6 +106,7 @@ class _YearBarChart extends StatefulWidget { @@ -106,6 +106,7 @@ class _YearBarChart extends StatefulWidget {
106 } 106 }
107 107
108 class _YearBarChartState extends State<_YearBarChart> { 108 class _YearBarChartState extends State<_YearBarChart> {
  109 + static const _maxStressScore = 100.0;
109 static const _axisLabelWidth = 23.0; 110 static const _axisLabelWidth = 23.0;
110 static const _plotLeft = _axisLabelWidth; 111 static const _plotLeft = _axisLabelWidth;
111 static const _plotRight = 0.0; 112 static const _plotRight = 0.0;
@@ -168,7 +169,8 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -168,7 +169,8 @@ class _YearBarChartState extends State<_YearBarChart> {
168 _touchedMonth == month ? [1] : [], 169 _touchedMonth == month ? [1] : [],
169 barRods: [ 170 barRods: [
170 BarChartRodData( 171 BarChartRodData(
171 - toY: widget.report.averageForMonth(month) ?? 0, 172 + toY: _barHeight(widget.report
  173 + .averageStressScoreForMonth(month)),
172 width: 8, 174 width: 8,
173 color: _colorForMonth(month), 175 color: _colorForMonth(month),
174 borderRadius: const BorderRadius.vertical( 176 borderRadius: const BorderRadius.vertical(
@@ -365,6 +367,11 @@ class _YearBarChartState extends State<_YearBarChart> { @@ -365,6 +367,11 @@ class _YearBarChartState extends State<_YearBarChart> {
365 return plotHeight * (1 - _tooltipAnchorY / 100) - _tooltipMargin; 367 return plotHeight * (1 - _tooltipAnchorY / 100) - _tooltipMargin;
366 } 368 }
367 369
  370 + double _barHeight(double? comprehensiveStressScore) =>
  371 + (_maxStressScore - (comprehensiveStressScore ?? _maxStressScore))
  372 + .clamp(0, _maxStressScore)
  373 + .toDouble();
  374 +
368 int? _monthForTouch(double? dx, double chartWidth) { 375 int? _monthForTouch(double? dx, double chartWidth) {
369 if (dx == null || chartWidth <= 0) return null; 376 if (dx == null || chartWidth <= 0) return null;
370 return ((dx / chartWidth) * 12).floor().clamp(0, 11).toInt() + 1; 377 return ((dx / chartWidth) * 12).floor().clamp(0, 11).toInt() + 1;
@@ -9,6 +9,13 @@ DateTime _today() { @@ -9,6 +9,13 @@ DateTime _today() {
9 return DateTime(now.year, now.month, now.day); 9 return DateTime(now.year, now.month, now.day);
10 } 10 }
11 11
  12 +class _ReportPeriodAnchor {
  13 + const _ReportPeriodAnchor({required this.date, this.sourceMonth});
  14 +
  15 + final DateTime date;
  16 + final DateTime? sourceMonth;
  17 +}
  18 +
12 abstract class ReportPeriodLogic { 19 abstract class ReportPeriodLogic {
13 ReportPeriodLogic({this.forceRefresh = true}); 20 ReportPeriodLogic({this.forceRefresh = true});
14 21
@@ -16,7 +23,7 @@ abstract class ReportPeriodLogic { @@ -16,7 +23,7 @@ abstract class ReportPeriodLogic {
16 final selectedPeriod = ReportPeriod.day.obs; 23 final selectedPeriod = ReportPeriod.day.obs;
17 final selectedDate = _today().obs; 24 final selectedDate = _today().obs;
18 final isLoading = false.obs; 25 final isLoading = false.obs;
19 - var _anchorDate = _today(); 26 + var _anchor = _ReportPeriodAnchor(date: _today());
20 27
21 List<ReportPeriod> get supportedPeriods => ReportPeriod.values; 28 List<ReportPeriod> get supportedPeriods => ReportPeriod.values;
22 29
@@ -50,7 +57,7 @@ abstract class ReportPeriodLogic { @@ -50,7 +57,7 @@ abstract class ReportPeriodLogic {
50 void initializeQuery(ReportPeriod period, DateTime date) { 57 void initializeQuery(ReportPeriod period, DateTime date) {
51 final nextPeriod = normalizePeriod(period); 58 final nextPeriod = normalizePeriod(period);
52 final nextDate = normalizeDate(nextPeriod, date); 59 final nextDate = normalizeDate(nextPeriod, date);
53 - _anchorDate = _anchorDateFor(nextPeriod, date); 60 + _setAnchorFor(nextPeriod, date);
54 selectedPeriod.value = nextPeriod; 61 selectedPeriod.value = nextPeriod;
55 selectedDate.value = nextDate; 62 selectedDate.value = nextDate;
56 } 63 }
@@ -69,7 +76,7 @@ abstract class ReportPeriodLogic { @@ -69,7 +76,7 @@ abstract class ReportPeriodLogic {
69 return Future.value(); 76 return Future.value();
70 } 77 }
71 if (!sameQuery) { 78 if (!sameQuery) {
72 - _anchorDate = _anchorDateFor(nextPeriod, date); 79 + _setAnchorFor(nextPeriod, date);
73 } 80 }
74 selectedPeriod.value = nextPeriod; 81 selectedPeriod.value = nextPeriod;
75 selectedDate.value = nextDate; 82 selectedDate.value = nextDate;
@@ -132,9 +139,14 @@ abstract class ReportPeriodLogic { @@ -132,9 +139,14 @@ abstract class ReportPeriodLogic {
132 if (!shouldRefresh && selectedPeriod.value == nextPeriod) { 139 if (!shouldRefresh && selectedPeriod.value == nextPeriod) {
133 return Future.value(); 140 return Future.value();
134 } 141 }
135 - final nextDate = normalizeDate(nextPeriod, _anchorDate); 142 + final nextDate = _dateForPeriodTransition(nextPeriod);
  143 + final sourceMonth = selectedPeriod.value == ReportPeriod.month &&
  144 + nextPeriod == ReportPeriod.week
  145 + ? monthStart
  146 + : null;
136 selectedPeriod.value = nextPeriod; 147 selectedPeriod.value = nextPeriod;
137 selectedDate.value = nextDate; 148 selectedDate.value = nextDate;
  149 + _setAnchorFor(nextPeriod, nextDate, sourceMonth: sourceMonth);
138 return loadReport(); 150 return loadReport();
139 } 151 }
140 152
@@ -144,7 +156,7 @@ abstract class ReportPeriodLogic { @@ -144,7 +156,7 @@ abstract class ReportPeriodLogic {
144 }) { 156 }) {
145 final shouldRefresh = forceRefresh ?? this.forceRefresh; 157 final shouldRefresh = forceRefresh ?? this.forceRefresh;
146 final nextDate = ReportDateRangeConfig.clampDate(date); 158 final nextDate = ReportDateRangeConfig.clampDate(date);
147 - _anchorDate = _anchorDateFor(ReportPeriod.day, nextDate); 159 + _setAnchorFor(ReportPeriod.day, nextDate);
148 if (!shouldRefresh && selectedDate.value == nextDate) return Future.value(); 160 if (!shouldRefresh && selectedDate.value == nextDate) return Future.value();
149 selectedDate.value = nextDate; 161 selectedDate.value = nextDate;
150 return loadReport(); 162 return loadReport();
@@ -160,7 +172,7 @@ abstract class ReportPeriodLogic { @@ -160,7 +172,7 @@ abstract class ReportPeriodLogic {
160 ReportDateRangeConfig.firstWeekStart(), 172 ReportDateRangeConfig.firstWeekStart(),
161 ReportDateRangeConfig.lastWeekStart(), 173 ReportDateRangeConfig.lastWeekStart(),
162 ); 174 );
163 - _anchorDate = _anchorDateFor(ReportPeriod.week, nextWeekStart); 175 + _setAnchorFor(ReportPeriod.week, nextWeekStart);
164 if (!shouldRefresh && weekStart == nextWeekStart) return Future.value(); 176 if (!shouldRefresh && weekStart == nextWeekStart) return Future.value();
165 selectedDate.value = nextWeekStart; 177 selectedDate.value = nextWeekStart;
166 return loadReport(); 178 return loadReport();
@@ -177,7 +189,7 @@ abstract class ReportPeriodLogic { @@ -177,7 +189,7 @@ abstract class ReportPeriodLogic {
177 ReportDateRangeConfig.firstMonth(), 189 ReportDateRangeConfig.firstMonth(),
178 ReportDateRangeConfig.lastMonth(), 190 ReportDateRangeConfig.lastMonth(),
179 ); 191 );
180 - _anchorDate = _anchorDateFor(ReportPeriod.month, nextMonth); 192 + _setAnchorFor(ReportPeriod.month, nextMonth);
181 if (!shouldRefresh && monthStart == nextMonth) return Future.value(); 193 if (!shouldRefresh && monthStart == nextMonth) return Future.value();
182 selectedDate.value = nextMonth; 194 selectedDate.value = nextMonth;
183 return loadReport(); 195 return loadReport();
@@ -192,11 +204,11 @@ abstract class ReportPeriodLogic { @@ -192,11 +204,11 @@ abstract class ReportPeriodLogic {
192 ReportDateRangeConfig.firstYear(), 204 ReportDateRangeConfig.firstYear(),
193 ReportDateRangeConfig.lastYear(), 205 ReportDateRangeConfig.lastYear(),
194 ); 206 );
  207 + final nextDate = DateTime(nextYear);
  208 + _setAnchorFor(ReportPeriod.year, nextDate);
195 if (!shouldRefresh && selectedDate.value.year == nextYear) { 209 if (!shouldRefresh && selectedDate.value.year == nextYear) {
196 return Future.value(); 210 return Future.value();
197 } 211 }
198 - final nextDate = DateTime(nextYear);  
199 - _anchorDate = _anchorDateFor(ReportPeriod.year, nextDate);  
200 selectedDate.value = nextDate; 212 selectedDate.value = nextDate;
201 return loadReport(); 213 return loadReport();
202 } 214 }
@@ -224,7 +236,7 @@ abstract class ReportPeriodLogic { @@ -224,7 +236,7 @@ abstract class ReportPeriodLogic {
224 Future<void> previousDay() { 236 Future<void> previousDay() {
225 final candidate = _previousCandidate; 237 final candidate = _previousCandidate;
226 if (_isBeforeRange(candidate)) return Future.value(); 238 if (_isBeforeRange(candidate)) return Future.value();
227 - _anchorDate = _anchorDateFor(selectedPeriod.value, candidate); 239 + _setAnchorFor(selectedPeriod.value, candidate);
228 selectedDate.value = candidate; 240 selectedDate.value = candidate;
229 return loadReport(); 241 return loadReport();
230 } 242 }
@@ -232,7 +244,7 @@ abstract class ReportPeriodLogic { @@ -232,7 +244,7 @@ abstract class ReportPeriodLogic {
232 Future<void> nextDay() { 244 Future<void> nextDay() {
233 final candidate = _nextCandidate; 245 final candidate = _nextCandidate;
234 if (_isAfterRange(candidate)) return Future.value(); 246 if (_isAfterRange(candidate)) return Future.value();
235 - _anchorDate = _anchorDateFor(selectedPeriod.value, candidate); 247 + _setAnchorFor(selectedPeriod.value, candidate);
236 selectedDate.value = candidate; 248 selectedDate.value = candidate;
237 return loadReport(); 249 return loadReport();
238 } 250 }
@@ -241,19 +253,60 @@ abstract class ReportPeriodLogic { @@ -241,19 +253,60 @@ abstract class ReportPeriodLogic {
241 253
242 void dispose() {} 254 void dispose() {}
243 255
244 - DateTime _anchorDateFor(ReportPeriod period, DateTime date) { 256 + void _setAnchorFor(
  257 + ReportPeriod period,
  258 + DateTime date, {
  259 + DateTime? sourceMonth,
  260 + }) {
245 final normalizedPeriod = normalizePeriod(period); 261 final normalizedPeriod = normalizePeriod(period);
246 final normalizedDate = normalizeDate(normalizedPeriod, date); 262 final normalizedDate = normalizeDate(normalizedPeriod, date);
247 - return switch (normalizedPeriod) {  
248 - ReportPeriod.day => normalizedDate,  
249 - ReportPeriod.week => ReportDateRangeConfig.clampDate(  
250 - normalizedDate.add(const Duration(days: 3)), 263 + final weekSourceMonth =
  264 + DateTime(normalizedDate.year, normalizedDate.month);
  265 + _anchor = switch (normalizedPeriod) {
  266 + ReportPeriod.day => _ReportPeriodAnchor(date: normalizedDate),
  267 + ReportPeriod.week => _ReportPeriodAnchor(
  268 + date: ReportDateRangeConfig.clampDate(
  269 + normalizedDate.add(const Duration(days: 6)),
  270 + ),
  271 + sourceMonth: sourceMonth ?? weekSourceMonth,
  272 + ),
  273 + ReportPeriod.month => _ReportPeriodAnchor(
  274 + date: ReportDateRangeConfig.clampDate(
  275 + _lastDayOfMonth(normalizedDate),
  276 + ),
  277 + ),
  278 + ReportPeriod.year => _ReportPeriodAnchor(
  279 + date: ReportDateRangeConfig.clampDate(
  280 + _lastDayOfYear(normalizedDate),
  281 + ),
251 ), 282 ),
252 - ReportPeriod.month => normalizedDate,  
253 - ReportPeriod.year => normalizedDate,  
254 }; 283 };
255 } 284 }
256 285
  286 + DateTime _dateForPeriodTransition(ReportPeriod nextPeriod) {
  287 + final currentPeriod = selectedPeriod.value;
  288 +
  289 + if (currentPeriod == ReportPeriod.week &&
  290 + nextPeriod == ReportPeriod.month) {
  291 + // Preserve the source month when entering from a month. A directly
  292 + // selected week uses the month containing its Monday as the source.
  293 + return normalizeDate(nextPeriod, _anchor.sourceMonth ?? weekStart);
  294 + }
  295 +
  296 + if (currentPeriod == ReportPeriod.week &&
  297 + nextPeriod == ReportPeriod.year) {
  298 + // A cross-year week belongs to the year containing its Monday.
  299 + return normalizeDate(nextPeriod, weekStart);
  300 + }
  301 +
  302 + return normalizeDate(nextPeriod, _anchor.date);
  303 + }
  304 +
  305 + DateTime _lastDayOfYear(DateTime date) => DateTime(date.year + 1, 1, 0);
  306 +
  307 + DateTime _lastDayOfMonth(DateTime date) =>
  308 + DateTime(date.year, date.month + 1, 0);
  309 +
257 DateTime get _previousCandidate => switch (selectedPeriod.value) { 310 DateTime get _previousCandidate => switch (selectedPeriod.value) {
258 ReportPeriod.year => DateTime(selectedDate.value.year - 1), 311 ReportPeriod.year => DateTime(selectedDate.value.year - 1),
259 ReportPeriod.month => 312 ReportPeriod.month =>
@@ -336,6 +336,7 @@ class LocalHealthDataConvert { @@ -336,6 +336,7 @@ class LocalHealthDataConvert {
336 timeKey: dateKey(item.day), 336 timeKey: dateKey(item.day),
337 hrvAverage: item.hrvAverage, 337 hrvAverage: item.hrvAverage,
338 hrAverage: item.hrAverage, 338 hrAverage: item.hrAverage,
  339 + comprehensiveStressScore: item.comprehensiveStressScore,
339 state: item.state, 340 state: item.state,
340 ), 341 ),
341 ]; 342 ];
@@ -706,6 +707,7 @@ class LocalHealthDataConvert { @@ -706,6 +707,7 @@ class LocalHealthDataConvert {
706 day: day, 707 day: day,
707 hrvAverage: hrvAverage?.toDouble(), 708 hrvAverage: hrvAverage?.toDouble(),
708 hrAverage: stress?.stressScore.toDouble(), 709 hrAverage: stress?.stressScore.toDouble(),
  710 + comprehensiveStressScore: stress?.stressScore.toDouble(),
709 state: stress?.state.value, 711 state: stress?.state.value,
710 ); 712 );
711 } 713 }
@@ -736,6 +738,12 @@ class LocalHealthDataConvert { @@ -736,6 +738,12 @@ class LocalHealthDataConvert {
736 hrAverage: _averageOrNull( 738 hrAverage: _averageOrNull(
737 entry.value.map((e) => e.hrAverage).whereType<num>().toList(), 739 entry.value.map((e) => e.hrAverage).whereType<num>().toList(),
738 )?.toDouble(), 740 )?.toDouble(),
  741 + comprehensiveStressScore: _averageOrNull(
  742 + entry.value
  743 + .map((e) => e.comprehensiveStressScore)
  744 + .whereType<num>()
  745 + .toList(),
  746 + )?.toDouble(),
739 state: _modeState(entry.value.map((e) => e.state).whereType<int>()), 747 state: _modeState(entry.value.map((e) => e.state).whereType<int>()),
740 ), 748 ),
741 ]; 749 ];
@@ -1011,12 +1019,14 @@ class _HrvDaySummary { @@ -1011,12 +1019,14 @@ class _HrvDaySummary {
1011 required this.day, 1019 required this.day,
1012 required this.hrvAverage, 1020 required this.hrvAverage,
1013 required this.hrAverage, 1021 required this.hrAverage,
  1022 + required this.comprehensiveStressScore,
1014 required this.state, 1023 required this.state,
1015 }); 1024 });
1016 1025
1017 final DateTime day; 1026 final DateTime day;
1018 final double? hrvAverage; 1027 final double? hrvAverage;
1019 final double? hrAverage; 1028 final double? hrAverage;
  1029 + final double? comprehensiveStressScore;
1020 final int? state; 1030 final int? state;
1021 1031
1022 bool get hasData => hrvAverage != null || hrAverage != null || state != null; 1032 bool get hasData => hrvAverage != null || hrAverage != null || state != null;
@@ -189,6 +189,8 @@ class HrvTrendList { @@ -189,6 +189,8 @@ class HrvTrendList {
189 this.timeKey, 189 this.timeKey,
190 this.hrvAverage, 190 this.hrvAverage,
191 this.hrAverage, 191 this.hrAverage,
  192 + this.lastRestingHrValue,
  193 + this.comprehensiveStressScore,
192 this.state, 194 this.state,
193 }); 195 });
194 196
@@ -196,12 +198,17 @@ class HrvTrendList { @@ -196,12 +198,17 @@ class HrvTrendList {
196 timeKey = json['time_key']; 198 timeKey = json['time_key'];
197 hrvAverage = _parseDouble(json['hrv_average']); 199 hrvAverage = _parseDouble(json['hrv_average']);
198 hrAverage = _parseDouble(json['hr_average']); 200 hrAverage = _parseDouble(json['hr_average']);
  201 + lastRestingHrValue = _parseInt(json['last_resting_hr_value']);
  202 + comprehensiveStressScore =
  203 + _parseDouble(json['comprehensive_stress_score']);
199 state = _parseInt(json['state']); 204 state = _parseInt(json['state']);
200 } 205 }
201 206
202 Object? timeKey; 207 Object? timeKey;
203 double? hrvAverage; 208 double? hrvAverage;
204 double? hrAverage; 209 double? hrAverage;
  210 + int? lastRestingHrValue;
  211 + double? comprehensiveStressScore;
205 /// 当天综合压力 212 /// 当天综合压力
206 int? state; 213 int? state;
207 214
@@ -210,6 +217,8 @@ class HrvTrendList { @@ -210,6 +217,8 @@ class HrvTrendList {
210 map['time_key'] = timeKey; 217 map['time_key'] = timeKey;
211 map['hrv_average'] = hrvAverage; 218 map['hrv_average'] = hrvAverage;
212 map['hr_average'] = hrAverage; 219 map['hr_average'] = hrAverage;
  220 + map['last_resting_hr_value'] = lastRestingHrValue;
  221 + map['comprehensive_stress_score'] = comprehensiveStressScore;
213 map['state'] = state; 222 map['state'] = state;
214 return map; 223 return map;
215 } 224 }
  1 +import 'package:doublefeel_flutter/app/modules/report_common/config/report_date_range_config.dart';
  2 +import 'package:doublefeel_flutter/app/modules/report_common/controllers/report_period_logic.dart';
  3 +import 'package:doublefeel_flutter/app/modules/report_common/models/report_period.dart';
  4 +import 'package:flutter_test/flutter_test.dart';
  5 +
  6 +class _TestReportPeriodLogic extends ReportPeriodLogic {
  7 + _TestReportPeriodLogic() : super(forceRefresh: false);
  8 +
  9 + @override
  10 + Future<void> loadReport() async {}
  11 +}
  12 +
  13 +class _MonthBoundaryReportPeriodLogic extends _TestReportPeriodLogic {
  14 + _MonthBoundaryReportPeriodLogic(this.sourceMonth);
  15 +
  16 + final DateTime sourceMonth;
  17 +
  18 + @override
  19 + DateTime normalizeDate(ReportPeriod period, DateTime date) {
  20 + if (period == ReportPeriod.week &&
  21 + date.year == sourceMonth.year &&
  22 + date.month == sourceMonth.month) {
  23 + // Simulates a month whose latest selectable week starts in the
  24 + // preceding month.
  25 + return sourceMonth.subtract(
  26 + Duration(days: sourceMonth.weekday - DateTime.monday),
  27 + );
  28 + }
  29 + return super.normalizeDate(period, date);
  30 + }
  31 +}
  32 +
  33 +void main() {
  34 + group('ReportPeriodLogic period transitions', () {
  35 + test('year to month selects December for a completed year', () async {
  36 + final logic = _TestReportPeriodLogic();
  37 + final year = DateTime.now().year - 1;
  38 + logic.initializeQuery(ReportPeriod.year, DateTime(year));
  39 +
  40 + await logic.selectPeriod(ReportPeriod.month);
  41 +
  42 + expect(logic.monthStart, DateTime(year, DateTime.december));
  43 + });
  44 +
  45 + test('year to month stops at the current month for the current year',
  46 + () async {
  47 + final logic = _TestReportPeriodLogic();
  48 + final now = DateTime.now();
  49 + logic.initializeQuery(ReportPeriod.year, now);
  50 +
  51 + await logic.selectPeriod(ReportPeriod.month);
  52 +
  53 + expect(logic.monthStart, DateTime(now.year, now.month));
  54 + });
  55 +
  56 + test('year to week selects the week containing the year end', () async {
  57 + final logic = _TestReportPeriodLogic();
  58 + final year = DateTime.now().year - 1;
  59 + final yearEnd = DateTime(year + 1, 1, 0);
  60 + logic.initializeQuery(ReportPeriod.year, DateTime(year));
  61 +
  62 + await logic.selectPeriod(ReportPeriod.week);
  63 +
  64 + expect(
  65 + logic.weekStart,
  66 + yearEnd.subtract(Duration(days: yearEnd.weekday - DateTime.monday)),
  67 + );
  68 + });
  69 +
  70 + test('week to year uses the year containing the week start', () async {
  71 + final logic = _TestReportPeriodLogic();
  72 + final year = DateTime.now().year - 1;
  73 + logic.initializeQuery(ReportPeriod.week, DateTime(year, 12, 1));
  74 +
  75 + await logic.selectPeriod(ReportPeriod.year);
  76 +
  77 + expect(logic.selectedDate.value, DateTime(year));
  78 + });
  79 +
  80 + test('cross-year week to year uses the year containing the week start',
  81 + () async {
  82 + final nowYear = DateTime.now().year;
  83 + final year = [nowYear - 1, nowYear - 2].firstWhere((candidate) {
  84 + final yearEnd = DateTime(candidate + 1, 1, 0);
  85 + return yearEnd.weekday != DateTime.sunday;
  86 + });
  87 + final yearEnd = DateTime(year + 1, 1, 0);
  88 + final weekStart = yearEnd.subtract(
  89 + Duration(days: yearEnd.weekday - DateTime.monday),
  90 + );
  91 + final logic = _TestReportPeriodLogic();
  92 + logic.initializeQuery(ReportPeriod.week, yearEnd);
  93 +
  94 + expect(weekStart.year, year);
  95 + expect(weekStart.add(const Duration(days: 6)).year, year + 1);
  96 +
  97 + await logic.selectPeriod(ReportPeriod.year);
  98 +
  99 + expect(logic.selectedDate.value, DateTime(year));
  100 + });
  101 +
  102 + test('month to week selects the week containing the month end', () async {
  103 + final logic = _TestReportPeriodLogic();
  104 + final month = DateTime(DateTime.now().year - 1, 10);
  105 + final monthEnd = DateTime(month.year, month.month + 1, 0);
  106 + logic.initializeQuery(ReportPeriod.month, month);
  107 +
  108 + await logic.selectPeriod(ReportPeriod.week);
  109 +
  110 + expect(
  111 + logic.weekStart,
  112 + monthEnd.subtract(Duration(days: monthEnd.weekday - DateTime.monday)),
  113 + );
  114 + });
  115 +
  116 + test('month to week to month keeps the source month',
  117 + () async {
  118 + final logic = _TestReportPeriodLogic();
  119 + final year = DateTime.now().year - 1;
  120 + logic.initializeQuery(ReportPeriod.year, DateTime(year));
  121 +
  122 + await logic.selectPeriod(ReportPeriod.month);
  123 + await logic.selectPeriod(ReportPeriod.week);
  124 + await logic.selectPeriod(ReportPeriod.month);
  125 +
  126 + expect(logic.monthStart, DateTime(year, DateTime.december));
  127 + });
  128 +
  129 + test('clamped cross-month week returns to the original source month',
  130 + () async {
  131 + final year = DateTime.now().year - 1;
  132 + final sourceMonth = List<DateTime>.generate(
  133 + 12,
  134 + (index) => DateTime(year, index + 1),
  135 + ).firstWhere((month) => month.weekday != DateTime.monday);
  136 + final logic = _MonthBoundaryReportPeriodLogic(sourceMonth);
  137 + final clampedWeekStart = sourceMonth.subtract(
  138 + Duration(days: sourceMonth.weekday - DateTime.monday),
  139 + );
  140 + logic.initializeQuery(ReportPeriod.month, sourceMonth);
  141 +
  142 + await logic.selectPeriod(ReportPeriod.week);
  143 + expect(logic.weekStart, clampedWeekStart);
  144 +
  145 + await logic.selectPeriod(ReportPeriod.month);
  146 +
  147 + expect(logic.monthStart, sourceMonth);
  148 + });
  149 +
  150 + test('week to day selects Sunday when the whole week is selectable',
  151 + () async {
  152 + final logic = _TestReportPeriodLogic();
  153 + logic.initializeQuery(
  154 + ReportPeriod.week,
  155 + DateTime(DateTime.now().year - 1, 10, 20),
  156 + );
  157 + final expectedDay = logic.weekEnd;
  158 +
  159 + await logic.selectPeriod(ReportPeriod.day);
  160 +
  161 + expect(logic.selectedDate.value, expectedDay);
  162 + });
  163 +
  164 + test('week to day clamps a partial latest week to the latest date',
  165 + () async {
  166 + final logic = _TestReportPeriodLogic();
  167 + logic.initializeQuery(
  168 + ReportPeriod.week,
  169 + ReportDateRangeConfig.lastWeekStart(),
  170 + );
  171 +
  172 + await logic.selectPeriod(ReportPeriod.day);
  173 +
  174 + expect(logic.selectedDate.value, ReportDateRangeConfig.lastDate());
  175 + });
  176 + });
  177 +}
@@ -385,6 +385,10 @@ void main() { @@ -385,6 +385,10 @@ void main() {
385 385
386 expect(statistics.hrvTrendList?.single.hrvAverage, 80); 386 expect(statistics.hrvTrendList?.single.hrvAverage, 80);
387 expect( 387 expect(
  388 + statistics.hrvTrendList?.single.comprehensiveStressScore,
  389 + 34,
  390 + );
  391 + expect(
388 statistics.hrvTrendList?.single.state, 392 statistics.hrvTrendList?.single.state,
389 HealthRawStressState.normal.value, 393 HealthRawStressState.normal.value,
390 ); 394 );