Commit cbc1b20105d61b40c1a2d653510890feb4294a7a
1 parent
3e129759
feat(app): hrv report add comprehensive_stress_score
Showing
9 changed files
with
313 additions
and
26 deletions
| @@ -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 | ); |
-
Please register or login to post a comment