Showing
4 changed files
with
109 additions
and
49 deletions
| @@ -348,7 +348,14 @@ class YearlyHrvReport implements HrvPeriodReport { | @@ -348,7 +348,14 @@ class YearlyHrvReport implements HrvPeriodReport { | ||
| 348 | days.where((day) => day.hasData).toList(); | 348 | days.where((day) => day.hasData).toList(); |
| 349 | 349 | ||
| 350 | @override | 350 | @override |
| 351 | - bool get hasData => daysWithData.isNotEmpty; | 351 | + // The yearly view is a stress report. A month can have a comprehensive |
| 352 | + // stress score even when there is no HRV average for that month. | ||
| 353 | + bool get hasData => days.any( | ||
| 354 | + (day) => | ||
| 355 | + day.comprehensiveStressScore != null || | ||
| 356 | + day.level != null || | ||
| 357 | + day.trendLevel != null, | ||
| 358 | + ); | ||
| 352 | 359 | ||
| 353 | @override | 360 | @override |
| 354 | int get validDays => | 361 | int get validDays => |
| @@ -395,22 +402,16 @@ class YearlyHrvReport implements HrvPeriodReport { | @@ -395,22 +402,16 @@ class YearlyHrvReport implements HrvPeriodReport { | ||
| 395 | }).length; | 402 | }).length; |
| 396 | } | 403 | } |
| 397 | 404 | ||
| 398 | - double? averageForMonth(int month) { | ||
| 399 | - final values = daysForMonth(month) | ||
| 400 | - .map((day) => day.averageHrv) | ||
| 401 | - .whereType<double>() | ||
| 402 | - .toList(); | ||
| 403 | - if (values.isEmpty) return null; | ||
| 404 | - return values.reduce((sum, value) => sum + value) / values.length; | ||
| 405 | - } | ||
| 406 | - | ||
| 407 | - double? averageStressScoreForMonth(int month) { | ||
| 408 | - final values = daysForMonth(month) | ||
| 409 | - .map((day) => day.comprehensiveStressScore) | ||
| 410 | - .whereType<double>() | ||
| 411 | - .toList(); | ||
| 412 | - if (values.isEmpty) return null; | ||
| 413 | - return values.reduce((sum, value) => sum + value) / values.length; | 405 | + /// The yearly API supplies one trend item per month. The data source places |
| 406 | + /// it on the first day of the month, so this reads the server score directly | ||
| 407 | + /// instead of aggregating daily values locally. | ||
| 408 | + double? stressScoreForMonth(int month) { | ||
| 409 | + for (final day in days) { | ||
| 410 | + if (day.date.month == month && day.date.day == 1) { | ||
| 411 | + return day.comprehensiveStressScore; | ||
| 412 | + } | ||
| 413 | + } | ||
| 414 | + return null; | ||
| 414 | } | 415 | } |
| 415 | 416 | ||
| 416 | HrvStressLevel? levelForMonth(int month) { | 417 | HrvStressLevel? levelForMonth(int month) { |
| @@ -421,12 +422,10 @@ class YearlyHrvReport implements HrvPeriodReport { | @@ -421,12 +422,10 @@ class YearlyHrvReport implements HrvPeriodReport { | ||
| 421 | if (trendLevels.isNotEmpty) return trendLevels.first; | 422 | if (trendLevels.isNotEmpty) return trendLevels.first; |
| 422 | 423 | ||
| 423 | final levels = daysForMonth(month) | 424 | final levels = daysForMonth(month) |
| 424 | - .where((day) => day.averageHrv != null) | ||
| 425 | .map((day) => day.level) | 425 | .map((day) => day.level) |
| 426 | .whereType<HrvStressLevel>() | 426 | .whereType<HrvStressLevel>() |
| 427 | .toList(); | 427 | .toList(); |
| 428 | - if (levels.isEmpty) return null; | ||
| 429 | - return levels.first; | 428 | + return levels.isEmpty ? null : levels.first; |
| 430 | } | 429 | } |
| 431 | 430 | ||
| 432 | List<int> get monthsWithLevel => [ | 431 | List<int> get monthsWithLevel => [ |
| @@ -438,10 +437,10 @@ class YearlyHrvReport implements HrvPeriodReport { | @@ -438,10 +437,10 @@ class YearlyHrvReport implements HrvPeriodReport { | ||
| 438 | final stressedValues = <MapEntry<int, double>>[]; | 437 | final stressedValues = <MapEntry<int, double>>[]; |
| 439 | final relaxedValues = <MapEntry<int, double>>[]; | 438 | final relaxedValues = <MapEntry<int, double>>[]; |
| 440 | for (var month = 1; month <= 12; month++) { | 439 | for (var month = 1; month <= 12; month++) { |
| 441 | - final average = averageForMonth(month); | 440 | + final score = stressScoreForMonth(month); |
| 442 | final level = levelForMonth(month); | 441 | final level = levelForMonth(month); |
| 443 | - if (average == null || level == null) continue; | ||
| 444 | - final entry = MapEntry(month, average); | 442 | + if (score == null || level == null) continue; |
| 443 | + final entry = MapEntry(month, score); | ||
| 445 | if (level == HrvStressLevel.attention || | 444 | if (level == HrvStressLevel.attention || |
| 446 | level == HrvStressLevel.overload) { | 445 | level == HrvStressLevel.overload) { |
| 447 | stressedValues.add(entry); | 446 | stressedValues.add(entry); |
| @@ -451,32 +450,21 @@ class YearlyHrvReport implements HrvPeriodReport { | @@ -451,32 +450,21 @@ class YearlyHrvReport implements HrvPeriodReport { | ||
| 451 | } | 450 | } |
| 452 | 451 | ||
| 453 | return ( | 452 | return ( |
| 454 | - mostStressed: _selectMonthsByHrv(stressedValues, lowestFirst: true), | ||
| 455 | - leastStressed: _selectMonthsByHrv(relaxedValues, lowestFirst: false), | 453 | + mostStressed: |
| 454 | + _selectMonthsByStressScore(stressedValues, highestFirst: true), | ||
| 455 | + leastStressed: | ||
| 456 | + _selectMonthsByStressScore(relaxedValues, highestFirst: false), | ||
| 456 | ); | 457 | ); |
| 457 | } | 458 | } |
| 458 | 459 | ||
| 459 | - List<int> _selectMonthsByHrv( | 460 | + List<int> _selectMonthsByStressScore( |
| 460 | List<MapEntry<int, double>> values, { | 461 | List<MapEntry<int, double>> values, { |
| 461 | - required bool lowestFirst, | 462 | + required bool highestFirst, |
| 462 | }) { | 463 | }) { |
| 463 | values.sort((a, b) { | 464 | values.sort((a, b) { |
| 464 | - final valueComparison = lowestFirst | ||
| 465 | - ? a.value.compareTo(b.value) | ||
| 466 | - : b.value.compareTo(a.value); | ||
| 467 | - return valueComparison == 0 ? a.key.compareTo(b.key) : valueComparison; | ||
| 468 | - }); | ||
| 469 | - return values.take(2).map((entry) => entry.key).toList()..sort(); | ||
| 470 | - } | ||
| 471 | - | ||
| 472 | - List<int> monthsAtExtreme({required bool maximum}) { | ||
| 473 | - final values = [ | ||
| 474 | - for (var month = 1; month <= 12; month++) | ||
| 475 | - if (averageForMonth(month) case final value?) MapEntry(month, value), | ||
| 476 | - ]; | ||
| 477 | - values.sort((a, b) { | ||
| 478 | - final valueComparison = | ||
| 479 | - maximum ? b.value.compareTo(a.value) : a.value.compareTo(b.value); | 465 | + final valueComparison = highestFirst |
| 466 | + ? b.value.compareTo(a.value) | ||
| 467 | + : a.value.compareTo(b.value); | ||
| 480 | return valueComparison == 0 ? a.key.compareTo(b.key) : valueComparison; | 468 | return valueComparison == 0 ? a.key.compareTo(b.key) : valueComparison; |
| 481 | }); | 469 | }); |
| 482 | return values.take(2).map((entry) => entry.key).toList()..sort(); | 470 | return values.take(2).map((entry) => entry.key).toList()..sort(); |
| @@ -170,7 +170,7 @@ class _YearBarChartState extends State<_YearBarChart> { | @@ -170,7 +170,7 @@ class _YearBarChartState extends State<_YearBarChart> { | ||
| 170 | barRods: [ | 170 | barRods: [ |
| 171 | BarChartRodData( | 171 | BarChartRodData( |
| 172 | toY: _barHeight(widget.report | 172 | toY: _barHeight(widget.report |
| 173 | - .averageStressScoreForMonth(month)), | 173 | + .stressScoreForMonth(month)), |
| 174 | width: 8, | 174 | width: 8, |
| 175 | color: _colorForMonth(month), | 175 | color: _colorForMonth(month), |
| 176 | borderRadius: const BorderRadius.vertical( | 176 | borderRadius: const BorderRadius.vertical( |
| @@ -242,7 +242,7 @@ class _YearBarChartState extends State<_YearBarChart> { | @@ -242,7 +242,7 @@ class _YearBarChartState extends State<_YearBarChart> { | ||
| 242 | constraints.maxWidth, | 242 | constraints.maxWidth, |
| 243 | ); | 243 | ); |
| 244 | final hasTouchedData = month != null && | 244 | final hasTouchedData = month != null && |
| 245 | - widget.report.averageForMonth(month) != null; | 245 | + widget.report.stressScoreForMonth(month) != null; |
| 246 | final nextMonth = hasTouchedData ? month : null; | 246 | final nextMonth = hasTouchedData ? month : null; |
| 247 | final nextOffset = | 247 | final nextOffset = |
| 248 | nextMonth == null ? null : response?.spot?.offset; | 248 | nextMonth == null ? null : response?.spot?.offset; |
| @@ -325,7 +325,7 @@ class _YearBarChartState extends State<_YearBarChart> { | @@ -325,7 +325,7 @@ class _YearBarChartState extends State<_YearBarChart> { | ||
| 325 | } | 325 | } |
| 326 | 326 | ||
| 327 | BarTooltipItem? _tooltipItem(BuildContext context, int month) { | 327 | BarTooltipItem? _tooltipItem(BuildContext context, int month) { |
| 328 | - if (widget.report.averageForMonth(month) == null) return null; | 328 | + if (widget.report.stressScoreForMonth(month) == null) return null; |
| 329 | final stressedDays = widget.report.stressedDaysForMonth(month); | 329 | final stressedDays = widget.report.stressedDaysForMonth(month); |
| 330 | final relaxedDays = widget.report.relaxedDaysForMonth(month); | 330 | final relaxedDays = widget.report.relaxedDaysForMonth(month); |
| 331 | return BarTooltipItem( | 331 | return BarTooltipItem( |
| 1 | +import 'package:doublefeel_flutter/app/modules/home/widgets/today/realtime_stress_explanation_bottom_sheet.dart'; | ||
| 1 | import 'package:doublefeel_flutter/core/theme/app_theme.dart'; | 2 | import 'package:doublefeel_flutter/core/theme/app_theme.dart'; |
| 2 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; | 3 | import 'package:doublefeel_flutter/l10n/l10n_extensions.dart'; |
| 3 | import 'package:flutter/material.dart'; | 4 | import 'package:flutter/material.dart'; |
| @@ -239,7 +240,12 @@ class _FaqLinksCard extends StatelessWidget { | @@ -239,7 +240,12 @@ class _FaqLinksCard extends StatelessWidget { | ||
| 239 | crossAxisAlignment: CrossAxisAlignment.start, | 240 | crossAxisAlignment: CrossAxisAlignment.start, |
| 240 | children: [ | 241 | children: [ |
| 241 | _FaqLink(l10n.todayFaqLinkNoData), | 242 | _FaqLink(l10n.todayFaqLinkNoData), |
| 242 | - _FaqLink(l10n.todayFaqLinkHrvRealtimeUpdate), | 243 | + _FaqLink( |
| 244 | + l10n.todayFaqLinkHrvRealtimeUpdate, | ||
| 245 | + onFaqTap: (text) { | ||
| 246 | + showRealtimeStressExplanationBottomSheet(Get.context!); | ||
| 247 | + }, | ||
| 248 | + ), | ||
| 243 | _FaqLink(l10n.todayFaqLinkWatchNoStatusAndInteractionNotification), | 249 | _FaqLink(l10n.todayFaqLinkWatchNoStatusAndInteractionNotification), |
| 244 | _FaqLink(l10n.todayFaqLinkWatchFaceDataDelay), | 250 | _FaqLink(l10n.todayFaqLinkWatchFaceDataDelay), |
| 245 | _FaqLink(l10n.todayFaqLinkWatchFaceBlackScreen, bottomSpacing: 0), | 251 | _FaqLink(l10n.todayFaqLinkWatchFaceBlackScreen, bottomSpacing: 0), |
| @@ -250,16 +256,23 @@ class _FaqLinksCard extends StatelessWidget { | @@ -250,16 +256,23 @@ class _FaqLinksCard extends StatelessWidget { | ||
| 250 | } | 256 | } |
| 251 | 257 | ||
| 252 | class _FaqLink extends StatelessWidget { | 258 | class _FaqLink extends StatelessWidget { |
| 253 | - const _FaqLink(this.text, {this.bottomSpacing = 14}); | 259 | + const _FaqLink(this.text, {this.bottomSpacing = 14, this.onFaqTap}); |
| 254 | 260 | ||
| 255 | final String text; | 261 | final String text; |
| 256 | final double bottomSpacing; | 262 | final double bottomSpacing; |
| 263 | + final Function(String)? onFaqTap; | ||
| 257 | 264 | ||
| 258 | @override | 265 | @override |
| 259 | Widget build(BuildContext context) { | 266 | Widget build(BuildContext context) { |
| 260 | return GestureDetector( | 267 | return GestureDetector( |
| 261 | behavior: HitTestBehavior.opaque, | 268 | behavior: HitTestBehavior.opaque, |
| 262 | - onTap: showTodayFaqBottomSheet, | 269 | + onTap: () { |
| 270 | + if (onFaqTap != null) { | ||
| 271 | + onFaqTap?.call(text); | ||
| 272 | + } else { | ||
| 273 | + showTodayFaqBottomSheet(text: text); | ||
| 274 | + } | ||
| 275 | + }, | ||
| 263 | child: Padding( | 276 | child: Padding( |
| 264 | padding: EdgeInsets.only(bottom: bottomSpacing), | 277 | padding: EdgeInsets.only(bottom: bottomSpacing), |
| 265 | child: Text( | 278 | child: Text( |
| @@ -18,4 +18,63 @@ void main() { | @@ -18,4 +18,63 @@ void main() { | ||
| 18 | ); | 18 | ); |
| 19 | }, | 19 | }, |
| 20 | ); | 20 | ); |
| 21 | + | ||
| 22 | + test('yearly stress extremes use comprehensive stress scores', () { | ||
| 23 | + final report = YearlyHrvReport( | ||
| 24 | + year: 2026, | ||
| 25 | + days: [ | ||
| 26 | + HrvDayReport( | ||
| 27 | + date: DateTime(2026, 1, 1), | ||
| 28 | + averageHrv: 20, | ||
| 29 | + comprehensiveStressScore: 30, | ||
| 30 | + level: HrvStressLevel.attention, | ||
| 31 | + ), | ||
| 32 | + HrvDayReport( | ||
| 33 | + date: DateTime(2026, 2, 1), | ||
| 34 | + averageHrv: 90, | ||
| 35 | + comprehensiveStressScore: 90, | ||
| 36 | + level: HrvStressLevel.overload, | ||
| 37 | + ), | ||
| 38 | + HrvDayReport( | ||
| 39 | + date: DateTime(2026, 3, 1), | ||
| 40 | + comprehensiveStressScore: 10, | ||
| 41 | + ), | ||
| 42 | + ], | ||
| 43 | + ); | ||
| 44 | + | ||
| 45 | + final extremes = report.stressExtremeMonths(); | ||
| 46 | + | ||
| 47 | + expect(extremes.mostStressed, [1, 2]); | ||
| 48 | + expect(extremes.leastStressed, [3]); | ||
| 49 | + expect(report.hasData, isTrue); | ||
| 50 | + }); | ||
| 51 | + | ||
| 52 | + test('yearly stress extremes break score ties by month', () { | ||
| 53 | + final report = YearlyHrvReport( | ||
| 54 | + year: 2026, | ||
| 55 | + days: [ | ||
| 56 | + HrvDayReport( | ||
| 57 | + date: DateTime(2026, 3, 1), | ||
| 58 | + comprehensiveStressScore: 50, | ||
| 59 | + level: HrvStressLevel.attention, | ||
| 60 | + ), | ||
| 61 | + HrvDayReport( | ||
| 62 | + date: DateTime(2026, 1, 1), | ||
| 63 | + comprehensiveStressScore: 50, | ||
| 64 | + level: HrvStressLevel.attention, | ||
| 65 | + ), | ||
| 66 | + HrvDayReport( | ||
| 67 | + date: DateTime(2026, 2, 1), | ||
| 68 | + comprehensiveStressScore: 30, | ||
| 69 | + level: HrvStressLevel.normal, | ||
| 70 | + ), | ||
| 71 | + ], | ||
| 72 | + ); | ||
| 73 | + | ||
| 74 | + final extremes = report.stressExtremeMonths(); | ||
| 75 | + | ||
| 76 | + expect(extremes.mostStressed, [1, 3]); | ||
| 77 | + expect(extremes.leastStressed, [2]); | ||
| 78 | + }); | ||
| 79 | + | ||
| 21 | } | 80 | } |
-
Please register or login to post a comment