Commit 2af8f8e5996d45e1239eb662048b073232261e7e

Authored by 常守达
1 parent 15bdd7ec

fix(today):看板问题修复

... ... @@ -544,7 +544,7 @@ class _LatestHrvCard extends StatelessWidget {
final colors = context.colors;
return Obx(() {
final latestHrv = controller.v2LatestHrv.value?.latestHrv;
final latestHrv = controller.v2LatestHrv.value?.latestHrv?.toInt();
final latestDataTime = controller.v2LatestHrv.value?.latestDataTime;
final state = controller.v2LatestHrv.value?.state;
final stateStr = controller.v2LatestHrv.value?.stateString();
... ...
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),
// ),
),
],
));
... ...
... ... @@ -135,7 +135,7 @@
"todayHrvMeasurementStep2": "2. Open Mindfulness on Apple Watch and start Breathe",
"todayHrvMeasurementStep3": "3. Keep breathing steadily and wait 1-3 minutes",
"todayHrvMeasurementStep4": "4. After breathing is complete, lock and unlock your iPhone once",
"todayHrvMeasurementStep5": "5. Wait about one minute. StressWatch will receive and display your data",
"todayHrvMeasurementStep5": "5. Wait about one minute. DoubleFeel will receive and display your data",
"todayHrvMeasurementHint": "Tip: Data comes from Apple Watch. After measurement, there may be delays or data may not sync immediately. If this happens, measure again and wait for the data to be read.",
"todayHrvMeasurementWarning": "Note: Health permissions must be enabled, and Low Power Mode must be turned off.",
"todayStressStatusWhatTitle": "What is overall stress status?",
... ...
... ... @@ -218,7 +218,7 @@
"todayHrvMeasurementStep2": "2、打开AppleWatch上的「正念」里的「呼吸」",
"todayHrvMeasurementStep3": "3、保持平稳的呼吸,等待1-3分钟",
"todayHrvMeasurementStep4": "4、完成呼吸后,锁屏一次再解锁你的iPhone",
"todayHrvMeasurementStep5": "5、等待一分钟左右,StressWatch会收到你的数据并展示",
"todayHrvMeasurementStep5": "5、等待一分钟左右,DoubleFeel会收到你的数据并展示",
"todayHrvMeasurementHint": "提示:数据源来自AppleWatch,在测量之后可能存在延迟或是数据未能同步的情况。如若出现上述情况,请重新测量并等待数据读取。",
"todayHrvMeasurementWarning": "注意:需打开健康里的权限,同时关闭省电模式。",
"todayStressStatusWhatTitle": "什么是综合压力状态?",
... ...
... ... @@ -912,7 +912,7 @@ abstract class AppLocalizations {
/// No description provided for @todayHrvMeasurementStep5.
///
/// In zh, this message translates to:
/// **'5、等待一分钟左右,StressWatch会收到你的数据并展示'**
/// **'5、等待一分钟左右,DoubleFeel会收到你的数据并展示'**
String get todayHrvMeasurementStep5;
/// No description provided for @todayHrvMeasurementHint.
... ...
... ... @@ -457,7 +457,7 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get todayHrvMeasurementStep5 =>
'5. Wait about one minute. StressWatch will receive and display your data';
'5. Wait about one minute. DoubleFeel will receive and display your data';
@override
String get todayHrvMeasurementHint =>
... ...
... ... @@ -430,7 +430,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get todayHrvMeasurementStep4 => '4、完成呼吸后,锁屏一次再解锁你的iPhone';
@override
String get todayHrvMeasurementStep5 => '5、等待一分钟左右,StressWatch会收到你的数据并展示';
String get todayHrvMeasurementStep5 => '5、等待一分钟左右,DoubleFeel会收到你的数据并展示';
@override
String get todayHrvMeasurementHint =>
... ...
... ... @@ -52,6 +52,7 @@ dependencies:
table_calendar: ^3.1.3
fluttertoast: ^8.2.2
path_provider: ^2.1.5
thinking_analytics: ^3.3.2
flutter_localizations:
sdk: flutter
intl: any
... ...