|
|
|
import 'dart:math' as math;
|
|
|
|
|
|
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:doublefeel_flutter/l10n/l10n_extensions.dart';
|
|
...
|
...
|
@@ -11,18 +9,19 @@ import '../models/sleep_report_models.dart'; |
|
|
|
class SleepHeartRateCard extends StatelessWidget {
|
|
|
|
const SleepHeartRateCard({
|
|
|
|
super.key,
|
|
|
|
this.date,
|
|
|
|
required this.summary,
|
|
|
|
});
|
|
|
|
|
|
|
|
final DateTime? date;
|
|
|
|
final SleepHeartRateSummary summary;
|
|
|
|
|
|
|
|
static const _h1 = Color(0xFF0F0F11);
|
|
|
|
static const _h3 = Color(0xFFB0B0B6);
|
|
|
|
static const _grid = Color(0xFFF3F3F3);
|
|
|
|
static const _red = Color(0xFFFF5279);
|
|
|
|
static const _orange = Color(0xFFFF9A6E);
|
|
|
|
static const _blue = Color(0xFF7B9BFB);
|
|
|
|
static const _green = Color(0xFF3BD49D);
|
|
|
|
static const _chartLeftInset = 0.0;
|
|
|
|
static const _chartRightInset = 20.0;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
...
|
...
|
@@ -31,7 +30,7 @@ class SleepHeartRateCard extends StatelessWidget { |
|
|
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
color: Colors.white,
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
borderRadius: BorderRadius.circular(16),
|
|
|
|
),
|
|
|
|
child: Column(
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
...
|
...
|
@@ -50,31 +49,55 @@ class SleepHeartRateCard extends StatelessWidget { |
|
|
|
_MetricRow(summary: summary),
|
|
|
|
const SizedBox(height: 18),
|
|
|
|
Expanded(
|
|
|
|
child: Stack(
|
|
|
|
alignment: Alignment.center,
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
children: [
|
|
|
|
LayoutBuilder(
|
|
|
|
builder: (context, constraints) => LineChart(
|
|
|
|
_chartData(constraints.maxWidth),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned.fill(
|
|
|
|
bottom: 20,
|
|
|
|
child: _HeartRateAxisLabels(
|
|
|
|
range: _heartRateYAxisRange,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
if (!summary.hasData)
|
|
|
|
Text(
|
|
|
|
context.l10n.reportWaitingForData,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xFFA1A0A5),
|
|
|
|
fontSize: 12,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
child: Builder(
|
|
|
|
builder: (context) {
|
|
|
|
final xAxis = _heartRateXAxis;
|
|
|
|
final chartPoints = _chartPoints(xAxis);
|
|
|
|
final hasChartData = chartPoints.isNotEmpty;
|
|
|
|
|
|
|
|
return Stack(
|
|
|
|
alignment: Alignment.center,
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
children: [
|
|
|
|
if (hasChartData) ...[
|
|
|
|
Positioned.fill(
|
|
|
|
bottom: 20,
|
|
|
|
child: LayoutBuilder(
|
|
|
|
builder: (context, constraints) => LineChart(
|
|
|
|
_chartData(
|
|
|
|
xAxis: xAxis,
|
|
|
|
chartPoints: chartPoints,
|
|
|
|
chartWidth: constraints.maxWidth,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned.fill(
|
|
|
|
bottom: 20,
|
|
|
|
child: _HeartRateAxisLabels(
|
|
|
|
range: _HeartRateYAxisRange.fromPoints(chartPoints),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
Positioned.fill(
|
|
|
|
child: _HeartRateTimeLabels(axis: xAxis),
|
|
|
|
),
|
|
|
|
] else
|
|
|
|
Positioned.fill(
|
|
|
|
bottom: 20,
|
|
|
|
child: Center(
|
|
|
|
child: Text(
|
|
|
|
context.l10n.reportWaitingForData,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: Color(0xFFA1A0A5),
|
|
|
|
fontSize: 12,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
),
|
|
|
|
],
|
|
...
|
...
|
@@ -82,34 +105,35 @@ class SleepHeartRateCard extends StatelessWidget { |
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
LineChartData _chartData(double chartWidth) {
|
|
|
|
final sleepStart = _sleepStart;
|
|
|
|
final sleepEnd = _sleepEnd;
|
|
|
|
final sleepPoints = _sleepPoints(sleepStart, sleepEnd);
|
|
|
|
final spots = sleepPoints
|
|
|
|
LineChartData _chartData({
|
|
|
|
required _HeartRateXAxis xAxis,
|
|
|
|
required List<SleepHeartRatePoint> chartPoints,
|
|
|
|
required double chartWidth,
|
|
|
|
}) {
|
|
|
|
final spots = chartPoints
|
|
|
|
.map(
|
|
|
|
(point) => FlSpot(
|
|
|
|
point.time.difference(sleepStart).inMinutes.toDouble(),
|
|
|
|
point.time.difference(xAxis.start).inMinutes.toDouble(),
|
|
|
|
point.bpm,
|
|
|
|
),
|
|
|
|
)
|
|
|
|
.toList();
|
|
|
|
final maxX = sleepEnd.difference(sleepStart).inMinutes.clamp(1, 1440);
|
|
|
|
const rightVisualInset = 20.0;
|
|
|
|
final availableWidth = chartWidth - rightVisualInset;
|
|
|
|
final maxX = xAxis.end.difference(xAxis.start).inMinutes.clamp(1, 1440);
|
|
|
|
final dataWidth = chartWidth - _chartLeftInset - _chartRightInset;
|
|
|
|
final leftXInset = dataWidth > 0 ? _chartLeftInset * maxX / dataWidth : 0.0;
|
|
|
|
final rightXInset =
|
|
|
|
availableWidth > 0 ? rightVisualInset * maxX / availableWidth : 0.0;
|
|
|
|
final yRange = _HeartRateYAxisRange.fromPoints(sleepPoints);
|
|
|
|
dataWidth > 0 ? _chartRightInset * maxX / dataWidth : 0.0;
|
|
|
|
final yRange = _HeartRateYAxisRange.fromPoints(chartPoints);
|
|
|
|
|
|
|
|
return LineChartData(
|
|
|
|
minX: 0,
|
|
|
|
minX: -leftXInset,
|
|
|
|
maxX: maxX + rightXInset,
|
|
|
|
minY: yRange.minY,
|
|
|
|
maxY: yRange.maxY,
|
|
|
|
gridData: FlGridData(
|
|
|
|
show: true,
|
|
|
|
drawVerticalLine: false,
|
|
|
|
horizontalInterval: 10,
|
|
|
|
horizontalInterval: _HeartRateYAxisRange.interval,
|
|
|
|
getDrawingHorizontalLine: (_) => const FlLine(
|
|
|
|
color: _grid,
|
|
|
|
strokeWidth: 1,
|
|
...
|
...
|
@@ -125,6 +149,12 @@ class SleepHeartRateCard extends StatelessWidget { |
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: const [2, 2],
|
|
|
|
),
|
|
|
|
HorizontalLine(
|
|
|
|
y: yRange.maxY,
|
|
|
|
color: _grid,
|
|
|
|
strokeWidth: 1,
|
|
|
|
dashArray: const [2, 2],
|
|
|
|
),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
borderData: FlBorderData(show: false),
|
|
...
|
...
|
@@ -136,28 +166,8 @@ class SleepHeartRateCard extends StatelessWidget { |
|
|
|
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
|
|
|
bottomTitles: AxisTitles(
|
|
|
|
sideTitles: SideTitles(
|
|
|
|
showTitles: true,
|
|
|
|
reservedSize: 20,
|
|
|
|
interval: maxX / 2,
|
|
|
|
getTitlesWidget: (value, meta) {
|
|
|
|
final label = _bottomLabel(
|
|
|
|
value: value,
|
|
|
|
sleepStart: sleepStart,
|
|
|
|
sleepEnd: sleepEnd,
|
|
|
|
);
|
|
|
|
if (label == null) return const SizedBox.shrink();
|
|
|
|
return Padding(
|
|
|
|
padding: const EdgeInsets.only(top: 3),
|
|
|
|
child: Text(
|
|
|
|
label,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: _h3,
|
|
|
|
fontSize: 10,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
},
|
|
|
|
showTitles: false,
|
|
|
|
reservedSize: 0,
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
...
|
...
|
@@ -166,183 +176,107 @@ class SleepHeartRateCard extends StatelessWidget { |
|
|
|
}
|
|
|
|
|
|
|
|
List<LineChartBarData> _lineSegments(List<FlSpot> spots) {
|
|
|
|
if (spots.length < 2) return const [];
|
|
|
|
|
|
|
|
final maxHr = summary.maxBpm?.toDouble();
|
|
|
|
final boundaries = <double>{
|
|
|
|
140,
|
|
|
|
160,
|
|
|
|
if (maxHr != null && maxHr > 0) ...[
|
|
|
|
maxHr * 0.6,
|
|
|
|
maxHr * 0.8,
|
|
|
|
maxHr * 0.9,
|
|
|
|
],
|
|
|
|
}.toList()
|
|
|
|
..sort();
|
|
|
|
final segments = <_HeartRateLineSegment>[];
|
|
|
|
Color? currentColor;
|
|
|
|
var currentSpots = <FlSpot>[];
|
|
|
|
|
|
|
|
for (var index = 0; index < spots.length - 1; index++) {
|
|
|
|
final splitSpots = _splitAtBoundaries(
|
|
|
|
spots[index],
|
|
|
|
spots[index + 1],
|
|
|
|
boundaries,
|
|
|
|
);
|
|
|
|
for (var splitIndex = 0;
|
|
|
|
splitIndex < splitSpots.length - 1;
|
|
|
|
splitIndex++) {
|
|
|
|
final from = splitSpots[splitIndex];
|
|
|
|
final to = splitSpots[splitIndex + 1];
|
|
|
|
final color = _zoneColor((from.y + to.y) / 2, maxHr);
|
|
|
|
if (color == currentColor && currentSpots.isNotEmpty) {
|
|
|
|
currentSpots.add(to);
|
|
|
|
continue;
|
|
|
|
}
|
|
|
|
if (currentColor != null && currentSpots.length > 1) {
|
|
|
|
segments.add(_HeartRateLineSegment(currentColor, currentSpots));
|
|
|
|
}
|
|
|
|
currentColor = color;
|
|
|
|
currentSpots = [from, to];
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if (currentColor != null && currentSpots.length > 1) {
|
|
|
|
segments.add(_HeartRateLineSegment(currentColor, currentSpots));
|
|
|
|
}
|
|
|
|
if (spots.isEmpty) return const <LineChartBarData>[];
|
|
|
|
|
|
|
|
return [
|
|
|
|
for (final segment in segments)
|
|
|
|
LineChartBarData(
|
|
|
|
spots: segment.spots,
|
|
|
|
color: segment.color,
|
|
|
|
barWidth: 2,
|
|
|
|
isCurved: false,
|
|
|
|
dotData: const FlDotData(show: false),
|
|
|
|
belowBarData: BarAreaData(
|
|
|
|
show: true,
|
|
|
|
gradient: LinearGradient(
|
|
|
|
begin: Alignment.topCenter,
|
|
|
|
end: Alignment.bottomCenter,
|
|
|
|
colors: [
|
|
|
|
segment.color.withValues(alpha: 0.20),
|
|
|
|
segment.color.withValues(alpha: 0.02),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
LineChartBarData(
|
|
|
|
spots: spots,
|
|
|
|
color: _red,
|
|
|
|
barWidth: spots.length > 1 ? 2 : 0,
|
|
|
|
isCurved: false,
|
|
|
|
dotData: FlDotData(show: spots.length == 1),
|
|
|
|
belowBarData: BarAreaData(
|
|
|
|
show: spots.length > 1,
|
|
|
|
gradient: LinearGradient(
|
|
|
|
begin: Alignment.topCenter,
|
|
|
|
end: Alignment.bottomCenter,
|
|
|
|
colors: [
|
|
|
|
_red.withValues(alpha: 0.20),
|
|
|
|
_red.withValues(alpha: 0.02),
|
|
|
|
],
|
|
|
|
),
|
|
|
|
),
|
|
|
|
),
|
|
|
|
];
|
|
|
|
}
|
|
|
|
|
|
|
|
List<FlSpot> _splitAtBoundaries(
|
|
|
|
FlSpot start,
|
|
|
|
FlSpot end,
|
|
|
|
List<double> boundaries,
|
|
|
|
) {
|
|
|
|
if (start.y == end.y) return [start, end];
|
|
|
|
final minY = math.min(start.y, end.y);
|
|
|
|
final maxY = math.max(start.y, end.y);
|
|
|
|
final crossings = <FlSpot>[];
|
|
|
|
for (final boundary in boundaries) {
|
|
|
|
if (boundary <= minY || boundary >= maxY) continue;
|
|
|
|
final progress = (boundary - start.y) / (end.y - start.y);
|
|
|
|
crossings.add(
|
|
|
|
FlSpot(
|
|
|
|
start.x + (end.x - start.x) * progress,
|
|
|
|
boundary,
|
|
|
|
),
|
|
|
|
_HeartRateXAxis get _heartRateXAxis {
|
|
|
|
final points = _visibleDatePoints;
|
|
|
|
if (points.isEmpty) {
|
|
|
|
final start = _chartDayStart;
|
|
|
|
return _HeartRateXAxis(
|
|
|
|
start: start,
|
|
|
|
end: start.add(const Duration(hours: 1)),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
crossings.sort((a, b) => a.x.compareTo(b.x));
|
|
|
|
return [start, ...crossings, end];
|
|
|
|
}
|
|
|
|
|
|
|
|
Color _zoneColor(double currentHr, double? maxHr) {
|
|
|
|
if (currentHr >= 160) return _red;
|
|
|
|
if (currentHr >= 140) return _orange;
|
|
|
|
final zoneRatio = maxHr == null || maxHr <= 0 ? 0 : currentHr / maxHr;
|
|
|
|
if (zoneRatio >= 0.9) return _red;
|
|
|
|
if (zoneRatio >= 0.8) return _orange;
|
|
|
|
if (zoneRatio >= 0.6) return _blue;
|
|
|
|
return _green;
|
|
|
|
}
|
|
|
|
|
|
|
|
_HeartRateYAxisRange get _heartRateYAxisRange {
|
|
|
|
final sleepStart = _sleepStart;
|
|
|
|
final sleepEnd = _sleepEnd;
|
|
|
|
return _HeartRateYAxisRange.fromPoints(
|
|
|
|
_sleepPoints(sleepStart, sleepEnd),
|
|
|
|
final start = points.first.time;
|
|
|
|
final end = points.last.time.isAfter(start)
|
|
|
|
? points.last.time
|
|
|
|
: start.add(const Duration(minutes: 1));
|
|
|
|
return _HeartRateXAxis(
|
|
|
|
start: start,
|
|
|
|
end: end,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
DateTime get _sleepStart {
|
|
|
|
final sleepStart = summary.sleepStart;
|
|
|
|
final sleepEnd = summary.sleepEnd;
|
|
|
|
if (sleepStart != null &&
|
|
|
|
(summary.points.isEmpty ||
|
|
|
|
sleepEnd == null ||
|
|
|
|
summary.points.any((point) =>
|
|
|
|
!point.time.isBefore(sleepStart) &&
|
|
|
|
!point.time.isAfter(sleepEnd)))) {
|
|
|
|
return sleepStart;
|
|
|
|
DateTime get _chartDayStart {
|
|
|
|
final selectedDate = date;
|
|
|
|
if (selectedDate != null && _hasPointOnDate(selectedDate)) {
|
|
|
|
return DateTime(selectedDate.year, selectedDate.month, selectedDate.day);
|
|
|
|
}
|
|
|
|
if (summary.points.isNotEmpty) return summary.points.first.time;
|
|
|
|
return DateTime(0, 1, 1, 22);
|
|
|
|
|
|
|
|
final source = summary.points.isEmpty
|
|
|
|
? selectedDate ?? summary.sleepStart
|
|
|
|
: summary.points.first.time;
|
|
|
|
final day = source ?? DateTime.now();
|
|
|
|
return DateTime(day.year, day.month, day.day);
|
|
|
|
}
|
|
|
|
|
|
|
|
DateTime get _sleepEnd {
|
|
|
|
final sleepStart = summary.sleepStart;
|
|
|
|
final sleepEnd = summary.sleepEnd;
|
|
|
|
if (sleepEnd != null &&
|
|
|
|
(summary.points.isEmpty ||
|
|
|
|
sleepStart == null ||
|
|
|
|
summary.points.any((point) =>
|
|
|
|
!point.time.isBefore(sleepStart) &&
|
|
|
|
!point.time.isAfter(sleepEnd)))) {
|
|
|
|
return sleepEnd;
|
|
|
|
}
|
|
|
|
if (summary.points.isNotEmpty) return summary.points.last.time;
|
|
|
|
return DateTime(0, 1, 2, 7);
|
|
|
|
bool _hasPointOnDate(DateTime date) {
|
|
|
|
final dayStart = DateTime(date.year, date.month, date.day);
|
|
|
|
final nextDay = dayStart.add(const Duration(days: 1));
|
|
|
|
return summary.points.any(
|
|
|
|
(point) => !point.time.isBefore(dayStart) && point.time.isBefore(nextDay),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
List<SleepHeartRatePoint> _sleepPoints(
|
|
|
|
DateTime sleepStart,
|
|
|
|
DateTime sleepEnd,
|
|
|
|
) {
|
|
|
|
List<SleepHeartRatePoint> _chartPoints(_HeartRateXAxis axis) {
|
|
|
|
return summary.points
|
|
|
|
.where((point) =>
|
|
|
|
!point.time.isBefore(sleepStart) && !point.time.isAfter(sleepEnd))
|
|
|
|
!point.time.isBefore(axis.start) && !point.time.isAfter(axis.end))
|
|
|
|
.toList();
|
|
|
|
}
|
|
|
|
|
|
|
|
String? _bottomLabel({
|
|
|
|
required double value,
|
|
|
|
required DateTime sleepStart,
|
|
|
|
required DateTime sleepEnd,
|
|
|
|
}) {
|
|
|
|
final maxX = sleepEnd.difference(sleepStart).inMinutes.toDouble();
|
|
|
|
final midX = maxX / 2;
|
|
|
|
final tolerance = maxX <= 2 ? 0.5 : 1.0;
|
|
|
|
|
|
|
|
if ((value - 0).abs() <= tolerance) {
|
|
|
|
return DateFormat('HH:mm').format(sleepStart);
|
|
|
|
}
|
|
|
|
if ((value - midX).abs() <= tolerance) {
|
|
|
|
return DateFormat('HH:mm').format(sleepStart.add(
|
|
|
|
Duration(minutes: midX.round()),
|
|
|
|
));
|
|
|
|
}
|
|
|
|
if ((value - maxX).abs() <= tolerance) {
|
|
|
|
return DateFormat('HH:mm').format(sleepEnd);
|
|
|
|
}
|
|
|
|
return null;
|
|
|
|
List<SleepHeartRatePoint> get _visibleDatePoints {
|
|
|
|
if (summary.points.isEmpty) return const <SleepHeartRatePoint>[];
|
|
|
|
|
|
|
|
final selectedDate = date;
|
|
|
|
final points = selectedDate == null
|
|
|
|
? summary.points
|
|
|
|
: summary.points.where((point) {
|
|
|
|
final dayStart = DateTime(
|
|
|
|
selectedDate.year,
|
|
|
|
selectedDate.month,
|
|
|
|
selectedDate.day,
|
|
|
|
);
|
|
|
|
final nextDay = dayStart.add(const Duration(days: 1));
|
|
|
|
return !point.time.isBefore(dayStart) &&
|
|
|
|
point.time.isBefore(nextDay);
|
|
|
|
}).toList();
|
|
|
|
final result = points.isEmpty ? summary.points : points;
|
|
|
|
return [...result]..sort((a, b) => a.time.compareTo(b.time));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HeartRateLineSegment {
|
|
|
|
const _HeartRateLineSegment(this.color, this.spots);
|
|
|
|
class _HeartRateXAxis {
|
|
|
|
const _HeartRateXAxis({
|
|
|
|
required this.start,
|
|
|
|
required this.end,
|
|
|
|
});
|
|
|
|
|
|
|
|
final Color color;
|
|
|
|
final List<FlSpot> spots;
|
|
|
|
final DateTime start;
|
|
|
|
final DateTime end;
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HeartRateAxisLabels extends StatelessWidget {
|
|
...
|
...
|
@@ -355,16 +289,18 @@ class _HeartRateAxisLabels extends StatelessWidget { |
|
|
|
return IgnorePointer(
|
|
|
|
child: LayoutBuilder(
|
|
|
|
builder: (context, constraints) {
|
|
|
|
final steps = ((range.maxY - range.minY) / 10).round();
|
|
|
|
final labels = range.labels;
|
|
|
|
return Stack(
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
children: [
|
|
|
|
for (var index = 0; index <= steps; index++)
|
|
|
|
for (final label in labels)
|
|
|
|
Positioned(
|
|
|
|
top: constraints.maxHeight * (1 - index / steps) - 16,
|
|
|
|
top: constraints.maxHeight *
|
|
|
|
(1 - (label - range.minY) / range.span) -
|
|
|
|
16,
|
|
|
|
right: 0,
|
|
|
|
child: Text(
|
|
|
|
'${range.minY.toInt() + index * 10}',
|
|
|
|
'${label.toInt()}',
|
|
|
|
style: const TextStyle(
|
|
|
|
color: SleepHeartRateCard._h3,
|
|
|
|
fontSize: 10,
|
|
...
|
...
|
@@ -381,15 +317,121 @@ class _HeartRateAxisLabels extends StatelessWidget { |
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HeartRateTimeLabels extends StatelessWidget {
|
|
|
|
const _HeartRateTimeLabels({
|
|
|
|
required this.axis,
|
|
|
|
});
|
|
|
|
|
|
|
|
final _HeartRateXAxis axis;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
final totalMinutes = axis.end.difference(axis.start).inMinutes;
|
|
|
|
final ticks = <DateTime>[
|
|
|
|
axis.start,
|
|
|
|
axis.start.add(Duration(minutes: totalMinutes ~/ 2)),
|
|
|
|
axis.end,
|
|
|
|
];
|
|
|
|
|
|
|
|
return IgnorePointer(
|
|
|
|
child: LayoutBuilder(
|
|
|
|
builder: (context, constraints) {
|
|
|
|
final width = constraints.maxWidth;
|
|
|
|
const labelWidth = 34.0;
|
|
|
|
|
|
|
|
return Stack(
|
|
|
|
clipBehavior: Clip.none,
|
|
|
|
children: [
|
|
|
|
for (var index = 0; index < ticks.length; index++)
|
|
|
|
_timeLabel(ticks, index, width, labelWidth),
|
|
|
|
],
|
|
|
|
);
|
|
|
|
},
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
Widget _timeLabel(
|
|
|
|
List<DateTime> ticks,
|
|
|
|
int index,
|
|
|
|
double width,
|
|
|
|
double labelWidth,
|
|
|
|
) {
|
|
|
|
final tick = ticks[index];
|
|
|
|
final totalMinutes = axis.end.difference(axis.start).inMinutes;
|
|
|
|
final tickMinutes = tick.difference(axis.start).inMinutes;
|
|
|
|
final progress = totalMinutes <= 0 ? 0.0 : tickMinutes / totalMinutes;
|
|
|
|
final plotLeft = SleepHeartRateCard._chartLeftInset;
|
|
|
|
final plotRight = width - SleepHeartRateCard._chartRightInset;
|
|
|
|
final plotWidth =
|
|
|
|
(plotRight - plotLeft).clamp(0, double.infinity).toDouble();
|
|
|
|
final tickX = plotLeft + plotWidth * progress;
|
|
|
|
final left = tickX - labelWidth / 2;
|
|
|
|
|
|
|
|
return Positioned(
|
|
|
|
left: left,
|
|
|
|
bottom: 0,
|
|
|
|
width: labelWidth,
|
|
|
|
child: _TimeLabel(
|
|
|
|
_tickLabel(tick),
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
String _tickLabel(DateTime tick) {
|
|
|
|
return DateFormat('HH:mm').format(tick);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _TimeLabel extends StatelessWidget {
|
|
|
|
const _TimeLabel(
|
|
|
|
this.text, {
|
|
|
|
required this.textAlign,
|
|
|
|
});
|
|
|
|
|
|
|
|
final String text;
|
|
|
|
final TextAlign textAlign;
|
|
|
|
|
|
|
|
@override
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
return Text(
|
|
|
|
text,
|
|
|
|
textAlign: textAlign,
|
|
|
|
style: const TextStyle(
|
|
|
|
color: SleepHeartRateCard._h3,
|
|
|
|
fontSize: 10,
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
height: 1.2,
|
|
|
|
),
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
class _HeartRateYAxisRange {
|
|
|
|
const _HeartRateYAxisRange({
|
|
|
|
required this.minY,
|
|
|
|
required this.maxY,
|
|
|
|
});
|
|
|
|
|
|
|
|
static const interval = 20.0;
|
|
|
|
|
|
|
|
final double minY;
|
|
|
|
final double maxY;
|
|
|
|
|
|
|
|
double get span => maxY - minY <= 0 ? interval : maxY - minY;
|
|
|
|
|
|
|
|
List<double> get labels {
|
|
|
|
final values = <double>[minY];
|
|
|
|
var value = minY + interval;
|
|
|
|
while (value < maxY) {
|
|
|
|
values.add(value);
|
|
|
|
value += interval;
|
|
|
|
}
|
|
|
|
if (values.last != maxY) values.add(maxY);
|
|
|
|
return values;
|
|
|
|
}
|
|
|
|
|
|
|
|
factory _HeartRateYAxisRange.fromPoints(List<SleepHeartRatePoint> points) {
|
|
|
|
if (points.isEmpty) {
|
|
|
|
return const _HeartRateYAxisRange(minY: 50, maxY: 80);
|
|
...
|
...
|
@@ -398,13 +440,13 @@ class _HeartRateYAxisRange { |
|
|
|
final values = points.map((point) => point.bpm);
|
|
|
|
final minBpm = values.reduce((min, bpm) => bpm < min ? bpm : min);
|
|
|
|
final maxBpm = values.reduce((max, bpm) => bpm > max ? bpm : max);
|
|
|
|
var minY = (minBpm ~/ 10) * 10;
|
|
|
|
var maxY = ((maxBpm + 9) ~/ 10) * 10;
|
|
|
|
if (minY == maxY) maxY += 10;
|
|
|
|
final minY = minBpm;
|
|
|
|
var maxY = (maxBpm / 5).ceil() * 5.0;
|
|
|
|
if (minY == maxY) maxY += interval;
|
|
|
|
|
|
|
|
return _HeartRateYAxisRange(
|
|
|
|
minY: minY.toDouble(),
|
|
|
|
maxY: maxY.toDouble(),
|
|
|
|
minY: minY,
|
|
|
|
maxY: maxY,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
...
|
...
|
|