Commit 2af8f8e5996d45e1239eb662048b073232261e7e

Authored by 常守达
1 parent 15bdd7ec

fix(today):看板问题修复

@@ -544,7 +544,7 @@ class _LatestHrvCard extends StatelessWidget { @@ -544,7 +544,7 @@ class _LatestHrvCard extends StatelessWidget {
544 final colors = context.colors; 544 final colors = context.colors;
545 545
546 return Obx(() { 546 return Obx(() {
547 - final latestHrv = controller.v2LatestHrv.value?.latestHrv; 547 + final latestHrv = controller.v2LatestHrv.value?.latestHrv?.toInt();
548 final latestDataTime = controller.v2LatestHrv.value?.latestDataTime; 548 final latestDataTime = controller.v2LatestHrv.value?.latestDataTime;
549 final state = controller.v2LatestHrv.value?.state; 549 final state = controller.v2LatestHrv.value?.state;
550 final stateStr = controller.v2LatestHrv.value?.stateString(); 550 final stateStr = controller.v2LatestHrv.value?.stateString();
  1 +import 'dart:async';
  2 +
1 import 'package:doublefeel_flutter/core/theme/app_theme.dart'; 3 import 'package:doublefeel_flutter/core/theme/app_theme.dart';
2 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart'; 4 import 'package:doublefeel_flutter/data/local/user_preferences_storage.dart';
3 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart'; 5 import 'package:doublefeel_flutter/data/models/health/health_v2_models.dart';
@@ -22,8 +24,6 @@ class TodayHrvChartCard extends StatelessWidget { @@ -22,8 +24,6 @@ class TodayHrvChartCard extends StatelessWidget {
22 24
23 final TodayController controller; 25 final TodayController controller;
24 26
25 - static const _h5 = Color(0xFFF3F3F3);  
26 -  
27 @override 27 @override
28 Widget build(BuildContext context) { 28 Widget build(BuildContext context) {
29 final userPrefs = Get.find<UserPreferencesStorage>(); 29 final userPrefs = Get.find<UserPreferencesStorage>();
@@ -61,8 +61,14 @@ class TodayHrvChartCard extends StatelessWidget { @@ -61,8 +61,14 @@ class TodayHrvChartCard extends StatelessWidget {
61 height: 174, 61 height: 174,
62 child: hrvSpots.isEmpty 62 child: hrvSpots.isEmpty
63 ? _EmptyChart(text: context.l10n.noDataAvailableForToday) 63 ? _EmptyChart(text: context.l10n.noDataAvailableForToday)
64 - : LineChart(  
65 - _hrvLineChartData(context, hrvSpots, hrvStateMap)), 64 + : _HrvLineChart(
  65 + spots: hrvSpots,
  66 + stateMap: hrvStateMap,
  67 + maxSeconds: _maxChartSeconds(),
  68 + getColorByState: _getColorByState,
  69 + getTooltipChildren: _getTooltipChildren,
  70 + timeLabel: _timeLabel,
  71 + ),
66 ), 72 ),
67 Padding( 73 Padding(
68 padding: const EdgeInsets.only(bottom: 14, top: 16), 74 padding: const EdgeInsets.only(bottom: 14, top: 16),
@@ -204,135 +210,6 @@ class TodayHrvChartCard extends StatelessWidget { @@ -204,135 +210,6 @@ class TodayHrvChartCard extends StatelessWidget {
204 return nextSlot * 6 * 3600.0; 210 return nextSlot * 6 * 3600.0;
205 } 211 }
206 212
207 - LineChartData _hrvLineChartData(  
208 - BuildContext context,  
209 - List<FlSpot> spots,  
210 - Map<double, int?> stateMap,  
211 - ) {  
212 - return LineChartData(  
213 - minX: 0,  
214 - maxX: _maxChartSeconds(),  
215 - minY: 0,  
216 - maxY: 80,  
217 - gridData: FlGridData(  
218 - show: true,  
219 - drawVerticalLine: true,  
220 - drawHorizontalLine: false,  
221 - verticalInterval: 6 * 3600,  
222 - checkToShowVerticalLine: (val) {  
223 - // if (val == startTs) return false;  
224 - // if (val > endTs) return false;  
225 - return true;  
226 - },  
227 - getDrawingVerticalLine: (_) => const FlLine(  
228 - color: _h5,  
229 - strokeWidth: 1,  
230 - dashArray: [2, 2],  
231 - ),  
232 - ),  
233 - borderData: FlBorderData(  
234 - show: false,  
235 - ),  
236 - extraLinesData: ExtraLinesData(  
237 - verticalLines: [  
238 - VerticalLine(x: 0, color: _h5, strokeWidth: 1, dashArray: [2, 2]),  
239 - VerticalLine(  
240 - x: _maxChartSeconds(),  
241 - color: _h5,  
242 - strokeWidth: 1,  
243 - dashArray: [2, 2]),  
244 - ],  
245 - ),  
246 - titlesData: FlTitlesData(  
247 - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
248 - rightTitles:  
249 - const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
250 - leftTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),  
251 - bottomTitles: AxisTitles(  
252 - sideTitles: SideTitles(  
253 - showTitles: true,  
254 - reservedSize: 20,  
255 - interval: 6 * 3600,  
256 - getTitlesWidget: (val, _) {  
257 - final label = _timeLabel(val);  
258 - if (label == null) return const SizedBox.shrink();  
259 - return Text(  
260 - label,  
261 - style:  
262 - TextStyle(fontSize: 10, color: context.colors.textTertiary),  
263 - );  
264 - },  
265 - ),  
266 - ),  
267 - ),  
268 - lineBarsData: [  
269 - LineChartBarData(  
270 - spots: spots,  
271 - isCurved: false,  
272 - color: const Color(0xFFD9D9D9),  
273 - barWidth: 2,  
274 - dotData: FlDotData(  
275 - getDotPainter: (spot, percent, barData, index) {  
276 - return FlDotCirclePainter(  
277 - radius: 4,  
278 - color: Colors.white,  
279 - strokeWidth: 3,  
280 - strokeColor: _getColorByState(stateMap[spot.x]),  
281 - );  
282 - },  
283 - ),  
284 - )  
285 - ],  
286 - lineTouchData: LineTouchData(  
287 - getTouchedSpotIndicator: (barData, spotIndexes) {  
288 - return spotIndexes.map((spotIndex) {  
289 - return TouchedSpotIndicatorData(  
290 - const FlLine(  
291 - color: Color(0xFFB0B0B6),  
292 - strokeWidth: 2,  
293 - ),  
294 - FlDotData(  
295 - getDotPainter: (spot, percent, barData, index) {  
296 - return FlDotCirclePainter(  
297 - radius: 5,  
298 - color: Colors.white,  
299 - strokeWidth: 3.5,  
300 - strokeColor: _getColorByState(stateMap[spot.x]),  
301 - );  
302 - },  
303 - ),  
304 - );  
305 - }).toList();  
306 - },  
307 - touchTooltipData: LineTouchTooltipData(  
308 - tooltipRoundedRadius: 8,  
309 - tooltipBorder: BorderSide.none,  
310 - maxContentWidth: 153,  
311 - getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),  
312 - tooltipPadding: const EdgeInsets.only(  
313 - left: 12,  
314 - right: 12,  
315 - top: 6,  
316 - bottom: 5,  
317 - ),  
318 - getTooltipItems: (touchedBarSpots) {  
319 - return touchedBarSpots.map((barSpot) {  
320 - return LineTooltipItem(  
321 - '',  
322 - const TextStyle(  
323 - color: Colors.white,  
324 - fontWeight: FontWeight.bold,  
325 - ),  
326 - children: _getTooltipChildren(barSpot, stateMap[barSpot.x]),  
327 - textAlign: TextAlign.start,  
328 - );  
329 - }).toList();  
330 - },  
331 - ),  
332 - ),  
333 - );  
334 - }  
335 -  
336 Widget _buildStressChart( 213 Widget _buildStressChart(
337 BuildContext context, 214 BuildContext context,
338 List<V2SleepTimeRange>? sleepList, 215 List<V2SleepTimeRange>? sleepList,
@@ -360,244 +237,16 @@ class TodayHrvChartCard extends StatelessWidget { @@ -360,244 +237,16 @@ class TodayHrvChartCard extends StatelessWidget {
360 if (stressPoints.isEmpty) 237 if (stressPoints.isEmpty)
361 _EmptyChart(text: context.l10n.noPressureDataAvailableAtThisTime) 238 _EmptyChart(text: context.l10n.noPressureDataAvailableAtThisTime)
362 else 239 else
363 - LayoutBuilder(  
364 - builder: (context, constraints) {  
365 - // 右轴 reservedSize=22,剩余宽度平分给所有槽  
366 - const rightAxisWidth = 22.0;  
367 - const slotSec = 6 * 60;  
368 - final slotCount =  
369 - (_maxChartSeconds() / slotSec).ceil().clamp(1, 9999);  
370 - // final barWidth =  
371 - // (constraints.maxWidth - rightAxisWidth) / slotCount;  
372 - const double groupsSpace = 0.5; // 设置间隔为 1 像素  
373 - final barWidth = (constraints.maxWidth -  
374 - rightAxisWidth -  
375 - (groupsSpace * (slotCount - 1))) /  
376 - slotCount;  
377 -  
378 - // 根据 V2SleepTimeRange 计算多段睡眠区间的 Positioned widgets  
379 - final sleepWidgets = <Widget>[];  
380 -  
381 - if (sleepList != null && sleepList.isNotEmpty) {  
382 - final todayMidnight = DateTime(  
383 - selectedDate.year, selectedDate.month, selectedDate.day);  
384 - final todayMidnightSec =  
385 - todayMidnight.millisecondsSinceEpoch ~/ 1000;  
386 - final maxSec = _maxChartSeconds();  
387 - final chartWidth = constraints.maxWidth - rightAxisWidth;  
388 -  
389 - // 1. 过滤并排序睡眠区间  
390 - final sortedList = List<V2SleepTimeRange>.from(sleepList)  
391 - ..sort((a, b) => a.fromTime.compareTo(b.fromTime));  
392 -  
393 - // 2. 合并相邻且间隔小于 2 小时的睡眠区间,避免因短暂醒来分段导致多个床图标或跨天首尾大面积相连  
394 - final mergedList = <V2SleepTimeRange>[];  
395 - for (final range in sortedList) {  
396 - if (mergedList.isEmpty) {  
397 - mergedList.add(range);  
398 - } else {  
399 - final lastRange = mergedList.last;  
400 - if (range.fromTime - lastRange.toTime <= 2 * 3600) {  
401 - mergedList[mergedList.length - 1] = V2SleepTimeRange(  
402 - fromTime: lastRange.fromTime,  
403 - toTime: range.toTime > lastRange.toTime  
404 - ? range.toTime  
405 - : lastRange.toTime,  
406 - );  
407 - } else {  
408 - mergedList.add(range);  
409 - }  
410 - }  
411 - }  
412 -  
413 - // 3. 渲染合并后的睡眠区间 Positioned widgets  
414 - for (final range in mergedList) {  
415 - final fromSec = range.fromTime - todayMidnightSec;  
416 - final toSec = range.toTime - todayMidnightSec;  
417 - final visibleFrom = fromSec.clamp(0.0, maxSec);  
418 - final visibleTo = toSec.clamp(0.0, maxSec);  
419 - if (visibleTo > visibleFrom) {  
420 - final sleepSpanLeft = chartWidth * (visibleFrom / maxSec);  
421 - final sleepSpanWidth =  
422 - (chartWidth * (visibleTo / maxSec)) - sleepSpanLeft;  
423 - if (sleepSpanWidth > 0) {  
424 - sleepWidgets.add(  
425 - Positioned(  
426 - left: sleepSpanLeft,  
427 - width: sleepSpanWidth,  
428 - top: 0,  
429 - bottom: 22,  
430 - child: IgnorePointer(  
431 - child: Stack(  
432 - clipBehavior: Clip.none,  
433 - children: [  
434 - Positioned(  
435 - left: 0,  
436 - right: 0,  
437 - top: 14,  
438 - bottom: 0,  
439 - child: Container(  
440 - decoration: const BoxDecoration(  
441 - gradient: LinearGradient(  
442 - begin: Alignment(0.50, -0.00),  
443 - end: Alignment(0.50, 1.00),  
444 - colors: [  
445 - Color(0x4C835DED),  
446 - Color(0x00845EEE)  
447 - ],  
448 - ),  
449 - ),  
450 - ),  
451 - ),  
452 - Positioned(  
453 - left: 0,  
454 - right: 0,  
455 - top: 14,  
456 - height: 2,  
457 - child:  
458 - ColoredBox(color: context.colors.primary),  
459 - ),  
460 - if (sleepSpanWidth >= 20.0)  
461 - Positioned(  
462 - left: (sleepSpanWidth - 12) / 2,  
463 - top: 0,  
464 - child: Image.asset(  
465 - 'assets/images/common/ic_health_bed.webp',  
466 - width: 12,  
467 - height: 12,  
468 - ),  
469 - ),  
470 - ],  
471 - ),  
472 - ),  
473 - ),  
474 - );  
475 - }  
476 - }  
477 - }  
478 - }  
479 -  
480 - return Stack(  
481 - clipBehavior: Clip.none,  
482 - children: [  
483 - Padding(  
484 - padding: const EdgeInsets.only(top: 6),  
485 - child: BarChart(  
486 - BarChartData(  
487 - minY: 0,  
488 - maxY: 100,  
489 - groupsSpace: groupsSpace,  
490 - alignment: BarChartAlignment.start,  
491 - barGroups: _buildStressBarGroups(stressPoints,  
492 - barWidth: barWidth),  
493 - gridData: FlGridData(  
494 - show: true,  
495 - drawVerticalLine: false,  
496 - drawHorizontalLine: true,  
497 - horizontalInterval: 25,  
498 - getDrawingHorizontalLine: (_) => const FlLine(  
499 - color: _h5,  
500 - strokeWidth: 1,  
501 - dashArray: [2, 2],  
502 - ),  
503 - ),  
504 - extraLinesData: ExtraLinesData(  
505 - horizontalLines: [  
506 - HorizontalLine(  
507 - y: 0,  
508 - color: _h5,  
509 - strokeWidth: 1,  
510 - dashArray: [2, 2],  
511 - ),  
512 - HorizontalLine(  
513 - y: 100,  
514 - color: _h5,  
515 - strokeWidth: 1,  
516 - dashArray: [2, 2],  
517 - ),  
518 - ],  
519 - ),  
520 - borderData: FlBorderData(show: false),  
521 - titlesData: FlTitlesData(  
522 - topTitles: const AxisTitles(  
523 - sideTitles: SideTitles(showTitles: false)),  
524 - leftTitles: const AxisTitles(  
525 - sideTitles: SideTitles(showTitles: false)),  
526 - rightTitles: AxisTitles(  
527 - sideTitles: SideTitles(  
528 - showTitles: true,  
529 - interval: 50,  
530 - reservedSize: 22,  
531 - getTitlesWidget: (val, _) {  
532 - if (val < 0 || val > 100) {  
533 - return const SizedBox.shrink();  
534 - }  
535 - return Text(  
536 - '${val.toInt()}',  
537 - style: TextStyle(  
538 - fontSize: 10,  
539 - color: context.colors.textTertiary),  
540 - );  
541 - },  
542 - ),  
543 - ),  
544 - bottomTitles: AxisTitles(  
545 - sideTitles: SideTitles(  
546 - showTitles: true,  
547 - reservedSize: 22,  
548 - interval: 60,  
549 - getTitlesWidget: (val, meta) {  
550 - final label = _timeLabel(val);  
551 - if (label == null) {  
552 - return const SizedBox.shrink();  
553 - }  
554 - return SideTitleWidget(  
555 - meta: meta,  
556 - child: Text(  
557 - label,  
558 - style: TextStyle(  
559 - fontSize: 10,  
560 - color: context.colors.textTertiary),  
561 - ),  
562 - );  
563 - },  
564 - ),  
565 - ),  
566 - ),  
567 - barTouchData: BarTouchData(  
568 - touchTooltipData: BarTouchTooltipData(  
569 - tooltipRoundedRadius: 8,  
570 - tooltipBorder: BorderSide.none,  
571 - getTooltipColor: (touchedSpot) =>  
572 - const Color(0xFFF3F3F3),  
573 - tooltipPadding: const EdgeInsets.only(  
574 - left: 12,  
575 - right: 12,  
576 - top: 6,  
577 - bottom: 5,  
578 - ),  
579 - getTooltipItem: (group, groupIndex, rod, rodIndex) {  
580 - // group.x 是槽时间秒,对应 slotStateMap 的 key  
581 - final state = slotStateMap[group.x]?.state;  
582 - return BarTooltipItem(  
583 - '',  
584 - const TextStyle(  
585 - color: Colors.white,  
586 - fontWeight: FontWeight.bold,  
587 - ),  
588 - children: _getTooltip(rod, group.x, state),  
589 - textAlign: TextAlign.start,  
590 - );  
591 - },  
592 - ),  
593 - ),  
594 - ),  
595 - ),  
596 - ),  
597 - ...sleepWidgets,  
598 - ],  
599 - );  
600 - }, 240 + _StressBarChart(
  241 + sleepList: sleepList,
  242 + stressPoints: stressPoints,
  243 + selectedDate: selectedDate,
  244 + isVip: isVip,
  245 + onTapPurchase: onTapPurchase,
  246 + maxSeconds: _maxChartSeconds(),
  247 + getColorByState: _getColorByState,
  248 + getTooltip: _getTooltip,
  249 + timeLabel: _timeLabel,
601 ), 250 ),
602 if (isVip != true) ...[ 251 if (isVip != true) ...[
603 GestureDetector( 252 GestureDetector(
@@ -777,18 +426,545 @@ class TodayHrvChartCard extends StatelessWidget { @@ -777,18 +426,545 @@ class TodayHrvChartCard extends StatelessWidget {
777 final m = ((total % 3600) ~/ 60).toString().padLeft(2, '0'); 426 final m = ((total % 3600) ~/ 60).toString().padLeft(2, '0');
778 return '$h:$m'; 427 return '$h:$m';
779 } 428 }
  429 +}
  430 +
  431 +class _HrvLineChart extends StatefulWidget {
  432 + const _HrvLineChart({
  433 + required this.spots,
  434 + required this.stateMap,
  435 + required this.maxSeconds,
  436 + required this.getColorByState,
  437 + required this.getTooltipChildren,
  438 + required this.timeLabel,
  439 + });
  440 +
  441 + final List<FlSpot> spots;
  442 + final Map<double, int?> stateMap;
  443 + final double maxSeconds;
  444 + final Color Function(int?) getColorByState;
  445 + final List<TextSpan>? Function(LineBarSpot, int?) getTooltipChildren;
  446 + final String? Function(double) timeLabel;
  447 +
  448 + @override
  449 + State<_HrvLineChart> createState() => _HrvLineChartState();
  450 +}
  451 +
  452 +class _HrvLineChartState extends State<_HrvLineChart> {
  453 + List<LineBarSpot> _hrvShowingSpots = [];
  454 + Timer? _hrvTooltipTimer;
  455 +
  456 + @override
  457 + void dispose() {
  458 + _hrvTooltipTimer?.cancel();
  459 + super.dispose();
  460 + }
  461 +
  462 + @override
  463 + Widget build(BuildContext context) {
  464 + return LineChart(
  465 + LineChartData(
  466 + minX: 0,
  467 + maxX: widget.maxSeconds,
  468 + minY: 0,
  469 + maxY: 80,
  470 + gridData: FlGridData(
  471 + show: true,
  472 + drawVerticalLine: true,
  473 + drawHorizontalLine: false,
  474 + verticalInterval: 6 * 3600,
  475 + checkToShowVerticalLine: (val) {
  476 + return true;
  477 + },
  478 + getDrawingVerticalLine: (_) => const FlLine(
  479 + color: Color(0xFFF3F3F3),
  480 + strokeWidth: 1,
  481 + dashArray: [2, 2],
  482 + ),
  483 + ),
  484 + borderData: FlBorderData(
  485 + show: false,
  486 + ),
  487 + extraLinesData: ExtraLinesData(
  488 + verticalLines: [
  489 + VerticalLine(
  490 + x: 0,
  491 + color: const Color(0xFFF3F3F3),
  492 + strokeWidth: 1,
  493 + dashArray: [2, 2]),
  494 + VerticalLine(
  495 + x: widget.maxSeconds,
  496 + color: const Color(0xFFF3F3F3),
  497 + strokeWidth: 1,
  498 + dashArray: [2, 2]),
  499 + ],
  500 + ),
  501 + titlesData: FlTitlesData(
  502 + topTitles:
  503 + const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  504 + rightTitles:
  505 + const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  506 + leftTitles:
  507 + const AxisTitles(sideTitles: SideTitles(showTitles: false)),
  508 + bottomTitles: AxisTitles(
  509 + sideTitles: SideTitles(
  510 + showTitles: true,
  511 + reservedSize: 20,
  512 + interval: 6 * 3600,
  513 + getTitlesWidget: (val, _) {
  514 + final label = widget.timeLabel(val);
  515 + if (label == null) return const SizedBox.shrink();
  516 + return Text(
  517 + label,
  518 + style: TextStyle(
  519 + fontSize: 10, color: context.colors.textTertiary),
  520 + );
  521 + },
  522 + ),
  523 + ),
  524 + ),
  525 + lineBarsData: [
  526 + LineChartBarData(
  527 + spots: widget.spots,
  528 + isCurved: false,
  529 + color: const Color(0xFFD9D9D9),
  530 + barWidth: 2,
  531 + showingIndicators:
  532 + _hrvShowingSpots.map((spot) => spot.spotIndex).toList(),
  533 + dotData: FlDotData(
  534 + getDotPainter: (spot, percent, barData, index) {
  535 + return FlDotCirclePainter(
  536 + radius: 4,
  537 + color: Colors.white,
  538 + strokeWidth: 3,
  539 + strokeColor: widget.getColorByState(widget.stateMap[spot.x]),
  540 + );
  541 + },
  542 + ),
  543 + )
  544 + ],
  545 + showingTooltipIndicators: _hrvShowingSpots.isEmpty
  546 + ? const []
  547 + : [
  548 + ShowingTooltipIndicators(_hrvShowingSpots),
  549 + ],
  550 + lineTouchData: LineTouchData(
  551 + handleBuiltInTouches: false,
  552 + touchCallback:
  553 + (FlTouchEvent event, LineTouchResponse? touchResponse) {
  554 + if (event is FlPanStartEvent ||
  555 + event is FlPanUpdateEvent ||
  556 + event is FlTapDownEvent ||
  557 + event is FlPointerHoverEvent ||
  558 + event is FlPointerEnterEvent) {
  559 + _hrvTooltipTimer?.cancel();
  560 + if (touchResponse != null &&
  561 + touchResponse.lineBarSpots != null &&
  562 + touchResponse.lineBarSpots!.isNotEmpty) {
  563 + setState(() {
  564 + _hrvShowingSpots = touchResponse.lineBarSpots!;
  565 + });
  566 + }
  567 + } else if (event is FlPanEndEvent ||
  568 + event is FlTapUpEvent ||
  569 + event is FlPointerExitEvent ||
  570 + event is FlTapCancelEvent ||
  571 + event is FlPanCancelEvent) {
  572 + _hrvTooltipTimer?.cancel();
  573 + _hrvTooltipTimer = Timer(const Duration(milliseconds: 4000), () {
  574 + if (mounted) {
  575 + setState(() {
  576 + _hrvShowingSpots = [];
  577 + });
  578 + }
  579 + });
  580 + }
  581 + },
  582 + getTouchedSpotIndicator: (barData, spotIndexes) {
  583 + return spotIndexes.map((spotIndex) {
  584 + return TouchedSpotIndicatorData(
  585 + const FlLine(
  586 + color: Color(0xFFB0B0B6),
  587 + strokeWidth: 2,
  588 + ),
  589 + FlDotData(
  590 + getDotPainter: (spot, percent, barData, index) {
  591 + return FlDotCirclePainter(
  592 + radius: 5,
  593 + color: Colors.white,
  594 + strokeWidth: 3.5,
  595 + strokeColor:
  596 + widget.getColorByState(widget.stateMap[spot.x]),
  597 + );
  598 + },
  599 + ),
  600 + );
  601 + }).toList();
  602 + },
  603 + touchTooltipData: LineTouchTooltipData(
  604 + tooltipRoundedRadius: 8,
  605 + tooltipBorder: BorderSide.none,
  606 + maxContentWidth: 400,
  607 + fitInsideHorizontally: true,
  608 + fitInsideVertically: true,
  609 + getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
  610 + tooltipPadding: const EdgeInsets.only(
  611 + left: 12,
  612 + right: 12,
  613 + top: 6,
  614 + bottom: 5,
  615 + ),
  616 + getTooltipItems: (touchedBarSpots) {
  617 + return touchedBarSpots.map((barSpot) {
  618 + return LineTooltipItem(
  619 + '',
  620 + const TextStyle(
  621 + color: Colors.white,
  622 + fontWeight: FontWeight.bold,
  623 + ),
  624 + children: widget.getTooltipChildren(
  625 + barSpot, widget.stateMap[barSpot.x]),
  626 + textAlign: TextAlign.start,
  627 + );
  628 + }).toList();
  629 + },
  630 + ),
  631 + ),
  632 + ),
  633 + );
  634 + }
  635 +}
  636 +
  637 +class _StressBarChart extends StatefulWidget {
  638 + const _StressBarChart({
  639 + required this.sleepList,
  640 + required this.stressPoints,
  641 + required this.selectedDate,
  642 + required this.isVip,
  643 + required this.onTapPurchase,
  644 + required this.maxSeconds,
  645 + required this.getColorByState,
  646 + required this.getTooltip,
  647 + required this.timeLabel,
  648 + });
  649 +
  650 + final List<V2SleepTimeRange>? sleepList;
  651 + final List<V2RealtimeStressItem> stressPoints;
  652 + final DateTime selectedDate;
  653 + final bool isVip;
  654 + final VoidCallback onTapPurchase;
  655 + final double maxSeconds;
  656 + final Color Function(int?) getColorByState;
  657 + final List<TextSpan>? Function(BarChartRodData, int, int?) getTooltip;
  658 + final String? Function(double) timeLabel;
  659 +
  660 + @override
  661 + State<_StressBarChart> createState() => _StressBarChartState();
  662 +}
  663 +
  664 +class _StressBarChartState extends State<_StressBarChart> {
  665 + int? _stressTouchedGroupIndex;
  666 + Timer? _stressTooltipTimer;
  667 +
  668 + @override
  669 + void dispose() {
  670 + _stressTooltipTimer?.cancel();
  671 + super.dispose();
  672 + }
  673 +
  674 + @override
  675 + Widget build(BuildContext context) {
  676 + return LayoutBuilder(
  677 + builder: (context, constraints) {
  678 + // 右轴 reservedSize=22,剩余宽度平分给所有槽
  679 + const rightAxisWidth = 22.0;
  680 + const slotSec = 6 * 60;
  681 + final slotCount = (widget.maxSeconds / slotSec).ceil().clamp(1, 9999);
  682 + const double groupsSpace = 0.5; // 设置间隔为 1 像素
  683 + final barWidth = (constraints.maxWidth -
  684 + rightAxisWidth -
  685 + (groupsSpace * (slotCount - 1))) /
  686 + slotCount;
  687 +
  688 + // 根据 V2SleepTimeRange 计算多段睡眠区间的 Positioned widgets
  689 + final sleepWidgets = <Widget>[];
  690 +
  691 + if (widget.sleepList != null && widget.sleepList!.isNotEmpty) {
  692 + final todayMidnight = DateTime(widget.selectedDate.year,
  693 + widget.selectedDate.month, widget.selectedDate.day);
  694 + final todayMidnightSec = todayMidnight.millisecondsSinceEpoch ~/ 1000;
  695 + final maxSec = widget.maxSeconds;
  696 + final chartWidth = constraints.maxWidth - rightAxisWidth;
  697 +
  698 + // 1. 过滤并排序睡眠区间
  699 + final sortedList = List<V2SleepTimeRange>.from(widget.sleepList!)
  700 + ..sort((a, b) => a.fromTime.compareTo(b.fromTime));
  701 +
  702 + // 2. 合并相邻且间隔小于 2 小时的睡眠区间,避免因短暂醒来分段导致多个床图标或跨天首尾大面积相连
  703 + final mergedList = <V2SleepTimeRange>[];
  704 + for (final range in sortedList) {
  705 + if (mergedList.isEmpty) {
  706 + mergedList.add(range);
  707 + } else {
  708 + final lastRange = mergedList.last;
  709 + if (range.fromTime - lastRange.toTime <= 2 * 3600) {
  710 + mergedList[mergedList.length - 1] = V2SleepTimeRange(
  711 + fromTime: lastRange.fromTime,
  712 + toTime: range.toTime > lastRange.toTime
  713 + ? range.toTime
  714 + : lastRange.toTime,
  715 + );
  716 + } else {
  717 + mergedList.add(range);
  718 + }
  719 + }
  720 + }
  721 +
  722 + // 3. 渲染合并后的睡眠区间 Positioned widgets
  723 + for (final range in mergedList) {
  724 + final fromSec = range.fromTime - todayMidnightSec;
  725 + final toSec = range.toTime - todayMidnightSec;
  726 + final visibleFrom = fromSec.clamp(0.0, maxSec);
  727 + final visibleTo = toSec.clamp(0.0, maxSec);
  728 + if (visibleTo > visibleFrom) {
  729 + final sleepSpanLeft = chartWidth * (visibleFrom / maxSec);
  730 + final sleepSpanWidth =
  731 + (chartWidth * (visibleTo / maxSec)) - sleepSpanLeft;
  732 + if (sleepSpanWidth > 0) {
  733 + sleepWidgets.add(
  734 + Positioned(
  735 + left: sleepSpanLeft,
  736 + width: sleepSpanWidth,
  737 + top: 0,
  738 + bottom: 22,
  739 + child: IgnorePointer(
  740 + child: Stack(
  741 + clipBehavior: Clip.none,
  742 + children: [
  743 + Positioned(
  744 + left: 0,
  745 + right: 0,
  746 + top: 14,
  747 + bottom: 0,
  748 + child: Container(
  749 + decoration: const BoxDecoration(
  750 + gradient: LinearGradient(
  751 + begin: Alignment(0.50, -0.00),
  752 + end: Alignment(0.50, 1.00),
  753 + colors: [
  754 + Color(0x4C835DED),
  755 + Color(0x00845EEE)
  756 + ],
  757 + ),
  758 + ),
  759 + ),
  760 + ),
  761 + Positioned(
  762 + left: 0,
  763 + right: 0,
  764 + top: 14,
  765 + height: 2,
  766 + child: ColoredBox(color: context.colors.primary),
  767 + ),
  768 + if (sleepSpanWidth >= 20.0)
  769 + Positioned(
  770 + left: (sleepSpanWidth - 12) / 2,
  771 + top: 0,
  772 + child: Image.asset(
  773 + 'assets/images/common/ic_health_bed.webp',
  774 + width: 12,
  775 + height: 12,
  776 + ),
  777 + ),
  778 + ],
  779 + ),
  780 + ),
  781 + ),
  782 + );
  783 + }
  784 + }
  785 + }
  786 + }
  787 +
  788 + // 槽时间秒 → state 映射
  789 + final slotStateMap = <int, ({int value, int? state})>{};
  790 + for (final p in widget.stressPoints) {
  791 + final sec =
  792 + TodayHrvChartCard._tsToSecondsFromMidnight(p.time).round();
  793 + final slot = (sec / slotSec).round() * slotSec;
  794 + final v = p.value ?? 0;
  795 + final existing = slotStateMap[slot];
  796 + if (existing == null || v > existing.value) {
  797 + slotStateMap[slot] = (value: v, state: p.state);
  798 + }
  799 + }
  800 +
  801 + return Stack(
  802 + clipBehavior: Clip.none,
  803 + children: [
  804 + Padding(
  805 + padding: const EdgeInsets.only(top: 6),
  806 + child: BarChart(
  807 + BarChartData(
  808 + minY: 0,
  809 + maxY: 100,
  810 + groupsSpace: groupsSpace,
  811 + alignment: BarChartAlignment.start,
  812 + barGroups: _buildStressBarGroups(widget.stressPoints,
  813 + barWidth: barWidth),
  814 + gridData: FlGridData(
  815 + show: true,
  816 + drawVerticalLine: false,
  817 + drawHorizontalLine: true,
  818 + horizontalInterval: 25,
  819 + getDrawingHorizontalLine: (_) => const FlLine(
  820 + color: Color(0xFFF3F3F3),
  821 + strokeWidth: 1,
  822 + dashArray: [2, 2],
  823 + ),
  824 + ),
  825 + extraLinesData: ExtraLinesData(
  826 + horizontalLines: [
  827 + HorizontalLine(
  828 + y: 0,
  829 + color: const Color(0xFFF3F3F3),
  830 + strokeWidth: 1,
  831 + dashArray: [2, 2],
  832 + ),
  833 + HorizontalLine(
  834 + y: 100,
  835 + color: const Color(0xFFF3F3F3),
  836 + strokeWidth: 1,
  837 + dashArray: [2, 2],
  838 + ),
  839 + ],
  840 + ),
  841 + borderData: FlBorderData(show: false),
  842 + titlesData: FlTitlesData(
  843 + topTitles: const AxisTitles(
  844 + sideTitles: SideTitles(showTitles: false)),
  845 + leftTitles: const AxisTitles(
  846 + sideTitles: SideTitles(showTitles: false)),
  847 + rightTitles: AxisTitles(
  848 + sideTitles: SideTitles(
  849 + showTitles: true,
  850 + interval: 50,
  851 + reservedSize: 22,
  852 + getTitlesWidget: (val, _) {
  853 + if (val < 0 || val > 100) {
  854 + return const SizedBox.shrink();
  855 + }
  856 + return Text(
  857 + '${val.toInt()}',
  858 + style: TextStyle(
  859 + fontSize: 10,
  860 + color: context.colors.textTertiary),
  861 + );
  862 + },
  863 + ),
  864 + ),
  865 + bottomTitles: AxisTitles(
  866 + sideTitles: SideTitles(
  867 + showTitles: true,
  868 + reservedSize: 22,
  869 + interval: 60,
  870 + getTitlesWidget: (val, meta) {
  871 + final label = widget.timeLabel(val);
  872 + if (label == null) {
  873 + return const SizedBox.shrink();
  874 + }
  875 + return SideTitleWidget(
  876 + meta: meta,
  877 + child: Text(
  878 + label,
  879 + style: TextStyle(
  880 + fontSize: 10,
  881 + color: context.colors.textTertiary),
  882 + ),
  883 + );
  884 + },
  885 + ),
  886 + ),
  887 + ),
  888 + barTouchData: BarTouchData(
  889 + handleBuiltInTouches: false,
  890 + touchCallback:
  891 + (FlTouchEvent event, BarTouchResponse? touchResponse) {
  892 + if (event is FlPanStartEvent ||
  893 + event is FlPanUpdateEvent ||
  894 + event is FlTapDownEvent ||
  895 + event is FlPointerHoverEvent ||
  896 + event is FlPointerEnterEvent) {
  897 + _stressTooltipTimer?.cancel();
  898 + if (touchResponse != null &&
  899 + touchResponse.spot != null) {
  900 + setState(() {
  901 + _stressTouchedGroupIndex =
  902 + touchResponse.spot!.touchedBarGroupIndex;
  903 + });
  904 + }
  905 + } else if (event is FlPanEndEvent ||
  906 + event is FlTapUpEvent ||
  907 + event is FlPointerExitEvent ||
  908 + event is FlTapCancelEvent ||
  909 + event is FlPanCancelEvent) {
  910 + _stressTooltipTimer?.cancel();
  911 + _stressTooltipTimer =
  912 + Timer(const Duration(milliseconds: 4000), () {
  913 + if (mounted) {
  914 + setState(() {
  915 + _stressTouchedGroupIndex = null;
  916 + });
  917 + }
  918 + });
  919 + }
  920 + },
  921 + touchTooltipData: BarTouchTooltipData(
  922 + tooltipRoundedRadius: 8,
  923 + tooltipBorder: BorderSide.none,
  924 + maxContentWidth: 400,
  925 + fitInsideHorizontally: true,
  926 + fitInsideVertically: true,
  927 + getTooltipColor: (touchedSpot) => const Color(0xFFF3F3F3),
  928 + tooltipPadding: const EdgeInsets.only(
  929 + left: 12,
  930 + right: 12,
  931 + top: 6,
  932 + bottom: 5,
  933 + ),
  934 + getTooltipItem: (group, groupIndex, rod, rodIndex) {
  935 + final state = slotStateMap[group.x]?.state;
  936 + return BarTooltipItem(
  937 + '',
  938 + const TextStyle(
  939 + color: Colors.white,
  940 + fontWeight: FontWeight.bold,
  941 + ),
  942 + children: widget.getTooltip(rod, group.x, state),
  943 + textAlign: TextAlign.start,
  944 + );
  945 + },
  946 + ),
  947 + ),
  948 + ),
  949 + ),
  950 + ),
  951 + ...sleepWidgets,
  952 + ],
  953 + );
  954 + },
  955 + );
  956 + }
780 957
781 - /// 构建压力柱状图分组:每 6 分钟一槽,同槽取最大值,无数据用透明占位  
782 List<BarChartGroupData> _buildStressBarGroups( 958 List<BarChartGroupData> _buildStressBarGroups(
783 List<V2RealtimeStressItem> stressPoints, 959 List<V2RealtimeStressItem> stressPoints,
784 {required double barWidth}) { 960 {required double barWidth}) {
785 const slotSec = 6 * 60; // 360 秒 = 6 分钟 961 const slotSec = 6 * 60; // 360 秒 = 6 分钟
786 - final maxSec = _maxChartSeconds().round(); 962 + final maxSec = widget.maxSeconds.round();
787 963
788 // 按槽分组,记录每槽的最大 value 及对应的 state 964 // 按槽分组,记录每槽的最大 value 及对应的 state
789 final slotMax = <int, ({int value, int? state})>{}; 965 final slotMax = <int, ({int value, int? state})>{};
790 for (final p in stressPoints) { 966 for (final p in stressPoints) {
791 - final sec = _tsToSecondsFromMidnight(p.time).round(); 967 + final sec = TodayHrvChartCard._tsToSecondsFromMidnight(p.time).round();
792 final slot = (sec / slotSec).round() * slotSec; 968 final slot = (sec / slotSec).round() * slotSec;
793 final v = p.value ?? 0; 969 final v = p.value ?? 0;
794 final existing = slotMax[slot]; 970 final existing = slotMax[slot];
@@ -800,20 +976,19 @@ class TodayHrvChartCard extends StatelessWidget { @@ -800,20 +976,19 @@ class TodayHrvChartCard extends StatelessWidget {
800 final groups = <BarChartGroupData>[]; 976 final groups = <BarChartGroupData>[];
801 for (int sec = 0; sec <= maxSec; sec += slotSec) { 977 for (int sec = 0; sec <= maxSec; sec += slotSec) {
802 final entry = slotMax[sec]; 978 final entry = slotMax[sec];
  979 + final groupIndex = groups.length;
  980 + final isTouched = groupIndex == _stressTouchedGroupIndex;
803 groups.add(BarChartGroupData( 981 groups.add(BarChartGroupData(
804 x: sec, 982 x: sec,
  983 + showingTooltipIndicators: isTouched ? [0] : [],
805 barRods: [ 984 barRods: [
806 BarChartRodData( 985 BarChartRodData(
807 toY: entry != null ? entry.value.toDouble() : 0, 986 toY: entry != null ? entry.value.toDouble() : 0,
808 width: barWidth, 987 width: barWidth,
809 color: entry != null 988 color: entry != null
810 - ? _getColorByState(entry.state) 989 + ? widget.getColorByState(entry.state)
811 : Colors.transparent, 990 : Colors.transparent,
812 borderRadius: BorderRadius.zero, 991 borderRadius: BorderRadius.zero,
813 - // borderRadius: const BorderRadius.only(  
814 - // topLeft: Radius.circular(2),  
815 - // topRight: Radius.circular(2),  
816 - // ),  
817 ), 992 ),
818 ], 993 ],
819 )); 994 ));
@@ -135,7 +135,7 @@ @@ -135,7 +135,7 @@
135 "todayHrvMeasurementStep2": "2. Open Mindfulness on Apple Watch and start Breathe", 135 "todayHrvMeasurementStep2": "2. Open Mindfulness on Apple Watch and start Breathe",
136 "todayHrvMeasurementStep3": "3. Keep breathing steadily and wait 1-3 minutes", 136 "todayHrvMeasurementStep3": "3. Keep breathing steadily and wait 1-3 minutes",
137 "todayHrvMeasurementStep4": "4. After breathing is complete, lock and unlock your iPhone once", 137 "todayHrvMeasurementStep4": "4. After breathing is complete, lock and unlock your iPhone once",
138 - "todayHrvMeasurementStep5": "5. Wait about one minute. StressWatch will receive and display your data", 138 + "todayHrvMeasurementStep5": "5. Wait about one minute. DoubleFeel will receive and display your data",
139 "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.", 139 "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.",
140 "todayHrvMeasurementWarning": "Note: Health permissions must be enabled, and Low Power Mode must be turned off.", 140 "todayHrvMeasurementWarning": "Note: Health permissions must be enabled, and Low Power Mode must be turned off.",
141 "todayStressStatusWhatTitle": "What is overall stress status?", 141 "todayStressStatusWhatTitle": "What is overall stress status?",
@@ -218,7 +218,7 @@ @@ -218,7 +218,7 @@
218 "todayHrvMeasurementStep2": "2、打开AppleWatch上的「正念」里的「呼吸」", 218 "todayHrvMeasurementStep2": "2、打开AppleWatch上的「正念」里的「呼吸」",
219 "todayHrvMeasurementStep3": "3、保持平稳的呼吸,等待1-3分钟", 219 "todayHrvMeasurementStep3": "3、保持平稳的呼吸,等待1-3分钟",
220 "todayHrvMeasurementStep4": "4、完成呼吸后,锁屏一次再解锁你的iPhone", 220 "todayHrvMeasurementStep4": "4、完成呼吸后,锁屏一次再解锁你的iPhone",
221 - "todayHrvMeasurementStep5": "5、等待一分钟左右,StressWatch会收到你的数据并展示", 221 + "todayHrvMeasurementStep5": "5、等待一分钟左右,DoubleFeel会收到你的数据并展示",
222 "todayHrvMeasurementHint": "提示:数据源来自AppleWatch,在测量之后可能存在延迟或是数据未能同步的情况。如若出现上述情况,请重新测量并等待数据读取。", 222 "todayHrvMeasurementHint": "提示:数据源来自AppleWatch,在测量之后可能存在延迟或是数据未能同步的情况。如若出现上述情况,请重新测量并等待数据读取。",
223 "todayHrvMeasurementWarning": "注意:需打开健康里的权限,同时关闭省电模式。", 223 "todayHrvMeasurementWarning": "注意:需打开健康里的权限,同时关闭省电模式。",
224 "todayStressStatusWhatTitle": "什么是综合压力状态?", 224 "todayStressStatusWhatTitle": "什么是综合压力状态?",
@@ -912,7 +912,7 @@ abstract class AppLocalizations { @@ -912,7 +912,7 @@ abstract class AppLocalizations {
912 /// No description provided for @todayHrvMeasurementStep5. 912 /// No description provided for @todayHrvMeasurementStep5.
913 /// 913 ///
914 /// In zh, this message translates to: 914 /// In zh, this message translates to:
915 - /// **'5、等待一分钟左右,StressWatch会收到你的数据并展示'** 915 + /// **'5、等待一分钟左右,DoubleFeel会收到你的数据并展示'**
916 String get todayHrvMeasurementStep5; 916 String get todayHrvMeasurementStep5;
917 917
918 /// No description provided for @todayHrvMeasurementHint. 918 /// No description provided for @todayHrvMeasurementHint.
@@ -457,7 +457,7 @@ class AppLocalizationsEn extends AppLocalizations { @@ -457,7 +457,7 @@ class AppLocalizationsEn extends AppLocalizations {
457 457
458 @override 458 @override
459 String get todayHrvMeasurementStep5 => 459 String get todayHrvMeasurementStep5 =>
460 - '5. Wait about one minute. StressWatch will receive and display your data'; 460 + '5. Wait about one minute. DoubleFeel will receive and display your data';
461 461
462 @override 462 @override
463 String get todayHrvMeasurementHint => 463 String get todayHrvMeasurementHint =>
@@ -430,7 +430,7 @@ class AppLocalizationsZh extends AppLocalizations { @@ -430,7 +430,7 @@ class AppLocalizationsZh extends AppLocalizations {
430 String get todayHrvMeasurementStep4 => '4、完成呼吸后,锁屏一次再解锁你的iPhone'; 430 String get todayHrvMeasurementStep4 => '4、完成呼吸后,锁屏一次再解锁你的iPhone';
431 431
432 @override 432 @override
433 - String get todayHrvMeasurementStep5 => '5、等待一分钟左右,StressWatch会收到你的数据并展示'; 433 + String get todayHrvMeasurementStep5 => '5、等待一分钟左右,DoubleFeel会收到你的数据并展示';
434 434
435 @override 435 @override
436 String get todayHrvMeasurementHint => 436 String get todayHrvMeasurementHint =>
@@ -52,6 +52,7 @@ dependencies: @@ -52,6 +52,7 @@ dependencies:
52 table_calendar: ^3.1.3 52 table_calendar: ^3.1.3
53 fluttertoast: ^8.2.2 53 fluttertoast: ^8.2.2
54 path_provider: ^2.1.5 54 path_provider: ^2.1.5
  55 + thinking_analytics: ^3.3.2
55 flutter_localizations: 56 flutter_localizations:
56 sdk: flutter 57 sdk: flutter
57 intl: any 58 intl: any