|
|
|
import 'dart:async';
|
|
|
|
|
|
|
|
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';
|
|
...
|
...
|
@@ -22,8 +24,6 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
|
|
|
|
final TodayController controller;
|
|
|
|
|
|
|
|
static const _h5 = Color(0xFFF3F3F3);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final userPrefs = Get.find<UserPreferencesStorage>();
|
|
...
|
...
|
@@ -61,8 +61,14 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
height: 174,
|
|
|
|
child: hrvSpots.isEmpty
|
|
|
|
? _EmptyChart(text: context.l10n.noDataAvailableForToday)
|
|
|
|
: LineChart(
|
|
|
|
_hrvLineChartData(context, hrvSpots, hrvStateMap)),
|
|
|
|
: _HrvLineChart(
|
|
|
|
spots: hrvSpots,
|
|
|
|
stateMap: hrvStateMap,
|
|
|
|
maxSeconds: _maxChartSeconds(),
|
|
|
|
getColorByState: _getColorByState,
|
|
|
|
getTooltipChildren: _getTooltipChildren,
|
|
|
|
timeLabel: _timeLabel,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Padding(
|
|
|
|
padding: const EdgeInsets.only(bottom: 14, top: 16),
|
|
...
|
...
|
@@ -204,135 +210,6 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
return nextSlot * 6 * 3600.0;
|
|
|
|
}
|
|
|
|
|
|
|
|
LineChartData _hrvLineChartData(
|
|
|
|
BuildContext context,
|
|
|
|
List<FlSpot> spots,
|
|
|
|
Map<double, int?> stateMap,
|
|
|
|
) {
|
|
|
|
return LineChartData(
|
|
|
|
minX: 0,
|
|
|
|
maxX: _maxChartSeconds(),
|
|
|
|
minY: 0,
|
|
|
|
maxY: 80,
|
|
|
|
gridData: FlGridData(
|
|
|
|
show: true,
|
|
|
|
drawVerticalLine: true,
|
|
|
|
drawHorizontalLine: false,
|
|
|
|
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,
|
|
|
|
),
|
|
|
|
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:
|
|
|
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
bottomTitles: AxisTitles(
|
|
|
|
sideTitles: SideTitles(
|
|
|
|
showTitles: true,
|
|
|
|
reservedSize: 20,
|
|
|
|
interval: 6 * 3600,
|
|
|
|
getTitlesWidget: (val, _) {
|
|
|
|
final label = _timeLabel(val);
|
|
|
|
if (label == null) return const SizedBox.shrink();
|
|
|
|
return Text(
|
|
|
|
label,
|
|
|
|
style:
|
|
|
|
TextStyle(fontSize: 10, color: context.colors.textTertiary),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
lineBarsData: [
|
|
|
|
LineChartBarData(
|
|
|
|
spots: spots,
|
|
|
|
isCurved: false,
|
|
|
|
color: const Color(0xFFD9D9D9),
|
|
|
|
barWidth: 2,
|
|
|
|
dotData: FlDotData(
|
|
|
|
getDotPainter: (spot, percent, barData, index) {
|
|
|
|
return FlDotCirclePainter(
|
|
|
|
radius: 4,
|
|
|
|
color: Colors.white,
|
|
|
|
strokeWidth: 3,
|
|
|
|
strokeColor: _getColorByState(stateMap[spot.x]),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
)
|
|
|
|
],
|
|
|
|
lineTouchData: LineTouchData(
|
|
|
|
getTouchedSpotIndicator: (barData, spotIndexes) {
|
|
|
|
return spotIndexes.map((spotIndex) {
|
|
|
|
return TouchedSpotIndicatorData(
|
|
|
|
const FlLine(
|
|
|
|
color: Color(0xFFB0B0B6),
|
|
|
|
strokeWidth: 2,
|
|
|
|
),
|
|
|
|
FlDotData(
|
|
|
|
getDotPainter: (spot, percent, barData, index) {
|
|
|
|
return FlDotCirclePainter(
|
|
|
|
radius: 5,
|
|
|
|
color: Colors.white,
|
|
|
|
strokeWidth: 3.5,
|
|
|
|
strokeColor: _getColorByState(stateMap[spot.x]),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}).toList();
|
|
|
|
},
|
|
|
|
touchTooltipData: LineTouchTooltipData(
|
|
|
|
tooltipRoundedRadius: 8,
|
|
|
|
tooltipBorder: BorderSide.none,
|
|
|
|
maxContentWidth: 153,
|
|
|
|
getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
|
|
|
|
tooltipPadding: const EdgeInsets.only(
|
|
|
|
left: 12,
|
|
|
|
right: 12,
|
|
|
|
top: 6,
|
|
|
|
bottom: 5,
|
|
|
|
),
|
|
|
|
getTooltipItems: (touchedBarSpots) {
|
|
|
|
return touchedBarSpots.map((barSpot) {
|
|
|
|
return LineTooltipItem(
|
|
|
|
'',
|
|
|
|
const TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
),
|
|
|
|
children: _getTooltipChildren(barSpot, stateMap[barSpot.x]),
|
|
|
|
textAlign: TextAlign.start,
|
|
|
|
);
|
|
|
|
}).toList();
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _buildStressChart(
|
|
|
|
BuildContext context,
|
|
|
|
List<V2SleepTimeRange>? sleepList,
|
|
...
|
...
|
@@ -360,244 +237,16 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
if (stressPoints.isEmpty)
|
|
|
|
_EmptyChart(text: context.l10n.noPressureDataAvailableAtThisTime)
|
|
|
|
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;
|
|
|
|
|
|
|
|
// 1. 过滤并排序睡眠区间
|
|
|
|
final sortedList = List<V2SleepTimeRange>.from(sleepList)
|
|
|
|
..sort((a, b) => a.fromTime.compareTo(b.fromTime));
|
|
|
|
|
|
|
|
// 2. 合并相邻且间隔小于 2 小时的睡眠区间,避免因短暂醒来分段导致多个床图标或跨天首尾大面积相连
|
|
|
|
final mergedList = <V2SleepTimeRange>[];
|
|
|
|
for (final range in sortedList) {
|
|
|
|
if (mergedList.isEmpty) {
|
|
|
|
mergedList.add(range);
|
|
|
|
} else {
|
|
|
|
final lastRange = mergedList.last;
|
|
|
|
if (range.fromTime - lastRange.toTime <= 2 * 3600) {
|
|
|
|
mergedList[mergedList.length - 1] = V2SleepTimeRange(
|
|
|
|
fromTime: lastRange.fromTime,
|
|
|
|
toTime: range.toTime > lastRange.toTime
|
|
|
|
? range.toTime
|
|
|
|
: lastRange.toTime,
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
mergedList.add(range);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 3. 渲染合并后的睡眠区间 Positioned widgets
|
|
|
|
for (final range in mergedList) {
|
|
|
|
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,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
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,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
...sleepWidgets,
|
|
|
|
],
|
|
|
|
);
|
|
|
|
},
|
|
|
|
_StressBarChart(
|
|
|
|
sleepList: sleepList,
|
|
|
|
stressPoints: stressPoints,
|
|
|
|
selectedDate: selectedDate,
|
|
|
|
isVip: isVip,
|
|
|
|
onTapPurchase: onTapPurchase,
|
|
|
|
maxSeconds: _maxChartSeconds(),
|
|
|
|
getColorByState: _getColorByState,
|
|
|
|
getTooltip: _getTooltip,
|
|
|
|
timeLabel: _timeLabel,
|
|
|
|
),
|
|
|
|
if (isVip != true) ...[
|
|
|
|
GestureDetector(
|
|
...
|
...
|
@@ -777,18 +426,545 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
final m = ((total % 3600) ~/ 60).toString().padLeft(2, '0');
|
|
|
|
return '$h:$m';
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HrvLineChart extends StatefulWidget {
|
|
|
|
const _HrvLineChart({
|
|
|
|
required this.spots,
|
|
|
|
required this.stateMap,
|
|
|
|
required this.maxSeconds,
|
|
|
|
required this.getColorByState,
|
|
|
|
required this.getTooltipChildren,
|
|
|
|
required this.timeLabel,
|
|
|
|
});
|
|
|
|
|
|
|
|
final List<FlSpot> spots;
|
|
|
|
final Map<double, int?> stateMap;
|
|
|
|
final double maxSeconds;
|
|
|
|
final Color Function(int?) getColorByState;
|
|
|
|
final List<TextSpan>? Function(LineBarSpot, int?) getTooltipChildren;
|
|
|
|
final String? Function(double) timeLabel;
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<_HrvLineChart> createState() => _HrvLineChartState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HrvLineChartState extends State<_HrvLineChart> {
|
|
|
|
List<LineBarSpot> _hrvShowingSpots = [];
|
|
|
|
Timer? _hrvTooltipTimer;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
_hrvTooltipTimer?.cancel();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return LineChart(
|
|
|
|
LineChartData(
|
|
|
|
minX: 0,
|
|
|
|
maxX: widget.maxSeconds,
|
|
|
|
minY: 0,
|
|
|
|
maxY: 80,
|
|
|
|
gridData: FlGridData(
|
|
|
|
show: true,
|
|
|
|
drawVerticalLine: true,
|
|
|
|
drawHorizontalLine: false,
|
|
|
|
verticalInterval: 6 * 3600,
|
|
|
|
checkToShowVerticalLine: (val) {
|
|
|
|
return true;
|
|
|
|
},
|
|
|
|
getDrawingVerticalLine: (_) => const FlLine(
|
|
|
|
color: Color(0xFFF3F3F3),
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
borderData: FlBorderData(
|
|
|
|
show: false,
|
|
|
|
),
|
|
|
|
extraLinesData: ExtraLinesData(
|
|
|
|
verticalLines: [
|
|
|
|
VerticalLine(
|
|
|
|
x: 0,
|
|
|
|
color: const Color(0xFFF3F3F3),
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2]),
|
|
|
|
VerticalLine(
|
|
|
|
x: widget.maxSeconds,
|
|
|
|
color: const 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: 20,
|
|
|
|
interval: 6 * 3600,
|
|
|
|
getTitlesWidget: (val, _) {
|
|
|
|
final label = widget.timeLabel(val);
|
|
|
|
if (label == null) return const SizedBox.shrink();
|
|
|
|
return Text(
|
|
|
|
label,
|
|
|
|
style: TextStyle(
|
|
|
|
fontSize: 10, color: context.colors.textTertiary),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
lineBarsData: [
|
|
|
|
LineChartBarData(
|
|
|
|
spots: widget.spots,
|
|
|
|
isCurved: false,
|
|
|
|
color: const Color(0xFFD9D9D9),
|
|
|
|
barWidth: 2,
|
|
|
|
showingIndicators:
|
|
|
|
_hrvShowingSpots.map((spot) => spot.spotIndex).toList(),
|
|
|
|
dotData: FlDotData(
|
|
|
|
getDotPainter: (spot, percent, barData, index) {
|
|
|
|
return FlDotCirclePainter(
|
|
|
|
radius: 4,
|
|
|
|
color: Colors.white,
|
|
|
|
strokeWidth: 3,
|
|
|
|
strokeColor: widget.getColorByState(widget.stateMap[spot.x]),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
)
|
|
|
|
],
|
|
|
|
showingTooltipIndicators: _hrvShowingSpots.isEmpty
|
|
|
|
? const []
|
|
|
|
: [
|
|
|
|
ShowingTooltipIndicators(_hrvShowingSpots),
|
|
|
|
],
|
|
|
|
lineTouchData: LineTouchData(
|
|
|
|
handleBuiltInTouches: false,
|
|
|
|
touchCallback:
|
|
|
|
(FlTouchEvent event, LineTouchResponse? touchResponse) {
|
|
|
|
if (event is FlPanStartEvent ||
|
|
|
|
event is FlPanUpdateEvent ||
|
|
|
|
event is FlTapDownEvent ||
|
|
|
|
event is FlPointerHoverEvent ||
|
|
|
|
event is FlPointerEnterEvent) {
|
|
|
|
_hrvTooltipTimer?.cancel();
|
|
|
|
if (touchResponse != null &&
|
|
|
|
touchResponse.lineBarSpots != null &&
|
|
|
|
touchResponse.lineBarSpots!.isNotEmpty) {
|
|
|
|
setState(() {
|
|
|
|
_hrvShowingSpots = touchResponse.lineBarSpots!;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} else if (event is FlPanEndEvent ||
|
|
|
|
event is FlTapUpEvent ||
|
|
|
|
event is FlPointerExitEvent ||
|
|
|
|
event is FlTapCancelEvent ||
|
|
|
|
event is FlPanCancelEvent) {
|
|
|
|
_hrvTooltipTimer?.cancel();
|
|
|
|
_hrvTooltipTimer = Timer(const Duration(milliseconds: 4000), () {
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {
|
|
|
|
_hrvShowingSpots = [];
|
|
|
|
});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
getTouchedSpotIndicator: (barData, spotIndexes) {
|
|
|
|
return spotIndexes.map((spotIndex) {
|
|
|
|
return TouchedSpotIndicatorData(
|
|
|
|
const FlLine(
|
|
|
|
color: Color(0xFFB0B0B6),
|
|
|
|
strokeWidth: 2,
|
|
|
|
),
|
|
|
|
FlDotData(
|
|
|
|
getDotPainter: (spot, percent, barData, index) {
|
|
|
|
return FlDotCirclePainter(
|
|
|
|
radius: 5,
|
|
|
|
color: Colors.white,
|
|
|
|
strokeWidth: 3.5,
|
|
|
|
strokeColor:
|
|
|
|
widget.getColorByState(widget.stateMap[spot.x]),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}).toList();
|
|
|
|
},
|
|
|
|
touchTooltipData: LineTouchTooltipData(
|
|
|
|
tooltipRoundedRadius: 8,
|
|
|
|
tooltipBorder: BorderSide.none,
|
|
|
|
maxContentWidth: 400,
|
|
|
|
fitInsideHorizontally: true,
|
|
|
|
fitInsideVertically: true,
|
|
|
|
getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
|
|
|
|
tooltipPadding: const EdgeInsets.only(
|
|
|
|
left: 12,
|
|
|
|
right: 12,
|
|
|
|
top: 6,
|
|
|
|
bottom: 5,
|
|
|
|
),
|
|
|
|
getTooltipItems: (touchedBarSpots) {
|
|
|
|
return touchedBarSpots.map((barSpot) {
|
|
|
|
return LineTooltipItem(
|
|
|
|
'',
|
|
|
|
const TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
),
|
|
|
|
children: widget.getTooltipChildren(
|
|
|
|
barSpot, widget.stateMap[barSpot.x]),
|
|
|
|
textAlign: TextAlign.start,
|
|
|
|
);
|
|
|
|
}).toList();
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _StressBarChart extends StatefulWidget {
|
|
|
|
const _StressBarChart({
|
|
|
|
required this.sleepList,
|
|
|
|
required this.stressPoints,
|
|
|
|
required this.selectedDate,
|
|
|
|
required this.isVip,
|
|
|
|
required this.onTapPurchase,
|
|
|
|
required this.maxSeconds,
|
|
|
|
required this.getColorByState,
|
|
|
|
required this.getTooltip,
|
|
|
|
required this.timeLabel,
|
|
|
|
});
|
|
|
|
|
|
|
|
final List<V2SleepTimeRange>? sleepList;
|
|
|
|
final List<V2RealtimeStressItem> stressPoints;
|
|
|
|
final DateTime selectedDate;
|
|
|
|
final bool isVip;
|
|
|
|
final VoidCallback onTapPurchase;
|
|
|
|
final double maxSeconds;
|
|
|
|
final Color Function(int?) getColorByState;
|
|
|
|
final List<TextSpan>? Function(BarChartRodData, int, int?) getTooltip;
|
|
|
|
final String? Function(double) timeLabel;
|
|
|
|
|
|
|
|
@override
|
|
|
|
State<_StressBarChart> createState() => _StressBarChartState();
|
|
|
|
}
|
|
|
|
|
|
|
|
class _StressBarChartState extends State<_StressBarChart> {
|
|
|
|
int? _stressTouchedGroupIndex;
|
|
|
|
Timer? _stressTooltipTimer;
|
|
|
|
|
|
|
|
@override
|
|
|
|
void dispose() {
|
|
|
|
_stressTooltipTimer?.cancel();
|
|
|
|
super.dispose();
|
|
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return LayoutBuilder(
|
|
|
|
builder: (context, constraints) {
|
|
|
|
// 右轴 reservedSize=22,剩余宽度平分给所有槽
|
|
|
|
const rightAxisWidth = 22.0;
|
|
|
|
const slotSec = 6 * 60;
|
|
|
|
final slotCount = (widget.maxSeconds / slotSec).ceil().clamp(1, 9999);
|
|
|
|
const double groupsSpace = 0.5; // 设置间隔为 1 像素
|
|
|
|
final barWidth = (constraints.maxWidth -
|
|
|
|
rightAxisWidth -
|
|
|
|
(groupsSpace * (slotCount - 1))) /
|
|
|
|
slotCount;
|
|
|
|
|
|
|
|
// 根据 V2SleepTimeRange 计算多段睡眠区间的 Positioned widgets
|
|
|
|
final sleepWidgets = <Widget>[];
|
|
|
|
|
|
|
|
if (widget.sleepList != null && widget.sleepList!.isNotEmpty) {
|
|
|
|
final todayMidnight = DateTime(widget.selectedDate.year,
|
|
|
|
widget.selectedDate.month, widget.selectedDate.day);
|
|
|
|
final todayMidnightSec = todayMidnight.millisecondsSinceEpoch ~/ 1000;
|
|
|
|
final maxSec = widget.maxSeconds;
|
|
|
|
final chartWidth = constraints.maxWidth - rightAxisWidth;
|
|
|
|
|
|
|
|
// 1. 过滤并排序睡眠区间
|
|
|
|
final sortedList = List<V2SleepTimeRange>.from(widget.sleepList!)
|
|
|
|
..sort((a, b) => a.fromTime.compareTo(b.fromTime));
|
|
|
|
|
|
|
|
// 2. 合并相邻且间隔小于 2 小时的睡眠区间,避免因短暂醒来分段导致多个床图标或跨天首尾大面积相连
|
|
|
|
final mergedList = <V2SleepTimeRange>[];
|
|
|
|
for (final range in sortedList) {
|
|
|
|
if (mergedList.isEmpty) {
|
|
|
|
mergedList.add(range);
|
|
|
|
} else {
|
|
|
|
final lastRange = mergedList.last;
|
|
|
|
if (range.fromTime - lastRange.toTime <= 2 * 3600) {
|
|
|
|
mergedList[mergedList.length - 1] = V2SleepTimeRange(
|
|
|
|
fromTime: lastRange.fromTime,
|
|
|
|
toTime: range.toTime > lastRange.toTime
|
|
|
|
? range.toTime
|
|
|
|
: lastRange.toTime,
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
mergedList.add(range);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 3. 渲染合并后的睡眠区间 Positioned widgets
|
|
|
|
for (final range in mergedList) {
|
|
|
|
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,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// 槽时间秒 → state 映射
|
|
|
|
final slotStateMap = <int, ({int value, int? state})>{};
|
|
|
|
for (final p in widget.stressPoints) {
|
|
|
|
final sec =
|
|
|
|
TodayHrvChartCard._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(
|
|
|
|
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(widget.stressPoints,
|
|
|
|
barWidth: barWidth),
|
|
|
|
gridData: FlGridData(
|
|
|
|
show: true,
|
|
|
|
drawVerticalLine: false,
|
|
|
|
drawHorizontalLine: true,
|
|
|
|
horizontalInterval: 25,
|
|
|
|
getDrawingHorizontalLine: (_) => const FlLine(
|
|
|
|
color: Color(0xFFF3F3F3),
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
extraLinesData: ExtraLinesData(
|
|
|
|
horizontalLines: [
|
|
|
|
HorizontalLine(
|
|
|
|
y: 0,
|
|
|
|
color: const Color(0xFFF3F3F3),
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: [2, 2],
|
|
|
|
),
|
|
|
|
HorizontalLine(
|
|
|
|
y: 100,
|
|
|
|
color: const Color(0xFFF3F3F3),
|
|
|
|
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 = widget.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(
|
|
|
|
handleBuiltInTouches: false,
|
|
|
|
touchCallback:
|
|
|
|
(FlTouchEvent event, BarTouchResponse? touchResponse) {
|
|
|
|
if (event is FlPanStartEvent ||
|
|
|
|
event is FlPanUpdateEvent ||
|
|
|
|
event is FlTapDownEvent ||
|
|
|
|
event is FlPointerHoverEvent ||
|
|
|
|
event is FlPointerEnterEvent) {
|
|
|
|
_stressTooltipTimer?.cancel();
|
|
|
|
if (touchResponse != null &&
|
|
|
|
touchResponse.spot != null) {
|
|
|
|
setState(() {
|
|
|
|
_stressTouchedGroupIndex =
|
|
|
|
touchResponse.spot!.touchedBarGroupIndex;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
} else if (event is FlPanEndEvent ||
|
|
|
|
event is FlTapUpEvent ||
|
|
|
|
event is FlPointerExitEvent ||
|
|
|
|
event is FlTapCancelEvent ||
|
|
|
|
event is FlPanCancelEvent) {
|
|
|
|
_stressTooltipTimer?.cancel();
|
|
|
|
_stressTooltipTimer =
|
|
|
|
Timer(const Duration(milliseconds: 4000), () {
|
|
|
|
if (mounted) {
|
|
|
|
setState(() {
|
|
|
|
_stressTouchedGroupIndex = null;
|
|
|
|
});
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
},
|
|
|
|
touchTooltipData: BarTouchTooltipData(
|
|
|
|
tooltipRoundedRadius: 8,
|
|
|
|
tooltipBorder: BorderSide.none,
|
|
|
|
maxContentWidth: 400,
|
|
|
|
fitInsideHorizontally: true,
|
|
|
|
fitInsideVertically: true,
|
|
|
|
getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
|
|
|
|
tooltipPadding: const EdgeInsets.only(
|
|
|
|
left: 12,
|
|
|
|
right: 12,
|
|
|
|
top: 6,
|
|
|
|
bottom: 5,
|
|
|
|
),
|
|
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
|
|
final state = slotStateMap[group.x]?.state;
|
|
|
|
return BarTooltipItem(
|
|
|
|
'',
|
|
|
|
const TextStyle(
|
|
|
|
color: Colors.white,
|
|
|
|
fontWeight: FontWeight.bold,
|
|
|
|
),
|
|
|
|
children: widget.getTooltip(rod, group.x, state),
|
|
|
|
textAlign: TextAlign.start,
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
...sleepWidgets,
|
|
|
|
],
|
|
|
|
);
|
|
|
|
},
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
/// 构建压力柱状图分组:每 6 分钟一槽,同槽取最大值,无数据用透明占位
|
|
|
|
List<BarChartGroupData> _buildStressBarGroups(
|
|
|
|
List<V2RealtimeStressItem> stressPoints,
|
|
|
|
{required double barWidth}) {
|
|
|
|
const slotSec = 6 * 60; // 360 秒 = 6 分钟
|
|
|
|
final maxSec = _maxChartSeconds().round();
|
|
|
|
final maxSec = widget.maxSeconds.round();
|
|
|
|
|
|
|
|
// 按槽分组,记录每槽的最大 value 及对应的 state
|
|
|
|
final slotMax = <int, ({int value, int? state})>{};
|
|
|
|
for (final p in stressPoints) {
|
|
|
|
final sec = _tsToSecondsFromMidnight(p.time).round();
|
|
|
|
final sec = TodayHrvChartCard._tsToSecondsFromMidnight(p.time).round();
|
|
|
|
final slot = (sec / slotSec).round() * slotSec;
|
|
|
|
final v = p.value ?? 0;
|
|
|
|
final existing = slotMax[slot];
|
|
...
|
...
|
@@ -800,20 +976,19 @@ class TodayHrvChartCard extends StatelessWidget { |
|
|
|
final groups = <BarChartGroupData>[];
|
|
|
|
for (int sec = 0; sec <= maxSec; sec += slotSec) {
|
|
|
|
final entry = slotMax[sec];
|
|
|
|
final groupIndex = groups.length;
|
|
|
|
final isTouched = groupIndex == _stressTouchedGroupIndex;
|
|
|
|
groups.add(BarChartGroupData(
|
|
|
|
x: sec,
|
|
|
|
showingTooltipIndicators: isTouched ? [0] : [],
|
|
|
|
barRods: [
|
|
|
|
BarChartRodData(
|
|
|
|
toY: entry != null ? entry.value.toDouble() : 0,
|
|
|
|
width: barWidth,
|
|
|
|
color: entry != null
|
|
|
|
? _getColorByState(entry.state)
|
|
|
|
? widget.getColorByState(entry.state)
|
|
|
|
: Colors.transparent,
|
|
|
|
borderRadius: BorderRadius.zero,
|
|
|
|
// borderRadius: const BorderRadius.only(
|
|
|
|
// topLeft: Radius.circular(2),
|
|
|
|
// topRight: Radius.circular(2),
|
|
|
|
// ),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
));
|
...
|
...
|
|