|
|
|
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
|
));
|