|
|
|
import 'package:doublefeel_flutter/app/routes/app_pages.dart';
|
|
|
|
import 'package:doublefeel_flutter/core/theme/app_theme.dart';
|
|
|
|
import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
|
|
|
|
import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
|
|
|
|
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
|
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
...
|
...
|
@@ -22,17 +22,29 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
|
|
|
|
final TodayController controller;
|
|
|
|
|
|
|
|
static const _h5 = Color(0xFFCCCCCC);
|
|
|
|
static const _h5 = Color(0xFFF3F3F3);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final userPrefs = Get.find<UserPreferencesStorage>();
|
|
|
|
return Obx(() {
|
|
|
|
final hrvSpots =
|
|
|
|
controller.hrvChartData.map((p) => FlSpot(p.hour, p.hrv)).toList();
|
|
|
|
final hrvSpots = controller.hrvChartData
|
|
|
|
.map((p) => FlSpot(
|
|
|
|
_tsToSecondsFromMidnight(p.time),
|
|
|
|
(p.trendHrv ?? 0).toDouble(),
|
|
|
|
))
|
|
|
|
.toList();
|
|
|
|
final stressPoints = controller.stressChartData.toList();
|
|
|
|
final preferences = userPrefs.preferences.value;
|
|
|
|
final vipInfo = preferences.vipInfo;
|
|
|
|
final sleepList = controller.v2HealthData.value?.sleepTimeList;
|
|
|
|
final selectedDate = controller.selectedDate.value;
|
|
|
|
|
|
|
|
// HRV: spot.x(秒偏移) → state 映射,供图表内部颜色/状态使用
|
|
|
|
final Map<double, int?> hrvStateMap = {
|
|
|
|
for (final p in controller.hrvChartData)
|
|
|
|
_tsToSecondsFromMidnight(p.time): p.state,
|
|
|
|
};
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
|
...
|
...
|
@@ -49,7 +61,8 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
height: 174,
|
|
|
|
child: hrvSpots.isEmpty
|
|
|
|
? _EmptyChart(text: context.l10n.noDataAvailableForToday)
|
|
|
|
: LineChart(_hrvLineChartData(context, hrvSpots)),
|
|
|
|
: LineChart(
|
|
|
|
_hrvLineChartData(context, hrvSpots, hrvStateMap)),
|
|
|
|
),
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.only(bottom: 14, top: 16),
|
|
...
|
...
|
@@ -97,10 +110,13 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
),
|
|
|
|
SizedBox(
|
|
|
|
height: 190,
|
|
|
|
child: stressPoints.isEmpty
|
|
|
|
? const _EmptyChart(text: '暂无压力数据')
|
|
|
|
: _buildStressChart(
|
|
|
|
context, stressPoints, vipInfo?.isVip == true),
|
|
|
|
child: _buildStressChart(
|
|
|
|
context,
|
|
|
|
sleepList,
|
|
|
|
stressPoints,
|
|
|
|
selectedDate,
|
|
|
|
vipInfo?.isVip == true,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
...
|
...
|
@@ -155,24 +171,62 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
LineChartData _hrvLineChartData(BuildContext context, List<FlSpot> spots) {
|
|
|
|
/// 将 Unix 时间戳(秒)转换为距当天 00:00 的秒数偏移
|
|
|
|
static double _tsToSecondsFromMidnight(int? ts) {
|
|
|
|
if (ts == null) return 0;
|
|
|
|
final dt = DateTime.fromMillisecondsSinceEpoch(ts * 1000);
|
|
|
|
final midnight = DateTime(dt.year, dt.month, dt.day);
|
|
|
|
return (ts - midnight.millisecondsSinceEpoch / 1000.0);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 下一个 6h 刻度相对当天 0 点的秒数
|
|
|
|
/// 例如当前 13:58 → 下一刻度 18:00 → 返回 18 * 3600 = 64800
|
|
|
|
double _maxChartSeconds() {
|
|
|
|
final now = DateTime.now();
|
|
|
|
final secondsFromMidnight = now.hour * 3600 + now.minute * 60 + now.second;
|
|
|
|
final nextSlot = ((secondsFromMidnight / (6 * 3600)).ceil()).clamp(1, 4);
|
|
|
|
return nextSlot * 6 * 3600.0;
|
|
|
|
}
|
|
|
|
|
|
|
|
LineChartData _hrvLineChartData(
|
|
|
|
BuildContext context,
|
|
|
|
List<FlSpot> spots,
|
|
|
|
Map<double, int?> stateMap,
|
|
|
|
) {
|
|
|
|
return LineChartData(
|
|
|
|
minX: 0,
|
|
|
|
maxX: _maxChartHour(spots),
|
|
|
|
maxX: _maxChartSeconds(),
|
|
|
|
minY: 0,
|
|
|
|
maxY: 80,
|
|
|
|
gridData: FlGridData(
|
|
|
|
show: true,
|
|
|
|
drawVerticalLine: true,
|
|
|
|
drawHorizontalLine: false,
|
|
|
|
verticalInterval: 6,
|
|
|
|
verticalInterval: 6 * 3600,
|
|
|
|
checkToShowVerticalLine: (val) {
|
|
|
|
// if (val == startTs) return false;
|
|
|
|
// if (val > endTs) return false;
|
|
|
|
return true;
|
|
|
|
},
|
|
|
|
getDrawingVerticalLine: (_) => const FlLine(
|
|
|
|
color: _h5,
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
borderData: FlBorderData(show: false),
|
|
|
|
borderData: FlBorderData(
|
|
|
|
show: false,
|
|
|
|
),
|
|
|
|
extraLinesData: ExtraLinesData(
|
|
|
|
verticalLines: [
|
|
|
|
VerticalLine(x: 0, color: _h5, strokeWidth: 1, dashArray: [2, 2]),
|
|
|
|
VerticalLine(
|
|
|
|
x: _maxChartSeconds(),
|
|
|
|
color: _h5,
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2]),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
titlesData: FlTitlesData(
|
|
|
|
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
rightTitles:
|
|
...
|
...
|
@@ -182,7 +236,7 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
sideTitles: SideTitles(
|
|
|
|
showTitles: true,
|
|
|
|
reservedSize: 20,
|
|
|
|
interval: 6,
|
|
|
|
interval: 6 * 3600,
|
|
|
|
getTitlesWidget: (val, _) {
|
|
|
|
final label = _timeLabel(val);
|
|
|
|
if (label == null) return const SizedBox.shrink();
|
|
...
|
...
|
@@ -207,7 +261,7 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
radius: 4,
|
|
|
|
color: Colors.white,
|
|
|
|
strokeWidth: 3,
|
|
|
|
strokeColor: _getColor(spot.y),
|
|
|
|
strokeColor: _getColorByState(stateMap[spot.x]),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
...
|
...
|
@@ -227,7 +281,7 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
radius: 5,
|
|
|
|
color: Colors.white,
|
|
|
|
strokeWidth: 3.5,
|
|
|
|
strokeColor: _getColor(spot.y),
|
|
|
|
strokeColor: _getColorByState(stateMap[spot.x]),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
...
|
...
|
@@ -237,6 +291,7 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
touchTooltipData: LineTouchTooltipData(
|
|
|
|
tooltipRoundedRadius: 8,
|
|
|
|
tooltipBorder: BorderSide.none,
|
|
|
|
maxContentWidth: 153,
|
|
|
|
getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
|
|
|
|
tooltipPadding: const EdgeInsets.only(
|
|
|
|
left: 12,
|
|
...
|
...
|
@@ -252,7 +307,7 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
color: Colors.white,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
),
|
|
|
|
children: _getTooltipChildren(barSpot),
|
|
|
|
children: _getTooltipChildren(barSpot, stateMap[barSpot.x]),
|
|
|
|
textAlign: TextAlign.start,
|
|
|
|
);
|
|
|
|
}).toList();
|
|
...
|
...
|
@@ -263,153 +318,247 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildStressChart(
|
|
|
|
BuildContext context, List<HrvDataPoint> stressPoints, bool isVip) {
|
|
|
|
void onTapPurchase() => Get.toNamed(Routes.PURCHASE);
|
|
|
|
final spanWidth = 74.0;
|
|
|
|
BuildContext context,
|
|
|
|
List<V2SleepTimeRange>? sleepList,
|
|
|
|
List<V2RealtimeStressItem> stressPoints,
|
|
|
|
DateTime selectedDate,
|
|
|
|
bool isVip) {
|
|
|
|
void onTapPurchase() {
|
|
|
|
controller.toPremiumPage();
|
|
|
|
}
|
|
|
|
|
|
|
|
// 槽时间秒 → state 映射(与 _buildStressBarGroups 一致:取 value 最大那条 of state)
|
|
|
|
const slotSec = 6 * 60;
|
|
|
|
final slotStateMap = <int, ({int value, int? state})>{};
|
|
|
|
for (final p in stressPoints) {
|
|
|
|
final sec = _tsToSecondsFromMidnight(p.time).round();
|
|
|
|
final slot = (sec / slotSec).round() * slotSec;
|
|
|
|
final v = p.value ?? 0;
|
|
|
|
final existing = slotStateMap[slot];
|
|
|
|
if (existing == null || v > existing.value) {
|
|
|
|
slotStateMap[slot] = (value: v, state: p.state);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return Stack(
|
|
|
|
children: [
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.only(top: 6),
|
|
|
|
child: BarChart(
|
|
|
|
BarChartData(
|
|
|
|
minY: 0,
|
|
|
|
maxY: 100,
|
|
|
|
groupsSpace: 1,
|
|
|
|
alignment: BarChartAlignment.start,
|
|
|
|
barGroups: List.generate(
|
|
|
|
stressPoints.length,
|
|
|
|
(i) {
|
|
|
|
final point = stressPoints[i];
|
|
|
|
return BarChartGroupData(
|
|
|
|
x: (point.hour * 10).round(),
|
|
|
|
barRods: [
|
|
|
|
BarChartRodData(
|
|
|
|
toY: point.hrv,
|
|
|
|
width: 2,
|
|
|
|
color: _getColor(point.hrv),
|
|
|
|
borderRadius: const BorderRadius.only(
|
|
|
|
topLeft: Radius.circular(2),
|
|
|
|
topRight: Radius.circular(2),
|
|
|
|
if (stressPoints.isEmpty)
|
|
|
|
_EmptyChart(text: '暂无压力数据')
|
|
|
|
else
|
|
|
|
LayoutBuilder(
|
|
|
|
builder: (context, constraints) {
|
|
|
|
// 右轴 reservedSize=22,剩余宽度平分给所有槽
|
|
|
|
const rightAxisWidth = 22.0;
|
|
|
|
const slotSec = 6 * 60;
|
|
|
|
final slotCount =
|
|
|
|
(_maxChartSeconds() / slotSec).ceil().clamp(1, 9999);
|
|
|
|
// final barWidth =
|
|
|
|
// (constraints.maxWidth - rightAxisWidth) / slotCount;
|
|
|
|
const double groupsSpace = 0.5; // 设置间隔为 1 像素
|
|
|
|
final barWidth = (constraints.maxWidth -
|
|
|
|
rightAxisWidth -
|
|
|
|
(groupsSpace * (slotCount - 1))) /
|
|
|
|
slotCount;
|
|
|
|
|
|
|
|
// 根据 V2SleepTimeRange 计算多段睡眠区间的 Positioned widgets
|
|
|
|
final sleepWidgets = <Widget>[];
|
|
|
|
|
|
|
|
if (sleepList != null && sleepList.isNotEmpty) {
|
|
|
|
final todayMidnight = DateTime(
|
|
|
|
selectedDate.year, selectedDate.month, selectedDate.day);
|
|
|
|
final todayMidnightSec =
|
|
|
|
todayMidnight.millisecondsSinceEpoch ~/ 1000;
|
|
|
|
final maxSec = _maxChartSeconds();
|
|
|
|
final chartWidth = constraints.maxWidth - rightAxisWidth;
|
|
|
|
|
|
|
|
for (final range in sleepList) {
|
|
|
|
final fromSec = range.fromTime - todayMidnightSec;
|
|
|
|
final toSec = range.toTime - todayMidnightSec;
|
|
|
|
final visibleFrom = fromSec.clamp(0.0, maxSec);
|
|
|
|
final visibleTo = toSec.clamp(0.0, maxSec);
|
|
|
|
if (visibleTo > visibleFrom) {
|
|
|
|
final sleepSpanLeft = chartWidth * (visibleFrom / maxSec);
|
|
|
|
final sleepSpanWidth =
|
|
|
|
(chartWidth * (visibleTo / maxSec)) - sleepSpanLeft;
|
|
|
|
if (sleepSpanWidth > 0) {
|
|
|
|
sleepWidgets.add(
|
|
|
|
Positioned(
|
|
|
|
left: sleepSpanLeft,
|
|
|
|
width: sleepSpanWidth,
|
|
|
|
top: 0,
|
|
|
|
bottom: 22,
|
|
|
|
child: IgnorePointer(
|
|
|
|
child: Stack(
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
children: [
|
|
|
|
Positioned(
|
|
|
|
left: 0,
|
|
|
|
right: 0,
|
|
|
|
top: 14,
|
|
|
|
bottom: 0,
|
|
|
|
child: Container(
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
gradient: LinearGradient(
|
|
|
|
begin: Alignment(0.50, -0.00),
|
|
|
|
end: Alignment(0.50, 1.00),
|
|
|
|
colors: [
|
|
|
|
Color(0x4C835DED),
|
|
|
|
Color(0x00845EEE)
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
left: 0,
|
|
|
|
right: 0,
|
|
|
|
top: 14,
|
|
|
|
height: 2,
|
|
|
|
child:
|
|
|
|
ColoredBox(color: context.colors.primary),
|
|
|
|
),
|
|
|
|
if (sleepSpanWidth >= 20.0)
|
|
|
|
Positioned(
|
|
|
|
left: (sleepSpanWidth - 12) / 2,
|
|
|
|
top: 0,
|
|
|
|
child: Image.asset(
|
|
|
|
'assets/images/common/ic_health_bed.webp',
|
|
|
|
width: 12,
|
|
|
|
height: 12,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
gridData: FlGridData(
|
|
|
|
show: true,
|
|
|
|
drawVerticalLine: false,
|
|
|
|
drawHorizontalLine: true,
|
|
|
|
horizontalInterval: 25,
|
|
|
|
getDrawingHorizontalLine: (_) => const FlLine(
|
|
|
|
color: _h5,
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
borderData: FlBorderData(show: false),
|
|
|
|
titlesData: FlTitlesData(
|
|
|
|
topTitles:
|
|
|
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
leftTitles:
|
|
|
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
rightTitles: AxisTitles(
|
|
|
|
sideTitles: SideTitles(
|
|
|
|
showTitles: true,
|
|
|
|
interval: 50,
|
|
|
|
reservedSize: 22,
|
|
|
|
getTitlesWidget: (val, _) {
|
|
|
|
if (val < 0 || val > 100) {
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
}
|
|
|
|
return Text(
|
|
|
|
'${val.toInt()}',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 10, color: context.colors.textTertiary),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
bottomTitles: AxisTitles(
|
|
|
|
sideTitles: SideTitles(
|
|
|
|
showTitles: true,
|
|
|
|
reservedSize: 22,
|
|
|
|
interval: 60,
|
|
|
|
getTitlesWidget: (val, meta) {
|
|
|
|
final label = _timeLabel(val / 10);
|
|
|
|
if (label == null) return const SizedBox.shrink();
|
|
|
|
return SideTitleWidget(
|
|
|
|
meta: meta,
|
|
|
|
child: Text(
|
|
|
|
label,
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 10, color: context.colors.textTertiary),
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return Stack(
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
children: [
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.only(top: 6),
|
|
|
|
child: BarChart(
|
|
|
|
BarChartData(
|
|
|
|
minY: 0,
|
|
|
|
maxY: 100,
|
|
|
|
groupsSpace: groupsSpace,
|
|
|
|
alignment: BarChartAlignment.start,
|
|
|
|
barGroups: _buildStressBarGroups(stressPoints,
|
|
|
|
barWidth: barWidth),
|
|
|
|
gridData: FlGridData(
|
|
|
|
show: true,
|
|
|
|
drawVerticalLine: false,
|
|
|
|
drawHorizontalLine: true,
|
|
|
|
horizontalInterval: 25,
|
|
|
|
getDrawingHorizontalLine: (_) => const FlLine(
|
|
|
|
color: _h5,
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
extraLinesData: ExtraLinesData(
|
|
|
|
horizontalLines: [
|
|
|
|
HorizontalLine(
|
|
|
|
y: 0,
|
|
|
|
color: _h5,
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2],
|
|
|
|
),
|
|
|
|
HorizontalLine(
|
|
|
|
y: 100,
|
|
|
|
color: _h5,
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2],
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
borderData: FlBorderData(show: false),
|
|
|
|
titlesData: FlTitlesData(
|
|
|
|
topTitles: const AxisTitles(
|
|
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
|
|
leftTitles: const AxisTitles(
|
|
|
|
sideTitles: SideTitles(showTitles: false)),
|
|
|
|
rightTitles: AxisTitles(
|
|
|
|
sideTitles: SideTitles(
|
|
|
|
showTitles: true,
|
|
|
|
interval: 50,
|
|
|
|
reservedSize: 22,
|
|
|
|
getTitlesWidget: (val, _) {
|
|
|
|
if (val < 0 || val > 100) {
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
}
|
|
|
|
return Text(
|
|
|
|
'${val.toInt()}',
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 10,
|
|
|
|
color: context.colors.textTertiary),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
bottomTitles: AxisTitles(
|
|
|
|
sideTitles: SideTitles(
|
|
|
|
showTitles: true,
|
|
|
|
reservedSize: 22,
|
|
|
|
interval: 60,
|
|
|
|
getTitlesWidget: (val, meta) {
|
|
|
|
final label = _timeLabel(val);
|
|
|
|
if (label == null) {
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
}
|
|
|
|
return SideTitleWidget(
|
|
|
|
meta: meta,
|
|
|
|
child: Text(
|
|
|
|
label,
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 10,
|
|
|
|
color: context.colors.textTertiary),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
barTouchData: BarTouchData(
|
|
|
|
touchTooltipData: BarTouchTooltipData(
|
|
|
|
tooltipRoundedRadius: 8,
|
|
|
|
tooltipBorder: BorderSide.none,
|
|
|
|
getTooltipColor: (touchedSpot) =>
|
|
|
|
const Color(0xFFF3F3F3),
|
|
|
|
tooltipPadding: const EdgeInsets.only(
|
|
|
|
left: 12,
|
|
|
|
right: 12,
|
|
|
|
top: 6,
|
|
|
|
bottom: 5,
|
|
|
|
),
|
|
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
|
|
// group.x 是槽时间秒,对应 slotStateMap 的 key
|
|
|
|
final state = slotStateMap[group.x]?.state;
|
|
|
|
return BarTooltipItem(
|
|
|
|
'',
|
|
|
|
const TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
),
|
|
|
|
children: _getTooltip(rod, group.x, state),
|
|
|
|
textAlign: TextAlign.start,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
barTouchData: BarTouchData(
|
|
|
|
touchTooltipData: BarTouchTooltipData(
|
|
|
|
tooltipRoundedRadius: 8,
|
|
|
|
tooltipBorder: BorderSide.none,
|
|
|
|
getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
|
|
|
|
tooltipPadding: const EdgeInsets.only(
|
|
|
|
left: 12,
|
|
|
|
right: 12,
|
|
|
|
top: 6,
|
|
|
|
bottom: 5,
|
|
|
|
),
|
|
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
|
|
return BarTooltipItem(
|
|
|
|
'',
|
|
|
|
const TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
),
|
|
|
|
children: _getTooltip(rod),
|
|
|
|
textAlign: TextAlign.start,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
left: 0,
|
|
|
|
top: 14,
|
|
|
|
bottom: 22,
|
|
|
|
child: Container(
|
|
|
|
width: spanWidth,
|
|
|
|
height: 174,
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
gradient: LinearGradient(
|
|
|
|
begin: Alignment(0.50, -0.00),
|
|
|
|
end: Alignment(0.50, 1.00),
|
|
|
|
colors: [Color(0x4C835DED), Color(0x00845EEE)],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
left: 0,
|
|
|
|
top: 14,
|
|
|
|
width: spanWidth,
|
|
|
|
height: 2,
|
|
|
|
child: ColoredBox(color: context.colors.primary),
|
|
|
|
),
|
|
|
|
Positioned(
|
|
|
|
left: spanWidth / 2 - 6,
|
|
|
|
top: 0,
|
|
|
|
child: Image.asset(
|
|
|
|
'assets/images/common/ic_health_bed.webp',
|
|
|
|
width: 12,
|
|
|
|
height: 12,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
...sleepWidgets,
|
|
|
|
],
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
if (isVip == false) ...[
|
|
|
|
if (isVip != true) ...[
|
|
|
|
GestureDetector(
|
|
|
|
onTap: onTapPurchase,
|
|
|
|
child: Image.asset(
|
|
...
|
...
|
@@ -480,59 +629,53 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
double _maxChartHour(List<FlSpot> spots) {
|
|
|
|
final maxHour = spots.fold<double>(
|
|
|
|
0,
|
|
|
|
(maxValue, spot) => spot.x > maxValue ? spot.x : maxValue,
|
|
|
|
);
|
|
|
|
return ((maxHour / 6).ceil() * 6).clamp(6, 24).toDouble();
|
|
|
|
}
|
|
|
|
|
|
|
|
String? _timeLabel(double value) {
|
|
|
|
return switch (value) {
|
|
|
|
0 => '00:00',
|
|
|
|
6 => '06:00',
|
|
|
|
12 => '12:00',
|
|
|
|
18 => '18:00',
|
|
|
|
24 => '24:00',
|
|
|
|
_ => null,
|
|
|
|
};
|
|
|
|
/// 将距当天0点的秒数转为 X 轴标签,仅在 6h 整点(0/6/12/18/24h)显示
|
|
|
|
String? _timeLabel(double seconds) {
|
|
|
|
// 允许 ±30 秒的浮点误差
|
|
|
|
final rounded = (seconds / 3600.0).round();
|
|
|
|
if ((seconds - rounded * 3600).abs() > 30) return null;
|
|
|
|
if (rounded < 0 || rounded > 24) return null;
|
|
|
|
if (rounded % 6 != 0) return null;
|
|
|
|
return '${rounded.toString().padLeft(2, '0')}:00';
|
|
|
|
}
|
|
|
|
|
|
|
|
Color _getColor(double value) {
|
|
|
|
if (value > 60) {
|
|
|
|
return const Color(0xFF3BD49D);
|
|
|
|
} else if (value > 50) {
|
|
|
|
return const Color(0xFF7B9BFB);
|
|
|
|
} else if (value > 30) {
|
|
|
|
return const Color(0xFFFF9A6E);
|
|
|
|
} else if (value > 20) {
|
|
|
|
return const Color(0xFFFF5279);
|
|
|
|
} else {
|
|
|
|
return const Color(0xFF7B9BFB);
|
|
|
|
/// 按 state(1=过载/2=注意/3=正常/4=优秀)返回颜色
|
|
|
|
Color _getColorByState(int? state) {
|
|
|
|
switch (state) {
|
|
|
|
case 4:
|
|
|
|
return const Color(0xFF3BD49D); // 优秀 → 绿
|
|
|
|
case 3:
|
|
|
|
return const Color(0xFF7B9BFB); // 正常 → 蓝
|
|
|
|
case 2:
|
|
|
|
return const Color(0xFFFF9A6E); // 注意 → 橙
|
|
|
|
case 1:
|
|
|
|
return const Color(0xFFFF5279); // 过载 → 红
|
|
|
|
default:
|
|
|
|
return const Color(0xFF3BD49D);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
String _getStatus(double value) {
|
|
|
|
if (value > 60) {
|
|
|
|
return '状态优秀';
|
|
|
|
} else if (value > 50) {
|
|
|
|
return '状态良好';
|
|
|
|
} else if (value > 30) {
|
|
|
|
return '状态一般';
|
|
|
|
} else if (value > 20) {
|
|
|
|
return '状态较差';
|
|
|
|
} else {
|
|
|
|
return '状态极差';
|
|
|
|
/// 按 state 返回状态文字
|
|
|
|
String _getStatusByState(int? state) {
|
|
|
|
switch (state) {
|
|
|
|
case 1:
|
|
|
|
return '压力过载';
|
|
|
|
case 2:
|
|
|
|
return '注意压力';
|
|
|
|
case 3:
|
|
|
|
return '状态正常';
|
|
|
|
case 4:
|
|
|
|
return '状态优秀';
|
|
|
|
}
|
|
|
|
return '等待数据';
|
|
|
|
}
|
|
|
|
|
|
|
|
List<TextSpan>? _getTooltip(BarChartRodData rod) {
|
|
|
|
List<TextSpan>? _getTooltip(BarChartRodData rod, int x, int? state) {
|
|
|
|
return [
|
|
|
|
TextSpan(
|
|
|
|
text: _getStatus(rod.toY),
|
|
|
|
text: '○ ${_getStatusByState(state)}',
|
|
|
|
style: TextStyle(
|
|
|
|
color: _getColor(rod.toY),
|
|
|
|
color: _getColorByState(state),
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
),
|
|
|
|
),
|
|
...
|
...
|
@@ -544,7 +687,7 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
),
|
|
|
|
),
|
|
|
|
TextSpan(
|
|
|
|
text: '压力 ${_formatNumber(rod.toY)}',
|
|
|
|
text: '♥ 压力 ${_formatNumber(rod.toY)} · ${_formatHour(x.toDouble())}',
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xFF78787D),
|
|
|
|
fontWeight: FontWeight.bold,
|
|
...
|
...
|
@@ -553,24 +696,25 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
List<TextSpan>? _getTooltipChildren(LineBarSpot flSpot) {
|
|
|
|
List<TextSpan>? _getTooltipChildren(LineBarSpot flSpot, int? state) {
|
|
|
|
return [
|
|
|
|
TextSpan(
|
|
|
|
text: _getStatus(flSpot.y),
|
|
|
|
text: '○ ${_getStatusByState(state)}',
|
|
|
|
style: TextStyle(
|
|
|
|
color: _getColor(flSpot.y),
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
color: _getColorByState(state),
|
|
|
|
// fontWeight: FontWeight.bold,
|
|
|
|
fontSize: 14,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
TextSpan(
|
|
|
|
text: '\n',
|
|
|
|
style: TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
fontSize: 14,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
TextSpan(
|
|
|
|
text: 'HRV ${_formatNumber(flSpot.y)}ms · ${_formatHour(flSpot.x)}',
|
|
|
|
text: '♥ HRV ${_formatNumber(flSpot.y)}ms · ${_formatHour(flSpot.x)}',
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xFF78787D),
|
|
|
|
fontWeight: FontWeight.bold,
|
|
...
|
...
|
@@ -584,12 +728,56 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
return value.toStringAsFixed(1);
|
|
|
|
}
|
|
|
|
|
|
|
|
String _formatHour(double hour) {
|
|
|
|
final totalMinutes = (hour * 60).round();
|
|
|
|
final h = (totalMinutes ~/ 60).clamp(0, 23).toString().padLeft(2, '0');
|
|
|
|
final m = (totalMinutes % 60).toString().padLeft(2, '0');
|
|
|
|
/// 将距当天0点的秒数转为 HH:MM 格式(用于 tooltip)
|
|
|
|
String _formatHour(double seconds) {
|
|
|
|
final total = seconds.round();
|
|
|
|
final h = (total ~/ 3600).clamp(0, 23).toString().padLeft(2, '0');
|
|
|
|
final m = ((total % 3600) ~/ 60).toString().padLeft(2, '0');
|
|
|
|
return '$h:$m';
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 构建压力柱状图分组:每 6 分钟一槽,同槽取最大值,无数据用透明占位
|
|
|
|
List<BarChartGroupData> _buildStressBarGroups(
|
|
|
|
List<V2RealtimeStressItem> stressPoints,
|
|
|
|
{required double barWidth}) {
|
|
|
|
const slotSec = 6 * 60; // 360 秒 = 6 分钟
|
|
|
|
final maxSec = _maxChartSeconds().round();
|
|
|
|
|
|
|
|
// 按槽分组,记录每槽的最大 value 及对应的 state
|
|
|
|
final slotMax = <int, ({int value, int? state})>{};
|
|
|
|
for (final p in stressPoints) {
|
|
|
|
final sec = _tsToSecondsFromMidnight(p.time).round();
|
|
|
|
final slot = (sec / slotSec).round() * slotSec;
|
|
|
|
final v = p.value ?? 0;
|
|
|
|
final existing = slotMax[slot];
|
|
|
|
if (existing == null || v > existing.value) {
|
|
|
|
slotMax[slot] = (value: v, state: p.state);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
final groups = <BarChartGroupData>[];
|
|
|
|
for (int sec = 0; sec <= maxSec; sec += slotSec) {
|
|
|
|
final entry = slotMax[sec];
|
|
|
|
groups.add(BarChartGroupData(
|
|
|
|
x: sec,
|
|
|
|
barRods: [
|
|
|
|
BarChartRodData(
|
|
|
|
toY: entry != null ? entry.value.toDouble() : 0,
|
|
|
|
width: barWidth,
|
|
|
|
color: entry != null
|
|
|
|
? _getColorByState(entry.state)
|
|
|
|
: Colors.transparent,
|
|
|
|
borderRadius: BorderRadius.zero,
|
|
|
|
// borderRadius: const BorderRadius.only(
|
|
|
|
// topLeft: Radius.circular(2),
|
|
|
|
// topRight: Radius.circular(2),
|
|
|
|
// ),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
));
|
|
|
|
}
|
|
|
|
return groups;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _EmptyChart extends StatelessWidget {
|
...
|
...
|
|