Commit 206eb9bf2889878d1ebf295fdc8b0dcf4e15bb9c

Authored by 刘宏哲
1 parent 26d0ee4c

feat(app): bug fixed

@@ -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 }