Commit 206eb9bf2889878d1ebf295fdc8b0dcf4e15bb9c

Authored by 刘宏哲
1 parent 26d0ee4c

feat(app): bug fixed

... ... @@ -348,7 +348,14 @@ class YearlyHrvReport implements HrvPeriodReport {
days.where((day) => day.hasData).toList();
@override
bool get hasData => daysWithData.isNotEmpty;
// The yearly view is a stress report. A month can have a comprehensive
// stress score even when there is no HRV average for that month.
bool get hasData => days.any(
(day) =>
day.comprehensiveStressScore != null ||
day.level != null ||
day.trendLevel != null,
);
@override
int get validDays =>
... ... @@ -395,22 +402,16 @@ class YearlyHrvReport implements HrvPeriodReport {
}).length;
}
double? averageForMonth(int month) {
final values = daysForMonth(month)
.map((day) => day.averageHrv)
.whereType<double>()
.toList();
if (values.isEmpty) return null;
return values.reduce((sum, value) => sum + value) / values.length;
}
double? averageStressScoreForMonth(int month) {
final values = daysForMonth(month)
.map((day) => day.comprehensiveStressScore)
.whereType<double>()
.toList();
if (values.isEmpty) return null;
return values.reduce((sum, value) => sum + value) / values.length;
/// The yearly API supplies one trend item per month. The data source places
/// it on the first day of the month, so this reads the server score directly
/// instead of aggregating daily values locally.
double? stressScoreForMonth(int month) {
for (final day in days) {
if (day.date.month == month && day.date.day == 1) {
return day.comprehensiveStressScore;
}
}
return null;
}
HrvStressLevel? levelForMonth(int month) {
... ... @@ -421,12 +422,10 @@ class YearlyHrvReport implements HrvPeriodReport {
if (trendLevels.isNotEmpty) return trendLevels.first;
final levels = daysForMonth(month)
.where((day) => day.averageHrv != null)
.map((day) => day.level)
.whereType<HrvStressLevel>()
.toList();
if (levels.isEmpty) return null;
return levels.first;
return levels.isEmpty ? null : levels.first;
}
List<int> get monthsWithLevel => [
... ... @@ -438,10 +437,10 @@ class YearlyHrvReport implements HrvPeriodReport {
final stressedValues = <MapEntry<int, double>>[];
final relaxedValues = <MapEntry<int, double>>[];
for (var month = 1; month <= 12; month++) {
final average = averageForMonth(month);
final score = stressScoreForMonth(month);
final level = levelForMonth(month);
if (average == null || level == null) continue;
final entry = MapEntry(month, average);
if (score == null || level == null) continue;
final entry = MapEntry(month, score);
if (level == HrvStressLevel.attention ||
level == HrvStressLevel.overload) {
stressedValues.add(entry);
... ... @@ -451,32 +450,21 @@ class YearlyHrvReport implements HrvPeriodReport {
}
return (
mostStressed: _selectMonthsByHrv(stressedValues, lowestFirst: true),
leastStressed: _selectMonthsByHrv(relaxedValues, lowestFirst: false),
mostStressed:
_selectMonthsByStressScore(stressedValues, highestFirst: true),
leastStressed:
_selectMonthsByStressScore(relaxedValues, highestFirst: false),
);
}
List<int> _selectMonthsByHrv(
List<int> _selectMonthsByStressScore(
List<MapEntry<int, double>> values, {
required bool lowestFirst,
required bool highestFirst,
}) {
values.sort((a, b) {
final valueComparison = lowestFirst
? a.value.compareTo(b.value)
: b.value.compareTo(a.value);
return valueComparison == 0 ? a.key.compareTo(b.key) : valueComparison;
});
return values.take(2).map((entry) => entry.key).toList()..sort();
}
List<int> monthsAtExtreme({required bool maximum}) {
final values = [
for (var month = 1; month <= 12; month++)
if (averageForMonth(month) case final value?) MapEntry(month, value),
];
values.sort((a, b) {
final valueComparison =
maximum ? b.value.compareTo(a.value) : a.value.compareTo(b.value);
final valueComparison = highestFirst
? b.value.compareTo(a.value)
: a.value.compareTo(b.value);
return valueComparison == 0 ? a.key.compareTo(b.key) : valueComparison;
});
return values.take(2).map((entry) => entry.key).toList()..sort();
... ...
... ... @@ -170,7 +170,7 @@ class _YearBarChartState extends State<_YearBarChart> {
barRods: [
BarChartRodData(
toY: _barHeight(widget.report
.averageStressScoreForMonth(month)),
.stressScoreForMonth(month)),
width: 8,
color: _colorForMonth(month),
borderRadius: const BorderRadius.vertical(
... ... @@ -242,7 +242,7 @@ class _YearBarChartState extends State<_YearBarChart> {
constraints.maxWidth,
);
final hasTouchedData = month != null &&
widget.report.averageForMonth(month) != null;
widget.report.stressScoreForMonth(month) != null;
final nextMonth = hasTouchedData ? month : null;
final nextOffset =
nextMonth == null ? null : response?.spot?.offset;
... ... @@ -325,7 +325,7 @@ class _YearBarChartState extends State<_YearBarChart> {
}
BarTooltipItem? _tooltipItem(BuildContext context, int month) {
if (widget.report.averageForMonth(month) == null) return null;
if (widget.report.stressScoreForMonth(month) == null) return null;
final stressedDays = widget.report.stressedDaysForMonth(month);
final relaxedDays = widget.report.relaxedDaysForMonth(month);
return BarTooltipItem(
... ...
import 'package:doublefeel_flutter/app/modules/home/widgets/today/realtime_stress_explanation_bottom_sheet.dart';
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
import 'package:flutter/material.dart';
... ... @@ -239,7 +240,12 @@ class _FaqLinksCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FaqLink(l10n.todayFaqLinkNoData),
_FaqLink(l10n.todayFaqLinkHrvRealtimeUpdate),
_FaqLink(
l10n.todayFaqLinkHrvRealtimeUpdate,
onFaqTap: (text) {
showRealtimeStressExplanationBottomSheet(Get.context!);
},
),
_FaqLink(l10n.todayFaqLinkWatchNoStatusAndInteractionNotification),
_FaqLink(l10n.todayFaqLinkWatchFaceDataDelay),
_FaqLink(l10n.todayFaqLinkWatchFaceBlackScreen, bottomSpacing: 0),
... ... @@ -250,16 +256,23 @@ class _FaqLinksCard extends StatelessWidget {
}
class _FaqLink extends StatelessWidget {
const _FaqLink(this.text, {this.bottomSpacing = 14});
const _FaqLink(this.text, {this.bottomSpacing = 14, this.onFaqTap});
final String text;
final double bottomSpacing;
final Function(String)? onFaqTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showTodayFaqBottomSheet,
onTap: () {
if (onFaqTap != null) {
onFaqTap?.call(text);
} else {
showTodayFaqBottomSheet(text: text);
}
},
child: Padding(
padding: EdgeInsets.only(bottom: bottomSpacing),
child: Text(
... ...
... ... @@ -18,4 +18,63 @@ void main() {
);
},
);
test('yearly stress extremes use comprehensive stress scores', () {
final report = YearlyHrvReport(
year: 2026,
days: [
HrvDayReport(
date: DateTime(2026, 1, 1),
averageHrv: 20,
comprehensiveStressScore: 30,
level: HrvStressLevel.attention,
),
HrvDayReport(
date: DateTime(2026, 2, 1),
averageHrv: 90,
comprehensiveStressScore: 90,
level: HrvStressLevel.overload,
),
HrvDayReport(
date: DateTime(2026, 3, 1),
comprehensiveStressScore: 10,
),
],
);
final extremes = report.stressExtremeMonths();
expect(extremes.mostStressed, [1, 2]);
expect(extremes.leastStressed, [3]);
expect(report.hasData, isTrue);
});
test('yearly stress extremes break score ties by month', () {
final report = YearlyHrvReport(
year: 2026,
days: [
HrvDayReport(
date: DateTime(2026, 3, 1),
comprehensiveStressScore: 50,
level: HrvStressLevel.attention,
),
HrvDayReport(
date: DateTime(2026, 1, 1),
comprehensiveStressScore: 50,
level: HrvStressLevel.attention,
),
HrvDayReport(
date: DateTime(2026, 2, 1),
comprehensiveStressScore: 30,
level: HrvStressLevel.normal,
),
],
);
final extremes = report.stressExtremeMonths();
expect(extremes.mostStressed, [1, 3]);
expect(extremes.leastStressed, [2]);
});
}
... ...