|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
|
|
import '../../report_common/widgets/chart_selection_line_overlay.dart';
|
|
|
|
import '../models/hrv_report_models.dart';
|
|
|
|
|
|
|
|
class HrvWeekReportView extends StatelessWidget {
|
|
|
|
const HrvWeekReportView({super.key, required this.report});
|
|
|
|
|
|
|
|
final WeeklyHrvReport? report;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final data = report ?? WeeklyHrvReport.empty(DateTime(2026, 5, 4));
|
|
|
|
return _HrvPeriodReportView(report: data, isMonth: false);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class HrvMonthReportView extends StatelessWidget {
|
|
|
|
const HrvMonthReportView({super.key, required this.report});
|
|
|
|
|
|
|
|
final MonthlyHrvReport? report;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final data = report ?? MonthlyHrvReport.empty(DateTime(2026, 5));
|
|
|
|
return _HrvPeriodReportView(report: data, isMonth: true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HrvPeriodReportView extends StatelessWidget {
|
|
|
|
const _HrvPeriodReportView({required this.report, required this.isMonth});
|
|
|
|
|
|
|
|
final HrvPeriodReport report;
|
|
|
|
final bool isMonth;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Column(
|
|
|
|
children: [
|
|
|
|
_DailyStressCard(report: report, isMonth: isMonth),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
_DistributionCard(report: report),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _DailyStressCard extends StatelessWidget {
|
|
|
|
const _DailyStressCard({required this.report, required this.isMonth});
|
|
|
|
|
|
|
|
final HrvPeriodReport report;
|
|
|
|
final bool isMonth;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return _Card(
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
const Text(
|
|
|
|
'每日压力趋势',
|
|
|
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
|
|
|
),
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
SizedBox(
|
|
|
|
height: isMonth ? 160 : 174,
|
|
|
|
child: _HrvBarChart(report: report, isMonth: isMonth),
|
|
|
|
),
|
|
|
|
const Divider(height: 25, color: Color(0xFFF3F3F3)),
|
|
|
|
Row(
|
|
|
|
children: [
|
|
|
|
Expanded(
|
|
|
|
child: _TrendMetric(
|
|
|
|
label: '较轻松',
|
|
|
|
value: report.hasData ? '${report.relaxedDays}' : '-',
|
|
|
|
previousValue: report.previousRelaxedDays,
|
|
|
|
currentValue: report.relaxedDays,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Expanded(
|
|
|
|
child: _TrendMetric(
|
|
|
|
label: '压力较大',
|
|
|
|
value: report.hasData ? '${report.stressedDays}' : '-',
|
|
|
|
previousValue: report.previousStressedDays,
|
|
|
|
currentValue: report.stressedDays,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HrvBarChart extends StatefulWidget {
|
|
|
|
const _HrvBarChart({required this.report, required this.isMonth});
|
|
|
|
|
|
|
|
final HrvPeriodReport report;
|
|
|
|
final bool isMonth;
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<_HrvBarChart> createState() => _HrvBarChartState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HrvBarChartState extends State<_HrvBarChart> {
|
|
|
|
int? _touchedIndex;
|
|
|
|
Offset? _touchedOffset;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final bars = <BarChartGroupData>[];
|
|
|
|
for (var i = 0; i < widget.report.days.length; i++) {
|
|
|
|
final day = widget.report.days[i];
|
|
|
|
bars.add(
|
|
|
|
BarChartGroupData(
|
|
|
|
x: i,
|
|
|
|
barRods: [
|
|
|
|
BarChartRodData(
|
|
|
|
toY: day.averageHrv ?? 0,
|
|
|
|
width: widget.isMonth ? 4 : 13,
|
|
|
|
color: day.level == null
|
|
|
|
? Colors.transparent
|
|
|
|
: Color(day.level!.colorValue),
|
|
|
|
borderRadius: const BorderRadius.vertical(
|
|
|
|
top: Radius.circular(7),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
return Stack(
|
|
|
|
alignment: Alignment.center,
|
|
|
|
children: [
|
|
|
|
BarChart(
|
|
|
|
BarChartData(
|
|
|
|
minY: 0,
|
|
|
|
maxY: 110,
|
|
|
|
alignment: BarChartAlignment.spaceAround,
|
|
|
|
barGroups: bars,
|
|
|
|
borderData: FlBorderData(show: false),
|
|
|
|
gridData: FlGridData(
|
|
|
|
show: true,
|
|
|
|
drawVerticalLine: false,
|
|
|
|
horizontalInterval: 27.5,
|
|
|
|
getDrawingHorizontalLine: (_) => const FlLine(
|
|
|
|
color: Color(0xFFF3F3F3),
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
titlesData: FlTitlesData(
|
|
|
|
topTitles:
|
|
|
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
rightTitles:
|
|
|
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
leftTitles:
|
|
|
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
bottomTitles: AxisTitles(
|
|
|
|
sideTitles: SideTitles(
|
|
|
|
showTitles: true,
|
|
|
|
reservedSize: 31,
|
|
|
|
interval: 1,
|
|
|
|
getTitlesWidget: (value, meta) {
|
|
|
|
final index = value.toInt();
|
|
|
|
if (index < 0 || index >= widget.report.days.length) {
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
}
|
|
|
|
final day = widget.report.days[index];
|
|
|
|
if (widget.isMonth &&
|
|
|
|
!const [1, 5, 10, 15, 20, 25, 31]
|
|
|
|
.contains(day.date.day)) {
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
}
|
|
|
|
const weekdays = ['一', '二', '三', '四', '五', '六', '日'];
|
|
|
|
return Padding(
|
|
|
|
padding: const EdgeInsets.only(top: 5),
|
|
|
|
child: Text(
|
|
|
|
widget.isMonth
|
|
|
|
? '${day.date.day}'
|
|
|
|
: '${day.date.day}\n${weekdays[index]}',
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xFFB0B0B6),
|
|
|
|
fontSize: 9,
|
|
|
|
height: 1.35,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
barTouchData: BarTouchData(
|
|
|
|
enabled: widget.report.hasData,
|
|
|
|
touchCallback: (event, response) {
|
|
|
|
final touchedIndex = response?.spot?.touchedBarGroupIndex;
|
|
|
|
final hasTouchedData = touchedIndex != null &&
|
|
|
|
touchedIndex >= 0 &&
|
|
|
|
touchedIndex < widget.report.days.length &&
|
|
|
|
widget.report.days[touchedIndex].averageHrv != null;
|
|
|
|
final nextIndex =
|
|
|
|
event.isInterestedForInteractions && hasTouchedData
|
|
|
|
? touchedIndex
|
|
|
|
: null;
|
|
|
|
final nextOffset =
|
|
|
|
nextIndex == null ? null : response?.spot?.offset;
|
|
|
|
if (_touchedIndex != nextIndex ||
|
|
|
|
_touchedOffset != nextOffset) {
|
|
|
|
setState(() {
|
|
|
|
_touchedIndex = nextIndex;
|
|
|
|
_touchedOffset = nextOffset;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
touchTooltipData: BarTouchTooltipData(
|
|
|
|
getTooltipColor: (_) => const Color(0xFFF3F3F3),
|
|
|
|
tooltipRoundedRadius: 8,
|
|
|
|
tooltipBorder: BorderSide.none,
|
|
|
|
tooltipPadding: const EdgeInsets.fromLTRB(8, 7, 8, 6),
|
|
|
|
tooltipMargin: 2,
|
|
|
|
maxContentWidth: 128,
|
|
|
|
fitInsideHorizontally: true,
|
|
|
|
fitInsideVertically: false,
|
|
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
|
|
final day = widget.report.days[group.x];
|
|
|
|
if (day.averageHrv == null) return null;
|
|
|
|
return BarTooltipItem(
|
|
|
|
'${day.date.month}月${day.date.day}日\n',
|
|
|
|
const TextStyle(color: Color(0xFF78787D), fontSize: 10),
|
|
|
|
children: [
|
|
|
|
TextSpan(
|
|
|
|
text: '${day.level?.label ?? ''}\n',
|
|
|
|
style: TextStyle(
|
|
|
|
color: Color(
|
|
|
|
day.level?.colorValue ?? 0xFFB0B0B6,
|
|
|
|
),
|
|
|
|
fontSize: 14,
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
TextSpan(
|
|
|
|
text:
|
|
|
|
'${day.averageHrv?.round() ?? '-'}ms · ${day.averageHeartRate ?? '-'}bpm',
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xFFB0B0B6),
|
|
|
|
fontSize: 10,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
if (_touchedOffset != null)
|
|
|
|
Positioned.fill(
|
|
|
|
child: ChartSelectionLineOverlay(
|
|
|
|
offset: _touchedOffset!,
|
|
|
|
color: const Color(0xFFB0B0B6),
|
|
|
|
bottomTitleHeight: 31,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
if (!widget.report.hasData)
|
|
|
|
const Text(
|
|
|
|
'暂无数据',
|
|
|
|
style: TextStyle(color: Color(0xFFB0B0B6), fontSize: 11),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _TrendMetric extends StatelessWidget {
|
|
|
|
const _TrendMetric({
|
|
|
|
required this.label,
|
|
|
|
required this.value,
|
|
|
|
required this.previousValue,
|
|
|
|
required this.currentValue,
|
|
|
|
});
|
|
|
|
|
|
|
|
final String label;
|
|
|
|
final String value;
|
|
|
|
final int? previousValue;
|
|
|
|
final int currentValue;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final difference =
|
|
|
|
previousValue == null ? null : currentValue - previousValue!;
|
|
|
|
final comparison = difference == null
|
|
|
|
? '比上周少-天'
|
|
|
|
: difference == 0
|
|
|
|
? '与上周持平'
|
|
|
|
: '比上周${difference > 0 ? '多' : '少'}${difference.abs()}天';
|
|
|
|
return Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Text(label,
|
|
|
|
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 11)),
|
|
|
|
const SizedBox(height: 3),
|
|
|
|
Row(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
|
children: [
|
|
|
|
Text(value,
|
|
|
|
style: const TextStyle(
|
|
|
|
fontSize: 25, fontWeight: FontWeight.w600, height: 1)),
|
|
|
|
const Padding(
|
|
|
|
padding: EdgeInsets.only(left: 4, bottom: 2),
|
|
|
|
child: Text('天',
|
|
|
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w500)),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
const SizedBox(height: 5),
|
|
|
|
Text(comparison,
|
|
|
|
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10)),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _DistributionCard extends StatelessWidget {
|
|
|
|
const _DistributionCard({required this.report});
|
|
|
|
|
|
|
|
final HrvPeriodReport report;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return _Card(
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
const Text('压力分布',
|
|
|
|
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
|
|
|
const SizedBox(height: 17),
|
|
|
|
Row(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
|
children: [
|
|
|
|
Expanded(
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
const Text('HRV有效天数',
|
|
|
|
style:
|
|
|
|
TextStyle(color: Color(0xFFB0B0B6), fontSize: 11)),
|
|
|
|
const SizedBox(height: 2),
|
|
|
|
_ValueWithUnit(value: '${report.validDays}', unit: '天'),
|
|
|
|
const SizedBox(height: 18),
|
|
|
|
_DistributionGrid(report: report),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
_DistributionBar(report: report),
|
|
|
|
const SizedBox(width: 20),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
const Divider(height: 29, color: Color(0xFFF3F3F3)),
|
|
|
|
Row(
|
|
|
|
children: [
|
|
|
|
Expanded(child: _ExtremeHrv(title: '最低HRV', day: report.minDay)),
|
|
|
|
Expanded(child: _ExtremeHrv(title: '最高HRV', day: report.maxDay)),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _DistributionGrid extends StatelessWidget {
|
|
|
|
const _DistributionGrid({required this.report});
|
|
|
|
final HrvPeriodReport report;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Wrap(
|
|
|
|
spacing: 22,
|
|
|
|
runSpacing: 13,
|
|
|
|
children: [
|
|
|
|
for (final level in HrvStressLevel.values)
|
|
|
|
SizedBox(
|
|
|
|
width: 74,
|
|
|
|
child: _DistributionItem(level: level, report: report)),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _DistributionItem extends StatelessWidget {
|
|
|
|
const _DistributionItem({required this.level, required this.report});
|
|
|
|
final HrvStressLevel level;
|
|
|
|
final HrvPeriodReport report;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final count = report.countFor(level);
|
|
|
|
final percent =
|
|
|
|
report.validDays == 0 ? 0 : (count / report.validDays * 100).round();
|
|
|
|
return Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Row(children: [
|
|
|
|
Container(
|
|
|
|
width: 9,
|
|
|
|
height: 9,
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Color(level.colorValue), shape: BoxShape.circle)),
|
|
|
|
const SizedBox(width: 3),
|
|
|
|
Text(level.label,
|
|
|
|
style:
|
|
|
|
const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)),
|
|
|
|
]),
|
|
|
|
const SizedBox(height: 3),
|
|
|
|
Text(report.hasData ? '$count天' : '等待数据',
|
|
|
|
style: TextStyle(
|
|
|
|
color: report.hasData
|
|
|
|
? const Color(0xFF0F0F11)
|
|
|
|
: const Color(0xFFB0B0B6),
|
|
|
|
fontSize: report.hasData ? 20 : 14,
|
|
|
|
fontWeight: FontWeight.w500)),
|
|
|
|
Text('$percent%',
|
|
|
|
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10)),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _DistributionBar extends StatelessWidget {
|
|
|
|
const _DistributionBar({required this.report});
|
|
|
|
final HrvPeriodReport report;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Container(
|
|
|
|
width: 33,
|
|
|
|
height: 180,
|
|
|
|
clipBehavior: Clip.antiAlias,
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: const Color(0xFFF3F3F3),
|
|
|
|
borderRadius: BorderRadius.circular(7)),
|
|
|
|
child: report.hasData
|
|
|
|
? Column(
|
|
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
|
|
children: [
|
|
|
|
for (final level in HrvStressLevel.values)
|
|
|
|
if (report.countFor(level) > 0)
|
|
|
|
Expanded(
|
|
|
|
flex: report.countFor(level),
|
|
|
|
child: Container(
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Color(level.colorValue),
|
|
|
|
border: Border.all(color: Colors.white, width: .5),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
)
|
|
|
|
: null,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _ExtremeHrv extends StatelessWidget {
|
|
|
|
const _ExtremeHrv({required this.title, required this.day});
|
|
|
|
final String title;
|
|
|
|
final HrvDayReport? day;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
children: [
|
|
|
|
Text(title,
|
|
|
|
style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500)),
|
|
|
|
const SizedBox(height: 5),
|
|
|
|
_ValueWithUnit(
|
|
|
|
value: day == null ? '-' : '${day!.averageHrv!.round()}',
|
|
|
|
unit: 'ms'),
|
|
|
|
const SizedBox(height: 3),
|
|
|
|
Text(
|
|
|
|
day == null ? '-月-日' : '${day!.date.month}月${day!.date.day}日',
|
|
|
|
style: const TextStyle(color: Color(0xFFB0B0B6), fontSize: 10),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _ValueWithUnit extends StatelessWidget {
|
|
|
|
const _ValueWithUnit({required this.value, required this.unit});
|
|
|
|
final String value;
|
|
|
|
final String unit;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Row(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
|
|
children: [
|
|
|
|
Text(value,
|
|
|
|
style: const TextStyle(
|
|
|
|
fontSize: 25, fontWeight: FontWeight.w600, height: 1)),
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.only(left: 3, bottom: 2),
|
|
|
|
child: Text(unit, style: const TextStyle(fontSize: 10)),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _Card extends StatelessWidget {
|
|
|
|
const _Card({required this.child});
|
|
|
|
final Widget child;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Container(
|
|
|
|
width: double.infinity,
|
|
|
|
padding: const EdgeInsets.fromLTRB(16, 17, 16, 18),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
|
|
|
child: child,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
} |
...
|
...
|
|