feat(train): a chart held shows the value under the finger, following it

WorkoutChart, the line of every chart in Train (a day's heart rate and skin
temperature, a workout's, a route's height): held, a line marks the point
nearest the finger and follows it as it moves, its value beside the title,
with its moment where the chart says how to write it (a day's heart rate:
the time of day). A tap does nothing; lifting the finger clears it (the
developer's review of 3 Nov 2026, as stock charts do).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-03 21:40:47 +01:00
commit 984527a632
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 191 additions and 14 deletions

View file

@ -274,6 +274,10 @@ class const _Heart(final List<HeartRun> _runs) extends StatelessWidget {
(x: run.at.inSeconds.toDouble(), value: mean.toDouble()),
],
label: (value) => l10n.dayBpm(value.round()),
// Each run's start is the band's time of day.
xLabel: (seconds) => DateFormat.Hm(
Localizations.localeOf(context).toString(),
).format(DateTime(2000).add(Duration(seconds: seconds.round()))),
);
}
}

View file

@ -1,5 +1,8 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/services.dart';
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_analysis.dart';
import 'package:material_ui/material_ui.dart';
@ -8,30 +11,94 @@ import 'package:material_ui/material_ui.dart';
/// value beside it as [label] writes them. [inverted] puts low values at the
/// top, as a faster pace is fewer seconds. [bands] are shaded behind the
/// line: a plan's work steps (D43).
///
/// Held, a line marks the point nearest the finger, following it, and its
/// value shows beside the title, with its moment as [xLabel] writes it
/// where given (the developer's review of 3 Nov 2026, as stock charts do).
class const WorkoutChart({
required final String title,
required final List<ChartPoint> points,
required final String Function(double value) label,
final String Function(double x)? xLabel,
final bool inverted = false,
final List<ChartBand> bands = const [],
super.key,
}) extends StatelessWidget {
}) extends StatefulWidget {
/// The plotted area, for tests to hold.
static const plotKey = Key('workout-chart-plot');
@override
State<WorkoutChart> createState() => _WorkoutChartState();
}
class _WorkoutChartState() extends State<WorkoutChart> {
/// The point under the finger while the chart is held.
ChartPoint? _held;
/// The point whose x is nearest to [dx] across a plot [width] wide.
ChartPoint _nearest(double dx, double width) {
final points = widget.points;
final first = points.first.x;
final span = max(points.last.x - first, 1);
final x = first + (dx / max(width, 1)).clamp(0, 1) * span;
var best = points.first;
for (final point in points) {
if ((point.x - x).abs() < (best.x - x).abs()) {
best = point;
}
}
return best;
}
void _hold(Offset at, double width, {bool first = false}) {
final point = _nearest(at.dx, width);
if (first) {
unawaited(HapticFeedback.selectionClick());
}
if (point != _held) {
setState(() => _held = point);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final points = widget.points;
final label = widget.label;
final inverted = widget.inverted;
final values = [for (final point in points) point.value];
final (low, high) = (values.reduce(min), values.reduce(max));
final caption = theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontFeatures: const [FontFeature.tabularFigures()],
);
final held = _held;
return Semantics(
label: '$title: ${label(low)} – ${label(high)}',
label: '${widget.title}: ${label(low)} – ${label(high)}',
excludeSemantics: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: theme.textTheme.titleMedium),
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Expanded(
child: Text(widget.title, style: theme.textTheme.titleMedium),
),
if (held != null)
Text(
[
label(held.value),
if (widget.xLabel case final xLabel?) xLabel(held.x),
].join(' · '),
style: theme.textTheme.titleSmall?.copyWith(
color: theme.colorScheme.primary,
fontFeatures: const [FontFeature.tabularFigures()],
),
),
],
),
const SizedBox(height: 8),
SizedBox(
height: 120,
@ -39,16 +106,35 @@ class const WorkoutChart({
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: CustomPaint(
painter: _LinePainter(
points,
bands: bands,
shade: theme.colorScheme.primary.withValues(alpha: 0.12),
low: low,
high: high,
inverted: inverted,
line: theme.colorScheme.primary,
grid: theme.colorScheme.outlineVariant,
child: LayoutBuilder(
builder: (context, constraints) => GestureDetector(
key: WorkoutChart.plotKey,
behavior: HitTestBehavior.opaque,
onLongPressStart: (details) => _hold(
details.localPosition,
constraints.maxWidth,
first: true,
),
onLongPressMoveUpdate: (details) =>
_hold(details.localPosition, constraints.maxWidth),
onLongPressEnd: (_) => setState(() => _held = null),
onLongPressCancel: () => setState(() => _held = null),
child: CustomPaint(
painter: _LinePainter(
points,
bands: widget.bands,
shade: theme.colorScheme.primary.withValues(
alpha: 0.12,
),
low: low,
high: high,
inverted: inverted,
line: theme.colorScheme.primary,
grid: theme.colorScheme.outlineVariant,
held: held,
mark: theme.colorScheme.onSurface,
),
),
),
),
),
@ -79,6 +165,8 @@ class _LinePainter(
required final bool inverted,
required final Color line,
required final Color grid,
required final Color mark,
final ChartPoint? held,
}) extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
@ -125,6 +213,27 @@ class _LinePainter(
..style = PaintingStyle.stroke
..strokeJoin = StrokeJoin.round,
);
// The point held: a line down the plot through it, and a dot on it.
if (held case final held?) {
final offset = at(held);
canvas
..drawLine(
Offset(offset.dx, 0),
Offset(offset.dx, size.height),
Paint()
..color = mark.withValues(alpha: 0.6)
..strokeWidth = 1,
)
..drawCircle(offset, 4, Paint()..color = line)
..drawCircle(
offset,
4,
Paint()
..color = mark
..style = PaintingStyle.stroke
..strokeWidth = 1.5,
);
}
}
@override
@ -133,5 +242,7 @@ class _LinePainter(
!identical(oldDelegate.bands, bands) ||
oldDelegate.shade != shade ||
oldDelegate.line != line ||
oldDelegate.grid != grid;
oldDelegate.grid != grid ||
oldDelegate.held != held ||
oldDelegate.mark != mark;
}

View file

@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
import 'package:material_ui/material_ui.dart';
void main() {
/// A heart rate from 60 at second 0 to 120 at second 100, in steps of 10
/// a second.
Future<void> pumpChart(WidgetTester tester) => tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SizedBox(
width: 400,
child: WorkoutChart(
title: 'Heart rate',
points: [
for (var second = 0; second <= 100; second += 10)
(x: second.toDouble(), value: 60 + second * 0.6),
],
label: (value) => '${value.round()} bpm',
xLabel: (x) => 'at ${x.round()} s',
),
),
),
),
);
testWidgets('held, the value and its moment under the finger show beside '
'the title, following the finger, and go when it lifts', (
tester,
) async {
await pumpChart(tester);
final plot = find.byKey(WorkoutChart.plotKey);
final box = tester.getRect(plot);
expect(find.text('at 0 s'), findsNothing);
// Held at the plot's start: the first point.
final gesture = await tester.startGesture(
box.centerLeft + const Offset(1, 0),
);
await tester.pump(const Duration(milliseconds: 600));
expect(find.text('60 bpm · at 0 s'), findsOneWidget);
// Dragged to the middle: second 50, 90 bpm.
await gesture.moveTo(box.center);
await tester.pump();
expect(find.text('90 bpm · at 50 s'), findsOneWidget);
expect(find.text('60 bpm · at 0 s'), findsNothing);
await gesture.up();
await tester.pump();
expect(find.textContaining(' · at '), findsNothing);
});
testWidgets('a tap shows nothing: only a long press inspects', (
tester,
) async {
await pumpChart(tester);
await tester.tap(find.byKey(WorkoutChart.plotKey));
await tester.pump();
expect(find.textContaining(' · at '), findsNothing);
});
}