|
|
|
import 'dart:async';
|
|
|
|
|
|
|
|
import 'package:doublefeel_flutter/core/utils/app_time_formatter.dart';
|
|
|
|
import 'package:doublefeel_flutter/r.dart';
|
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
...
|
...
|
@@ -201,12 +202,14 @@ class _SleepPeriodTrendContent extends StatelessWidget { |
|
|
|
tooltipAutoDismissMs: tooltipAutoDismissMs,
|
|
|
|
),
|
|
|
|
leftMetric: _ExtremeMetric.time(
|
|
|
|
context: context,
|
|
|
|
title: context.l10n.sleepEarliestBedtime,
|
|
|
|
color: SleepWeekReportView.green,
|
|
|
|
report:
|
|
|
|
earliestSleepOverride ?? _bedtimeExtreme(days, earliest: true),
|
|
|
|
),
|
|
|
|
rightMetric: _ExtremeMetric.time(
|
|
|
|
context: context,
|
|
|
|
title: context.l10n.sleepLatestBedtime,
|
|
|
|
color: SleepWeekReportView.red,
|
|
|
|
report:
|
|
...
|
...
|
@@ -931,7 +934,7 @@ class _BedtimeChart extends StatefulWidget { |
|
|
|
|
|
|
|
class _BedtimeChartState extends State<_BedtimeChart> {
|
|
|
|
static const _xAxisLeftInset = 10.0;
|
|
|
|
static const _xAxisRightInset = 40.0;
|
|
|
|
static const _xAxisRightPadding = 8.0;
|
|
|
|
|
|
|
|
int? _selectedDayIndex;
|
|
|
|
Timer? _tooltipTimer;
|
|
...
|
...
|
@@ -957,6 +960,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
(day) => day.hasSleepData && day.heartRate.sleepStart != null,
|
|
|
|
);
|
|
|
|
final axis = _BedtimeAxis.fromReports(widget.days);
|
|
|
|
final rightAxisWidth = _rightAxisWidth(axis);
|
|
|
|
return LayoutBuilder(
|
|
|
|
builder: (context, constraints) => Stack(
|
|
|
|
alignment: Alignment.center,
|
|
...
|
...
|
@@ -973,6 +977,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
lineX: _lineXForDay(
|
|
|
|
constraints.maxWidth,
|
|
|
|
_selectedDayIndex!,
|
|
|
|
rightAxisWidth,
|
|
|
|
),
|
|
|
|
lineTop: _bedtimeChartTopInset,
|
|
|
|
),
|
|
...
|
...
|
@@ -983,6 +988,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
interval: 1,
|
|
|
|
topInset: _bedtimeChartTopInset,
|
|
|
|
insetChildHorizontally: false,
|
|
|
|
rightAxisWidth: rightAxisWidth,
|
|
|
|
rightLabel: _timeAxisLabel,
|
|
|
|
child: LayoutBuilder(
|
|
|
|
builder: (context, constraints) => Listener(
|
|
...
|
...
|
@@ -990,10 +996,12 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
onPointerDown: (event) => _selectSpotAt(
|
|
|
|
event.localPosition.dx,
|
|
|
|
constraints.maxWidth,
|
|
|
|
rightAxisWidth,
|
|
|
|
),
|
|
|
|
onPointerMove: (event) => _selectSpotAt(
|
|
|
|
event.localPosition.dx,
|
|
|
|
constraints.maxWidth,
|
|
|
|
rightAxisWidth,
|
|
|
|
),
|
|
|
|
onPointerUp: (_) => _scheduleTooltipDismissal(),
|
|
|
|
onPointerCancel: (_) => _scheduleTooltipDismissal(),
|
|
...
|
...
|
@@ -1001,6 +1009,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
_chartData(
|
|
|
|
axis,
|
|
|
|
constraints.maxWidth,
|
|
|
|
rightAxisWidth,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
...
|
...
|
@@ -1012,18 +1021,20 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
double _lineXForDay(double width, int dayIndex) {
|
|
|
|
double _lineXForDay(double width, int dayIndex, double rightAxisWidth) {
|
|
|
|
final xAxisRightInset = _xAxisRightInset(rightAxisWidth);
|
|
|
|
final count = widget.days.length;
|
|
|
|
if (count <= 1 || width <= _xAxisLeftInset + _xAxisRightInset) {
|
|
|
|
if (count <= 1 || width <= _xAxisLeftInset + xAxisRightInset) {
|
|
|
|
return width / 2;
|
|
|
|
}
|
|
|
|
final dataWidth = width - _xAxisLeftInset - _xAxisRightInset;
|
|
|
|
final dataWidth = width - _xAxisLeftInset - xAxisRightInset;
|
|
|
|
return _xAxisLeftInset + dataWidth * dayIndex / (count - 1);
|
|
|
|
}
|
|
|
|
|
|
|
|
LineChartData _chartData(
|
|
|
|
_BedtimeAxis axis,
|
|
|
|
double chartWidth,
|
|
|
|
double rightAxisWidth,
|
|
|
|
) {
|
|
|
|
final points = <FlSpot>[
|
|
|
|
for (var i = 0; i < widget.days.length; i++)
|
|
...
|
...
|
@@ -1072,7 +1083,7 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
}
|
|
|
|
|
|
|
|
final count = widget.days.length;
|
|
|
|
final xBounds = _xBounds(chartWidth, count);
|
|
|
|
final xBounds = _xBounds(chartWidth, count, rightAxisWidth);
|
|
|
|
|
|
|
|
return LineChartData(
|
|
|
|
minX: xBounds.minX,
|
|
...
|
...
|
@@ -1087,7 +1098,10 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
getTouchedSpotIndicator: _lineTouchedIndicators,
|
|
|
|
touchTooltipData: _lineTooltipData(
|
|
|
|
days: widget.days,
|
|
|
|
dataBuilder: _ChartSummaryData.bedtime,
|
|
|
|
dataBuilder: (report) => _ChartSummaryData.bedtime(
|
|
|
|
report,
|
|
|
|
context: context,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
titlesData: _SleepTrendXAxis.titlesData(
|
|
...
|
...
|
@@ -1103,9 +1117,13 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
void _selectSpotAt(double dx, double chartWidth) {
|
|
|
|
void _selectSpotAt(
|
|
|
|
double dx,
|
|
|
|
double chartWidth,
|
|
|
|
double rightAxisWidth,
|
|
|
|
) {
|
|
|
|
_tooltipTimer?.cancel();
|
|
|
|
final index = _dayIndexForTouch(dx, chartWidth);
|
|
|
|
final index = _dayIndexForTouch(dx, chartWidth, rightAxisWidth);
|
|
|
|
final hasData = index != null &&
|
|
|
|
widget.days[index].hasSleepData &&
|
|
|
|
widget.days[index].heartRate.sleepStart != null;
|
|
...
|
...
|
@@ -1114,31 +1132,40 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
setState(() => _selectedDayIndex = nextIndex);
|
|
|
|
}
|
|
|
|
|
|
|
|
({double minX, double maxX}) _xBounds(double width, int count) {
|
|
|
|
if (count <= 1 || width <= _xAxisLeftInset + _xAxisRightInset) {
|
|
|
|
({double minX, double maxX}) _xBounds(
|
|
|
|
double width,
|
|
|
|
int count,
|
|
|
|
double rightAxisWidth,
|
|
|
|
) {
|
|
|
|
final xAxisRightInset = _xAxisRightInset(rightAxisWidth);
|
|
|
|
if (count <= 1 || width <= _xAxisLeftInset + xAxisRightInset) {
|
|
|
|
return (minX: -0.5, maxX: 0.5);
|
|
|
|
}
|
|
|
|
final scale = (count - 1) / (width - _xAxisLeftInset - _xAxisRightInset);
|
|
|
|
final scale = (count - 1) / (width - _xAxisLeftInset - xAxisRightInset);
|
|
|
|
return (
|
|
|
|
minX: -_xAxisLeftInset * scale,
|
|
|
|
maxX: count - 1 + _xAxisRightInset * scale,
|
|
|
|
maxX: count - 1 + xAxisRightInset * scale,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
int? _dayIndexForTouch(double? dx, double width) {
|
|
|
|
int? _dayIndexForTouch(
|
|
|
|
double? dx,
|
|
|
|
double width,
|
|
|
|
double rightAxisWidth,
|
|
|
|
) {
|
|
|
|
final xAxisRightInset = _xAxisRightInset(rightAxisWidth);
|
|
|
|
final count = widget.days.length;
|
|
|
|
if (dx == null || width <= 0 || count <= 0) return null;
|
|
|
|
if (count == 1 || width <= _xAxisLeftInset + _xAxisRightInset) {
|
|
|
|
return dx <= width - _xAxisRightInset ? 0 : null;
|
|
|
|
if (count == 1 || width <= _xAxisLeftInset + xAxisRightInset) {
|
|
|
|
return dx <= width - xAxisRightInset ? 0 : null;
|
|
|
|
}
|
|
|
|
final dataWidth = width - _xAxisLeftInset - _xAxisRightInset;
|
|
|
|
final dataWidth = width - _xAxisLeftInset - xAxisRightInset;
|
|
|
|
final firstX = _xAxisLeftInset;
|
|
|
|
final lastX = width - _xAxisRightInset;
|
|
|
|
final lastX = width - xAxisRightInset;
|
|
|
|
final step = dataWidth / (count - 1);
|
|
|
|
final minTouchX = (firstX - step / 2).clamp(0, width).toDouble();
|
|
|
|
final maxTouchX = (lastX + step / 2)
|
|
|
|
.clamp(0, width - _ChartPlotFrame.rightAxisWidth)
|
|
|
|
.toDouble();
|
|
|
|
final maxTouchX =
|
|
|
|
(lastX + step / 2).clamp(0, width - rightAxisWidth).toDouble();
|
|
|
|
if (dx < minTouchX || dx > maxTouchX) return null;
|
|
|
|
return ((dx - _xAxisLeftInset) / dataWidth * (count - 1))
|
|
|
|
.round()
|
|
...
|
...
|
@@ -1169,9 +1196,31 @@ class _BedtimeChartState extends State<_BedtimeChart> { |
|
|
|
}
|
|
|
|
|
|
|
|
String _timeAxisLabel(double value) {
|
|
|
|
final hour = value.toInt() % 24;
|
|
|
|
return '${hour.toString().padLeft(2, '0')}:00';
|
|
|
|
return AppTimeFormatter.hourAxis(value.toInt(), context: context);
|
|
|
|
}
|
|
|
|
|
|
|
|
double _rightAxisWidth(_BedtimeAxis axis) {
|
|
|
|
final steps = (axis.maxHour - axis.minHour).ceil();
|
|
|
|
final textPainter = TextPainter(
|
|
|
|
textDirection: Directionality.of(context),
|
|
|
|
textScaler: MediaQuery.textScalerOf(context),
|
|
|
|
maxLines: 1,
|
|
|
|
);
|
|
|
|
var maxWidth = 0.0;
|
|
|
|
for (var index = 0; index <= steps; index++) {
|
|
|
|
textPainter.text = TextSpan(
|
|
|
|
text: _timeAxisLabel((axis.minHour + index).toDouble()),
|
|
|
|
style: _RightAxisLabels.labelStyle,
|
|
|
|
);
|
|
|
|
textPainter.layout();
|
|
|
|
if (textPainter.width > maxWidth) maxWidth = textPainter.width;
|
|
|
|
}
|
|
|
|
textPainter.dispose();
|
|
|
|
return maxWidth + _xAxisRightPadding;
|
|
|
|
}
|
|
|
|
|
|
|
|
double _xAxisRightInset(double rightAxisWidth) =>
|
|
|
|
rightAxisWidth + _xAxisRightPadding;
|
|
|
|
}
|
|
|
|
|
|
|
|
double _barWidth(bool compact) => compact ? 4 : 16;
|
|
...
|
...
|
@@ -1448,11 +1497,14 @@ class _ChartSummaryData { |
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
factory _ChartSummaryData.bedtime(SleepReport report) {
|
|
|
|
factory _ChartSummaryData.bedtime(
|
|
|
|
SleepReport report, {
|
|
|
|
required BuildContext context,
|
|
|
|
}) {
|
|
|
|
final start = report.heartRate.sleepStart!;
|
|
|
|
return _ChartSummaryData(
|
|
|
|
title: l10n.sleepFellAsleepAt(
|
|
|
|
'${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}',
|
|
|
|
AppTimeFormatter.time(start, context: context),
|
|
|
|
),
|
|
|
|
subtitle: _fullDateText(report.date),
|
|
|
|
titleColor: SleepWeekReportView.brand,
|
|
...
|
...
|
@@ -1590,11 +1642,12 @@ class _ChartPlotFrame extends StatelessWidget { |
|
|
|
this.minY = 0,
|
|
|
|
this.topInset = 0,
|
|
|
|
this.insetChildHorizontally = true,
|
|
|
|
this.rightAxisWidth = defaultRightAxisWidth,
|
|
|
|
this.referenceLines = const [],
|
|
|
|
});
|
|
|
|
|
|
|
|
static const horizontalInset = 20.0;
|
|
|
|
static const rightAxisWidth = 32.0;
|
|
|
|
static const defaultRightAxisWidth = 32.0;
|
|
|
|
static const bottomTitleHeight = 28.0;
|
|
|
|
|
|
|
|
final Widget child;
|
|
...
|
...
|
@@ -1603,6 +1656,7 @@ class _ChartPlotFrame extends StatelessWidget { |
|
|
|
final double interval;
|
|
|
|
final double topInset;
|
|
|
|
final bool insetChildHorizontally;
|
|
|
|
final double rightAxisWidth;
|
|
|
|
final String Function(double value) rightLabel;
|
|
|
|
final List<_ChartReferenceLine> referenceLines;
|
|
|
|
|
|
...
|
...
|
@@ -1637,9 +1691,7 @@ class _ChartPlotFrame extends StatelessWidget { |
|
|
|
padding: EdgeInsets.only(
|
|
|
|
top: topInset,
|
|
|
|
left: insetChildHorizontally ? horizontalInset : 0,
|
|
|
|
right: insetChildHorizontally
|
|
|
|
? rightAxisWidth
|
|
|
|
: 0,
|
|
|
|
right: insetChildHorizontally ? rightAxisWidth : 0,
|
|
|
|
),
|
|
|
|
child: child,
|
|
|
|
),
|
|
...
|
...
|
@@ -1650,6 +1702,7 @@ class _ChartPlotFrame extends StatelessWidget { |
|
|
|
minY: minY,
|
|
|
|
maxY: maxY,
|
|
|
|
interval: interval,
|
|
|
|
width: rightAxisWidth,
|
|
|
|
labelBuilder: rightLabel,
|
|
|
|
),
|
|
|
|
),
|
|
...
|
...
|
@@ -1741,14 +1794,23 @@ class _RightAxisLabels extends StatelessWidget { |
|
|
|
required this.minY,
|
|
|
|
required this.maxY,
|
|
|
|
required this.interval,
|
|
|
|
required this.width,
|
|
|
|
required this.labelBuilder,
|
|
|
|
});
|
|
|
|
|
|
|
|
final double minY;
|
|
|
|
final double maxY;
|
|
|
|
final double interval;
|
|
|
|
final double width;
|
|
|
|
final String Function(double value) labelBuilder;
|
|
|
|
|
|
|
|
static const labelStyle = TextStyle(
|
|
|
|
color: SleepWeekReportView.h3,
|
|
|
|
fontSize: 10,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
height: 1.2,
|
|
|
|
);
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return IgnorePointer(
|
|
...
|
...
|
@@ -1762,19 +1824,14 @@ class _RightAxisLabels extends StatelessWidget { |
|
|
|
Positioned(
|
|
|
|
top: constraints.maxHeight * (1 - index / steps) - 16,
|
|
|
|
right: 0,
|
|
|
|
width: _ChartPlotFrame.rightAxisWidth,
|
|
|
|
width: width,
|
|
|
|
child: Align(
|
|
|
|
alignment: Alignment.centerRight,
|
|
|
|
child: Text(
|
|
|
|
labelBuilder(minY + interval * index),
|
|
|
|
maxLines: 1,
|
|
|
|
textAlign: TextAlign.right,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: SleepWeekReportView.h3,
|
|
|
|
fontSize: 10,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
height: 1.2,
|
|
|
|
),
|
|
|
|
style: labelStyle,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
...
|
...
|
@@ -1837,7 +1894,7 @@ class _SleepTrendXAxis { |
|
|
|
return Column(
|
|
|
|
children: [
|
|
|
|
Text('${day.day}', style: labelStyle),
|
|
|
|
Text(reportWeekdayLabel(day.weekday), style: labelStyle),
|
|
|
|
Text(reportWeekdayNarrowLabel(day.weekday), style: labelStyle),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
}
|
|
...
|
...
|
@@ -1901,6 +1958,7 @@ class _ExtremeMetric extends StatelessWidget { |
|
|
|
}
|
|
|
|
|
|
|
|
factory _ExtremeMetric.time({
|
|
|
|
required BuildContext context,
|
|
|
|
required String title,
|
|
|
|
required Color color,
|
|
|
|
required SleepReport? report,
|
|
...
|
...
|
@@ -1912,7 +1970,7 @@ class _ExtremeMetric extends StatelessWidget { |
|
|
|
color: color,
|
|
|
|
value: start == null
|
|
|
|
? '--:--'
|
|
|
|
: '${start.hour.toString().padLeft(2, '0')}:${start.minute.toString().padLeft(2, '0')}',
|
|
|
|
: AppTimeFormatter.time(start, context: context),
|
|
|
|
unit: '',
|
|
|
|
date: report?.date,
|
|
|
|
);
|
...
|
...
|
|