From 5fc3edaf112bd17944205941222fa2ed57b2d115 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sat, 7 Nov 2026 05:47:30 -0700 Subject: [PATCH 01/23] test(graphs): red tests for scroll-first scrub, scrub panel and chart annotations Inert stubs only (chart_annotations.dart, ChartFrame.annotations/annotationCursor, MetricDetail.annotationLoader/journalToggleKey) so the suites compile. 187 tests, 165 failing on assertions. --- lib/ui2/chart_annotations.dart | 315 ++++++++ lib/ui2/grammar.dart | 10 + lib/ui2/screens/metric_detail.dart | 11 +- lib/ui2/ui2.dart | 1 + test/chart_annotation_layout_test.dart | 655 ++++++++++++++++ test/chart_annotation_sources_test.dart | 256 +++++++ test/chart_annotation_widget_test.dart | 800 ++++++++++++++++++++ test/graphs_metric_annotations_test.dart | 172 +++++ test/graphs_scroll_first_scrub_test.dart | 267 +++++++ test/graphs_scrub_panel_test.dart | 538 +++++++++++++ test/main_sleep_annotation_layout_test.dart | 351 +++++++++ 11 files changed, 3375 insertions(+), 1 deletion(-) create mode 100644 lib/ui2/chart_annotations.dart create mode 100644 test/chart_annotation_layout_test.dart create mode 100644 test/chart_annotation_sources_test.dart create mode 100644 test/chart_annotation_widget_test.dart create mode 100644 test/graphs_metric_annotations_test.dart create mode 100644 test/graphs_scroll_first_scrub_test.dart create mode 100644 test/graphs_scrub_panel_test.dart create mode 100644 test/main_sleep_annotation_layout_test.dart diff --git a/lib/ui2/chart_annotations.dart b/lib/ui2/chart_annotations.dart new file mode 100644 index 000000000..f2f856310 --- /dev/null +++ b/lib/ui2/chart_annotations.dart @@ -0,0 +1,315 @@ +// CHART ANNOTATIONS - RED-PHASE STUBS. Every function and widget here is inert +// on purpose: the red tests (test/chart_annotation_*_test.dart, +// test/main_sleep_annotation_layout_test.dart, test/graphs_*_test.dart) must +// compile and then FAIL ON ASSERTIONS. The green phase replaces this file with +// the real layout (see edge.research/sol/graphs/scope-map.md, section 3). +// +// The intended behaviour, for the implementer: +// * An item is an ICON and a DASHED vertical line on its true x. +// * Icons never overlap: close unlinked points collapse to one icon with "+n"; +// a tap on the icon steps through the members. +// * A linked start/end pair is a SHADED range with one icon and two edges. +// * The main sleep is a priority mark: never in a "+n", the last icon given up. +// * An unusable scale (zero width, empty domain) lays out nothing. + +import 'package:flutter/material.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import 'theme.dart'; + +/// What an annotation is. One icon and one colour per kind. +enum AnnotationKind { + water, + assumedWater, + moment, + symptom, + workout, + nap, + mainSleep, + review, + journal, + algoVersion, +} + +/// The glyph for [k]. STUB: one glyph for every kind. +IconData annotationIcon(AnnotationKind k) => LucideIcons.circle; + +/// The pigment for [k]. STUB: one colour for every kind. +Color annotationColor(AnnotationKind k) => C.n400; + +/// One thing to mark, in the chart's own x domain (epoch seconds, or a slot +/// index on a daily chart). A non-null [until] greater than [at] is a range. +@immutable +class ChartAnnotation { + const ChartAnnotation({ + required this.id, + required this.kind, + required this.at, + required this.label, + this.until, + }); + final String id; + final AnnotationKind kind; + final double at; + final double? until; + final String label; +} + +/// Domain-to-pixel scale of one chart plus the footprint of one icon. +@immutable +class AnnotationScale { + const AnnotationScale({ + required this.domainStart, + required this.domainEnd, + required this.width, + this.iconWidth = 24, + this.badgeWidth = 22, + }); + final double domainStart; + final double domainEnd; + final double width; + final double iconWidth; + final double badgeWidth; + + /// STUB: always 0. + double xOf(double at) => 0; + + /// STUB: never usable. + bool get usable => false; +} + +enum AnnotationLabelSlot { topStart } + +/// One icon after layout, possibly standing for a cluster. +@immutable +class PlacedAnnotation { + const PlacedAnnotation({ + required this.id, + required this.kind, + required this.label, + required this.x, + required this.iconLeft, + required this.iconWidth, + required this.badgeWidth, + required this.memberIds, + required this.focused, + required this.isRange, + }); + + final String id; + final AnnotationKind kind; + final String label; + final double x; + final double iconLeft; + final double iconWidth; + final double badgeWidth; + final List memberIds; + final bool focused; + final bool isRange; + + /// How many more items the icon stands for ("+n"). + int get more => memberIds.length - 1; + + double get footprintRight => + iconLeft + iconWidth + (more > 0 ? badgeWidth : 0); + + /// The member a tap on the icon moves to after [current] (wraps). + /// STUB: never moves. + String next(String? current) => memberIds.first; +} + +/// A shaded span for a linked start/end pair. +@immutable +class ShadedRange { + const ShadedRange({ + required this.id, + required this.kind, + required this.left, + required this.right, + required this.focused, + this.startsInside = true, + this.endsInside = true, + }); + final String id; + final AnnotationKind kind; + final double left; + final double right; + final bool focused; + final bool startsInside; + final bool endsInside; +} + +/// Everything the lane draws. +@immutable +class AnnotationLayout { + const AnnotationLayout({ + required this.items, + required this.shades, + required this.unplaced, + required this.focusedId, + required this.labelText, + this.labelSlot = AnnotationLabelSlot.topStart, + }); + + final List items; + final List shades; + final List unplaced; + final String? focusedId; + final String? labelText; + final AnnotationLabelSlot labelSlot; +} + +/// STUB: lays out nothing. +AnnotationLayout layoutAnnotations({ + required List annotations, + required AnnotationScale scale, + String? focusId, +}) => + const AnnotationLayout( + items: [], + shades: [], + unplaced: [], + focusedId: null, + labelText: null, + ); + +/// The annotation the finger is on (nearest, a range when inside it). +/// STUB: nothing. +String? pickAnnotationFocus({ + required List annotations, + required AnnotationScale scale, + required double? cursorPx, + double? reach, +}) => + null; + +/// Moves the focus [delta] stops through the laid-out members (clamped). +/// STUB: nothing. +String? stepAnnotationFocus( + AnnotationLayout layout, + String? current, + int delta, +) => + null; + +/// An algorithm-version change as annotations in the daily slot domain. +/// STUB: none. +List algoBreakAnnotations({ + required List breakDaysBehind, + required int seriesLength, + required String label, +}) => + const []; + +/// Intraday annotations (epoch seconds) onto a daily chart's slots. +/// STUB: none. +List dailyAnnotations( + List timed, + List dayLabels, +) => + const []; + +/// Reads the annotations for the local days [from]..[to] inclusive +/// ('YYYY-MM-DD'), in epoch seconds. Failure is an empty list, never null. +typedef AnnotationLoader = Future> Function( + String from, String to); + +/// What a chart is annotated with. +@immutable +class AnnotationSet { + const AnnotationSet({ + required this.items, + required this.domainStart, + required this.domainEnd, + this.reach, + }); + final List items; + final double domainStart; + final double domainEnd; + final double? reach; +} + +@immutable +class AnnotationLine { + const AnnotationLine({ + required this.id, + required this.x, + required this.color, + required this.bold, + required this.strokeWidth, + this.dashed = true, + }); + final String id; + final double x; + final Color color; + final bool bold; + final double strokeWidth; + final bool dashed; +} + +/// STUB: paints nothing. +class AnnotationLinesPainter extends CustomPainter { + const AnnotationLinesPainter({required this.lines}); + final List lines; + + static const double dash = 4, gap = 3; + + @override + void paint(Canvas canvas, Size size) {} + + @override + bool shouldRepaint(covariant AnnotationLinesPainter old) => false; +} + +/// STUB: an empty box. +class AnnotationIcon extends StatelessWidget { + const AnnotationIcon({super.key, required this.kind, required this.bold}); + final AnnotationKind kind; + final bool bold; + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} + +/// STUB: draws nothing. +class ChartAnnotationLane extends StatefulWidget { + const ChartAnnotationLane({ + super.key, + required this.set, + required this.cursor, + required this.plotHeight, + }); + final AnnotationSet set; + final double? cursor; + final double plotHeight; + + static const double iconSize = 24; + static const double labelHeight = 16; + static const double header = labelHeight + iconSize; + + static TextStyle labelStyle() => F.cap; + + static double labelRowHeight(TextScaler scaler, + [TextDirection direction = TextDirection.ltr]) => + labelHeight; + + static double headerFor(TextScaler scaler, + [TextDirection direction = TextDirection.ltr]) => + header; + + static const laneKey = ValueKey('annotation-lane'); + static const linesKey = ValueKey('annotation-lines'); + static const boundaryKey = ValueKey('annotation-boundary'); + static const labelKey = ValueKey('annotation-label'); + static ValueKey iconKey(String id) => ValueKey('annotation-icon:$id'); + static ValueKey moreKey(String id) => ValueKey('annotation-more:$id'); + static ValueKey shadeKey(String id) => ValueKey('annotation-shade:$id'); + + @override + State createState() => _ChartAnnotationLaneState(); +} + +class _ChartAnnotationLaneState extends State { + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index 46e6afce8..69906728d 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -41,6 +41,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../data/journal_fields.dart' show formatMinuteOfDay; import '../models/metric.dart'; +import 'chart_annotations.dart' show AnnotationSet; import 'charts.dart'; import 'scroll_hint.dart'; import 'theme.dart'; @@ -2268,6 +2269,13 @@ class ChartFrame extends StatelessWidget { /// The band's ink. Defaults to a wash of the muted ink. final Color? bandColor; + /// Icon + dashed-line marks over the plot. RED STUB: accepted, not drawn. + final AnnotationSet? annotations; + + /// Where the finger is, 0..1 across the plot, so the nearest annotation can + /// be the focused one. RED STUB: accepted, not used. + final double? annotationCursor; + const ChartFrame({ super.key, required this.title, @@ -2283,6 +2291,8 @@ class ChartFrame extends StatelessWidget { this.xMarks = const [], this.band, this.bandColor, + this.annotations, + this.annotationCursor, }); /// Width and height of [s] as it will actually be laid out — including the diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index 4717d6ce0..89ca8245f 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -701,7 +701,16 @@ Widget metricDayScreen(String key, String day) => switch (key) { class MetricDetail extends StatefulWidget { final String metricKey; final MetricData? data; - const MetricDetail(this.metricKey, {super.key, this.data}); + + /// Reads the journal annotations behind the hero's "Journal marks" chip for + /// the local days from..to. RED STUB: accepted, not used. + final AnnotationLoader? annotationLoader; + + /// The "Journal marks" chip. RED STUB: nothing carries it yet. + static const journalToggleKey = ValueKey('metric-journal-toggle'); + + const MetricDetail(this.metricKey, + {super.key, this.data, this.annotationLoader}); @override State createState() => _MetricDetailState(); diff --git a/lib/ui2/ui2.dart b/lib/ui2/ui2.dart index 91dd40ca6..c8ea60f53 100644 --- a/lib/ui2/ui2.dart +++ b/lib/ui2/ui2.dart @@ -6,6 +6,7 @@ // should import its parts individually. export 'app_shell.dart'; +export 'chart_annotations.dart'; export 'charts.dart'; export 'community_links.dart'; export 'grammar.dart'; diff --git a/test/chart_annotation_layout_test.dart b/test/chart_annotation_layout_test.dart new file mode 100644 index 000000000..9a3b4b2bc --- /dev/null +++ b/test/chart_annotation_layout_test.dart @@ -0,0 +1,655 @@ +// The pure annotation layout: annotations + pixel scale + focus -> placed +// icons, "+n" clusters, shaded ranges and one static label. +// +// Everything here is arithmetic on numbers, so the rules that make a chart +// honest are pinned without a frame: icons never overlap, a crowd collapses to +// ONE icon (the focused member, else the OLDEST) with "+n", a linked start/end +// pair is a shaded area with one icon, and nothing is ever invented — an item +// the chart cannot show is dropped or reported, never moved onto the plot. +// +// Pixel maths is easy to read on purpose: domain 0..300 over 300 px, so one +// domain unit is one pixel; the icon is 24 px wide and the "+n" badge 22. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/chart_annotations.dart'; + +/// A test that reads the one item a layout should hold must FAIL ON AN +/// ASSERTION when the layout is empty, not die with a StateError. +extension _One on Iterable { + T get one { + expect(this, hasLength(1), reason: 'expected exactly one'); + return first; + } +} + +T _pick(Iterable xs, bool Function(T) f) { + final m = xs.where(f).toList(); + expect(m, isNotEmpty, reason: 'nothing matching was laid out'); + return m.first; +} + +const _scale = AnnotationScale(domainStart: 0, domainEnd: 300, width: 300); + +ChartAnnotation _pt(String id, double at, + {AnnotationKind kind = AnnotationKind.moment, String? label}) => + ChartAnnotation(id: id, kind: kind, at: at, label: label ?? 'L-$id'); + +ChartAnnotation _range(String id, double at, double until, + {AnnotationKind kind = AnnotationKind.workout}) => + ChartAnnotation( + id: id, kind: kind, at: at, until: until, label: 'L-$id'); + +AnnotationLayout _lay(List a, + {String? focus, AnnotationScale scale = _scale}) => + layoutAnnotations(annotations: a, scale: scale, focusId: focus); + +Iterable _allIds(AnnotationLayout l) => + [for (final i in l.items) ...i.memberIds, ...l.unplaced]; + +void _expectNoOverlapInBounds(AnnotationLayout l, double width) { + for (var i = 0; i < l.items.length; i++) { + final a = l.items[i]; + expect(a.iconLeft, greaterThanOrEqualTo(-1e-9), reason: 'left of the plot'); + expect(a.footprintRight, lessThanOrEqualTo(width + 1e-9), + reason: 'right of the plot'); + for (var j = i + 1; j < l.items.length; j++) { + final b = l.items[j]; + final apart = a.footprintRight <= b.iconLeft + 1e-9 || + b.footprintRight <= a.iconLeft + 1e-9; + expect(apart, isTrue, + reason: '${a.id} [${a.iconLeft}, ${a.footprintRight}] overlaps ' + '${b.id} [${b.iconLeft}, ${b.footprintRight}]'); + } + } +} + +void main() { + group('scale', () { + test('xOf is linear from the domain onto the width', () { + expect(_scale.xOf(0), 0); + expect(_scale.xOf(150), 150); + expect(_scale.xOf(300), 300); + const wide = AnnotationScale(domainStart: 100, domainEnd: 200, width: 400); + expect(wide.xOf(150), 200); + }); + + test('a 23-hour DST day maps its own length, not 86400 s', () { + // Epoch-second domain of a spring-forward day: 23 h, not 24. + const day = 23 * 3600.0; + const s = AnnotationScale(domainStart: 0, domainEnd: day, width: 460); + expect(s.xOf(day), 460); + expect(s.xOf(day / 2), 230); + final l = _lay([_pt('late', day)], scale: s); + expect(l.items.one.x, 460, + reason: 'the last instant of the day is the right edge'); + // A 25 h fall-back day: the same wall time is NOT the same x. + const fall = AnnotationScale(domainStart: 0, domainEnd: 25 * 3600.0, width: 500); + expect(fall.xOf(3600 * 12.5), 250); + }); + }); + + group('placing points', () { + test('nothing in, nothing out: no annotations never invents an item', () { + final l = _lay(const []); + expect(l.items, isEmpty); + expect(l.shades, isEmpty); + expect(l.unplaced, isEmpty); + expect(l.focusedId, isNull); + expect(l.labelText, isNull); + }); + + test('one point: line at its true x, icon centred on it, no badge', () { + final l = _lay([_pt('a', 100)]); + final a = l.items.one; + expect(a.id, 'a'); + expect(a.x, 100); + expect(a.iconLeft, 88); + expect(a.iconWidth, 24); + expect(a.more, 0); + expect(a.memberIds, ['a']); + expect(a.focused, isFalse); + expect(a.isRange, isFalse); + expect(a.kind, AnnotationKind.moment); + expect(l.shades, isEmpty); + }); + + test('points outside the plot are dropped, the edges are kept', () { + final l = _lay([ + _pt('before', -1), + _pt('start', 0), + _pt('end', 300), + _pt('after', 300.5), + ]); + expect([for (final i in l.items) i.id], ['start', 'end']); + expect(l.unplaced, isEmpty, + reason: 'outside the window is not "no room": it is not on this chart'); + }); + + test('a non-finite position is dropped, never drawn at 0', () { + final l = _lay([ + _pt('nan', double.nan), + _pt('inf', double.infinity), + _pt('ok', 50), + ]); + expect([for (final i in l.items) i.id], ['ok']); + }); + + test('the icon stays inside the plot while its line stays on the item', () { + final l = _lay([_pt('l', 0), _pt('r', 300)]); + expect(l.items, hasLength(2)); + expect(l.items[0].x, 0); + expect(l.items[0].iconLeft, 0); + expect(l.items[1].x, 300); + expect(l.items[1].iconLeft, 276); + _expectNoOverlapInBounds(l, 300); + }); + + test('items come back left to right whatever order they went in', () { + final l = _lay([_pt('c', 250), _pt('a', 20), _pt('b', 140)]); + expect([for (final i in l.items) i.id], ['a', 'b', 'c']); + }); + }); + + group('clusters', () { + test('exactly one icon width apart is two icons, closer is one', () { + final apart = _lay([_pt('a', 100), _pt('b', 124)]); + expect(apart.items.length, 2); + expect([for (final i in apart.items) i.more], [0, 0]); + final close = _lay([_pt('a', 100), _pt('b', 123.9)]); + expect(close.items.length, 1); + expect(close.items.one.more, 1); + }); + + test('a crowd is ONE icon, oldest shown, "+n" counts the rest', () { + // Fed newest-first on purpose. + final l = _lay([_pt('c', 110), _pt('b', 105), _pt('a', 100)]); + final i = l.items.one; + expect(i.id, 'a', reason: 'oldest in the neighbourhood'); + expect(i.more, 2); + expect(i.memberIds, ['a', 'b', 'c'], reason: 'oldest first'); + expect(i.focused, isFalse); + expect(i.x, 100, reason: 'the shown item\'s own position'); + }); + + test('oldest is by time, then by id when the times are equal', () { + final byTime = _lay([_pt('z', 100), _pt('y', 100.2)]); + expect(byTime.items.one.id, 'z'); + final byId = _lay([_pt('b', 100), _pt('a', 100)]); + expect(byId.items.one.id, 'a'); + expect(byId.items.one.memberIds, ['a', 'b']); + }); + + test('focus on a hidden member shows THAT member, the cluster is unchanged', + () { + final plain = _lay([_pt('a', 100), _pt('b', 105), _pt('c', 110)]); + final l = _lay([_pt('a', 100), _pt('b', 105), _pt('c', 110)], focus: 'c'); + final i = l.items.one; + expect(i.id, 'c'); + expect(i.focused, isTrue); + expect(i.more, 2, reason: 'still two more behind it'); + expect(i.memberIds, plain.items.one.memberIds); + expect(i.x, 110, reason: 'its line moves to where it really is'); + expect(l.focusedId, 'c'); + expect(l.labelText, 'L-c'); + }); + + test('focus never changes who is in which cluster', () { + final all = [ + _pt('a', 20), _pt('b', 30), // one cluster + _pt('c', 120), // alone + _pt('d', 220), _pt('e', 230), _pt('f', 240), // one cluster + ]; + List> sets(AnnotationLayout l) => + [for (final i in l.items) i.memberIds]; + final base = sets(_lay(all)); + expect(base, [ + ['a', 'b'], + ['c'], + ['d', 'e', 'f'], + ]); + for (final f in ['a', 'b', 'c', 'd', 'e', 'f']) { + expect(sets(_lay(all, focus: f)), base, reason: 'focus $f'); + } + }); + + test('a chain of near points splits into clusters no wider than one icon', () { + // Two runs of 8 points 5 px apart. A naive single-link chain would make + // each run one 35 px "cluster"; the sweep starts a new one once a point + // is a full icon width from the first member: 5 + 3 in each run. + final pts = [ + for (final base in [20, 180]) + for (var i = 0; i < 8; i++) _pt('p$base-$i', base + i * 5.0), + ]; + final l = _lay(pts); + expect([for (final i in l.items) i.memberIds.length], [5, 3, 5, 3]); + for (final i in l.items) { + final xs = [ + for (final m in i.memberIds) 20.0 + int.parse(m.split('-')[1]) * 5.0 + ]; + expect(xs.reduce(math.max) - xs.reduce(math.min), lessThan(24)); + } + expect(_allIds(l).toSet().length, 16); + _expectNoOverlapInBounds(l, 300); + }); + + test('the "+n" badge takes room: the next icon is nudged, its line is not', + () { + // a(+2) at 100 -> icon 88..112, badge 112..134. b is 30 px away so it is + // its own icon, but its centred box (118..142) would sit on the badge. + final l = _lay([_pt('a', 100), _pt('a2', 101), _pt('a3', 102), _pt('b', 130)]); + expect(l.items, hasLength(2)); + final a = l.items[0], b = l.items[1]; + expect(a.more, 2); + expect(b.id, 'b'); + expect(b.iconLeft, greaterThanOrEqualTo(a.footprintRight)); + expect(b.x, 130, reason: 'the line stays on the item'); + _expectNoOverlapInBounds(l, 300); + }); + + test('a badge at the right edge is pulled in, not clipped', () { + final l = _lay([_pt('a', 290), _pt('b', 295)]); + final i = l.items.one; + expect(i.more, 1); + expect(i.footprintRight, lessThanOrEqualTo(300)); + expect(i.x, 290); + _expectNoOverlapInBounds(l, 300); + }); + + test('PlacedAnnotation.next walks the members and wraps', () { + final i = + _lay([_pt('a', 100), _pt('b', 101), _pt('c', 102)]).items.one; + expect(i.next(null), 'a'); + expect(i.next('a'), 'b'); + expect(i.next('b'), 'c'); + expect(i.next('c'), 'a'); + expect(i.next('nope'), 'a'); + }); + }); + + group('ranges', () { + test('a linked pair is a shaded area with ONE icon', () { + final l = _lay([_range('w', 60, 140)]); + expect(l.shades.length, 1); + final s = l.shades.one; + expect([s.id, s.kind, s.left, s.right], + ['w', AnnotationKind.workout, 60, 140]); + expect(l.items.length, 1); + expect(l.items.one.id, 'w'); + expect(l.items.one.isRange, isTrue); + expect(l.items.one.x, 60, reason: 'the icon and line sit at the start'); + expect(l.items.one.more, 0); + }); + + test('the shade is clipped to the plot and a range off the plot is gone', () { + final l = _lay([ + _range('left', -50, 40, kind: AnnotationKind.nap), + _range('right', 260, 400, kind: AnnotationKind.workout), + _range('gone', -80, -10, kind: AnnotationKind.review), + _range('gone2', 310, 400, kind: AnnotationKind.review), + ]); + final byId = {for (final s in l.shades) s.id: s}; + expect(byId.keys.toSet(), {'left', 'right'}); + expect([byId['left']!.left, byId['left']!.right], [0, 40]); + expect([byId['right']!.left, byId['right']!.right], [260, 300]); + final icons = {for (final i in l.items) i.id: i}; + expect(icons['left']!.x, 0, reason: 'icon at the first VISIBLE instant'); + expect(icons['right']!.x, 260); + expect(l.items.length, 2); + }); + + test('a range with no usable end is a point, never a made-up span', () { + for (final until in [null, 100, 90, double.nan]) { + final l = _lay([ + ChartAnnotation( + id: 'r', + kind: AnnotationKind.nap, + at: 100, + until: until, + label: 'nap') + ]); + expect(l.shades, isEmpty, reason: 'until=$until'); + expect(l.items.one.isRange, isFalse, reason: 'until=$until'); + expect(l.items.one.x, 100, reason: 'until=$until'); + } + }); + + test('a range never joins a cluster, and points inside it stay separate', () { + final l = _lay([ + _range('w', 100, 200), + _pt('p', 100.5), + _pt('q', 150), + ]); + expect(l.shades.length, 1); + final w = _pick(l.items, (i) => i.id == 'w'); + expect(w.more, 0); + expect(w.memberIds, ['w']); + expect(l.items.length, 3, reason: 'range icon + p + q'); + _expectNoOverlapInBounds(l, 300); + }); + + test('two ranges starting together both keep their icon and their shade', () { + final l = _lay([ + _range('a', 100, 200, kind: AnnotationKind.workout), + _range('b', 100, 150, kind: AnnotationKind.nap), + ]); + expect(l.shades.length, 2); + expect(l.items.length, 2); + expect([for (final s in l.shades) s.left], [100, 100], + reason: 'collision nudging moves icons, never shaded areas'); + _expectNoOverlapInBounds(l, 300); + }); + + test('focus inside a cluster-free range marks the range focused', () { + final l = _lay([_range('w', 60, 140)], focus: 'w'); + expect(l.shades.one.focused, isTrue); + expect(l.items.one.focused, isTrue); + expect(l.labelText, 'L-w'); + }); + }); + + group('kinds', () { + test('every kind has its own icon and its own colour', () { + final icons = {for (final k in AnnotationKind.values) annotationIcon(k)}; + final colours = { + for (final k in AnnotationKind.values) annotationColor(k).toARGB32() + }; + expect(icons.length, AnnotationKind.values.length); + expect(colours.length, AnnotationKind.values.length, + reason: 'a shade is told apart by colour, so no two kinds share one'); + }); + + test('ranges carry their kind through to the shade', () { + final l = _lay([ + _range('w', 10, 30, kind: AnnotationKind.workout), + _range('n', 100, 130, kind: AnnotationKind.nap), + _range('r', 200, 230, kind: AnnotationKind.review), + ]); + expect({for (final s in l.shades) s.id: s.kind}, { + 'w': AnnotationKind.workout, + 'n': AnnotationKind.nap, + 'r': AnnotationKind.review, + }); + }); + }); + + group('never overlap, never lose, never invent', () { + test('a dense random chart: no overlap, in bounds, every id exactly once', () { + final rnd = math.Random(7); + final all = [ + for (var i = 0; i < 200; i++) + if (i % 9 == 0) + () { + final s = rnd.nextDouble() * 280; + return _range('r$i', s, s + 1 + rnd.nextDouble() * 60, + kind: AnnotationKind.values[3 + i % 3]); + }() + else + _pt('p$i', rnd.nextDouble() * 300, + kind: AnnotationKind.values[i % AnnotationKind.values.length]), + ]; + final l = _lay(all); + _expectNoOverlapInBounds(l, 300); + final ids = _allIds(l).toList(); + expect(ids.toSet().length, ids.length, reason: 'no id twice'); + expect(ids.toSet(), {for (final a in all) a.id}, + reason: 'every item is under an icon or reported unplaced'); + }); + + test('a chart too narrow for its icons reports them instead of overlapping', + () { + const narrow = AnnotationScale(domainStart: 0, domainEnd: 100, width: 60); + final l = _lay([for (var i = 0; i < 10; i++) _pt('p$i', i * 10.0)], + scale: narrow); + _expectNoOverlapInBounds(l, 60); + expect(l.items.length, lessThanOrEqualTo(2)); + expect(_allIds(l).toSet().length, 10, + reason: 'folded into a cluster or listed as unplaced'); + }); + + test('ranges that cannot all have an icon keep their shade and are reported', + () { + const narrow = AnnotationScale(domainStart: 0, domainEnd: 100, width: 60); + final l = _lay([ + _range('a', 0, 10), + _range('b', 20, 30, kind: AnnotationKind.nap), + _range('c', 40, 50, kind: AnnotationKind.review), + ], scale: narrow); + _expectNoOverlapInBounds(l, 60); + expect(l.shades.length, 3); + expect(l.unplaced, isNotEmpty); + expect(_allIds(l).toSet(), {'a', 'b', 'c'}); + }); + + test('an id that is not on the chart is not focused and has no label', () { + final l = _lay([_pt('a', 100)], focus: 'ghost'); + expect(l.focusedId, isNull); + expect(l.labelText, isNull); + expect(l.items.one.focused, isFalse); + }); + + test('a focused item outside the plot is not focused either', () { + final l = _lay([_pt('a', 100), _pt('off', 999)], focus: 'off'); + expect(l.focusedId, isNull); + expect(l.labelText, isNull); + }); + + test('20,000 annotations lay out fast and to a bounded icon count', () { + final rnd = math.Random(3); + final all = [ + for (var i = 0; i < 20000; i++) _pt('p$i', rnd.nextDouble() * 300), + ]; + final sw = Stopwatch()..start(); + final l = _lay(all, focus: 'p123'); + sw.stop(); + expect(l.items.length, lessThanOrEqualTo((300 / 24).floor())); + _expectNoOverlapInBounds(l, 300); + expect(_allIds(l).length, 20000); + // O(n log n); a quadratic pass would be ~4e8 steps. Generous on purpose. + expect(sw.elapsedMilliseconds, lessThan(1500)); + }); + }); + + group('the static label', () { + test('no focus, no label', () { + final l = _lay([_pt('a', 100)]); + expect(l.labelText, isNull); + }); + + test('the label is the focused item\'s label', () { + expect(_lay([_pt('a', 100, label: 'Drank water')], focus: 'a').labelText, + 'Drank water'); + }); + + test('the slot is the same wherever the focus is', () { + final pts = [_pt('left', 10), _pt('mid', 150), _pt('right', 290)]; + final slots = { + for (final f in [null, 'left', 'mid', 'right']) + _lay(pts, focus: f).labelSlot, + }; + expect(slots, {AnnotationLabelSlot.topStart}); + }); + }); + + group('picking the focus from the scrub cursor', () { + final pts = [_pt('a', 100), _pt('b', 120), _pt('c', 250)]; + + test('nearest wins, on or near', () { + String? pick(double px) => pickAnnotationFocus( + annotations: pts, scale: _scale, cursorPx: px); + expect(pick(100), 'a'); + expect(pick(98), 'a'); + expect(pick(113), 'b'); + expect(pick(200), 'c'); + expect(pick(0), 'a'); + expect(pick(-40), 'a', reason: 'past the edge is the edge'); + expect(pick(999), 'c'); + }); + + test('no cursor or nothing to pick is null', () { + expect( + pickAnnotationFocus( + annotations: pts, scale: _scale, cursorPx: null), + isNull); + expect( + pickAnnotationFocus( + annotations: const [], scale: _scale, cursorPx: 50), + isNull); + }); + + test('equally near picks the older', () { + expect( + pickAnnotationFocus( + annotations: pts, scale: _scale, cursorPx: 110), + 'a'); + }); + + test('items at one x pick the oldest, then the lowest id', () { + final same = [_pt('z', 100), _pt('m', 100), _pt('k', 100.0)]; + expect( + pickAnnotationFocus( + annotations: same, scale: _scale, cursorPx: 100), + 'k'); + }); + + test('reach limits "nearest": too far is nothing, not the least-bad item', + () { + expect( + pickAnnotationFocus( + annotations: [_pt('a', 100)], + scale: _scale, + cursorPx: 200, + reach: 30), + isNull); + expect( + pickAnnotationFocus( + annotations: [_pt('a', 100)], + scale: _scale, + cursorPx: 125, + reach: 30), + 'a'); + expect( + pickAnnotationFocus( + annotations: [_pt('a', 100)], scale: _scale, cursorPx: 290), + 'a', + reason: 'no reach given: the nearest, however far'); + }); + + test('a cursor inside a range is ON it, even with a point nearer an edge', + () { + final a = [_range('w', 100, 200), _pt('p', 205)]; + expect( + pickAnnotationFocus(annotations: a, scale: _scale, cursorPx: 190), + 'w'); + }); + + test('inside a range with a point at the cursor, the point wins', () { + final a = [_range('w', 100, 200), _pt('p', 150)]; + expect( + pickAnnotationFocus(annotations: a, scale: _scale, cursorPx: 150), + 'p'); + }); + + test('items off the plot are never picked', () { + expect( + pickAnnotationFocus( + annotations: [_pt('off', 500)], scale: _scale, cursorPx: 290), + isNull); + }); + + test('a dense cluster is walked by moving the finger across it', () { + final dense = [_pt('a', 100), _pt('b', 108), _pt('c', 116)]; + String? pick(double px) => pickAnnotationFocus( + annotations: dense, scale: _scale, cursorPx: px); + expect([pick(99), pick(107), pick(117)], ['a', 'b', 'c']); + }); + }); + + group('an unusable scale lays out nothing (and a usable one lays out)', () { + final one = [_pt('a', 100)]; + AnnotationLayout on(AnnotationScale s) => + layoutAnnotations(annotations: one, scale: s); + + test('control: a usable scale places the item', () { + expect( + on(const AnnotationScale(domainStart: 0, domainEnd: 300, width: 300)) + .items + .one + .id, + 'a'); + }); + + for (final (name, scale) in [ + ('zero width', + const AnnotationScale(domainStart: 0, domainEnd: 300, width: 0)), + ('empty domain', + const AnnotationScale(domainStart: 100, domainEnd: 100, width: 300)), + ('inverted domain', + const AnnotationScale(domainStart: 300, domainEnd: 0, width: 300)), + ('non-finite domain', + const AnnotationScale( + domainStart: 0, domainEnd: double.infinity, width: 300)), + ]) { + test('$name: no items, no shades, nothing unplaced, no throw', () { + // Not "usable" means not drawn; it never means "dropped silently as + // unplaced" and never a crash. + expect(scale.usable, isFalse); + final l = on(scale); + expect(l.items, isEmpty); + expect(l.shades, isEmpty); + expect(l.unplaced, isEmpty); + expect(l.labelText, isNull); + // And the same item on a scale that works is still placed, so the + // empty answer above is the scale's doing. + expect( + on(const AnnotationScale(domainStart: 0, domainEnd: 300, width: 300)) + .items, + hasLength(1)); + }); + } + }); + + group('stepping the focus', () { + final pts = [ + _pt('a', 100), _pt('b', 105), _pt('c', 110), // one icon + _pt('d', 200), // alone + ]; + late AnnotationLayout l; + setUp(() => l = _lay(pts)); + + test('from nothing: forward is the first, back is the last', () { + expect(stepAnnotationFocus(l, null, 1), 'a'); + expect(stepAnnotationFocus(l, null, -1), 'd'); + }); + + test('walks a cluster oldest to newest, then on to the next icon', () { + var f = stepAnnotationFocus(l, null, 1); + final seen = [f]; + for (var i = 0; i < 3; i++) { + f = stepAnnotationFocus(l, f, 1); + seen.add(f); + } + expect(seen, ['a', 'b', 'c', 'd']); + f = stepAnnotationFocus(l, 'd', -1); + expect(f, 'c'); + }); + + test('clamps at both ends', () { + expect(stepAnnotationFocus(l, 'd', 1), 'd'); + expect(stepAnnotationFocus(l, 'a', -1), 'a'); + }); + + test('an unknown current counts as none; an empty chart steps to nothing', + () { + expect(stepAnnotationFocus(l, 'ghost', 1), 'a'); + expect(stepAnnotationFocus(_lay(const []), null, 1), isNull); + }); + + test('a range is one stop', () { + final r = _lay([_pt('a', 50), _range('w', 100, 200), _pt('z', 250)]); + expect(stepAnnotationFocus(r, 'a', 1), 'w'); + expect(stepAnnotationFocus(r, 'w', 1), 'z'); + }); + }); +} diff --git a/test/chart_annotation_sources_test.dart b/test/chart_annotation_sources_test.dart new file mode 100644 index 000000000..744ccd84f --- /dev/null +++ b/test/chart_annotation_sources_test.dart @@ -0,0 +1,256 @@ +// Where annotations come from, and how they reach a chart. +// +// The layout (chart_annotation_layout_test.dart) is pure arithmetic; this file +// pins the seams in front of it: the algorithm-version marks keep landing where +// the dotted lines used to, the day timeline's moments become typed +// annotations, and an intraday item reaches a daily chart on its LOCAL day. +// (Ported from the fork's chart_annotation_sources_test.dart; the day-timeline +// and hero-stamp groups are left to the screens that own them on upstream.) +// +// Fixed dates only; no test reads the clock. The suite runs under TZ=UTC, so +// the day arithmetic below is built from calendar fields (DateTime(y, m, d, +// h, m)) exactly as production does — never from a day start plus 86400. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/chart_annotations.dart'; + +/// A test that reads the one item a layout should hold must FAIL ON AN +/// ASSERTION when the layout is empty, not die with a StateError. +extension _One on Iterable { + T get one { + expect(this, hasLength(1), reason: 'expected exactly one'); + return first; + } +} + +T _pick(Iterable xs, bool Function(T) f) { + final m = xs.where(f).toList(); + expect(m, isNotEmpty, reason: 'nothing matching was laid out'); + return m.first; +} + +final int _day = _sec(2026, 8, 14); +int _sec(int y, int mo, int d, [int h = 0, int mi = 0]) => + DateTime(y, mo, d, h, mi).millisecondsSinceEpoch ~/ 1000; + + +ChartAnnotation _timed(String id, int at, + {int? until, AnnotationKind kind = AnnotationKind.journal}) => + ChartAnnotation( + id: id, + kind: kind, + at: at.toDouble(), + until: until?.toDouble(), + label: id); + +void main() { + group('algorithm-version marks join the annotation system', () { + // The formula the dotted marks used (metric_detail.dart `marks`): a break + // `b` days behind today, in a series of n slots, sat at + // (n - 1 - b - .5) / (n - 1) of the way across. + double old(int b, int n) => (n - 1 - b - .5) / (n - 1); + + test('each break lands exactly where its dotted line used to', () { + const n = 30; + final a = algoBreakAnnotations( + breakDaysBehind: const [3, 12, 20], seriesLength: n, label: 'Algo'); + expect(a.length, 3); + for (var i = 0; i < a.length; i++) { + final b = const [3, 12, 20][i]; + expect(a[i].at / (n - 1), moreOrLessEquals(old(b, n), epsilon: 1e-12)); + expect(a[i].kind, AnnotationKind.algoVersion); + expect(a[i].until, isNull, reason: 'a boundary, not a span'); + expect(a[i].label, 'Algo'); + } + }); + + test('a break at slot 0, behind the window or in the future is dropped', () { + const n = 10; + final a = algoBreakAnnotations( + breakDaysBehind: const [9, 10, 400, -1, 0], + seriesLength: n, + label: 'Algo'); + // 9 -> slot 0: nothing before it to be incomparable with. 10, 400: older + // than the window. -1: after today. 0 -> today's slot: kept (half a slot + // left of it). + expect(a.length, 1); + expect(a.one.at, n - 1 - 0 - .5); + }); + + test('a one-slot or empty series has no boundary to mark', () { + expect( + algoBreakAnnotations( + breakDaysBehind: const [0], seriesLength: 1, label: 'x'), + isEmpty); + expect( + algoBreakAnnotations( + breakDaysBehind: const [0], seriesLength: 0, label: 'x'), + isEmpty); + }); + + test('no breaks, no marks', () { + expect( + algoBreakAnnotations( + breakDaysBehind: const [], seriesLength: 30, label: 'x'), + isEmpty); + }); + + test('ids are unique and stable across calls', () { + List ids() => [ + for (final a in algoBreakAnnotations( + breakDaysBehind: const [3, 12, 20], seriesLength: 30, label: 'x')) + a.id + ]; + expect(ids().toSet().length, 3); + expect(ids(), ids()); + }); + + test('a mark keeps its own icon beside a journal item at the same place', + () { + final mark = algoBreakAnnotations( + breakDaysBehind: const [5], seriesLength: 30, label: 'Algo').one; + final water = ChartAnnotation( + id: 'water', + kind: AnnotationKind.water, + at: mark.at + .01, + label: 'Water'); + final l = layoutAnnotations( + annotations: [water, mark], + scale: const AnnotationScale( + domainStart: 0, domainEnd: 29, width: 290), + ); + // Provenance is not an event: it never folds into a journal cluster. + expect(l.items.length, 2); + expect([for (final i in l.items) i.more], [0, 0]); + expect({for (final i in l.items) i.id}, {mark.id, 'water'}); + // ...but it still avoids the other icon: nudged, not overlapped. + expect(l.items, hasLength(2)); + final a = l.items[0], b = l.items[1]; + expect(a.footprintRight, lessThanOrEqualTo(b.iconLeft + 1e-9)); + expect(_pick(l.items, (i) => i.id == mark.id).x, + moreOrLessEquals(mark.at * 10, epsilon: 1e-9), + reason: 'the line stays on the boundary'); + }); + + test('two marks close together are two icons, never a +n', () { + final marks = algoBreakAnnotations( + breakDaysBehind: const [5, 6], seriesLength: 30, label: 'Algo'); + final l = layoutAnnotations( + annotations: marks, + scale: const AnnotationScale( + domainStart: 0, domainEnd: 29, width: 290), + ); + expect(l.items.length, 2); + expect([for (final i in l.items) i.more], [0, 0]); + }); + }); + + group('dailyAnnotations: an intraday item on a daily chart', () { + const days = [ + '2026-08-10', + '2026-08-11', + '2026-08-12', + '2026-08-13', + '2026-08-14', + '2026-08-15', + '2026-08-16', + ]; + + test('a point is on its local day\'s slot, however late in the day', () { + final a = dailyAnnotations([ + _timed('early', _sec(2026, 8, 12, 0, 5)), + _timed('late', _sec(2026, 8, 12, 23, 55)), + _timed('next', _sec(2026, 8, 13, 0, 5)), + ], days); + expect({for (final x in a) x.id: x.at}, { + 'early': 2.0, + 'late': 2.0, + 'next': 3.0, + }); + expect(a.every((x) => x.until == null), isTrue); + }); + + test('a range inside one day is that day\'s point', () { + final a = dailyAnnotations([ + _timed('run', _sec(2026, 8, 14, 18), + until: _sec(2026, 8, 14, 19), kind: AnnotationKind.workout), + ], days); + expect(a.one.at, 4.0); + expect(a.one.until, isNull); + expect(a.one.kind, AnnotationKind.workout); + }); + + test('a range across days spans its first slot to its last', () { + final a = dailyAnnotations([ + _timed('trip', _sec(2026, 8, 11, 22), + until: _sec(2026, 8, 13, 7), kind: AnnotationKind.review), + ], days); + expect(a.one.at, 1.0); + expect(a.one.until, 3.0); + }); + + test('a range that runs past the window keeps its off-window end', () { + final a = dailyAnnotations([ + _timed('long', _sec(2026, 8, 15, 20), + until: _sec(2026, 8, 18, 6), kind: AnnotationKind.review), + ], days); + // Starts on slot 5. Its end is three days past slot 6 (the last), and it + // stays off the chart: clamping it to slot 6 made the layout draw a + // dashed END edge on a day the range did not end on. + expect(a.one.at, 5.0); + expect(a.one.until, greaterThan(6.0), + reason: 'off-window bounds go through to the layout, which clips'); + }); + + test('a range that began before the window keeps its off-window start', () { + final a = dailyAnnotations([ + _timed('long', _sec(2026, 8, 7, 20), + until: _sec(2026, 8, 11, 6), kind: AnnotationKind.review), + ], days); + expect(a.one.at, lessThan(0.0)); + expect(a.one.until, 1.0); + }); + + test('a range ending on the last visible day still ends there', () { + final a = dailyAnnotations([ + _timed('trip', _sec(2026, 8, 15, 20), + until: _sec(2026, 8, 16, 6), kind: AnnotationKind.review), + ], days); + expect(a.one.at, 5.0); + expect(a.one.until, 6.0); + }); + + test('items outside the window are dropped, not clamped onto an edge', () { + final a = dailyAnnotations([ + _timed('old', _sec(2026, 8, 9, 12)), + _timed('future', _sec(2026, 8, 17, 12)), + ], days); + expect(a, isEmpty); + }); + + test('nothing in, nothing out; no labels, nothing', () { + expect(dailyAnnotations(const [], days), isEmpty); + expect(dailyAnnotations([_timed('x', _day)], const []), isEmpty); + }); + + test('several items on one day share its slot and keep their identity', () { + final a = dailyAnnotations([ + _timed('a', _sec(2026, 8, 14, 8), kind: AnnotationKind.water), + _timed('b', _sec(2026, 8, 14, 9), kind: AnnotationKind.water), + _timed('c', _sec(2026, 8, 14, 21), kind: AnnotationKind.symptom), + ], days); + expect([for (final x in a) x.id], ['a', 'b', 'c']); + expect({for (final x in a) x.at}, {4.0}); + expect([for (final x in a) x.kind], + [AnnotationKind.water, AnnotationKind.water, AnnotationKind.symptom]); + }); + + test('a day with no data of its own is still a day with an item', () { + // The labels say nothing about whether the metric derived that day; the + // journal entry is real either way. No item is invented for the others. + final a = dailyAnnotations([_timed('a', _sec(2026, 8, 13, 12))], days); + expect(a.length, 1); + expect(a.one.at, 3.0); + }); + }); +} diff --git a/test/chart_annotation_widget_test.dart b/test/chart_annotation_widget_test.dart new file mode 100644 index 000000000..0d4327790 --- /dev/null +++ b/test/chart_annotation_widget_test.dart @@ -0,0 +1,800 @@ +// The annotation lane and its place in a ChartFrame. +// +// The layout maths is pinned in chart_annotation_layout_test.dart. This file +// pins what a person sees: an icon and a DASHED line per item, bold on the +// focused one, ONE label that stays where it is while the finger moves, a +// shaded area per range in the colour of its kind, a "+n" after a crowd's one +// icon — and that the plot's RepaintBoundary (AGENTS.md §4.11) is untouched. +// +// Domain 0..300 over a 300 px lane, so one unit is one pixel. Fixed numbers +// only; nothing reads the clock. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +/// A test that reads the one item a layout should hold must FAIL ON AN +/// ASSERTION when the layout is empty, not die with a StateError. +extension _One on Iterable { + T get one { + expect(this, hasLength(1), reason: 'expected exactly one'); + return first; + } +} + +T _pick(Iterable xs, bool Function(T) f) { + final m = xs.where(f).toList(); + expect(m, isNotEmpty, reason: 'nothing matching was laid out'); + return m.first; +} + +/// Geometry reads that fail on an ASSERTION when the thing was never drawn. +Rect _rect(WidgetTester t, Finder f) { + expect(f, findsOneWidget, reason: 'not drawn'); + return t.getRect(f); +} + +Offset _topLeft(WidgetTester t, Finder f) { + expect(f, findsOneWidget, reason: 'not drawn'); + return t.getTopLeft(f); +} + +Offset _centre(WidgetTester t, Finder f) { + expect(f, findsOneWidget, reason: 'not drawn'); + return t.getCenter(f); +} + +const _w = 300.0; +const _plotH = 100.0; + +ChartAnnotation _pt(String id, double at, + {AnnotationKind kind = AnnotationKind.moment, String? label}) => + ChartAnnotation(id: id, kind: kind, at: at, label: label ?? 'L-$id'); + +ChartAnnotation _range(String id, double at, double until, + {AnnotationKind kind = AnnotationKind.workout}) => + ChartAnnotation(id: id, kind: kind, at: at, until: until, label: 'L-$id'); + +AnnotationSet _set(List a) => + AnnotationSet(items: a, domainStart: 0, domainEnd: 300); + +Widget _app(Widget child) => MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: Center(child: SizedBox(width: _w, child: child)), + ), + ); + +Widget _lane(List a, {double? cursorPx}) => _app( + ChartAnnotationLane( + set: _set(a), + cursor: cursorPx == null ? null : cursorPx / _w, + plotHeight: _plotH, + ), + ); + +Future _pumpLane(WidgetTester t, List a, + {double? cursorPx}) async { + await t.pumpWidget(_lane(a, cursorPx: cursorPx)); + await t.pump(); +} + +double _laneLeft(WidgetTester t) => + _topLeft(t, find.byKey(ChartAnnotationLane.laneKey)).dx; + +double _iconCentre(WidgetTester t, String id) => + _centre(t, find.byKey(ChartAnnotationLane.iconKey(id))).dx - _laneLeft(t); + +AnnotationIcon _icon(WidgetTester t, String id) { + final f = find.byKey(ChartAnnotationLane.iconKey(id)); + expect(f, findsOneWidget, reason: 'no icon was drawn for "$id"'); + return t.widget(f); +} + +List _lines(WidgetTester t) { + final f = find.byKey(ChartAnnotationLane.linesKey); + expect(f, findsOneWidget, reason: 'no dashed lines were drawn'); + final cp = t.widget(f); + return (cp.painter as AnnotationLinesPainter).lines; +} + +AnnotationLine _line(WidgetTester t, String id) => + _pick(_lines(t), (l) => l.id == id); + +Text _label(WidgetTester t) { + final f = find.byKey(ChartAnnotationLane.labelKey); + expect(f, findsOneWidget, reason: 'no label was drawn'); + return t.widget(f); +} + +void main() { + _dailyRangeTests(); + testWidgets( + 'a lane laid out at zero width draws nothing and does not throw ' + '(layout used to sort a const list), then draws once it has width', + (t) async { + Widget at(double width) => MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: width, + child: ChartAnnotationLane( + set: _set([_pt('a', 60)]), + cursor: 0.5, + plotHeight: _plotH, + ), + ), + ), + ), + ); + await t.pumpWidget(at(0)); + await t.pump(); + expect(t.takeException(), isNull); + expect(find.byKey(ChartAnnotationLane.iconKey('a')), findsNothing); + // The same lane, given a plot: a chart's first layout pass can be zero + // wide, and the mark must appear on the next one. + await t.pumpWidget(at(_w)); + await t.pump(); + expect(find.byKey(ChartAnnotationLane.iconKey('a')), findsOneWidget); + }); + group('icon and dashed line', () { + testWidgets('every item has an icon of its kind on its own x', (t) async { + await _pumpLane(t, [ + _pt('a', 60, kind: AnnotationKind.water), + _pt('b', 200, kind: AnnotationKind.symptom), + ]); + expect(_icon(t, 'a').kind, AnnotationKind.water); + expect(_icon(t, 'b').kind, AnnotationKind.symptom); + expect(_iconCentre(t, 'a'), moreOrLessEquals(60, epsilon: 1)); + expect(_iconCentre(t, 'b'), moreOrLessEquals(200, epsilon: 1)); + expect(find.byType(AnnotationIcon), findsNWidgets(2)); + }); + + testWidgets('the line is dashed, on the item, and as tall as the plot', + (t) async { + await _pumpLane(t, [_pt('a', 60)]); + final l = _line(t, 'a'); + expect(l.x, moreOrLessEquals(60, epsilon: .5)); + expect(l.dashed, isTrue); + // Dashed means many short strokes, not one long one. + expect( + t.renderObject(find.byKey(ChartAnnotationLane.linesKey)), + paints + ..line() + ..line() + ..line() + ..line(), + ); + }); + + testWidgets('an unfocused item is not bold', (t) async { + await _pumpLane(t, [_pt('a', 60)]); + expect(_icon(t, 'a').bold, isFalse); + expect(_line(t, 'a').bold, isFalse); + }); + + testWidgets('an icon that would sit past the edge is pulled in, its line is not', + (t) async { + await _pumpLane(t, [_pt('l', 0), _pt('r', 300)]); + expect(_iconCentre(t, 'l'), moreOrLessEquals(12, epsilon: 1)); + expect(_iconCentre(t, 'r'), moreOrLessEquals(288, epsilon: 1)); + expect(_line(t, 'l').x, moreOrLessEquals(0, epsilon: .5)); + expect(_line(t, 'r').x, moreOrLessEquals(300, epsilon: .5)); + }); + + testWidgets('no annotations draws nothing at all', (t) async { + await _pumpLane(t, const []); + expect(find.byType(AnnotationIcon), findsNothing); + expect(find.byKey(ChartAnnotationLane.labelKey), findsNothing); + expect(find.byKey(ChartAnnotationLane.linesKey), findsNothing, + reason: 'a lane with nothing in it has no lines to paint'); + }); + + testWidgets('items off the plot are not drawn', (t) async { + await _pumpLane(t, [_pt('in', 100), _pt('out', 450)]); + expect(find.byKey(ChartAnnotationLane.iconKey('in')), findsOneWidget); + expect(find.byKey(ChartAnnotationLane.iconKey('out')), findsNothing); + }); + + testWidgets('icons and badges never overlap on a crowded chart', (t) async { + // 30 items, some within a pixel of each other, some in runs. + final a = [ + for (var i = 0; i < 30; i++) + _pt('p$i', (i * 37 % 300).toDouble(), + kind: AnnotationKind.values[i % 5]), + _range('r1', 50, 120, kind: AnnotationKind.nap), + _range('r2', 55, 90, kind: AnnotationKind.review), + ]; + await _pumpLane(t, a); + final rects = [ + for (final e in find.byType(AnnotationIcon).evaluate()) + _rect(t, find.byWidget(e.widget)), + for (final e in find.textContaining(RegExp(r'^\+\d+$')).evaluate()) + _rect(t, find.byWidget(e.widget)), + ]; + expect(rects.length, greaterThan(2)); + for (var i = 0; i < rects.length; i++) { + for (var j = i + 1; j < rects.length; j++) { + expect(rects[i].overlaps(rects[j]), isFalse, + reason: '${rects[i]} overlaps ${rects[j]}'); + } + } + final lane = _rect(t, find.byKey(ChartAnnotationLane.laneKey)); + for (final r in rects) { + expect(r.left, greaterThanOrEqualTo(lane.left - .01)); + expect(r.right, lessThanOrEqualTo(lane.right + .01)); + } + }); + }); + + group('focus', () { + testWidgets('the item under the cursor goes bold: icon, line and label', + (t) async { + await _pumpLane(t, [_pt('a', 60), _pt('b', 200)], cursorPx: 62); + expect(_icon(t, 'a').bold, isTrue); + expect(_line(t, 'a').bold, isTrue); + expect(_line(t, 'a').strokeWidth, greaterThan(_line(t, 'b').strokeWidth)); + expect(_icon(t, 'b').bold, isFalse); + expect(_line(t, 'b').bold, isFalse); + expect(_label(t).data, 'L-a'); + expect(_label(t).style!.fontWeight!.value, greaterThanOrEqualTo(600)); + }); + + testWidgets('the NEAREST item is focused when the cursor is not on one', + (t) async { + await _pumpLane(t, [_pt('a', 60), _pt('b', 200)], cursorPx: 150); + expect(_icon(t, 'b').bold, isTrue); + expect(_label(t).data, 'L-b'); + }); + + testWidgets('a reach limits how far the cursor still focuses', (t) async { + Widget lane(double px) => _app(ChartAnnotationLane( + set: const AnnotationSet( + items: [ChartAnnotation( + id: 'a', + kind: AnnotationKind.moment, + at: 60, + label: 'L-a')], + domainStart: 0, + domainEnd: 300, + reach: 48), + cursor: px / _w, + plotHeight: _plotH, + )); + await t.pumpWidget(lane(100)); + expect(_icon(t, 'a').bold, isTrue, reason: '40 px away, inside 2 icons'); + await t.pumpWidget(lane(200)); + expect(_icon(t, 'a').bold, isFalse, reason: '140 px away'); + expect(find.byKey(ChartAnnotationLane.labelKey), findsNothing); + }); + + testWidgets('no cursor, no focus, no label', (t) async { + await _pumpLane(t, [_pt('a', 60)]); + expect(find.byKey(ChartAnnotationLane.labelKey), findsNothing); + }); + + testWidgets('moving the cursor moves the focus', (t) async { + final a = [_pt('a', 60), _pt('b', 200)]; + await _pumpLane(t, a, cursorPx: 60); + expect(_icon(t, 'a').bold, isTrue); + await _pumpLane(t, a, cursorPx: 205); + expect(_icon(t, 'a').bold, isFalse); + expect(_icon(t, 'b').bold, isTrue); + expect(_label(t).data, 'L-b'); + }); + + testWidgets('bold does not change the footprint: nothing shifts on focus', + (t) async { + final a = [_pt('a', 60), _pt('b', 200)]; + await _pumpLane(t, a); + final before = _rect(t, find.byKey(ChartAnnotationLane.iconKey('a'))); + final laneBefore = t.getSize(find.byKey(ChartAnnotationLane.laneKey)); + await _pumpLane(t, a, cursorPx: 60); + expect(_rect(t, find.byKey(ChartAnnotationLane.iconKey('a'))), before); + expect(t.getSize(find.byKey(ChartAnnotationLane.laneKey)), laneBefore, + reason: 'the label has its own reserved room; the lane never jumps'); + }); + }); + + group('the static label', () { + testWidgets('sits in the same place whichever item is focused', (t) async { + final a = [ + _pt('left', 20, label: 'Drank water'), + _pt('mid', 150, label: 'Marked moment'), + _pt('right', 280, label: 'Headache'), + ]; + final spots = []; + for (final (px, text) in [ + (20.0, 'Drank water'), + (150.0, 'Marked moment'), + (280.0, 'Headache'), + ]) { + await _pumpLane(t, a, cursorPx: px); + expect(_label(t).data, text); + spots.add(_topLeft(t, find.byKey(ChartAnnotationLane.labelKey))); + } + expect(spots[1], spots[0]); + expect(spots[2], spots[0]); + }); + + testWidgets('does not follow the finger within an item either', (t) async { + final a = [_range('w', 50, 250, kind: AnnotationKind.workout)]; + await _pumpLane(t, a, cursorPx: 80); + final first = _topLeft(t, find.byKey(ChartAnnotationLane.labelKey)); + await _pumpLane(t, a, cursorPx: 220); + expect(_topLeft(t, find.byKey(ChartAnnotationLane.labelKey)), first); + }); + + testWidgets('a long label stays inside the lane', (t) async { + await _pumpLane( + t, + [ + _pt('a', 290, + label: 'A very long description of a symptom that goes on and on ' + 'and on and on and on and on and on') + ], + cursorPx: 290); + final lane = _rect(t, find.byKey(ChartAnnotationLane.laneKey)); + final label = _rect(t, find.byKey(ChartAnnotationLane.labelKey)); + expect(label.left, greaterThanOrEqualTo(lane.left - .01)); + expect(label.right, lessThanOrEqualTo(lane.right + .01)); + }); + }); + + group('ranges', () { + testWidgets('a linked pair is one shaded area and ONE icon', (t) async { + await _pumpLane(t, [_range('w', 60, 140)]); + expect(find.byType(AnnotationIcon), findsOneWidget); + final shade = find.byKey(ChartAnnotationLane.shadeKey('w')); + expect(shade, findsOneWidget); + expect(_topLeft(t, shade).dx - _laneLeft(t), + moreOrLessEquals(60, epsilon: .5)); + expect(t.getSize(shade).width, moreOrLessEquals(80, epsilon: .5)); + }); + + testWidgets('both ends of a range are dashed lines, the icon is at the start', + (t) async { + await _pumpLane(t, [_range('w', 60, 140)]); + final start = _line(t, 'w'), end = _line(t, 'w:end'); + expect(start.x, moreOrLessEquals(60, epsilon: .5)); + expect(end.x, moreOrLessEquals(140, epsilon: .5)); + expect(start.dashed && end.dashed, isTrue); + expect(_iconCentre(t, 'w'), moreOrLessEquals(60, epsilon: 1)); + }); + + testWidgets('a cut end gets no line: the range did not end at the edge', + (t) async { + await _pumpLane(t, [_range('w', 200, 500)]); + expect(_lines(t).map((l) => l.id), ['w']); + await _pumpLane(t, [_range('w', -100, 120)]); + expect(_lines(t).map((l) => l.id), ['w:end'], + reason: 'its start is off the plot, so no start line either'); + expect(find.byKey(ChartAnnotationLane.iconKey('w')), findsOneWidget); + }); + + testWidgets('focusing a range bolds both of its lines', (t) async { + await _pumpLane(t, [_range('w', 60, 140)], cursorPx: 100); + expect(_line(t, 'w').bold && _line(t, 'w:end').bold, isTrue); + }); + + testWidgets('the shade takes the colour of its kind, translucent', (t) async { + await _pumpLane(t, [ + _range('w', 10, 60, kind: AnnotationKind.workout), + _range('n', 100, 160, kind: AnnotationKind.nap), + _range('r', 200, 260, kind: AnnotationKind.review), + ]); + final colours = {}; + for (final (id, kind) in [ + ('w', AnnotationKind.workout), + ('n', AnnotationKind.nap), + ('r', AnnotationKind.review), + ]) { + final c = t + .widget(find.byKey(ChartAnnotationLane.shadeKey(id))) + .color; + colours[id] = c; + expect(c.a, lessThan(1), reason: 'the data behind it must still show'); + expect(c.a, greaterThan(0)); + expect(c.withValues(alpha: 1).toARGB32(), + annotationColor(kind).toARGB32(), + reason: '$id is a ${kind.name}'); + } + expect({for (final c in colours.values) c.toARGB32()}.length, 3); + }); + + testWidgets('a range is clipped to the lane', (t) async { + await _pumpLane(t, [_range('w', -100, 40), _range('x', 270, 500)]); + final lane = _rect(t, find.byKey(ChartAnnotationLane.laneKey)); + final w = _rect(t, find.byKey(ChartAnnotationLane.shadeKey('w'))); + final x = _rect(t, find.byKey(ChartAnnotationLane.shadeKey('x'))); + expect(w.left, moreOrLessEquals(lane.left, epsilon: .5)); + expect(w.width, moreOrLessEquals(40, epsilon: .5)); + expect(x.right, moreOrLessEquals(lane.right, epsilon: .5)); + }); + + testWidgets('focusing a range goes bold and says its label', (t) async { + await _pumpLane(t, [_range('w', 60, 140)], cursorPx: 100); + expect(_icon(t, 'w').bold, isTrue); + expect(_label(t).data, 'L-w'); + }); + + testWidgets('a range with no usable end is a point: no shade', (t) async { + await _pumpLane(t, [ + ChartAnnotation( + id: 'n', + kind: AnnotationKind.nap, + at: 100, + until: 90, + label: 'nap'), + ]); + expect(find.byKey(ChartAnnotationLane.shadeKey('n')), findsNothing); + expect(find.byKey(ChartAnnotationLane.iconKey('n')), findsOneWidget); + }); + }); + + group('crowds collapse to one icon with +n', () { + final crowd = [_pt('a', 100), _pt('b', 105), _pt('c', 110)]; + + testWidgets('one icon, the oldest, with +2 right after it', (t) async { + await _pumpLane(t, crowd); + expect(find.byType(AnnotationIcon), findsOneWidget); + expect(find.byKey(ChartAnnotationLane.iconKey('a')), findsOneWidget); + expect(find.text('+2'), findsOneWidget); + final icon = _rect(t, find.byKey(ChartAnnotationLane.iconKey('a'))); + final badge = _rect(t, find.byKey(ChartAnnotationLane.moreKey('a'))); + expect(badge.left, greaterThanOrEqualTo(icon.right - .01)); + expect(badge.left - icon.right, lessThan(8), reason: 'right after it'); + expect(badge.center.dy, moreOrLessEquals(icon.center.dy, epsilon: 4)); + }); + + testWidgets('a lone item has no badge', (t) async { + await _pumpLane(t, [_pt('a', 100)]); + expect(find.textContaining(RegExp(r'^\+\d+$')), findsNothing); + }); + + testWidgets('focusing a hidden member reveals it, +2 stays', (t) async { + await _pumpLane(t, crowd, cursorPx: 110); + expect(find.byType(AnnotationIcon), findsOneWidget); + expect(find.byKey(ChartAnnotationLane.iconKey('c')), findsOneWidget); + expect(_icon(t, 'c').bold, isTrue); + expect(_label(t).data, 'L-c'); + expect(find.text('+2'), findsOneWidget); + }); + + testWidgets('tapping the +n steps to the next member, and wraps', (t) async { + await _pumpLane(t, crowd); + await t.tap(find.text('+2')); + await t.pump(); + expect(_label(t).data, 'L-a', + reason: 'first tap reveals the shown (oldest) member'); + await t.tap(find.text('+2')); + await t.pump(); + expect(_label(t).data, 'L-b'); + expect(find.byKey(ChartAnnotationLane.iconKey('b')), findsOneWidget); + await t.tap(find.text('+2')); + await t.pump(); + expect(_label(t).data, 'L-c'); + await t.tap(find.text('+2')); + await t.pump(); + expect(_label(t).data, 'L-a', reason: 'wraps'); + }); + + testWidgets('tapping the icon steps the same way', (t) async { + await _pumpLane(t, crowd); + await t.tap(find.byType(AnnotationIcon)); + await t.pump(); + await t.tap(find.byType(AnnotationIcon)); + await t.pump(); + expect(_label(t).data, 'L-b'); + }); + + testWidgets('moving the cursor lets go of a stepped pick', (t) async { + final a = [..._pt2(), ...crowd]; + await _pumpLane(t, a); + await t.tap(find.text('+2')); + await t.pump(); + await t.tap(find.text('+2')); + await t.pump(); + expect(_label(t).data, 'L-b'); + await _pumpLane(t, a, cursorPx: 240); + expect(_label(t).data, 'L-far'); + }); + }); + + group('semantics', () { + testWidgets('the lane says what is marked, flat, for a screen reader', + (t) async { + final h = t.ensureSemantics(); + await _pumpLane(t, [ + _pt('a', 60, label: 'Drank water'), + _range('w', 120, 200, kind: AnnotationKind.workout), + ]); + final label = + t.getSemantics(find.byKey(ChartAnnotationLane.laneKey)).label; + expect(label, contains('Drank water')); + expect(label, contains('L-w')); + h.dispose(); + }); + }); + + group('RepaintBoundary (AGENTS.md section 4.11)', () { + testWidgets('the lane paints inside its own boundary', (t) async { + await _pumpLane(t, [_pt('a', 60)]); + expect(find.byKey(ChartAnnotationLane.boundaryKey), findsOneWidget); + expect(t.widget(find.byKey(ChartAnnotationLane.boundaryKey)), + isA()); + }); + }); + + group('in a ChartFrame', () { + const plotKey = ValueKey('test-plot'); + late _CountingPainter painter; + + Future pumpFrame(WidgetTester t, List a, + {AnnotationSet? aset, bool withAxis = true}) async { + painter = _CountingPainter(); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: Padding( + padding: const EdgeInsets.all(8), + child: _ScrubHost( + builder: (value, set) => ChartFrame( + title: 'Resting heart rate', + unit: 'bpm', + height: _plotH, + yAxis: withAxis + ? AxisSpec(min: 0, max: 100, format: (v) => '${v.round()}') + : null, + annotations: aset ?? _set(a), + annotationCursor: value, + series: const [60, 62, null, 64, 66, 65, 61], + // Upstream's own scrub primitive drives the cursor: the + // fork's ChartScrub does not exist here. + child: Scrubber( + value: value, + onChanged: set, + label: 'Resting heart rate', + describe: (v) => '', + step: 1 / 6, + child: RepaintBoundary( + key: plotKey, + child: CustomPaint(size: Size.infinite, painter: painter), + ), + ), + ), + ), + ), + ), + )); + await t.pump(); + } + + testWidgets('the lane lines up with the plot, not with the axis gutter', + (t) async { + await pumpFrame(t, [_pt('a', 0), _pt('z', 300)]); + final lane = _rect(t, find.byKey(ChartAnnotationLane.laneKey)); + final plot = _rect(t, find.byKey(plotKey)); + expect(lane.left, moreOrLessEquals(plot.left, epsilon: .5)); + expect(lane.width, moreOrLessEquals(plot.width, epsilon: .5)); + expect(_line(t, 'a').x, moreOrLessEquals(0, epsilon: .5)); + expect(_line(t, 'z').x, moreOrLessEquals(plot.width, epsilon: .5)); + }); + + testWidgets('the dashed line runs down through the plot', (t) async { + await pumpFrame(t, [_pt('a', 150)]); + final lines = _rect(t, find.byKey(ChartAnnotationLane.linesKey)); + final plot = _rect(t, find.byKey(plotKey)); + expect(lines.bottom, greaterThanOrEqualTo(plot.bottom - .5)); + expect(lines.top, lessThanOrEqualTo(plot.top + .5)); + }); + + testWidgets('scrubbing onto an item bolds it and shows its label', + (t) async { + // Seven slots, domain = slot index. + final set = const AnnotationSet(items: [ + ChartAnnotation( + id: 'water', + kind: AnnotationKind.water, + at: 1, + label: 'Drank water'), + ChartAnnotation( + id: 'run', + kind: AnnotationKind.workout, + at: 4, + label: 'Evening run'), + ], domainStart: 0, domainEnd: 6); + await pumpFrame(t, const [], aset: set); + final plot = _rect(t, find.byKey(plotKey)); + await t.tapAt(Offset(plot.left + plot.width * 4 / 6, plot.center.dy)); + await t.pump(); + expect(_icon(t, 'run').bold, isTrue); + expect(_icon(t, 'water').bold, isFalse); + expect(_label(t).data, 'Evening run'); + await t.tapAt(Offset(plot.left + plot.width * 1 / 6, plot.center.dy)); + await t.pump(); + expect(_icon(t, 'water').bold, isTrue); + expect(_label(t).data, 'Drank water'); + }); + + testWidgets('scrubbing repaints the lane and never the plot', (t) async { + await pumpFrame(t, [_pt('a', 60), _pt('b', 200)]); + expect(find.byKey(ChartAnnotationLane.laneKey), findsOneWidget, + reason: 'the frame must draw the annotations it was given'); + final before = painter.paints; + expect(before, greaterThan(0)); + final plot = _rect(t, find.byKey(plotKey)); + final g = await t.startGesture(Offset(plot.left + 30, plot.center.dy)); + await t.pump(); + await g.moveTo(Offset(plot.left + 200, plot.center.dy)); + await t.pump(); + await g.up(); + await t.pump(); + expect(painter.paints, before, + reason: 'the plot sits inside its RepaintBoundary (section 4.11); ' + 'focus changes must not repaint it'); + expect(find.byKey(plotKey), findsOneWidget); + }); + + testWidgets('a frame with no annotations has no lane', (t) async { + painter = _CountingPainter(); + await t.pumpWidget(MaterialApp( + theme: buildTheme(Brightness.light), + home: Scaffold( + body: ChartFrame( + title: 'x', + unit: 'bpm', + child: CustomPaint(size: Size.infinite, painter: painter), + ), + ), + )); + expect(find.byKey(ChartAnnotationLane.laneKey), findsNothing); + }); + + testWidgets('an empty annotation set adds no lane and no label room', + (t) async { + await pumpFrame(t, const []); + expect(find.byKey(ChartAnnotationLane.laneKey), findsNothing); + }); + + testWidgets('an algorithm-version mark is an icon with a dashed line', + (t) async { + final marks = algoBreakAnnotations( + breakDaysBehind: const [2], seriesLength: 7, label: 'Algorithm v91'); + await pumpFrame(t, const [], + aset: AnnotationSet(items: marks, domainStart: 0, domainEnd: 6)); + expect(_icon(t, marks.one.id).kind, AnnotationKind.algoVersion); + expect(_line(t, marks.one.id).dashed, isTrue); + final plot = _rect(t, find.byKey(plotKey)); + // 7 slots, break 2 days behind today: half a slot left of slot 4. + expect(_line(t, marks.one.id).x, + moreOrLessEquals(plot.width * 3.5 / 6, epsilon: .5)); + }); + }); +} + +/// Holds the scrub position a [Scrubber] reports and hands it back to the +/// frame, so a tap on the plot moves the annotation focus. +class _ScrubHost extends StatefulWidget { + const _ScrubHost({required this.builder}); + final Widget Function(double? value, ValueChanged set) builder; + @override + State<_ScrubHost> createState() => _ScrubHostState(); +} + +class _ScrubHostState extends State<_ScrubHost> { + double? _v; + @override + Widget build(BuildContext context) => + widget.builder(_v, (v) => setState(() => _v = v)); +} + +/// Two items far from the crowd, so the cursor tests have somewhere to go. +List _pt2() => [_pt('far', 240)]; + +class _CountingPainter extends CustomPainter { + int paints = 0; + @override + void paint(Canvas canvas, Size size) { + paints++; + canvas.drawRect(Offset.zero & size, Paint()..color = const Color(0x11000000)); + } + + @override + bool shouldRepaint(covariant CustomPainter old) => false; +} + +// ── daily ranges: the rendered boundary ───────────────────────────────────── + +/// Seven local days, drawn as a 0..6 slot domain over a 300 px lane (the +/// metric-detail hero's), through the SAME seam the hero uses. +const _week = [ + '2026-08-10', + '2026-08-11', + '2026-08-12', + '2026-08-13', + '2026-08-14', + '2026-08-15', + '2026-08-16', +]; + +double _sec(int d, [int h = 12]) => + DateTime(2026, 8, d, h).millisecondsSinceEpoch / 1000; + +Widget _weekLane(List timed) => _app(ChartAnnotationLane( + set: AnnotationSet( + items: dailyAnnotations(timed, _week), domainStart: 0, domainEnd: 6), + cursor: null, + plotHeight: _plotH, + )); + +void _dailyRangeTests() { + group('a daily range at the edge of its window', () { + testWidgets('one that runs past the last day has no end edge there', + (t) async { + await t.pumpWidget(_weekLane([ + ChartAnnotation( + id: 'trip', + kind: AnnotationKind.review, + at: _sec(15), + until: _sec(19), + label: 'trip'), + ])); + await t.pump(); + expect(_lines(t).map((l) => l.id), ['trip'], + reason: 'the range did not end on the last visible day'); + final lane = _rect(t, find.byKey(ChartAnnotationLane.laneKey)); + final shade = _rect(t, find.byKey(ChartAnnotationLane.shadeKey('trip'))); + expect(shade.left - lane.left, moreOrLessEquals(_w * 5 / 6, epsilon: .5)); + expect(shade.right, moreOrLessEquals(lane.right, epsilon: .5), + reason: 'it still shades to the edge of the plot'); + }); + + testWidgets('one that began before the first day has no start edge there', + (t) async { + await t.pumpWidget(_weekLane([ + ChartAnnotation( + id: 'trip', + kind: AnnotationKind.review, + at: _sec(7), + until: _sec(12), + label: 'trip'), + ])); + await t.pump(); + expect(_lines(t).map((l) => l.id), ['trip:end']); + expect(_line(t, 'trip:end').x, moreOrLessEquals(_w * 2 / 6, epsilon: .5)); + }); + + testWidgets('one that ends ON the last day keeps its end edge', + (t) async { + await t.pumpWidget(_weekLane([ + ChartAnnotation( + id: 'trip', + kind: AnnotationKind.review, + at: _sec(15), + until: _sec(16, 18), + label: 'trip'), + ])); + await t.pump(); + expect(_lines(t).map((l) => l.id), unorderedEquals(['trip', 'trip:end'])); + expect(_line(t, 'trip:end').x, moreOrLessEquals(_w, epsilon: .5)); + }); + + testWidgets('one spanning the whole window has neither edge', (t) async { + await t.pumpWidget(_weekLane([ + ChartAnnotation( + id: 'trip', + kind: AnnotationKind.review, + at: _sec(1), + until: _sec(28), + label: 'trip'), + ])); + await t.pump(); + expect(find.byKey(ChartAnnotationLane.shadeKey('trip')), findsOneWidget); + expect(find.byKey(ChartAnnotationLane.linesKey), findsNothing, + reason: 'no edge is on the plot, so there is nothing to dash'); + }); + }); +} diff --git a/test/graphs_metric_annotations_test.dart b/test/graphs_metric_annotations_test.dart new file mode 100644 index 000000000..1321b0ee3 --- /dev/null +++ b/test/graphs_metric_annotations_test.dart @@ -0,0 +1,172 @@ +// ANNOTATIONS ON THE METRIC-DETAIL HERO (Graphs PR, red tests). +// +// WHY: the hero trend draws an algorithm-version change as a bare dotted line +// that only a footnote explains. The annotation system draws it as an icon with +// a DASHED line (provenance, in its own neutral icon) and puts the user's own +// journal items (workouts, meals, timed journal fields) on the days they +// happened, behind a "Journal marks" chip that is OFF by default and reads +// nothing until it is turned on. Ported from the fork's +// metric_detail_annotations_test.dart; the clock is the real one (upstream's +// screen counts slots back from DateTime.now()), so every date is relative. +import 'dart:ui' show Tristate; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/day_label.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +DateTime _day(int back, [int h = 12]) { + final n = DateTime.now(); + return DateTime(n.year, n.month, n.day - back, h); +} + +int _stamp(DateTime d) => d.millisecondsSinceEpoch ~/ 1000; + +// Seven days; the break is the first day computed the new way, 2 days behind +// today: slot 6 - 2 = 4, the mark half a slot to its left. +MetricData _data() => MetricData( + daysAvailable: 40, + series: [ + for (var back = 6; back >= 0; back--) + (t: _stamp(_day(back)), v: 58.0 + back % 3), + ], + algoBreaks: [_stamp(_day(2))], + ); + +ChartAnnotation _water() => ChartAnnotation( + id: 'jw', + kind: AnnotationKind.water, + // Yesterday, 09:00: slot 5 of 0..6. + at: _day(1, 9).millisecondsSinceEpoch / 1000, + label: 'Drank water', + ); + +const _algoId = 'algo:2'; + +Future> _open(WidgetTester t, + {Future> Function(String, String)? loader}) async { + t.view.physicalSize = const Size(390 * 3, 1800 * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); + final calls = <(String, String)>[]; + await t.pumpWidget(MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildTheme(Brightness.light), + home: Scaffold( + body: MetricDetail( + 'resting_hr', + data: _data(), + annotationLoader: (a, b) { + calls.add((a, b)); + return (loader ?? (_, _) async => [_water()])(a, b); + }, + ), + ), + )); + await t.pumpAndSettle(); + await t.tap(find.text('7 days')); + await t.pumpAndSettle(); + return calls; +} + +Finder get _chip => find.byKey(MetricDetail.journalToggleKey); +Finder _icon(String id) => find.byKey(ChartAnnotationLane.iconKey(id)); + +bool _on(WidgetTester t) { + return t.getSemantics(_chip).getSemanticsData().flagsCollection.isSelected == Tristate.isTrue; +} + +void main() { + group('algorithm-version marks', () { + testWidgets('are an icon with a dashed line, always on, on the day\'s ' + 'boundary', (t) async { + await _open(t); + expect(_icon(_algoId), findsOneWidget); + expect(_icon(_algoId), findsOneWidget); + expect(t.widget(_icon(_algoId)).kind, + AnnotationKind.algoVersion); + expect(find.byKey(ChartAnnotationLane.laneKey), findsOneWidget); + expect(find.byKey(ChartAnnotationLane.linesKey), findsOneWidget); + final lane = t.getRect(find.byKey(ChartAnnotationLane.laneKey)); + final lines = (t + .widget(find.byKey(ChartAnnotationLane.linesKey)) + .painter as AnnotationLinesPainter) + .lines; + final line = lines.firstWhere((l) => l.id == _algoId); + expect(line.dashed, isTrue); + // The dotted line sat at (n - 1 - b - .5) / (n - 1) = 3.5 / 6. + expect(line.x, moreOrLessEquals(lane.width * 3.5 / 6, epsilon: .5)); + }); + + testWidgets('the footnote calls them dashed, not dotted, in English', + (t) async { + await _open(t); + expect(find.textContaining('dashed line'), findsOneWidget); + expect(find.textContaining('dotted'), findsNothing); + }); + }); + + group('the journal chip', () { + testWidgets('is on the hero, off by default, and nothing is read for it', + (t) async { + final calls = await _open(t); + expect(_chip, findsOneWidget); + expect(_on(t), isFalse); + expect(calls, isEmpty, reason: 'off means no read at all'); + expect(_icon('jw'), findsNothing); + }); + + testWidgets('speaks its label so a screen reader can find it', (t) async { + final h = t.ensureSemantics(); + await _open(t); + expect(find.bySemanticsLabel('Journal marks'), findsOneWidget); + h.dispose(); + }); + + testWidgets('turning it on reads the window\'s days once and draws the ' + 'marks on their day\'s slot', (t) async { + final calls = await _open(t); + await t.tap(_chip); + await t.pumpAndSettle(); + expect(_on(t), isTrue); + expect(calls, [(dayLabelOf(_day(6)), dayLabelOf(_day(0)))]); + expect(_icon('jw'), findsOneWidget); + expect(t.widget(_icon('jw')).kind, AnnotationKind.water); + expect(find.byKey(ChartAnnotationLane.laneKey), findsOneWidget); + expect(find.byKey(ChartAnnotationLane.linesKey), findsOneWidget); + final lane = t.getRect(find.byKey(ChartAnnotationLane.laneKey)); + final lines = (t + .widget(find.byKey(ChartAnnotationLane.linesKey)) + .painter as AnnotationLinesPainter) + .lines; + expect(lines.firstWhere((l) => l.id == 'jw').x, + moreOrLessEquals(lane.width * 5 / 6, epsilon: .5), + reason: 'yesterday is slot 5 of 0..6, where the line chart plots it'); + }); + + testWidgets('turning it off removes the journal marks and keeps the ' + 'algorithm mark', (t) async { + await _open(t); + await t.tap(_chip); + await t.pumpAndSettle(); + await t.tap(_chip); + await t.pumpAndSettle(); + expect(_on(t), isFalse); + expect(_icon('jw'), findsNothing); + expect(_icon(_algoId), findsOneWidget); + }); + + testWidgets('a failed read draws nothing and does not throw', (t) async { + await _open(t, loader: (_, _) async => const []); + await t.tap(_chip); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + expect(_icon('jw'), findsNothing); + expect(_icon(_algoId), findsOneWidget); + }); + }); +} diff --git a/test/graphs_scroll_first_scrub_test.dart b/test/graphs_scroll_first_scrub_test.dart new file mode 100644 index 000000000..d9b298ff9 --- /dev/null +++ b/test/graphs_scroll_first_scrub_test.dart @@ -0,0 +1,267 @@ +// SCROLL FIRST, SCRUB DELIBERATELY (Graphs PR, red tests). +// +// WHY: a thumb that starts a page scroll on top of a chart must not drag the +// chart's marker. Upstream's `Scrubber` is a bare `Listener` that moves the +// marker on every pointer-down and pointer-move, and a `Listener` never joins +// the gesture arena, so the page scrolls AND the marker jumps. The intended +// behaviour (the fork's APK1 batch, bf016f7e / 11744b52 / a893e511): +// * pointer-down alone selects nothing; +// * a mostly-vertical move belongs to the page: no marker, page scrolls; +// * a horizontal-dominant move (more than 1.5x the vertical) past the DEVICE +// touch slop belongs to the chart: marker follows, page does not move; +// * a tap selects on release; a long press selects and then follows the +// finger on any axis; +// * the recognizers use the device's touch slop, not the 18 pt default. +// +// Two production charts are driven (metric detail trend, sleep hypnogram) plus +// the bare Scrubber in a ListView for the slop law. +import 'package:flutter/gestures.dart' show DeviceGestureSettings; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +final _onset = DateTime(2026, 5, 19, 23).millisecondsSinceEpoch ~/ 1000; +final _wake = DateTime(2026, 5, 20, 7).millisecondsSinceEpoch ~/ 1000; + +SleepData _sleep() => SleepData( + day: '2026-05-20', + night: { + 'duration_min': 420, + 'in_bed_min': 480, + 'awake_min': 15, + 'efficiency': .88, + 'onset_ts': _onset, + 'wake_ts': _wake, + 'light_min': 200, + 'deep_min': 120, + 'rem_min': 100, + 'hypnogram': [ + {'t': _onset, 'stage': 'light'}, + {'t': _onset + 3600, 'stage': 'deep'}, + {'t': _onset + 10800, 'stage': 'rem'}, + {'t': _wake, 'stage': 'awake'}, + ], + }, + ); + +MetricData _metric() { + final n = DateTime.now(); + return MetricData(daysAvailable: 10, series: [ + for (var back = 9; back >= 0; back--) + ( + t: DateTime(n.year, n.month, n.day - back, 12).millisecondsSinceEpoch ~/ + 1000, + v: 50.0 + back, + ), + ]); +} + +class _Rig { + const _Rig(this.name, this.mount, this.plot); + final String name; + final Future Function(WidgetTester t, double scale) mount; + final Finder Function() plot; +} + +void _phone(WidgetTester t, double h) { + t.view.physicalSize = Size(390 * 3, h * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); +} + +Widget _scaled(double scale, Widget home) => MaterialApp( + theme: buildTheme(Brightness.light), + builder: (c, child) => MediaQuery( + data: MediaQuery.of(c).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: home, + ); + +final _rigs = <_Rig>[ + _Rig( + 'metric detail trend', + (t, scale) async { + _phone(t, 640); + await t.pumpWidget(_scaled( + scale, Scaffold(body: MetricDetail('resting_hr', data: _metric())))); + await t.pumpAndSettle(); + await t.tap(find.text('7 days')); + await t.pumpAndSettle(); + }, + () => find.descendant( + of: find.byType(ChartFrame).first, matching: find.byType(Scrubber)), + ), + _Rig( + 'sleep hypnogram', + (t, scale) async { + _phone(t, 640); + await t.pumpWidget(_scaled(scale, SleepDetail(data: _sleep()))); + await t.pumpAndSettle(); + }, + () => find.byWidgetPredicate( + (w) => w is Scrubber && w.label == 'Hypnogram'), + ), +]; + +Finder get _verticalScroll => find.byWidgetPredicate( + (w) => w is Scrollable && w.axis == Axis.vertical); + +Future _mount(WidgetTester t, _Rig r, double scale) async { + await r.mount(t, scale); + final plot = r.plot(); + expect(plot, findsOneWidget, reason: '${r.name}: the chart is on screen'); + await t.ensureVisible(plot); + await t.pumpAndSettle(); + expect(t.widget(plot).value, isNull, + reason: 'nothing is selected before a touch'); + final scroll = t + .state( + find.ancestor(of: plot, matching: _verticalScroll).first) + .position; + expect(scroll.maxScrollExtent, greaterThan(100), + reason: 'a real scrollable page, not a chart in an unscrollable box'); + expect(t.takeException(), isNull, reason: '${r.name} lays out at $scale'); + return scroll; +} + +/// Brings the chart back on screen without touching it (a drag can carry it off +/// and rebuild a lazy item), then reads the marker. +Future _marker(WidgetTester t, _Rig r) async { + await t.ensureVisible(r.plot()); + await t.pumpAndSettle(); + return t.widget(r.plot()).value; +} + +void main() { + for (final r in _rigs) { + for (final scale in [1.0, 2.0]) { + testWidgets( + '${r.name}: a mostly vertical drag on the chart scrolls the page ' + 'and never moves the marker (x$scale)', (t) async { + final scroll = await _mount(t, r, scale); + final before = scroll.pixels; + final dy = scroll.extentAfter > 150 ? -130.0 : 130.0; + await t.timedDrag( + r.plot(), Offset(12, dy), const Duration(milliseconds: 350)); + await t.pumpAndSettle(); + expect((scroll.pixels - before).abs(), greaterThan(40), + reason: 'a vertical drag that begins on the chart belongs to the page'); + expect(await _marker(t, r), isNull, + reason: 'neither pointer-down nor a vertical move may scrub'); + expect(t.takeException(), isNull); + }); + + testWidgets( + '${r.name}: a first move of 24 across and 60 down scrolls without ' + 'selecting (x$scale)', (t) async { + final scroll = await _mount(t, r, scale); + final before = scroll.pixels; + final dy = scroll.extentAfter > 150 ? -60.0 : 60.0; + final g = await t.startGesture(t.getCenter(r.plot())); + // One event crosses BOTH slops, so the dominance check decides, not + // whichever axis happened to cross first. + await g.moveBy(Offset(24, dy)); + await t.pump(); + expect(t.widget(r.plot()).value, isNull); + await g.moveBy(Offset(0, dy)); + await g.up(); + await t.pumpAndSettle(); + expect((scroll.pixels - before).abs(), greaterThan(40)); + expect(await _marker(t, r), isNull); + expect(t.takeException(), isNull); + }); + + testWidgets( + '${r.name}: pointer-down alone selects nothing; a horizontal drag ' + 'past slop scrubs and the page stays put (x$scale)', (t) async { + final scroll = await _mount(t, r, scale); + final before = scroll.pixels; + final pending = await t.startGesture(t.getCenter(r.plot())); + await t.pump(); + expect(t.widget(r.plot()).value, isNull, + reason: 'an unresolved pointer-down must not flash a marker ' + 'during what may be a page scroll'); + await pending.up(); + await t.pump(); + // A few points of vertical noise must not turn a deliberate sideways + // scrub into a page scroll. + await t.drag(r.plot(), const Offset(60, 5)); + await t.pumpAndSettle(); + expect(scroll.pixels, closeTo(before, 1), + reason: 'a horizontal-dominant drag does not scroll the page'); + expect(t.widget(r.plot()).value, isNotNull, + reason: 'the chart took the drag'); + expect(t.takeException(), isNull); + }); + + testWidgets( + '${r.name}: a long press then a vertical drag selects without ' + 'scrolling (x$scale)', (t) async { + final scroll = await _mount(t, r, scale); + final before = scroll.pixels; + final g = await t.startGesture(t.getCenter(r.plot())); + await t.pump(const Duration(milliseconds: 700)); + await g.moveBy(const Offset(0, 40)); + await t.pump(); + expect(t.widget(r.plot()).value, isNotNull, + reason: 'a held finger is a deliberate scrub on any axis'); + expect(scroll.pixels, closeTo(before, 1)); + await g.up(); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + }); + + testWidgets('${r.name}: a tap still places the marker (x$scale)', + (t) async { + await _mount(t, r, scale); + await t.tapAt(t.getCenter(r.plot())); + await t.pumpAndSettle(); + expect(t.widget(r.plot()).value, closeTo(.5, .1)); + }); + } + } + + // The recognizers must take the DEVICE's touch slop, as GestureDetector + // does. With an 8 pt device slop and the 18 pt default, a gradual + // horizontal-dominant drag would lose to the page scroll at 9 pt. + testWidgets('with an 8 pt device slop a gradual horizontal-dominant drag ' + 'scrubs instead of scrolling', (t) async { + final picked = ValueNotifier(null); + await t.pumpWidget(MaterialApp( + home: MediaQuery( + data: const MediaQueryData( + size: Size(360, 800), + gestureSettings: DeviceGestureSettings(touchSlop: 8)), + child: Scaffold( + body: ListView(children: [ + const SizedBox(height: 200), + ValueListenableBuilder( + valueListenable: picked, + builder: (_, v, _) => Scrubber( + value: v, + onChanged: (x) => picked.value = x, + label: 'Chart', + describe: (x) => x.toStringAsFixed(2), + child: const SizedBox(height: 160, width: 360), + ), + ), + const SizedBox(height: 2000), + ]), + ), + ), + )); + final scroll = t.state(find.byType(Scrollable)).position; + final g = await t.startGesture(t.getCenter(find.byType(Scrubber))); + // Small steps, each 14:9 (horizontal dominant by more than 1.5x). + for (var i = 0; i < 6; i++) { + await g.moveBy(const Offset(14 / 3, 3)); + await t.pump(); + } + await g.up(); + await t.pump(); + expect(picked.value, isNotNull, reason: 'the scrubber won the arena'); + expect(scroll.pixels, 0, reason: 'the page did not scroll'); + }); +} diff --git a/test/graphs_scrub_panel_test.dart b/test/graphs_scrub_panel_test.dart new file mode 100644 index 000000000..df021646b --- /dev/null +++ b/test/graphs_scrub_panel_test.dart @@ -0,0 +1,538 @@ +// THE ALWAYS-PRESENT SCRUB PANEL (Graphs PR, red tests). +// +// WHY: when you stop on a time, an inset panel under the chart reads every +// series at that instant. Idle it says LATEST (the newest reading, or the +// stage at wake on a night); touched it says SELECTED. Upstream shows a +// pop-in row only WHILE a finger is down (`MetricDetail._picked`, +// `SleepDetail._scrubCard`), so the chart jumps in height under the thumb and +// nothing is readable at rest. The panel contract (owner decision 2026-10-22, +// ported from the fork's support/scrub_panel_laws.dart) uses ValueKeys: +// chart-panel the inset box: card2 fill, rMd radius, no shadow, +// no visible border at rest, a border in the series +// colour while scrubbing. Fixed rect. +// chart-panel-caption Text 'LATEST · ' / 'SELECTED · ', +// in the series colour while scrubbing. +// chart-panel-value the big value + unit; '—' where nothing was recorded. +// chart-panel-stat: a secondary stat of the one series. +// Spoken form: the slider says the selected instant ONCE; the panel adds only +// what the slider does not (the stats, 'label value'), and a day with a value +// offers an 'Open ' action of at least 48 dp. +import 'package:flutter/material.dart'; +import 'package:flutter/semantics.dart' show SemanticsNode; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/l10n/app_localizations.dart'; +import 'package:openstrap_edge/state/clock_format.dart'; +import 'package:openstrap_edge/ui2/screens/screens.dart'; +import 'package:openstrap_edge/ui2/ui2.dart'; + +const _panelKey = ValueKey('chart-panel'); +const _captionKey = ValueKey('chart-panel-caption'); +const _valueKey = ValueKey('chart-panel-value'); + +String _plain(Text x) => x.data ?? x.textSpan?.toPlainText() ?? ''; + +Widget _scaled(double scale, Widget home, {Brightness b = Brightness.light}) => + MaterialApp( + locale: const Locale('en'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + theme: buildTheme(b), + builder: (c, child) => MediaQuery( + data: MediaQuery.of(c).copyWith(textScaler: TextScaler.linear(scale)), + child: child!, + ), + home: home, + ); + +void _phone(WidgetTester t, double w, double h) { + t.view.physicalSize = Size(w * 3, h * 3); + t.view.devicePixelRatio = 3; + addTearDown(t.view.reset); +} + +/// Every string a screen reader could be given, from the whole tree. +List _spoken(WidgetTester t) { + final out = []; + void walk(SemanticsNode n) { + final d = n.getSemanticsData(); + for (final s in [d.label, d.value, d.increasedValue, d.decreasedValue]) { + if (s.isNotEmpty) out.add(s); + } + n.visitChildren((c) { + walk(c); + return true; + }); + } + + walk(t.binding.renderViews.first.owner!.semanticsOwner!.rootSemanticsNode!); + return out; +} + +int _times(WidgetTester t, String what) => + _spoken(t).where((s) => s.contains(what)).length; + +/// Whether [caption] names [back] days ago: the weekday and the day of the +/// month, or 'today' / 'yesterday' where the chart words it so. +bool _namesDay(String caption, int back) { + final c = caption.toLowerCase(); + final n = DateTime.now(); + final d = DateTime(n.year, n.month, n.day - back); + const wd = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun']; + if (back == 0 && c.contains('today')) return true; + if (back == 1 && c.contains('yesterday')) return true; + return c.contains(wd[d.weekday - 1]) && RegExp('\\b${d.day}\\b').hasMatch(c); +} + +BoxDecoration _decoration(WidgetTester t, Finder panel) { + final w = t.widget(panel); + if (w is Container) return w.decoration! as BoxDecoration; + if (w is DecoratedBox) return w.decoration as BoxDecoration; + fail('chart-panel must be a Container or DecoratedBox, got ${w.runtimeType}'); +} + +int _rgb(Color c) => c.toARGB32() & 0xFFFFFF; + +// ═══════════════ metric detail: resting heart rate, 7 days ═══════════════ +// +// A value of 50 + back (back = days ago, 0 = today) on every day of the +// window except a GAP at 2 days ago. +const _gapBack = 2, _scrubbedBack = 3; + +MetricData _rhr() { + final n = DateTime.now(); + return MetricData(daysAvailable: 10, series: [ + for (var back = 9; back >= 0; back--) + if (back != _gapBack) + ( + t: DateTime(n.year, n.month, n.day - back, 12) + .millisecondsSinceEpoch ~/ + 1000, + v: 50.0 + back, + ), + ]); +} + +Future _openRhr(WidgetTester t, + {double width = 390, double scale = 1}) async { + _phone(t, width, 1800); + await t.pumpWidget(_scaled( + scale, Scaffold(body: MetricDetail('resting_hr', data: _rhr())))); + await t.pumpAndSettle(); + await t.tap(find.text('7 days')); + await t.pumpAndSettle(); +} + +Finder get _plot => find.descendant( + of: find.byType(ChartFrame).first, matching: find.byType(Scrubber)); + +/// Taps the plot on the day [back] days ago (7 slots, the last is today). +Future _scrubBack(WidgetTester t, int back) async { + final r = t.getRect(_plot); + await t.tapAt(Offset(r.left + r.width * (6 - back) / 6, r.center.dy)); + await t.pumpAndSettle(); +} + +String _valueOf(WidgetTester t, Finder panel) { + final f = find.descendant(of: panel, matching: find.byKey(_valueKey)); + expect(f, findsOneWidget, reason: 'the panel has no value'); + return [ + for (final x in t.widgetList( + find.descendant(of: f, matching: find.byType(Text)))) + _plain(x) + ].join().replaceAll(RegExp(r'\s+'), ''); +} + +String _captionOf(WidgetTester t, Finder panel) { + final f = find.descendant(of: panel, matching: find.byKey(_captionKey)); + expect(f, findsOneWidget, reason: 'the panel has no caption'); + return _plain(t.widget(f)); +} + +void main() { + setUp(() => ClockFormatController.seed(ClockFormat.h24)); + tearDown(ClockFormatController.debugReset); + + group('metric detail panel', () { + testWidgets('at rest: one panel, LATEST, today, the value and unit, the ' + 'stats', (t) async { + await _openRhr(t); + final panel = find.byKey(_panelKey); + expect(panel, findsOneWidget, reason: 'no panel at rest'); + final cap = _captionOf(t, panel); + expect(cap.toLowerCase(), startsWith('latest')); + expect(_namesDay(cap, 0), isTrue, reason: 'caption "$cap" names no day'); + expect(_valueOf(t, panel), '50bpm'); + final stats = find.descendant( + of: panel, + matching: find.byWidgetPredicate((w) => + w.key is ValueKey && + (w.key! as ValueKey).value.startsWith('chart-panel-stat:'))); + expect(stats, findsAtLeastNWidgets(2), reason: '7-day avg and vs avg'); + final texts = [ + for (final x in t.widgetList( + find.descendant(of: stats, matching: find.byType(Text)))) + _plain(x).replaceAll(RegExp(r'\s+'), ' ') + ]; + // Days 6,5,4,3,1,0 ago read 56,55,54,53,51,50: mean 53.17. + expect(texts, contains('53 bpm')); + expect(texts.where((s) => RegExp(r'^[+\-−]3 bpm$').hasMatch(s)), + isNotEmpty, + reason: 'vs avg for the latest day (50 against 53.17)'); + }); + + testWidgets('scrubbing: SELECTED, the scrubbed day, its value, a border in ' + 'the series colour', (t) async { + await _openRhr(t); + final panel = find.byKey(_panelKey); + expect(panel, findsOneWidget, reason: 'no panel at rest'); + final rest = _decoration(t, panel).border; + expect(rest == null || rest.top.width == 0 || rest.top.color.a == 0, + isTrue, + reason: 'no visible border at rest'); + await _scrubBack(t, _scrubbedBack); + expect(panel, findsOneWidget); + final cap = _captionOf(t, panel); + expect(cap.toLowerCase(), startsWith('selected')); + expect(_namesDay(cap, _scrubbedBack), isTrue, + reason: 'caption "$cap" is not $_scrubbedBack days back'); + expect(_valueOf(t, panel), '53bpm'); + final colour = _rgb( + P.of(t.element(panel)).on(specOf('resting_hr').color)); + final b = _decoration(t, panel).border; + expect(b, isNotNull, reason: 'scrubbing draws a border'); + expect(b!.top.width, greaterThan(0)); + expect(_rgb(b.top.color), colour); + final capText = t.widget( + find.descendant(of: panel, matching: find.byKey(_captionKey))); + expect(_rgb(capText.style!.color!), colour, + reason: 'SELECTED is in the series colour'); + }); + + testWidgets('a day with no reading says so: SELECTED and a dash, never a ' + 'neighbour\'s number', (t) async { + await _openRhr(t); + final panel = find.byKey(_panelKey); + expect(panel, findsOneWidget, reason: 'no panel at rest'); + await _scrubBack(t, _gapBack); + expect(_captionOf(t, panel).toLowerCase(), startsWith('selected')); + expect(_namesDay(_captionOf(t, panel), _gapBack), isTrue); + expect(_valueOf(t, panel), startsWith('—')); + }); + + testWidgets('the panel and the frame keep their size and place at rest, ' + 'scrubbing, on a gap day and back on today; no pop-in row appears', + (t) async { + await _openRhr(t); + final panel = find.byKey(_panelKey); + expect(panel, findsOneWidget, reason: 'no panel at rest'); + final rect = t.getRect(panel); + final frame = t.getRect(find.byType(ChartFrame).first); + final surfaces = find.byType(Surface).evaluate().length; + for (final back in [_scrubbedBack, _gapBack, 4, 0]) { + await _scrubBack(t, back); + expect(panel, findsOneWidget); + expect(t.getRect(panel), rect, reason: 'panel moved on day -$back'); + expect(t.getRect(find.byType(ChartFrame).first), frame, + reason: 'frame moved on day -$back'); + expect(find.byType(Surface).evaluate().length, surfaces, + reason: 'the old pop-in Surface must not come back (day -$back)'); + } + }); + + testWidgets('inset style: card2 fill, rMd radius, no shadow, under the ' + 'plot and inside the card', (t) async { + await _openRhr(t); + final panel = find.byKey(_panelKey); + expect(panel, findsOneWidget, reason: 'no panel at rest'); + final p = P.of(t.element(panel)); + final d = _decoration(t, panel); + expect(d.color, p.card2); + expect(d.borderRadius, R.rMd); + expect(d.boxShadow, anyOf(isNull, isEmpty)); + expect(t.getRect(panel).top, greaterThanOrEqualTo(t.getRect(_plot).bottom)); + }); + + for (final w in [360.0, 390.0]) { + for (final s in [1.0, 1.3]) { + testWidgets('no overflow and no shift at width $w, text scale $s', + (t) async { + await _openRhr(t, width: w, scale: s); + final panel = find.byKey(_panelKey); + expect(panel, findsOneWidget, reason: 'no panel at rest'); + expect(t.takeException(), isNull); + final rect = t.getRect(panel); + final box = t.getRect(find.byType(ChartFrame).first); + expect(rect.left, greaterThanOrEqualTo(box.left - .01)); + expect(rect.right, lessThanOrEqualTo(box.right + .01)); + for (final back in [_scrubbedBack, _gapBack]) { + await _scrubBack(t, back); + expect(t.takeException(), isNull); + expect(t.getRect(panel), rect); + } + }); + } + } + + testWidgets('spoken once: the slider says the selected day and value; the ' + 'panel does not repeat it and speaks the stats as "label value"', + (t) async { + final h = t.ensureSemantics(); + await _openRhr(t); + await _scrubBack(t, _scrubbedBack); + final scrubber = t.widget(_plot); + final said = scrubber.describe(scrubber.value!); + expect(said, contains('53 bpm')); + expect(_times(t, said), 1, + reason: 'the selection is voiced once across slider and panel'); + expect(find.bySemanticsLabel(RegExp(r'^(LATEST|SELECTED)', caseSensitive: false)), + findsNothing, + reason: 'the visual caption is not a second announcement'); + expect(_spoken(t).where((s) => RegExp(r'7-day avg.*53 bpm').hasMatch(s)), + hasLength(1), + reason: 'a stat is spoken as one "label value" node'); + h.dispose(); + }); + + testWidgets('a day with a value offers "Open " of at least 48 dp; a ' + 'day without one offers nothing to open', (t) async { + final h = t.ensureSemantics(); + await _openRhr(t); + await _scrubBack(t, _scrubbedBack); + final open = find.bySemanticsLabel(RegExp(r'^Open ')); + expect(open, findsOneWidget); + expect(t.getSemantics(open).rect.height, greaterThanOrEqualTo(48)); + await _scrubBack(t, _gapBack); + expect(find.bySemanticsLabel(RegExp(r'^Open ')), findsNothing, + reason: 'an action button is a promise; an empty day has no screen'); + h.dispose(); + }); + }); + + // ═══════════════ sleep: the hypnogram and the night's signals ═══════════════ + group('sleep panels', () { + final onset = DateTime(2026, 5, 19, 23).millisecondsSinceEpoch ~/ 1000; + final wake = DateTime(2026, 5, 20, 7).millisecondsSinceEpoch ~/ 1000; + + SleepData data({bool signals = true}) => SleepData( + day: '2026-05-20', + night: { + 'duration_min': 360, + 'in_bed_min': 480, + 'awake_min': 15, + 'efficiency': .75, + 'onset_ts': onset, + 'wake_ts': wake, + 'light_min': 120, + 'deep_min': 120, + 'rem_min': 105, + 'hypnogram': [ + {'t': onset, 'stage': 'light'}, + {'t': onset + 3600, 'stage': 'deep'}, + {'t': onset + 10800, 'stage': 'unobserved'}, + {'t': onset + 18000, 'stage': 'rem'}, + // The display samples miss this: two minutes before wake. + {'t': wake - 60, 'stage': 'awake'}, + {'t': wake, 'stage': 'awake'}, + ], + }, + timeline: signals + ? { + 'hr': [ + {'t': onset, 'v': 50}, + {'t': onset + 7200, 'v': 54}, + {'t': onset + 14400, 'v': 58}, + {'t': wake, 'v': 66}, + ], + 'hrv': [ + {'t': onset + 7200, 'v': 42} + ], + 'resp': [ + {'t': onset + 14400, 'v': 17.5} + ], + } + : {}, + ); + + Future pump(WidgetTester t, + {double scale = 1, + bool signals = true, + Brightness b = Brightness.light}) async { + _phone(t, 390, 6200 * scale); + await t.pumpWidget(_scaled( + scale, SleepDetail(data: data(signals: signals)), + b: b)); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + } + + Finder plot(String label) => + find.byWidgetPredicate((w) => w is Scrubber && w.label == label); + Finder inPanel(Finder panel, String text) => + find.descendant(of: panel, matching: find.text(text)); + + Future scrub(WidgetTester t, String label, double at) async { + final rect = t.getRect(plot(label)); + await t.tapAt(Offset(rect.left + rect.width * at, rect.center.dy)); + await t.pumpAndSettle(); + expect(t.takeException(), isNull); + } + + for (final scale in [1.0, 1.3, 3.1]) { + testWidgets('both night panels are there at rest (LATEST at wake) and ' + 'keep their size and place at ${scale}x text', (t) async { + await pump(t, scale: scale); + final panels = find.byKey(_panelKey); + expect(panels, findsNWidgets(2), + reason: 'one under the hypnogram, one under the night signals'); + final rects = [for (var i = 0; i < 2; i++) t.getRect(panels.at(i))]; + for (var i = 0; i < 2; i++) { + expect(inPanel(panels.at(i), 'LATEST · 07:00'), findsOneWidget); + expect(inPanel(panels.at(i), 'Awake'), findsOneWidget); + expect(inPanel(panels.at(i), '66 bpm'), findsOneWidget); + expect(find.descendant(of: panels.at(i), matching: find.text('—')), + findsAtLeastNWidgets(2), + reason: 'HRV and breathing were not recorded at wake: a dash, ' + 'never a neighbour\'s reading'); + final box = t.widget(panels.at(i)).decoration as BoxDecoration; + expect(box.border, isNull, reason: 'no border at rest'); + } + final surfaces = find.byType(Surface).evaluate().length; + for (final (label, at, time) in [ + ('Hypnogram', .25, '01:00'), + ('Through the night', .5, '03:00'), + ('Hypnogram', .75, '05:00'), + ]) { + await scrub(t, label, at); + expect(panels, findsNWidgets(2)); + expect(find.byType(Surface).evaluate().length, surfaces, + reason: 'the old readout added a Surface on every scrub'); + for (var i = 0; i < 2; i++) { + expect(inPanel(panels.at(i), 'SELECTED · $time'), findsOneWidget); + expect(t.getRect(panels.at(i)), rects[i], reason: 'no shift at $time'); + final box = + t.widget(panels.at(i)).decoration as BoxDecoration; + expect(box.border, isNotNull); + } + } + }); + } + + testWidgets('SELECTED shows the stage and EVERY signal at that instant, ' + 'and a dash where one was not recorded', (t) async { + await pump(t); + final panels = find.byKey(_panelKey); + expect(panels, findsNWidgets(2), reason: 'no panels under the charts'); + // 01:00: deep sleep; heart rate and HRV were both recorded here. + await scrub(t, 'Hypnogram', .25); + for (var i = 0; i < 2; i++) { + expect(inPanel(panels.at(i), 'Deep sleep'), findsOneWidget); + expect(inPanel(panels.at(i), '54 bpm'), findsOneWidget); + expect(inPanel(panels.at(i), '42 ms'), findsOneWidget); + } + // 03:00: an unobserved stretch; heart rate and breathing, no HRV. + await scrub(t, 'Through the night', .5); + for (var i = 0; i < 2; i++) { + expect(inPanel(panels.at(i), 'Not measured'), findsOneWidget, + reason: 'a hole in the record is not a stage'); + expect(inPanel(panels.at(i), '58 bpm'), findsOneWidget); + expect(find.descendant(of: panels.at(i), matching: find.textContaining('17.5')), + findsOneWidget); + expect(find.descendant(of: panels.at(i), matching: find.textContaining(' ms')), + findsNothing, + reason: 'no HRV was recorded near 03:00'); + } + // 05:00: REM, but no signal within reach: nothing is invented. + await scrub(t, 'Hypnogram', .75); + for (var i = 0; i < 2; i++) { + expect(inPanel(panels.at(i), 'REM'), findsOneWidget); + for (final unit in [' bpm', ' ms', 'br/min']) { + expect(find.descendant(of: panels.at(i), matching: find.textContaining(unit)), + findsNothing, + reason: 'no signal near 05:00, so no $unit value'); + } + } + }); + + testWidgets('the stage comes from the timestamped boundaries, even ' + 'between display samples; the slider agrees', (t) async { + await pump(t); + final panels = find.byKey(_panelKey); + expect(panels, findsNWidgets(2), reason: 'no panels under the charts'); + for (final (seconds, time, stage) in [ + (3599, '23:59', 'Light sleep'), + (3600, '00:00', 'Deep sleep'), + (10799, '01:59', 'Deep sleep'), + (10800, '02:00', 'Not measured'), + (17999, '03:59', 'Not measured'), + (18000, '04:00', 'REM'), + (28739, '06:58', 'REM'), + (28740, '06:59', 'Awake'), + ]) { + await scrub(t, 'Hypnogram', seconds / 28800); + for (var i = 0; i < 2; i++) { + expect(inPanel(panels.at(i), 'SELECTED · $time'), findsOneWidget, + reason: 'panel $i at $seconds s'); + expect(inPanel(panels.at(i), stage), findsOneWidget, + reason: 'panel $i at $seconds s'); + } + final s = t.widget(plot('Hypnogram')); + final spoken = stage == 'Not measured' ? 'not measured' : stage; + expect(s.describe(seconds / 28800), '$time, $spoken'); + } + }); + + for (final b in Brightness.values) { + testWidgets('the stage caption and value clear 4.5:1 on the panel in $b', + (t) async { + await pump(t, b: b); + expect(find.byKey(_panelKey), findsNWidgets(2)); + final panel = find.byKey(_panelKey).first; + final p = P.of(t.element(panel)); + for (final (at, stage, pigment) in [ + (0.0, 'Light sleep', C.sky), + (.25, 'Deep sleep', C.blue), + (.75, 'REM', C.teal), + (1.0, 'Awake', C.orange), + ]) { + await scrub(t, 'Hypnogram', at == 1 ? .9999 : at); + final caption = find.descendant( + of: panel, matching: find.byKey(_captionKey)); + for (final text in [inPanel(panel, stage), caption]) { + final color = t.widget(text).style!.color!; + expect(color, p.on(pigment)); + expect(P.contrast(color, p.card2), greaterThanOrEqualTo(4.5)); + } + } + }); + } + + testWidgets('a night with no recorded signals keeps one panel for the ' + 'hypnogram and fabricates no values', (t) async { + await pump(t, signals: false); + final panels = find.byKey(_panelKey); + expect(panels, findsOneWidget); + expect(inPanel(panels, 'LATEST · 07:00'), findsOneWidget); + expect(inPanel(panels, 'Awake'), findsOneWidget); + for (final unit in [' bpm', ' ms', 'br/min']) { + expect(find.descendant(of: panels, matching: find.textContaining(unit)), + findsNothing); + } + }); + + testWidgets('spoken once: the slider says the selected time and stage; ' + 'the panels add no second announcement', (t) async { + final h = t.ensureSemantics(); + await pump(t); + expect(find.byKey(_panelKey), findsNWidgets(2), + reason: 'there must be a panel for the slider NOT to repeat'); + await scrub(t, 'Hypnogram', .25); + final s = t.widget(plot('Hypnogram')); + final said = s.describe(s.value!); + expect(said, '01:00, Deep sleep'); + expect(_times(t, said), 1); + expect(find.bySemanticsLabel(RegExp(r'^(LATEST|SELECTED)', caseSensitive: false)), + findsNothing); + h.dispose(); + }); + }); +} diff --git a/test/main_sleep_annotation_layout_test.dart b/test/main_sleep_annotation_layout_test.dart new file mode 100644 index 000000000..bfceba252 --- /dev/null +++ b/test/main_sleep_annotation_layout_test.dart @@ -0,0 +1,351 @@ +// The main-sleep annotation kind in the pure annotation layout. +// +// The night's main sleep is drawn on the day chart as an annotation (a moon, +// its own colour), not as a band. In the layout it has PRIORITY over every +// other kind: +// 1. it is NEVER folded into a "+n" cluster (other icons are nudged or +// clustered around it), whether it arrives as a range or as a point; +// 2. it is the last icon given up when the chart is too narrow for all; +// 3. when nothing is focused, ITS label is the static label. Any focus +// (a scrub, a tap) replaces it; an absent or off-chart night leaves the +// label empty -- nothing is invented. +// Every other kind behaves exactly as before (chart_annotation_layout_test). +// +// Pixel maths as in the other layout tests: domain 0..300 over 300 px, the icon +// is 24 px wide and the "+n" badge 22. + +import 'dart:math' as math; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; +import 'package:openstrap_edge/ui2/chart_annotations.dart'; + +/// A test that reads the one item a layout should hold must FAIL ON AN +/// ASSERTION when the layout is empty, not die with a StateError. +extension _One on Iterable { + T get one { + expect(this, hasLength(1), reason: 'expected exactly one'); + return first; + } +} + +T _pick(Iterable xs, bool Function(T) f) { + final m = xs.where(f).toList(); + expect(m, isNotEmpty, reason: 'nothing matching was laid out'); + return m.first; +} + +const _scale = AnnotationScale(domainStart: 0, domainEnd: 300, width: 300); + +ChartAnnotation _pt(String id, double at, + {AnnotationKind kind = AnnotationKind.moment}) => + ChartAnnotation(id: id, kind: kind, at: at, label: 'L-$id'); + +/// The main sleep as a POINT (a degenerate or one-sided night): the case a +/// plain point would fold into a cluster. +ChartAnnotation _sleepPt(double at, {String id = 'sleep'}) => + ChartAnnotation( + id: id, + kind: AnnotationKind.mainSleep, + at: at, + label: 'Main sleep L-$id'); + +ChartAnnotation _sleep(double at, double until, {String id = 'sleep'}) => + ChartAnnotation( + id: id, + kind: AnnotationKind.mainSleep, + at: at, + until: until, + label: 'Main sleep L-$id'); + +AnnotationLayout _lay(List a, + {String? focus, AnnotationScale scale = _scale}) => + layoutAnnotations(annotations: a, scale: scale, focusId: focus); + +PlacedAnnotation _icon(AnnotationLayout l, String id) => + l.items.singleWhere((i) => i.id == id, + orElse: () => throw TestFailure( + '$id has no icon of its own; icons: ' + '${[for (final i in l.items) '${i.id}${i.memberIds}']}')); + +Iterable _allIds(AnnotationLayout l) => + [for (final i in l.items) ...i.memberIds, ...l.unplaced]; + +void _expectNoOverlapInBounds(AnnotationLayout l, double width) { + for (var i = 0; i < l.items.length; i++) { + final a = l.items[i]; + expect(a.iconLeft, greaterThanOrEqualTo(-1e-9)); + expect(a.footprintRight, lessThanOrEqualTo(width + 1e-9)); + for (var j = i + 1; j < l.items.length; j++) { + final b = l.items[j]; + expect( + a.footprintRight <= b.iconLeft + 1e-9 || + b.footprintRight <= a.iconLeft + 1e-9, + isTrue, + reason: '${a.id} overlaps ${b.id}'); + } + } +} + +void main() { + group('the kind', () { + test('a moon, and a colour no other kind uses', () { + expect(annotationIcon(AnnotationKind.mainSleep), LucideIcons.moon); + final others = [ + for (final k in AnnotationKind.values) + if (k != AnnotationKind.mainSleep) k + ]; + expect( + others.map(annotationIcon), + isNot(contains(annotationIcon(AnnotationKind.mainSleep)))); + expect( + others.map((k) => annotationColor(k).toARGB32()), + isNot(contains( + annotationColor(AnnotationKind.mainSleep).toARGB32()))); + }); + }); + + group('(1) never folded into a "+n" cluster', () { + test('a main-sleep point between two moments next to it keeps its own ' + 'icon, with no badge', () { + final l = _lay([_pt('a', 100), _sleepPt(110), _pt('b', 120)]); + final s = _icon(l, 'sleep'); + expect(s.memberIds, ['sleep']); + expect(s.more, 0, reason: 'no +n on the sleep icon'); + expect(s.x, 110, reason: 'its dashed line is on its true x'); + expect(_allIds(l).toSet(), {'a', 'sleep', 'b'}); + _expectNoOverlapInBounds(l, 300); + }); + + test('the other icons are nudged or clustered around it, not under it', + () { + final l = _lay([_pt('a', 100), _sleepPt(104), _pt('b', 108)]); + expect(_icon(l, 'sleep').memberIds, ['sleep']); + // a and b may share one icon (a "+1"), but never the sleep's. + for (final i in l.items) { + if (i.id != 'sleep') expect(i.memberIds, isNot(contains('sleep'))); + } + _expectNoOverlapInBounds(l, 300); + }); + + test('earlier than every neighbour (it would have been the OLDEST, shown ' + 'and swallowing the rest) it still stands alone', () { + final l = _lay([_sleepPt(95), _pt('a', 100), _pt('b', 108)]); + expect(_icon(l, 'sleep').memberIds, ['sleep']); + expect(_allIds(l).toSet(), {'sleep', 'a', 'b'}); + }); + + test('later than every neighbour (a plain point would be a "+n" member ' + 'behind an older icon) it still stands alone', () { + final l = _lay([_pt('a', 100), _pt('b', 108), _sleepPt(116)]); + final s = _icon(l, 'sleep'); + expect(s.memberIds, ['sleep']); + expect(s.more, 0); + expect(l.unplaced, isEmpty); + }); + + test('a crowd of 60 moments across the chart cannot swallow it', () { + final rnd = math.Random(11); + final crowd = [ + for (var i = 0; i < 60; i++) _pt('p$i', rnd.nextDouble() * 300), + _sleepPt(150), + ]; + final l = _lay(crowd); + final s = _icon(l, 'sleep'); + expect(s.memberIds, ['sleep']); + expect(l.unplaced, isNot(contains('sleep'))); + expect(_allIds(l).toSet(), {for (final a in crowd) a.id}); + _expectNoOverlapInBounds(l, 300); + }); + + test('as a range it is shaded, has one icon and members of nobody else', + () { + final l = _lay([_pt('a', 20), _sleep(0, 120), _pt('b', 30)]); + expect(l.shades.map((s) => s.id), ['sleep']); + expect(l.shades.one.kind, AnnotationKind.mainSleep); + expect(_icon(l, 'sleep').memberIds, ['sleep']); + expect(_icon(l, 'sleep').isRange, isTrue); + _expectNoOverlapInBounds(l, 300); + }); + + test('a night that began before the chart is clipped to its edge and ' + 'still stands alone next to a crowd at that edge', () { + final l = _lay([ + _pt('a', 2), + _sleep(-3600, 80), + _pt('b', 6), + _pt('c', 10), + ]); + final s = _icon(l, 'sleep'); + expect(s.memberIds, ['sleep']); + expect(l.shades.one.startsInside, isFalse); + expect(l.shades.one.endsInside, isTrue); + _expectNoOverlapInBounds(l, 300); + }); + }); + + group('(2) the last icon to be given up', () { + // A 60 px chart holds two icons, or one icon with a badge and one without. + const narrow = AnnotationScale(domainStart: 0, domainEnd: 60, width: 60); + + test('when the chart is too narrow for everything the others go, ' + 'not the sleep', () { + final l = _lay([_pt('a', 5), _pt('b', 25), _sleepPt(58)], scale: narrow); + expect(l.items.map((i) => i.id), contains('sleep'), + reason: 'the sleep keeps an icon'); + expect(l.unplaced, isNot(contains('sleep'))); + expect(l.unplaced.toSet(), {'a', 'b'}, + reason: 'the rest are REPORTED, never silently dropped'); + _expectNoOverlapInBounds(l, 60); + }); + + test('with several kinds in the way it is still the one that stays', () { + final l = _lay([ + _pt('a', 5, kind: AnnotationKind.water), + _pt('b', 20, kind: AnnotationKind.symptom), + _sleepPt(40), + _pt('c', 58, kind: AnnotationKind.journal), + ], scale: narrow); + expect(l.items.map((i) => i.id), contains('sleep')); + expect(l.unplaced, isNot(contains('sleep'))); + _expectNoOverlapInBounds(l, 60); + expect(_allIds(l).toSet(), {'a', 'b', 'sleep', 'c'}); + }); + + // The night outranks FOCUS too: a focused item keeps the fixed label slot, + // but its icon may be folded or reported unplaced to leave the night's. + for (final focus in ['a', 'b']) { + test('a scrub or a pin on "$focus" (a 2-moment cluster, 46 px) does not ' + 'push the night off a 60 px chart', () { + final l = _lay([_pt('a', 5), _pt('b', 25), _sleepPt(58)], + scale: narrow, focus: focus); + expect(l.items.map((i) => i.id), contains('sleep'), + reason: 'the night keeps its icon whatever is focused'); + expect(l.unplaced, isNot(contains('sleep'))); + expect(l.focusedId, focus); + expect(l.labelText, 'L-$focus', + reason: 'the focused item still owns the label slot'); + expect(_allIds(l).toSet(), {'a', 'b', 'sleep'}, + reason: 'nothing vanishes: reported unplaced at worst'); + _expectNoOverlapInBounds(l, 60); + }); + } + + test('a focused lone moment is given up before the night, and still ' + 'labels', () { + // 40 px: two 24 px icons cannot both fit. + const tiny = AnnotationScale(domainStart: 0, domainEnd: 40, width: 40); + final l = + _lay([_pt('a', 5), _sleepPt(36)], scale: tiny, focus: 'a'); + expect(l.items.map((i) => i.id), ['sleep']); + expect(l.unplaced, ['a']); + expect(l.labelText, 'L-a'); + }); + + test('a night that is a range is held the same way', () { + final l = _lay([_pt('a', 5), _pt('b', 25), _sleep(40, 59)], + scale: narrow, focus: 'b'); + expect(l.items.map((i) => i.id), contains('sleep')); + expect(l.shades.map((s) => s.id), ['sleep']); + expect(l.labelText, 'L-b'); + }); + }); + + group('(3) its label is the default label', () { + final night = _sleep(0, 90, id: 'sleep'); + + test('nothing focused: the label is the main sleep\'s, nothing is focused', + () { + final l = _lay([_pt('a', 150), night]); + expect(l.focusedId, isNull); + expect(l.labelText, 'Main sleep L-sleep'); + expect(l.labelSlot, AnnotationLabelSlot.topStart, + reason: 'the same one fixed slot'); + expect(l.items.every((i) => !i.focused), isTrue, + reason: 'a default label is not a focus: no bold icon'); + }); + + test('focusing another item replaces it', () { + final l = _lay([_pt('a', 150), night], focus: 'a'); + expect(l.focusedId, 'a'); + expect(l.labelText, 'L-a'); + }); + + test('focusing the sleep itself shows the same label, now focused', () { + final l = _lay([_pt('a', 150), night], focus: 'sleep'); + expect(l.focusedId, 'sleep'); + expect(l.labelText, 'Main sleep L-sleep'); + expect(_icon(l, 'sleep').focused, isTrue); + }); + + test('a focus id that is not on the chart falls back to the default', + () { + final l = _lay([_pt('a', 150), night], focus: 'ghost'); + expect(l.focusedId, isNull); + expect(l.labelText, 'Main sleep L-sleep'); + }); + + test('works for a night that is only a point as well', () { + final l = _lay([_pt('a', 150), _sleepPt(100)]); + expect(l.labelText, 'Main sleep L-sleep'); + }); + + test('a night clipped at the chart\'s start still labels', () { + final l = _lay([_sleep(-7200, 60)]); + expect(l.labelText, 'Main sleep L-sleep'); + }); + + test('NO night: no default label, however many other items', () { + final l = _lay([_pt('a', 100), _pt('b', 200)]); + expect(l.focusedId, isNull); + expect(l.labelText, isNull, reason: 'nothing focused, nothing said'); + expect(_lay(const []).labelText, isNull); + }); + + test('a night wholly off the chart is not on it: no label', () { + expect(_lay([_pt('a', 100), _sleep(-9000, -100)]).labelText, isNull); + expect(_lay([_pt('a', 100), _sleep(400, 900)]).labelText, isNull); + expect(_lay([_pt('a', 100), _sleepPt(-5)]).labelText, isNull); + }); + + test('other kinds never get a default label of their own', () { + for (final k in AnnotationKind.values) { + if (k == AnnotationKind.mainSleep) continue; + expect(_lay([_pt('x', 100, kind: k)]).labelText, isNull, + reason: k.name); + } + }); + }); + + group('everything else behaves as before', () { + test('a nap has no priority: a nap point folds into a neighbour like ' + 'any other point, and a nap range is its own shaded icon beside the ' + 'night', () { + final l = _lay([ + _pt('n1', 100, kind: AnnotationKind.nap), + _pt('b', 110), + _sleep(0, 60), + ChartAnnotation( + id: 'n2', + kind: AnnotationKind.nap, + at: 200, + until: 230, + label: 'L-n2'), + ]); + final folded = _pick(l.items, (i) => i.memberIds.contains('n1')); + expect(folded.memberIds, ['n1', 'b']); + expect(folded.more, 1); + expect(l.shades.map((s) => s.id).toSet(), {'sleep', 'n2'}); + expect(_icon(l, 'n2').memberIds, ['n2']); + expect(_icon(l, 'sleep').memberIds, ['sleep']); + expect(l.labelText, 'Main sleep L-sleep'); + }); + + test('two points close together still collapse to one icon with +1', () { + final l = _lay([_pt('a', 100), _pt('b', 110), _sleep(200, 250)]); + final c = _pick(l.items, (i) => i.memberIds.contains('a')); + expect(c.memberIds, ['a', 'b']); + expect(c.more, 1); + }); + }); +} From d0d4428597c8be15ba668f68a4cf19f751abef44 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sat, 7 Nov 2026 05:50:14 -0700 Subject: [PATCH 02/23] feat(charts): annotation lane on ChartFrame Icon and dashed-line marks, shaded ranges and +n clusters laid out by a pure engine in chart_annotations.dart. ChartFrame draws the lane above the plot in its own RepaintBoundary; with no annotations it renders as before. --- lib/ui2/chart_annotations.dart | 779 ++++++++++++++++++++++++++++++--- lib/ui2/grammar.dart | 50 ++- 2 files changed, 752 insertions(+), 77 deletions(-) diff --git a/lib/ui2/chart_annotations.dart b/lib/ui2/chart_annotations.dart index f2f856310..0350e3026 100644 --- a/lib/ui2/chart_annotations.dart +++ b/lib/ui2/chart_annotations.dart @@ -1,20 +1,38 @@ -// CHART ANNOTATIONS - RED-PHASE STUBS. Every function and widget here is inert -// on purpose: the red tests (test/chart_annotation_*_test.dart, -// test/main_sleep_annotation_layout_test.dart, test/graphs_*_test.dart) must -// compile and then FAIL ON ASSERTIONS. The green phase replaces this file with -// the real layout (see edge.research/sol/graphs/scope-map.md, section 3). +// CHART ANNOTATIONS — journal items and provenance marks drawn on a chart. // -// The intended behaviour, for the implementer: -// * An item is an ICON and a DASHED vertical line on its true x. -// * Icons never overlap: close unlinked points collapse to one icon with "+n"; -// a tap on the icon steps through the members. -// * A linked start/end pair is a SHADED range with one icon and two edges. -// * The main sleep is a priority mark: never in a "+n", the last icon given up. -// * An unusable scale (zero width, empty domain) lays out nothing. +// One pure layout function turns "things that happened" plus "how many pixels +// the chart has" plus "what the finger is on" into placed icons, "+n" clusters, +// shaded ranges and ONE static label. The widgets only draw what it returns. +// +// THE RULES (each is pinned by test/chart_annotation_*_test.dart): +// • An item is an ICON and a DASHED vertical line. The line is always on the +// item's true x; only the icon is ever nudged to keep icons apart. +// • Icons never overlap. Unlinked points closer than an icon's width collapse +// to ONE icon — the focused member, else the OLDEST — with "+n" right after +// it. If the chart is still too crowded the clusters widen; if it is too +// narrow for even that, ids are reported in `unplaced`, never dropped. +// • A linked start/end pair is a SHADED AREA in its kind's colour with ONE icon +// at its start and dashed lines at both ends. Ranges never cluster. +// • An algorithm-version mark is provenance, not an event: it keeps its own +// icon (never clustered, with journal items or other marks) but is nudged +// like any other icon. +// • The label of the focused item is drawn in ONE fixed place; it never +// follows the finger. With nothing focused that place says the main sleep's +// label (when there is one on the chart). With several main sleeps on the +// chart, the first by x, then oldest, then id. +// • The main sleep has priority: never in a "+n", the last icon given up. +// • Absent is absent: nothing outside the plot, nothing non-finite and no +// range with a nonsense end is ever drawn or invented. +// • The positions are in the chart's own x domain (epoch seconds, slot index), +// so a 23-hour DST day is just a shorter domain; nothing here assumes 86400. + +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; +import '../data/day_label.dart' show dayLabelOf; +import 'grammar.dart' show Pressable; import 'theme.dart'; /// What an annotation is. One icon and one colour per kind. @@ -25,20 +43,58 @@ enum AnnotationKind { symptom, workout, nap, + + /// The night's main sleep, onset to wake (design: day timeline, owner-approved + /// 2026-10-08). The one kind with PRIORITY: never folded into a "+n" cluster, + /// never the icon given up first, and its label is the lane's default label + /// while nothing is focused. mainSleep, + + /// A nap/workout range created by the marked-moment review. + // TODO(moments-review): no producer yet — it arrives with the + // feature/moments-review branch (not merged). Keep the kind so its colour and + // icon are settled before then. review, journal, algoVersion, } -/// The glyph for [k]. STUB: one glyph for every kind. -IconData annotationIcon(AnnotationKind k) => LucideIcons.circle; - -/// The pigment for [k]. STUB: one colour for every kind. -Color annotationColor(AnnotationKind k) => C.n400; - -/// One thing to mark, in the chart's own x domain (epoch seconds, or a slot -/// index on a daily chart). A non-null [until] greater than [at] is a range. +/// The glyph for [k]. Unique per kind. +IconData annotationIcon(AnnotationKind k) => switch (k) { + AnnotationKind.water => LucideIcons.glassWater, + AnnotationKind.assumedWater => LucideIcons.droplet, + AnnotationKind.moment => LucideIcons.bookmark, + AnnotationKind.symptom => LucideIcons.heartPulse, + AnnotationKind.workout => LucideIcons.dumbbell, + AnnotationKind.nap => LucideIcons.bedDouble, + AnnotationKind.mainSleep => LucideIcons.moon, + AnnotationKind.review => LucideIcons.clipboardCheck, + AnnotationKind.journal => LucideIcons.notebookPen, + AnnotationKind.algoVersion => LucideIcons.gitCommitVertical, + }; + +/// The pigment for [k]. Unique per kind; a range's shade is this at low alpha. +Color annotationColor(AnnotationKind k) => switch (k) { + AnnotationKind.water => C.sky, + AnnotationKind.assumedWater => C.blueSoft, + AnnotationKind.moment => C.teal, + AnnotationKind.symptom => C.red, + AnnotationKind.workout => C.orange, + AnnotationKind.nap => C.indigo, + // Pink, not a blue: the Asleep band this replaces was blue, and a range + // shade in a blue would read as that band coming back. + AnnotationKind.mainSleep => C.pink, + AnnotationKind.review => C.purple, + AnnotationKind.journal => C.yellow, + // Neutral on purpose: a version change is provenance, not an event that + // happened to the person. + AnnotationKind.algoVersion => C.n400, + }; + +/// One thing to mark. [at] is in the chart's own x domain (epoch seconds on an +/// intraday chart, slot index on a daily one) — the layout never assumes a +/// day is 86400 s. A non-null [until] greater than [at] makes it a RANGE (a +/// linked start/end pair); anything else is a point. @immutable class ChartAnnotation { const ChartAnnotation({ @@ -67,20 +123,29 @@ class AnnotationScale { }); final double domainStart; final double domainEnd; + + /// Plot width in logical pixels. final double width; final double iconWidth; + + /// Extra footprint the "+n" badge takes right after a cluster's icon. final double badgeWidth; - /// STUB: always 0. - double xOf(double at) => 0; + /// Pixel x of domain value [at] (not clamped). + double xOf(double at) { + final span = domainEnd - domainStart; + if (!(span > 0)) return 0; + return (at - domainStart) * width / span; + } - /// STUB: never usable. - bool get usable => false; + bool get usable => + width > 0 && width.isFinite && domainEnd > domainStart && domainStart.isFinite && domainEnd.isFinite; } +/// Where the one label goes. Static: it never follows the finger. enum AnnotationLabelSlot { topStart } -/// One icon after layout, possibly standing for a cluster. +/// One icon on the lane. @immutable class PlacedAnnotation { const PlacedAnnotation({ @@ -96,29 +161,40 @@ class PlacedAnnotation { required this.isRange, }); + /// The annotation this icon shows (the focused one, else the oldest). final String id; final AnnotationKind kind; final String label; + + /// Where the dashed line is: the displayed item's true position. final double x; + + /// Left edge of the icon box after collision nudging. The line stays at [x]. final double iconLeft; final double iconWidth; final double badgeWidth; + + /// Every annotation this icon stands for, OLDEST FIRST. One for a lone item. final List memberIds; final bool focused; final bool isRange; - /// How many more items the icon stands for ("+n"). + /// The "+n": members not shown. Zero means no badge. int get more => memberIds.length - 1; + /// Right edge of everything this icon takes (icon plus badge). double get footprintRight => iconLeft + iconWidth + (more > 0 ? badgeWidth : 0); - /// The member a tap on the icon moves to after [current] (wraps). - /// STUB: never moves. - String next(String? current) => memberIds.first; + /// The member after [current] in this icon, wrapping; the first member if + /// [current] is not one of them. + String next(String? current) { + final i = current == null ? -1 : memberIds.indexOf(current); + return memberIds[(i + 1) % memberIds.length]; + } } -/// A shaded span for a linked start/end pair. +/// The shaded area of a range, clipped to the plot. @immutable class ShadedRange { const ShadedRange({ @@ -135,11 +211,14 @@ class ShadedRange { final double left; final double right; final bool focused; + + /// Whether the range's real start / end are on the plot (not cut off by its + /// edge). A cut edge gets no dashed line: a line at the edge would claim the + /// range began or ended there. final bool startsInside; final bool endsInside; } -/// Everything the lane draws. @immutable class AnnotationLayout { const AnnotationLayout({ @@ -151,68 +230,342 @@ class AnnotationLayout { this.labelSlot = AnnotationLabelSlot.topStart, }); + /// Icons, left to right. Their footprints never overlap and stay in bounds. final List items; + + /// One per range that is visible at all, whether or not it got an icon. final List shades; + + /// Ids that are in the plot but could not get any icon (the chart is too + /// narrow). Never silently dropped. final List unplaced; + + /// The annotation that is focused, or null. final String? focusedId; + + /// The static label's text — the focused annotation's label, else the main + /// sleep's label when one is on the chart, else null. With several main + /// sleeps on the chart the default is the first by x, then oldest, then id. final String? labelText; final AnnotationLabelSlot labelSlot; } -/// STUB: lays out nothing. +// ── layout ────────────────────────────────────────────────────────────────── + +/// An annotation that is on the plot, with its pixel geometry. +class _Vis { + _Vis(this.a, this.x, this.right, this.startsInside, this.endsInside); + final ChartAnnotation a; + + /// Pixel x of the first VISIBLE instant (the icon and the line). + final double x; + + /// Pixel x of the last visible instant for a range, else null. + final double? right; + final bool startsInside; + final bool endsInside; + + bool get isRange => right != null; + + /// Ranges, version marks and the main sleep keep their own icon: they never + /// cluster. The main sleep is solo even as a point (a night with no usable + /// end), where a plain point would fold into a neighbour's "+n". + bool get solo => + isRange || + a.kind == AnnotationKind.algoVersion || + a.kind == AnnotationKind.mainSleep; +} + +int _byTime(ChartAnnotation a, ChartAnnotation b) { + final c = a.at.compareTo(b.at); + return c != 0 ? c : a.id.compareTo(b.id); +} + +int _byX(_Vis a, _Vis b) { + final c = a.x.compareTo(b.x); + return c != 0 ? c : _byTime(a.a, b.a); +} + +List<_Vis> _visible(List items, AnnotationScale s) { + // A growable list, not `const []`: the caller sorts it in place. An unusable + // scale (no width, empty or inverted domain) shows nothing. + if (!s.usable) return <_Vis>[]; + final out = <_Vis>[]; + for (final a in items) { + if (!a.at.isFinite) continue; + final u = a.until; + if (u != null && u.isFinite && u > a.at) { + if (u < s.domainStart || a.at > s.domainEnd) continue; + final l = math.max(a.at, s.domainStart), r = math.min(u, s.domainEnd); + out.add(_Vis(a, s.xOf(l), s.xOf(r), a.at >= s.domainStart, + u <= s.domainEnd)); + } else { + if (a.at < s.domainStart || a.at > s.domainEnd) continue; + out.add(_Vis(a, s.xOf(a.at), null, true, true)); + } + } + return out; +} + +/// One icon being placed. +class _Icon { + _Icon(this.members, this.shown, this.fw); + final List<_Vis> members; // oldest first + final _Vis shown; + final double fw; // footprint width: icon (+ badge) + double left = 0; +} + +/// Left-to-right push, right-to-left pull. Returns whether everything fits. +bool _place(List<_Icon> icons, double width, double iconWidth) { + var prevRight = 0.0; + for (final i in icons) { + final desired = i.shown.x - iconWidth / 2; + double l = desired.clamp(0.0, math.max(0.0, width - i.fw)).toDouble(); + if (l < prevRight) l = prevRight; + i.left = l; + prevRight = l + i.fw; + } + var limit = width; + for (var k = icons.length - 1; k >= 0; k--) { + final i = icons[k]; + if (i.left + i.fw > limit) i.left = limit - i.fw; + limit = i.left; + } + return icons.isEmpty || icons.first.left >= -1e-9; +} + +/// THE LAYOUT. Pure. See the file header for the rules. AnnotationLayout layoutAnnotations({ required List annotations, required AnnotationScale scale, String? focusId, -}) => - const AnnotationLayout( - items: [], - shades: [], - unplaced: [], - focusedId: null, - labelText: null, - ); +}) { + final vis = _visible(annotations, scale)..sort(_byX); + final byId = {for (final v in vis) v.a.id: v}; + final focusedId = focusId != null && byId.containsKey(focusId) ? focusId : null; + final solos = [for (final v in vis) if (v.solo) v]; + final points = [for (final v in vis) if (!v.solo) v]; + + // Point clusters, widening until everything fits: a cluster carries a "+n" + // badge, so the first pass (an icon's width) can leave too little room. + var t = scale.iconWidth; + late List<_Icon> icons; + for (;;) { + final groups = >[]; + double anchor = 0; + for (final p in points) { + if (groups.isEmpty || p.x - anchor >= t) { + groups.add([p]); + anchor = p.x; + } else { + groups.last.add(p); + } + } + icons = [ + for (final g in groups) _iconOf(g, focusedId, byId, scale), + for (final s in solos) _iconOf([s], focusedId, byId, scale), + ]..sort((a, b) => _byX(a.shown, b.shown)); + if (_place(icons, scale.width, scale.iconWidth)) break; + if (t > scale.width + scale.iconWidth) break; // all points are one cluster + t += scale.badgeWidth; + } + + // Still no room: give up icons from the right. The main sleep goes LAST, of + // every other icon, the focused one included (a focused item keeps the fixed + // label slot whether or not its icon survives). Among the rest the focused + // one goes last. Their ids are reported, and a range keeps its shade. + final unplaced = []; + bool holdsFocus(_Icon i) => i.members.any((m) => m.a.id == focusedId); + bool holdsNight(_Icon i) => + i.members.any((m) => m.a.kind == AnnotationKind.mainSleep); + while (!_place(icons, scale.width, scale.iconWidth)) { + var drop = icons.lastIndexWhere((i) => !holdsFocus(i) && !holdsNight(i)); + if (drop < 0) drop = icons.lastIndexWhere((i) => !holdsNight(i)); + if (drop < 0) drop = icons.length - 1; + unplaced.addAll([for (final m in icons[drop].members) m.a.id]); + icons.removeAt(drop); + } + + final placed = [ + for (final i in icons) + PlacedAnnotation( + id: i.shown.a.id, + kind: i.shown.a.kind, + label: i.shown.a.label, + x: i.shown.x, + iconLeft: i.left, + iconWidth: scale.iconWidth, + badgeWidth: scale.badgeWidth, + memberIds: [for (final m in i.members) m.a.id], + focused: i.shown.a.id == focusedId, + isRange: i.shown.isRange, + ), + ]; + return AnnotationLayout( + items: placed, + shades: [ + for (final v in vis) + if (v.isRange) + ShadedRange( + id: v.a.id, + kind: v.a.kind, + left: v.x, + right: v.right!, + focused: v.a.id == focusedId, + startsInside: v.startsInside, + endsInside: v.endsInside, + ), + ], + unplaced: unplaced, + focusedId: focusedId, + labelText: focusedId != null + ? byId[focusedId]!.a.label + // Nothing focused: the night's own label is the default, if the night + // is on the chart at all. Never invented. + : [ + for (final v in vis) + if (v.a.kind == AnnotationKind.mainSleep) v.a.label + ].firstOrNull, + ); +} -/// The annotation the finger is on (nearest, a range when inside it). -/// STUB: nothing. +_Icon _iconOf(List<_Vis> g, String? focusedId, Map byId, + AnnotationScale s) { + final members = [...g]..sort((a, b) => _byTime(a.a, b.a)); + final shown = focusedId != null && members.any((m) => m.a.id == focusedId) + ? byId[focusedId]! + : members.first; + return _Icon(members, shown, + s.iconWidth + (members.length > 1 ? s.badgeWidth : 0)); +} + +/// The annotation a scrub at [cursorPx] is ON or NEAREST to, or null (no +/// cursor, nothing to pick, or farther than [reach] px when given). String? pickAnnotationFocus({ required List annotations, required AnnotationScale scale, required double? cursorPx, double? reach, -}) => - null; +}) { + if (cursorPx == null || !cursorPx.isFinite) return null; + _Vis? best; + double bestD = 0; + for (final v in _visible(annotations, scale)) { + final r = v.right; + final d = r != null && cursorPx >= v.x && cursorPx <= r + ? 0.0 + : r != null && cursorPx > r + ? cursorPx - r + : (v.x - cursorPx).abs(); + var better = best == null || d < bestD; + if (!better && d == bestD) { + // Equally near: a point is more specific than a range, then the older. + if (v.isRange != best.isRange) { + better = !v.isRange; + } else { + better = _byTime(v.a, best.a) < 0; + } + } + if (better) { + best = v; + bestD = d; + } + } + if (best == null) return null; + if (reach != null && bestD > reach) return null; + return best.a.id; +} -/// Moves the focus [delta] stops through the laid-out members (clamped). -/// STUB: nothing. +/// Steps the focus [delta] places through every annotation in layout order +/// (icons left to right, a cluster's members oldest to newest). Clamped at the +/// ends; an unknown [current] counts as no focus. From no focus a positive +/// [delta] lands on the first stop and a negative one on the last. A [delta] of +/// 0 is not a step: from no focus it returns the LAST stop (current behaviour, +/// not a promise; callers step by +1 or -1). String? stepAnnotationFocus( AnnotationLayout layout, String? current, int delta, -) => - null; +) { + final seq = [for (final i in layout.items) ...i.memberIds]; + if (seq.isEmpty) return null; + final at = current == null ? -1 : seq.indexOf(current); + final base = at >= 0 ? at : (delta > 0 ? -1 : seq.length); + return seq[(base + delta).clamp(0, seq.length - 1)]; +} -/// An algorithm-version change as annotations in the daily slot domain. -/// STUB: none. +// ── sources ───────────────────────────────────────────────────────────────── + +/// An algorithm-version change as annotations, in the daily slot domain +/// (slot i is at i, the last slot is today). [breakDaysBehind] is each break's +/// stamp as "days behind today" — the first day computed the new way. The mark +/// sits half a slot LEFT of it, and a break at slot 0 (nothing before it in +/// the window) or outside the window is dropped. List algoBreakAnnotations({ required List breakDaysBehind, required int seriesLength, required String label, -}) => - const []; +}) { + if (seriesLength < 2) return const []; + final seen = {}; + return [ + for (final b in breakDaysBehind) + if (b >= 0 && b < seriesLength && seriesLength - 1 - b > 0 && seen.add(b)) + ChartAnnotation( + id: 'algo:$b', + kind: AnnotationKind.algoVersion, + at: seriesLength - 1 - b - .5, + label: label, + ), + ]; +} -/// Intraday annotations (epoch seconds) onto a daily chart's slots. -/// STUB: none. +/// Intraday annotations (domain: epoch seconds) onto a daily chart whose slots +/// are the local day [dayLabels] ('YYYY-MM-DD', oldest first). Slot i is at +/// domain i. A point lands on its LOCAL day; a range inside one day is that +/// day's point; a range across days spans its first to its last day's slot. A +/// bound outside the window is kept as slot -1 / length, not clamped, so the +/// layout can tell a range that ended at the edge from one that ran past it. Anything outside the labels is dropped. Never +/// invents an item. List dailyAnnotations( List timed, List dayLabels, -) => - const []; - -/// Reads the annotations for the local days [from]..[to] inclusive -/// ('YYYY-MM-DD'), in epoch seconds. Failure is an empty list, never null. -typedef AnnotationLoader = Future> Function( - String from, String to); +) { + if (dayLabels.isEmpty) return const []; + final slot = {for (var i = 0; i < dayLabels.length; i++) dayLabels[i]: i}; + String dayOf(double sec) => + dayLabelOf(DateTime.fromMillisecondsSinceEpoch((sec * 1000).round())); + // -1: before the window; length: after it; else the slot. + int? slotOf(double sec) { + if (!sec.isFinite) return null; + final d = dayOf(sec); + if (d.compareTo(dayLabels.first) < 0) return -1; + if (d.compareTo(dayLabels.last) > 0) return dayLabels.length; + return slot[d]; + } + + final out = []; + for (final a in timed) { + final s = slotOf(a.at); + if (s == null) continue; + final u = a.until; + final e = u != null && u.isFinite && u > a.at ? slotOf(u) : null; + if (e != null && e < 0) continue; // the whole range is before the window + if (s >= dayLabels.length) continue; + if (e == null && s < 0) continue; + // Off-window bounds (-1 / length) are KEPT: the layout clips the shade and + // draws no dashed edge on a side that lies outside the window. + out.add(ChartAnnotation( + id: a.id, + kind: a.kind, + at: s.toDouble(), + until: e != null && e > s ? e.toDouble() : null, + label: a.label, + )); + } + return out; +} /// What a chart is annotated with. @immutable @@ -226,9 +579,15 @@ class AnnotationSet { final List items; final double domainStart; final double domainEnd; + + /// How far (px) the scrub may be from an item and still focus it; null means + /// the nearest item is focused however far away. final double? reach; } +// ── drawing ───────────────────────────────────────────────────────────────── + +/// One dashed line, as the painter draws it. @immutable class AnnotationLine { const AnnotationLine({ @@ -247,7 +606,7 @@ class AnnotationLine { final bool dashed; } -/// STUB: paints nothing. +/// Paints the dashed lines. Public so tests can read what it will draw. class AnnotationLinesPainter extends CustomPainter { const AnnotationLinesPainter({required this.lines}); final List lines; @@ -255,23 +614,77 @@ class AnnotationLinesPainter extends CustomPainter { static const double dash = 4, gap = 3; @override - void paint(Canvas canvas, Size size) {} + void paint(Canvas canvas, Size size) { + if (size.width <= 0 || size.height <= 0) return; + for (final l in lines) { + final paint = Paint() + ..color = l.color + ..strokeWidth = l.strokeWidth; + final x = l.x.clamp(l.strokeWidth / 2, size.width - l.strokeWidth / 2); + if (!l.dashed) { + canvas.drawLine(Offset(x, 0), Offset(x, size.height), paint); + continue; + } + for (var y = 0.0; y < size.height; y += dash + gap) { + canvas.drawLine( + Offset(x, y), Offset(x, math.min(y + dash, size.height)), paint); + } + } + } @override - bool shouldRepaint(covariant AnnotationLinesPainter old) => false; + bool shouldRepaint(covariant AnnotationLinesPainter old) { + if (old.lines.length != lines.length) return true; + for (var i = 0; i < lines.length; i++) { + final a = lines[i], b = old.lines[i]; + if (a.x != b.x || + a.color != b.color || + a.strokeWidth != b.strokeWidth || + a.dashed != b.dashed) { + return true; + } + } + return false; + } } -/// STUB: an empty box. +/// One kind's glyph at the lane's icon size. [bold] is the focused look: a +/// filled disc and a ring instead of a tint. The footprint never changes. class AnnotationIcon extends StatelessWidget { const AnnotationIcon({super.key, required this.kind, required this.bold}); final AnnotationKind kind; final bool bold; @override - Widget build(BuildContext context) => const SizedBox.shrink(); + Widget build(BuildContext context) { + final p = P.of(context); + final accent = annotationColor(kind); + final ink = p.on(accent); + return SizedBox( + width: ChartAnnotationLane.iconSize, + height: ChartAnnotationLane.iconSize, + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: bold ? p.fill(accent) : ink.withValues(alpha: .14), + border: Border.all(color: ink, width: bold ? 2 : 0), + ), + child: Center( + child: Icon(annotationIcon(kind), + size: bold ? 15 : 13, color: bold ? p.inkOnFill : ink), + ), + ), + ); + } } -/// STUB: draws nothing. +/// The annotation lane: icons (and "+n"), the static label, the shaded ranges +/// and the dashed lines running [plotHeight] down through the plot. Width is +/// whatever it is given; the layout is computed from it. +/// +/// [cursor] is the scrub position 0…1 (null: none). A tap on an icon or its +/// "+n" steps to the next member of that icon, and a moved [cursor] clears +/// that pin. class ChartAnnotationLane extends StatefulWidget { const ChartAnnotationLane({ super.key, @@ -284,18 +697,40 @@ class ChartAnnotationLane extends StatefulWidget { final double plotHeight; static const double iconSize = 24; + + /// The label row at 1x text: the least the row ever is. At a larger text + /// scale use [labelRowHeight], which is what the lane really reserves. static const double labelHeight = 16; + + /// Everything above the plot at 1x text: the label row, then the icon row. + /// Use [headerFor] wherever the text scale is known. static const double header = labelHeight + iconSize; - static TextStyle labelStyle() => F.cap; + /// The style of the static label. One place, so the row is sized from the + /// very style it draws with. + static TextStyle labelStyle() => + F.cap.copyWith(fontWeight: FontWeight.w700, height: 1); + /// The row reserved for the static label (always there, so focus never + /// changes the lane's height): one line of [labelStyle] at the ambient + /// [scaler], never less than [labelHeight]. Sized from the real line height + /// so a large-text setting cannot clip the default label. static double labelRowHeight(TextScaler scaler, - [TextDirection direction = TextDirection.ltr]) => - labelHeight; - + [TextDirection direction = TextDirection.ltr]) { + final tp = TextPainter( + text: TextSpan(text: 'Ag', style: labelStyle()), + textDirection: direction, + textScaler: scaler, + maxLines: 1, + )..layout(); + return math.max(labelHeight, tp.height.ceilToDouble()); + } + + /// Everything above the plot at [scaler]: the label row, then the icon row. + /// This is what pushes the plot down, so the dashed lines and icons stay on it. static double headerFor(TextScaler scaler, [TextDirection direction = TextDirection.ltr]) => - header; + labelRowHeight(scaler, direction) + iconSize; static const laneKey = ValueKey('annotation-lane'); static const linesKey = ValueKey('annotation-lines'); @@ -309,7 +744,205 @@ class ChartAnnotationLane extends StatefulWidget { State createState() => _ChartAnnotationLaneState(); } +/// How far a 44 pt tap target overhangs the 24 pt icon on each side. +const double _tap = 44; +const double _overhang = (_tap - ChartAnnotationLane.iconSize) / 2; + class _ChartAnnotationLaneState extends State { + /// The member a tap on an icon stepped to. Cleared when the cursor moves. + String? _pin; + + @override + void didUpdateWidget(covariant ChartAnnotationLane old) { + super.didUpdateWidget(old); + if (old.cursor != widget.cursor) _pin = null; + } + @override - Widget build(BuildContext context) => const SizedBox.shrink(); + Widget build(BuildContext context) { + final p = P.of(context); + final set = widget.set; + final labelH = ChartAnnotationLane.labelRowHeight( + MediaQuery.textScalerOf(context), Directionality.of(context)); + final header = labelH + ChartAnnotationLane.iconSize; + return LayoutBuilder(builder: (context, box) { + final width = box.maxWidth.isFinite ? box.maxWidth : 0.0; + final scale = AnnotationScale( + domainStart: set.domainStart, domainEnd: set.domainEnd, width: width); + final cur = widget.cursor; + final cursorPx = cur == null ? null : cur.clamp(0.0, 1.0) * width; + AnnotationLayout layoutFor(String? f) => layoutAnnotations( + annotations: set.items, scale: scale, focusId: f); + final picked = pickAnnotationFocus( + annotations: set.items, + scale: scale, + cursorPx: cursorPx, + reach: set.reach); + var layout = layoutFor(_pin ?? picked); + if (_pin != null && layout.focusedId != _pin) layout = layoutFor(picked); + + final lines = []; + final shadeFor = {for (final s in layout.shades) s.id: s}; + for (final s in layout.shades) { + // Both ends of a range are dashed lines; a cut end is not an end. + final c = p.on(annotationColor(s.kind)); + if (s.endsInside) { + lines.add(AnnotationLine( + id: '${s.id}:end', + x: s.right, + color: c, + bold: s.focused, + strokeWidth: s.focused ? 2 : 1)); + } + } + for (final i in layout.items) { + final s = shadeFor[i.id]; + if (i.isRange && s != null && !s.startsInside) continue; + lines.add(AnnotationLine( + id: i.id, + x: i.x, + color: p.on(annotationColor(i.kind)), + bold: i.focused, + strokeWidth: i.focused ? 2 : 1)); + } + // Items with a shade but no icon still get their edges. + for (final s in layout.shades) { + if (layout.items.any((i) => i.id == s.id) || !s.startsInside) continue; + lines.add(AnnotationLine( + id: s.id, + x: s.left, + color: p.on(annotationColor(s.kind)), + bold: s.focused, + strokeWidth: s.focused ? 2 : 1)); + } + + final byId = {for (final a in set.items) a.id: a}; + final said = [ + for (final id in [ + for (final i in layout.items) ...i.memberIds, + ...layout.unplaced, + ]) + if (byId[id] != null) byId[id]!, + ]..sort(_byTime); + + void step(PlacedAnnotation i) => + setState(() => _pin = i.next(layout.focusedId)); + + final children = [ + for (final s in layout.shades) + Positioned( + left: s.left, + width: math.max(s.right - s.left, 1), + top: header, + height: widget.plotHeight, + child: IgnorePointer( + child: ColoredBox( + key: ChartAnnotationLane.shadeKey(s.id), + color: annotationColor(s.kind) + .withValues(alpha: s.focused ? .26 : .14), + ), + ), + ), + if (lines.isNotEmpty) + Positioned( + left: 0, + right: 0, + top: header, + height: widget.plotHeight, + child: IgnorePointer( + child: CustomPaint( + key: ChartAnnotationLane.linesKey, + painter: AnnotationLinesPainter(lines: lines), + ), + ), + ), + if (layout.labelText != null) + Positioned( + left: 0, + right: 0, + top: 0, + height: labelH, + child: IgnorePointer( + child: Align( + alignment: Alignment.centerLeft, + child: Text( + layout.labelText!, + key: ChartAnnotationLane.labelKey, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: ChartAnnotationLane.labelStyle() + .copyWith(color: p.ink), + ), + ), + ), + ), + for (final i in layout.items) ...[ + // The tap target is 44 pt (Pressable) around a 24 pt icon: it + // overhangs the icon's box rather than pushing the lane apart. + Positioned( + left: i.iconLeft - _overhang, + top: labelH - _overhang, + child: Pressable( + onTap: () => step(i), + child: AnnotationIcon( + key: ChartAnnotationLane.iconKey(i.id), + kind: i.kind, + bold: i.focused), + ), + ), + if (i.more > 0) + Positioned( + left: i.iconLeft + i.iconWidth - (_tap - i.badgeWidth) / 2, + top: labelH - _overhang, + child: Pressable( + onTap: () => step(i), + child: SizedBox( + width: i.badgeWidth, + height: ChartAnnotationLane.iconSize, + child: Align( + alignment: Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.only(left: 2), + child: FittedBox( + fit: BoxFit.scaleDown, + child: Text( + '+${i.more}', + key: ChartAnnotationLane.moreKey(i.id), + maxLines: 1, + style: F.over.copyWith( + color: p.ink3, + fontWeight: i.focused + ? FontWeight.w700 + : FontWeight.w500), + ), + ), + ), + ), + ), + ), + ), + ], + ]; + + return RepaintBoundary( + key: ChartAnnotationLane.boundaryKey, + child: Semantics( + container: true, + excludeSemantics: true, + label: said.isEmpty ? null : said.map((a) => a.label).join(', '), + child: SizedBox( + key: ChartAnnotationLane.laneKey, + height: header + widget.plotHeight, + width: double.infinity, + child: Stack(clipBehavior: Clip.none, children: children), + ), + ), + ); + }); + } } + +/// Reads the annotations for the local days [from]..[to] inclusive +/// ('YYYY-MM-DD'), in epoch seconds. Failure is an empty list, never null. +typedef AnnotationLoader = Future> Function( + String from, String to); diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index 69906728d..ac2210e40 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -41,7 +41,7 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../data/journal_fields.dart' show formatMinuteOfDay; import '../models/metric.dart'; -import 'chart_annotations.dart' show AnnotationSet; +import 'chart_annotations.dart' show AnnotationSet, ChartAnnotationLane; import 'charts.dart'; import 'scroll_hint.dart'; import 'theme.dart'; @@ -2269,11 +2269,11 @@ class ChartFrame extends StatelessWidget { /// The band's ink. Defaults to a wash of the muted ink. final Color? bandColor; - /// Icon + dashed-line marks over the plot. RED STUB: accepted, not drawn. + /// Icon + dashed-line marks over the plot, in a lane above it. final AnnotationSet? annotations; /// Where the finger is, 0..1 across the plot, so the nearest annotation can - /// be the focused one. RED STUB: accepted, not used. + /// be the focused one. final double? annotationCursor; const ChartFrame({ @@ -2309,6 +2309,43 @@ class ChartFrame extends StatelessWidget { return tp.size; } + /// [plot] with the annotation lane over it, when there is anything to mark. + /// The lane sits in a row above the plot, in its own RepaintBoundary, and its + /// dashed lines run down through the plot; it is never inside the plot's own + /// boundary (AGENTS.md 4.11) and takes taps only on its icons, so a touch + /// anywhere else reaches the scrub underneath. With nothing to mark, [plot] + /// is returned untouched. + Widget _annotated( + double inset, + Widget plot, + TextScaler scaler, + TextDirection dir, + ) { + final set = annotations; + if (set == null || set.items.isEmpty) return plot; + final header = ChartAnnotationLane.headerFor(scaler, dir); + return SizedBox( + height: height + header, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned(left: 0, right: 0, top: header, bottom: 0, child: plot), + Positioned( + left: inset, + right: 0, + top: 0, + bottom: 0, + child: ChartAnnotationLane( + set: set, + cursor: annotationCursor, + plotHeight: height, + ), + ), + ], + ), + ); + } + /// The chart in a sentence: what it ends on, what it spanned, and which way /// it went. Null when there is nothing to say. /// @@ -2489,7 +2526,9 @@ class ChartFrame extends StatelessWidget { child: Center(child: empty), ) else - SizedBox( + _annotated( + inset, + SizedBox( height: height, child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -2561,6 +2600,9 @@ class ChartFrame extends StatelessWidget { ], ), ), + scaler, + dir, + ), // ── x axis ── if (empty == null && xLabels.isNotEmpty) From d74f80ae32d0765e5d1f986054735f3df33dbd64 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sat, 7 Nov 2026 05:50:53 -0700 Subject: [PATCH 03/23] test(charts): the ChartFrame annotation rig describes its scrub positions Scrubber states increase/decrease values whenever it has a value, and Flutter rejects an empty neighbour; real callers always describe the position. --- test/chart_annotation_widget_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/chart_annotation_widget_test.dart b/test/chart_annotation_widget_test.dart index 0d4327790..336b49368 100644 --- a/test/chart_annotation_widget_test.dart +++ b/test/chart_annotation_widget_test.dart @@ -557,7 +557,7 @@ void main() { value: value, onChanged: set, label: 'Resting heart rate', - describe: (v) => '', + describe: (v) => 'day ${(v * 6).round() + 1}', step: 1 / 6, child: RepaintBoundary( key: plotKey, From 817d5418fcfa24279837c10341084cb556b3195a Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sat, 7 Nov 2026 05:52:50 -0700 Subject: [PATCH 04/23] fix(charts): scrub only on a horizontal drag; a vertical drag scrolls the page The Scrubber was a bare Listener that moved the marker on every pointer-down and move, so a page scroll that began on a chart also dragged the marker. It now joins the gesture arena: a tap selects on release, a drag scrubs only when it is horizontal-dominant (more than 1.5x the vertical) past the device touch slop, and a long press selects and then follows any axis. --- lib/ui2/grammar.dart | 73 +++++++++++++++++++++++++++++++++++++++----- 1 file changed, 65 insertions(+), 8 deletions(-) diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index ac2210e40..02a68c70e 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -35,6 +35,7 @@ import '../l10n/decimal_text.dart' show parseTypedNumber; import '../l10n/presentation_text.dart'; import 'dart:math' as math; +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart' show RenderAbstractViewport; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -178,6 +179,8 @@ class Scrubber extends StatelessWidget { // either direction places the cursor rather than doing nothing. final up = ((v ?? -step) + step).clamp(0.0, 1.0); final down = ((v ?? 1 + step) - step).clamp(0.0, 1.0); + // A local function so the heavy-work guard can follow the callback. + void end() => onEnd?.call(); return Semantics( label: label, slider: true, @@ -202,18 +205,48 @@ class Scrubber extends StatelessWidget { behavior: HitTestBehavior.opaque, onLongPressStart: (e) => set(e.localPosition), onLongPressMoveUpdate: (e) => set(e.localPosition), - onLongPressEnd: (_) => onEnd?.call(), - onLongPressCancel: () => onEnd?.call(), + onLongPressEnd: (_) => end(), + onLongPressCancel: end, child: child, ); } - return Listener( - // Opaque, like Pressable. A `Listener` defers to its child by - // default, so the strip was only touchable where the painter - // happened to claim a hit — which is a coincidence, not a target. + // Decide dominance before accepting the arena: a diagonal move can + // cross horizontal slop while still belonging to the page scroll. + // Raw recognizers don't inherit the device's touch slop the way + // GestureDetector does, so hand it over explicitly. + final gs = MediaQuery.maybeGestureSettingsOf(c); + return RawGestureDetector( behavior: HitTestBehavior.opaque, - onPointerDown: (e) => set(e.localPosition), - onPointerMove: (e) => set(e.localPosition), + gestures: { + TapGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => TapGestureRecognizer(), + (r) => r + ..gestureSettings = gs + ..onTapUp = (e) => set(e.localPosition), + ), + _ScrubDragRecognizer: GestureRecognizerFactoryWithHandlers<_ScrubDragRecognizer>( + () => _ScrubDragRecognizer(), + (r) { + r.gestureSettings = gs; + r.dragStartBehavior = DragStartBehavior.down; + r.onlyAcceptDragOnThreshold = true; + r.onStart = (e) => set(e.localPosition); + r.onUpdate = (e) => set(e.localPosition); + r.onEnd = (_) => end(); + r.onCancel = end; + }, + ), + LongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers( + () => LongPressGestureRecognizer(), + (r) { + r.gestureSettings = gs; + r.onLongPressStart = (e) => set(e.localPosition); + r.onLongPressMoveUpdate = (e) => set(e.localPosition); + r.onLongPressEnd = (_) => end(); + r.onLongPressCancel = end; + }, + ), + }, child: child, ); }, @@ -222,6 +255,30 @@ class Scrubber extends StatelessWidget { } } +/// Owns the pending pointer displacement; Flutter owns the drag after intent +/// is accepted. A long press uses its separate recognizer and stays free-axis. +class _ScrubDragRecognizer extends HorizontalDragGestureRecognizer { + Offset _pending = Offset.zero; + + @override + void addAllowedPointer(PointerDownEvent event) { + _pending = Offset.zero; + super.addAllowedPointer(event); + } + + @override + void handleEvent(PointerEvent event) { + if (event is PointerMoveEvent) _pending += event.delta; + if (event is PointerPanZoomUpdateEvent) _pending = event.pan; + super.handleEvent(event); + } + + @override + bool hasSufficientGlobalDistanceToAccept(PointerDeviceKind kind, double? slop) => + super.hasSufficientGlobalDistanceToAccept(kind, slop) && + _pending.dx.abs() > 1.5 * _pending.dy.abs(); +} + /// The base card surface. Elevation, not outline. class Surface extends StatelessWidget { final Widget child; From 12ce6206bde18d0da95ec54bbb1c131682ad3869 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sat, 7 Nov 2026 06:08:47 -0700 Subject: [PATCH 05/23] feat(l10n): strings for the chart scrub panel LATEST and SELECTED captions and the 7-day avg and vs avg stats, in all eight locales. --- lib/l10n/app_de.arb | 4 ++++ lib/l10n/app_en.arb | 16 ++++++++++++++++ lib/l10n/app_es.arb | 4 ++++ lib/l10n/app_fr.arb | 4 ++++ lib/l10n/app_hi.arb | 4 ++++ lib/l10n/app_ru.arb | 4 ++++ lib/l10n/app_th.arb | 4 ++++ lib/l10n/app_zh.arb | 4 ++++ 8 files changed, 44 insertions(+) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index fe1fa7f57..32efbb4eb 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -1176,6 +1176,10 @@ "metricDetailSlotNoValueRecording": "{day}, kein Wert · {who}", "metricDetailSlotNothingRecording": "{day}, keine Aufzeichnung", "metricDetailOpenDay": "{day} öffnen", + "chartPanelLatest": "ZULETZT", + "chartPanelSelected": "AUSGEWÄHLT", + "chartPanelSevenDayAvg": "7-Tage-Schnitt", + "chartPanelVsAvg": "ggü. Schnitt", "metricDetailNoRecordLabel": "Kein Eintrag", "metricDetailLowest": "Niedrigster", "metricDetailTypical": "Typisch", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 4ea800171..5a49ab871 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -5668,6 +5668,22 @@ } } }, + "chartPanelLatest": "LATEST", + "@chartPanelLatest": { + "description": "Caption prefix on the panel under a chart when nothing is selected: the newest reading. Shown upper-case where the script has case. It is not a judgement such as best." + }, + "chartPanelSelected": "SELECTED", + "@chartPanelSelected": { + "description": "Caption prefix on the panel under a chart while a finger is on the chart: the reading the finger is on." + }, + "chartPanelSevenDayAvg": "7-day avg", + "@chartPanelSevenDayAvg": { + "description": "Metric-detail panel stat: the average of the last 7 days up to the shown day." + }, + "chartPanelVsAvg": "vs avg", + "@chartPanelVsAvg": { + "description": "Metric-detail panel stat: the shown value minus that 7-day average." + }, "metricDetailNoRecordLabel": "No record", "@metricDetailNoRecordLabel": { "description": "Value text shown in the touched-day row when that day has no record." diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 415c2de32..a1c22eb7e 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -1133,6 +1133,10 @@ "metricDetailSlotNoRecord": "{day}, sin registro", "metricDetailSlotWithValue": "{day}, {value} {unit}", "metricDetailOpenDay": "Abrir {day}", + "chartPanelLatest": "ÚLTIMO", + "chartPanelSelected": "SELECCIONADO", + "chartPanelSevenDayAvg": "Media de 7 días", + "chartPanelVsAvg": "vs. media", "metricDetailNoRecordLabel": "Sin registro", "metricDetailLowest": "Mínimo", "metricDetailTypical": "Típico", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 7c26d7e89..bc8505b67 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -1133,6 +1133,10 @@ "metricDetailSlotNoRecord": "{day}, aucun enregistrement", "metricDetailSlotWithValue": "{day}, {value} {unit}", "metricDetailOpenDay": "Ouvrir {day}", + "chartPanelLatest": "DERNIER", + "chartPanelSelected": "SÉLECTIONNÉ", + "chartPanelSevenDayAvg": "Moy. sur 7 jours", + "chartPanelVsAvg": "vs moy.", "metricDetailNoRecordLabel": "Aucun enregistrement", "metricDetailLowest": "Minimum", "metricDetailTypical": "Typique", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 990d67975..46039c5f9 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -1133,6 +1133,10 @@ "metricDetailSlotNoRecord": "{day}, कोई रिकॉर्ड नहीं", "metricDetailSlotWithValue": "{day}, {value} {unit}", "metricDetailOpenDay": "{day} खोलें", + "chartPanelLatest": "नवीनतम", + "chartPanelSelected": "चुना गया", + "chartPanelSevenDayAvg": "7 दिन का औसत", + "chartPanelVsAvg": "औसत से अंतर", "metricDetailNoRecordLabel": "कोई रिकॉर्ड नहीं", "metricDetailLowest": "न्यूनतम", "metricDetailTypical": "सामान्य", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index c86544a36..99839c0f7 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -2507,6 +2507,10 @@ } } }, + "chartPanelLatest": "ПОСЛЕДНЕЕ", + "chartPanelSelected": "ВЫБРАНО", + "chartPanelSevenDayAvg": "Среднее за 7 дн.", + "chartPanelVsAvg": "от среднего", "metricDetailNoRecordLabel": "Нет записи", "metricDetailLowest": "Наименьшее", "metricDetailTypical": "Обычное", diff --git a/lib/l10n/app_th.arb b/lib/l10n/app_th.arb index 924d3dbf9..cdb48da1e 100644 --- a/lib/l10n/app_th.arb +++ b/lib/l10n/app_th.arb @@ -5668,6 +5668,10 @@ } } }, + "chartPanelLatest": "ล่าสุด", + "chartPanelSelected": "ที่เลือก", + "chartPanelSevenDayAvg": "ค่าเฉลี่ย 7 วัน", + "chartPanelVsAvg": "เทียบค่าเฉลี่ย", "metricDetailNoRecordLabel": "ไม่มีข้อมูลบันทึก", "@metricDetailNoRecordLabel": { "description": "Value text shown in the touched-day row when that day has no record." diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 1cf2c9848..19599d7bc 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -1133,6 +1133,10 @@ "metricDetailSlotNoRecord": "{day},无记录", "metricDetailSlotWithValue": "{day},{value} {unit}", "metricDetailOpenDay": "打开{day}", + "chartPanelLatest": "最新", + "chartPanelSelected": "已选", + "chartPanelSevenDayAvg": "7 天平均", + "chartPanelVsAvg": "与平均相比", "metricDetailNoRecordLabel": "无记录", "metricDetailLowest": "最低", "metricDetailTypical": "典型", From 9fb49995a88e0afb8378df788217d260c59192f5 Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sat, 7 Nov 2026 06:08:47 -0700 Subject: [PATCH 06/23] feat(charts): an always-present scrub panel on the metric, sleep and readiness charts An inset panel under the chart reads LATEST at rest and SELECTED while a finger is on it, so the chart no longer changes height under the thumb. A gap reads a dash, never a neighbour. Metric detail adds 7-day avg and vs avg and an Open action; the sleep charts read the stage from the timestamped boundaries and each signal within 15 minutes; readiness gets the same panel. --- lib/ui2/grammar.dart | 280 +++++++++++++++++++++++++ lib/ui2/screens/metric_detail.dart | 106 +++++----- lib/ui2/screens/readiness_detail.dart | 38 +++- lib/ui2/screens/sleep_detail.dart | 151 ++++++++----- test/russian_review_findings_test.dart | 4 + test/ui2_trend_reading_test.dart | 9 +- 6 files changed, 471 insertions(+), 117 deletions(-) diff --git a/lib/ui2/grammar.dart b/lib/ui2/grammar.dart index 02a68c70e..c9b9e6ed4 100644 --- a/lib/ui2/grammar.dart +++ b/lib/ui2/grammar.dart @@ -279,6 +279,286 @@ class _ScrubDragRecognizer extends HorizontalDragGestureRecognizer { _pending.dx.abs() > 1.5 * _pending.dy.abs(); } +/// One cell of a [ChartPanel]: a label and what the series reads at the shown +/// instant. A null [value] reads a dash — nothing was recorded there, and the +/// panel never fills it from a neighbour. +@immutable +class ChartPanelCell { + final String label; + + /// The series colour: the swatch beside the label. + final Color? color; + final String? value; + + /// The value's own ink; the plain ink when null. + final Color? valueColor; + const ChartPanelCell(this.label, {this.color, this.value, this.valueColor}); +} + +/// ── CHART PANEL ── what a chart reads at one instant, always on screen ──── +/// +/// An inset box under the chart that is there at rest (`LATEST · `, the +/// newest reading) and while a finger is on the chart (`SELECTED · `), so +/// the chart never changes height under the thumb and nothing is readable only +/// while it is held. Every series is listed with its value at that instant; a +/// series with nothing there reads a dash — never a neighbour's value, never an +/// interpolation. +/// +/// The slider ([Scrubber.describe]) speaks the selected instant once, so the +/// caption and the cells are excluded from semantics. What the slider does not +/// say is spoken here: each of [stats] as one `label value` node, and [onOpen] +/// as an `Open ` button. +class ChartPanel extends StatelessWidget { + /// The caption's time: a day or a clock time. Null reads a dash. + final String? time; + + /// A finger is on the chart: SELECTED, with a border and caption in [color]. + final bool scrubbed; + + /// The series colour for the caption and the border while [scrubbed]. + final Color color; + + /// The series read at [time]. A single cell is drawn large. + final List cells; + + /// Summary figures of that one series (a 7-day average), spoken `label value`. + final List stats; + + /// Every [time] the panel can show. The caption reserves the height of the + /// tallest of them, so a scrub never reflows the panel. + final List timeSpan; + + /// Opens the shown day; null on a day with no value, and then the panel + /// offers nothing to press. + final VoidCallback? onOpen; + final String? openLabel; + + const ChartPanel({ + super.key, + required this.time, + required this.scrubbed, + required this.color, + required this.cells, + this.stats = const [], + this.timeSpan = const [], + this.onOpen, + this.openLabel, + }); + + static const panelKey = ValueKey('chart-panel'); + static const captionKey = ValueKey('chart-panel-caption'); + static const valueKey = ValueKey('chart-panel-value'); + static const absent = '—'; + + /// The tallest caption of a span, per (span, width, style, scale, direction, + /// strings): measured once, not on every pointer move. + static final _captionHeights = {}; + + static const _border = 1.5; + + @override + Widget build(BuildContext c) { + final p = P.of(c); + final big = cells.length == 1; + // Container counts the border as padding; take it off so a scrub moves + // nothing inside the panel. + final inset = scrubbed ? _border : 0.0; + final panel = Container( + key: panelKey, + decoration: BoxDecoration( + color: p.card2, + borderRadius: R.rMd, + border: scrubbed ? Border.all(color: color, width: _border) : null, + ), + padding: EdgeInsets.symmetric( + horizontal: S.x3 - inset, vertical: S.x2 - inset), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ExcludeSemantics(child: _caption(c, p)), + const SizedBox(height: S.x1), + _equal(c, [ + for (final x in cells) + ExcludeSemantics(child: _cell(c, p, x, big: big)), + ]), + if (stats.isNotEmpty) ...[ + const SizedBox(height: S.x2), + _equal(c, [ + for (final x in stats) + KeyedSubtree( + key: ValueKey('chart-panel-stat:${x.label}'), + child: Semantics( + container: true, + label: x.value == null ? null : '${x.label} ${x.value}', + child: ExcludeSemantics(child: _cell(c, p, x)), + ), + ), + ]), + ], + ], + ), + ); + if (onOpen == null) return panel; + return Semantics( + container: true, + button: true, + label: openLabel, + onTap: onOpen, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onOpen, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: S.tap), child: panel), + ), + ); + } + + Widget _caption(BuildContext c, P p) { + final l = AppLocalizations.of(c); + final latest = l?.chartPanelLatest ?? 'LATEST'; + final selected = l?.chartPanelSelected ?? 'SELECTED'; + final measure = F.over.copyWith(fontWeight: FontWeight.w700); + final style = measure.copyWith(color: scrubbed ? color : p.ink2); + final scaler = MediaQuery.textScalerOf(c); + final dir = Directionality.of(c); + final now = time ?? absent; + return LayoutBuilder(builder: (_, box) { + double tallest(Iterable times) { + var h = 0.0; + for (final mode in [latest, selected]) { + for (final at in times) { + final tp = TextPainter( + text: TextSpan(text: '$mode · $at', style: measure), + textDirection: dir, + textScaler: scaler, + )..layout(maxWidth: box.maxWidth); + h = math.max(h, tp.height); + tp.dispose(); + } + } + return h; + } + + final key = ( + measure, scaler, box.maxWidth, dir, '$latest|$selected', + Object.hashAll(timeSpan), timeSpan.length + ); + var height = _captionHeights[key]; + if (height == null) { + height = tallest(timeSpan); + if (_captionHeights.length > 64) _captionHeights.clear(); + _captionHeights[key] = height; + } + // A caption the span does not hold is measured alone. + if (!timeSpan.contains(now)) height = math.max(height, tallest([now])); + return ConstrainedBox( + constraints: BoxConstraints(minHeight: height), + child: Text('${scrubbed ? selected : latest} · $now', + key: captionKey, style: style), + ); + }); + } + + /// Equal-width cells: one row when they fit, wrapped when they do not. + Widget _equal(BuildContext c, List items) { + final scale = math.max(1.0, MediaQuery.textScalerOf(c).scale(1)); + const gap = S.x3; + final minW = 92.0 * scale; + return LayoutBuilder(builder: (_, box) { + final w = box.maxWidth; + final cols = w.isFinite + ? ((w + gap) / (minW + gap)).floor().clamp(1, items.length) + : items.length; + final cellW = w.isFinite ? ((w - gap * (cols - 1)) / cols) - .01 : minW; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var r = 0; r * cols < items.length; r++) ...[ + if (r > 0) const SizedBox(height: S.x2), + IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var k = 0; k < cols; k++) ...[ + if (k > 0) const SizedBox(width: gap), + SizedBox( + width: cellW, + child: r * cols + k < items.length + ? items[r * cols + k] + : null), + ], + ], + ), + ), + ], + ], + ); + }); + } + + Widget _cell(BuildContext c, P p, ChartPanelCell cell, {bool big = false}) { + final present = cell.value != null; + final style = (big ? F.n34 : F.cap).copyWith( + color: present ? (cell.valueColor ?? p.ink) : p.ink3, + fontWeight: big ? FontWeight.w700 : FontWeight.w600, + fontFeatures: const [FontFeature.tabularFigures()]); + // A dash is as tall as a number, so a gap does not shorten the panel. + final tp = TextPainter( + text: TextSpan(text: absent, style: style), + textDirection: Directionality.of(c), + textScaler: MediaQuery.textScalerOf(c), + )..layout(); + final height = tp.height; + tp.dispose(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (cell.color != null) ...[ + Padding( + padding: const EdgeInsets.only(top: 2.5), + child: Container( + width: 9, + height: 9, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: cell.color, + border: Border.all(color: p.line, width: .5), + ), + ), + ), + const SizedBox(width: S.x1), + ], + // A label is a constant, so it wraps rather than clipping. + Expanded( + child: Text(cell.label, + style: F.over.copyWith( + color: p.ink3, fontWeight: FontWeight.w600)), + ), + ]), + Padding( + padding: const EdgeInsets.only(top: 2), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: height), + child: Row(key: big ? valueKey : null, children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: AlignmentDirectional.centerStart, + child: Text(cell.value ?? absent, maxLines: 1, style: style), + ), + ), + ]), + ), + ), + ], + ); + } +} + /// The base card surface. Elevation, not outline. class Surface extends StatelessWidget { final Widget child; diff --git a/lib/ui2/screens/metric_detail.dart b/lib/ui2/screens/metric_detail.dart index 89ca8245f..5941623fc 100644 --- a/lib/ui2/screens/metric_detail.dart +++ b/lib/ui2/screens/metric_detail.dart @@ -10,6 +10,8 @@ // the old UI ended up with forty different opinions about what a chart is. import '../../l10n/display_text.dart'; +import 'dart:math' as math; + import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; @@ -1489,7 +1491,7 @@ class _MetricDetailState extends State { style: F.over.copyWith(color: p.ink3), ), ), - if (_pick != null) _picked(c, spec, series, d), + if (win > 1) _panel(c, spec, series, d), // L4 — the coverage denominator, under the curve it belongs to. // // Deliberately unflattering, and gated to the ranges where it changes @@ -1606,12 +1608,13 @@ class _MetricDetailState extends State { return l?.metricDetailSlotNoRecord(pretty) ?? '$pretty, no record'; } - /// The touched day, and the door into it. + /// The panel under the chart: the newest reading at rest, the touched day + /// while a finger is on the chart, and the door into that day. /// /// A day with a value is a day that derived, so the door always leads - /// somewhere. A day with no value says so and offers nothing — an action + /// somewhere. A day with no value reads a dash and offers nothing — an action /// button is a promise, and there is no screen behind an empty day. - Widget _picked( + Widget _panel( BuildContext c, MetricSpec spec, List series, @@ -1619,60 +1622,53 @@ class _MetricDetailState extends State { ) { final p = P.of(c); final l = AppLocalizations.of(c); - final i = _pick!.clamp(0, series.length - 1); - final day = _dayOfSlot(i, series.length); + final len = series.length; + var latest = len - 1; + while (latest > 0 && series[latest] == null) { + latest--; + } + final i = (_pick ?? latest).clamp(0, len - 1); + final day = _dayOfSlot(i, len); final v = series[i]; - final attributed = _labelSources(d) && _labelsFor(day, d.coverage, d.sources).isNotEmpty; - final who = attributed ? _contributors(_labelsFor(day, d.coverage, d.sources)) : ''; + final ub = unitBeside(spec.unit); + final u = ub.isEmpty ? '' : ' ${uiText(c, ub)}'; + // The last 7 days up to today, whatever day is shown: the figure the shown + // day is set against. Only recorded days count; none recorded is a dash. + final week = [for (var k = math.max(0, len - 7); k < len; k++) ?series[k]]; + final avg = + week.isEmpty ? null : week.reduce((a, b) => a + b) / week.length; + String? versus; + if (v != null && avg != null) { + final diff = v - avg; + final shown = _fmt(spec, diff.abs()); + // No signed zero: a difference that rounds to nothing carries no sign. + final zero = shown == _fmt(spec, 0); + versus = '${zero ? shown : signed(diff, shown)}$u'; + } return Padding( padding: const EdgeInsets.only(top: S.x3), - child: Surface( - color: p.card2, - elevation: 0, - onTap: v == null ? null : () => go(c, _dayScreen(widget.metricKey, day)), - semanticLabel: [ - v == null - ? (l?.metricDetailSlotNoRecord(prettyDay(day, l)) ?? - '${prettyDay(day, l)}, no record') - : (l?.metricDetailOpenDay(prettyDay(day, l)) ?? - 'Open ${prettyDay(day, l)}'), - if (attributed) who, - ].join(' · '), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - dayNavLabel(day, l), - style: F.body - .copyWith(color: p.ink, fontWeight: FontWeight.w600), - maxLines: 1, - overflow: TextOverflow.ellipsis), - ), - const SizedBox(width: S.x3), - Text( - v == null - ? (l?.metricDetailNoRecordLabel ?? 'No record') - : '${_fmt(spec, v)} ${unitBeside(spec.unit)}'.trimRight(), - style: v == null - ? F.cap.copyWith(color: p.ink3) - : F.n17.copyWith(color: p.ink), - ), - if (v != null) ...[ - const SizedBox(width: S.x2), - Icon(LucideIcons.chevronRight, size: 18, color: p.ink3), - ], - ]), - // WHICH DEVICE, second line, only under the gate. A day-level scalar - // from a merged substrate has no single producer, so this names the - // SET (final-plan §4.4) and never picks one of two. - if (attributed) ...[ - const SizedBox(height: S.x1), - Text(who, style: F.over.copyWith(color: p.ink3)), - ], - ]), + child: ChartPanel( + time: dayNavLabel(day, l), + timeSpan: [ + for (var k = 0; k < len; k++) dayNavLabel(_dayOfSlot(k, len), l), + ], + scrubbed: _pick != null, + color: p.on(spec.color), + cells: [ + ChartPanelCell(uiText(c, spec.title), + color: p.on(spec.color), + value: v == null ? null : '${_fmt(spec, v)}$u'), + ], + stats: [ + ChartPanelCell(l?.chartPanelSevenDayAvg ?? '7-day avg', + value: avg == null ? null : '${_fmt(spec, avg)}$u'), + ChartPanelCell(l?.chartPanelVsAvg ?? 'vs avg', value: versus), + ], + onOpen: v == null + ? null + : () => go(c, _dayScreen(widget.metricKey, day)), + openLabel: l?.metricDetailOpenDay(prettyDay(day, l)) ?? + 'Open ${prettyDay(day, l)}', ), ); } diff --git a/lib/ui2/screens/readiness_detail.dart b/lib/ui2/screens/readiness_detail.dart index 3e17a1a8f..526de3697 100644 --- a/lib/ui2/screens/readiness_detail.dart +++ b/lib/ui2/screens/readiness_detail.dart @@ -472,12 +472,38 @@ class _ReadinessDetailState extends State { ), ), ), - if (pick != null) ...[ - const SizedBox(height: S.x2), - ExcludeSemantics( - child: Text(says(pick), - style: F.cap.copyWith(color: p.ink, fontWeight: FontWeight.w600)), - ), + // Always there: the newest scored day at rest, the touched one while a + // finger is on the chart. The slider speaks the selection; the panel is + // the same reading in print. + if (win.length >= 2) ...[ + const SizedBox(height: S.x3), + Builder(builder: (c) { + var latest = win.length - 1; + while (latest > 0 && win[latest] == null) { + latest--; + } + final i = pick ?? latest; + final v = win[i]; + final band = v == null ? null : readinessBand(v, l); + String dayName(int k) { + final day = + dayLabelOf(DateTime(n.year, n.month, n.day - (win.length - 1 - k))); + return dayNavLabel(day, l); + } + + return ChartPanel( + time: dayName(i), + timeSpan: [for (var k = 0; k < win.length; k++) dayName(k)], + scrubbed: pick != null, + color: p.on(C.green), + cells: [ + ChartPanelCell(l?.readinessDetailTitle ?? 'Recovery', + color: band == null ? null : p.on(band.color), + value: v == null ? null : '${v.round()} · ${band!.label}', + valueColor: band == null ? null : p.on(band.color)), + ], + ); + }), ], ]); } diff --git a/lib/ui2/screens/sleep_detail.dart b/lib/ui2/screens/sleep_detail.dart index bf2f8a4c2..50019f5c3 100644 --- a/lib/ui2/screens/sleep_detail.dart +++ b/lib/ui2/screens/sleep_detail.dart @@ -289,6 +289,25 @@ class SleepData { ]; } + /// The stage at second [t], read from the night's own timestamped + /// boundaries rather than from the 240-column resample [stagesOf] draws, so a + /// one-second step across a boundary changes the stage. `known` is false when + /// the night carries no staging at all; a stretch the band did not observe is + /// known, with a null stage. + static (bool, SleepStage?) stageAt(Map night, int t) { + final pts = night['hypnogram']; + if (pts is! List) return (false, null); + final ts = [], st = []; + for (final e in pts) { + if (e is Map && e['t'] is num) { + ts.add((e['t'] as num).round()); + st.add(_stageOf(e['stage'])); + } + } + if (ts.length < 2) return (false, null); + return (true, st[_indexAt(ts, t)]); + } + static int _indexAt(List ts, int t) { var lo = 0; for (var i = 0; i < ts.length; i++) { @@ -829,10 +848,8 @@ class _SleepDetailState extends State with RevisionReload { ], if (_deviceNight(d) case final dn?) _night(c, p, d, dn, device: dn) - else ...[ + else _night(c, p, d, n), - if (_scrub != null) _scrubCard(c, p, d), - ], // ── 2b · WHOSE WINDOW IS THIS ── ...?_windowCard(c, p, d, n), @@ -1331,6 +1348,7 @@ class _SleepDetailState extends State with RevisionReload { ], child: _labelledHypnogram(c, p, stages, n, lane, totals), ), + _scrubPanel(c, p, d, n), if (device != null) ..._deviceStaging(c, p, device), // Not our staging: say whose it is, wherever it is drawn. if (device == null && n['sleep_source'] == 'vendor_staged') ...[ @@ -1472,13 +1490,19 @@ class _SleepDetailState extends State with RevisionReload { final l = AppLocalizations.of(c); final t0 = (n['onset_ts'] as num?)?.toInt(); final t1 = (n['wake_ts'] as num?)?.toInt(); - final st = stages.isEmpty - ? null - : stages[(v * (stages.length - 1)).round().clamp(0, stages.length - 1)]; final at = (t0 == null || t1 == null || t1 <= t0) ? (l?.sleepDetailPercentThroughNight((v * 100).round()) ?? '${(v * 100).round()}% through the night') : clockOfTs(t0 + ((t1 - t0) * v).round()); + // The same boundaries the panel reads, at the same second. + final st = (t0 == null || t1 == null || t1 <= t0) + ? (stages.isEmpty + ? null + : stages[(v * (stages.length - 1)) + .round() + .clamp(0, stages.length - 1)]) + : SleepData.stageAt(n, t0 + ((t1 - t0) * v).round()) + .$2; final stageName = st == null ? (l?.sleepDetailNotMeasured ?? 'not measured') : _stageName(c, st); @@ -1514,15 +1538,20 @@ class _SleepDetailState extends State with RevisionReload { ), ); - /// What every signal read at the scrubbed instant. Each line abstains on its - /// own — a night with no respiration series still shows heart rate. - Widget _scrubCard(BuildContext c, P p, SleepData d) { + /// The panel under a night chart: at rest the stage and every signal at wake + /// (LATEST), while scrubbing the same at that instant (SELECTED). Both + /// charts share one instant, so both panels read the same thing. + /// + /// The stage comes from the night's timestamped boundaries ([n], so a + /// device's own staging reads its own night); each signal is its nearest + /// reading within 15 minutes or a dash — never a neighbour further away. + Widget _scrubPanel(BuildContext c, P p, SleepData d, Map n) { final l = AppLocalizations.of(c); - final n = d.night; final t0 = (n['onset_ts'] as num?)?.toInt(); final t1 = (n['wake_ts'] as num?)?.toInt(); if (t0 == null || t1 == null || t1 <= t0) return const SizedBox.shrink(); - final t = t0 + ((t1 - t0) * _scrub!).round(); + final scrubbed = _scrub != null; + final t = scrubbed ? t0 + ((t1 - t0) * _scrub!).round() : t1; num? at(String key) { final list = d.timeline[key]; @@ -1541,52 +1570,64 @@ class _SleepDetailState extends State with RevisionReload { return bestGap > 900 ? null : best; } - final stages = d.stages; - final stage = stages.isEmpty - ? null - : stages[(_scrub! * (stages.length - 1)).round()]; - final items = <(String, String, Color)>[ - if (at('hr') != null) - (l?.sleepDetailHeartRate ?? 'Heart rate', - sleepReadingText(l, at('hr')!.round(), 0, 'bpm'), C.red), - if (at('hrv') != null) - (l?.sleepDetailHrv ?? 'HRV', - sleepReadingText(l, at('hrv')!.round(), 0, 'ms'), C.green), - if (at('resp') != null) - (l?.sleepDetailBreathing ?? 'Breathing', - sleepReadingText(l, at('resp')!, 1, 'br/min'), C.teal), - if (at('skin_temp') != null) - (l?.sleepDetailTempRelative ?? 'Temp (rel.)', - sleepScrubTempDelta(at('skin_temp')!), C.orange), - ]; + // A signal gets a cell when the night recorded it at all; then a moment + // without it reads a dash rather than the cell disappearing. + bool recorded(String key) { + final list = d.timeline[key]; + return list is List && + list.any((e) => e is Map && e['t'] is num && e['v'] is num); + } + + final (known, stage) = SleepData.stageAt(n, t); + final pigment = stage == null ? null : Hypnogram.pigment[stage]; + final stageInk = pigment == null ? null : p.on(pigment); + String sentence(String s) => + s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); + String? reading(String key, int places, String unit) { + final v = at(key); + return v == null + ? null + : sleepReadingText(l, places == 0 ? v.round() : v, places, unit); + } + final cells = [ + if (known) + ChartPanelCell(sentence(l?.sleepDetailUnitStage ?? 'stage'), + color: stageInk, + value: stage == null + ? (l?.sleepDetailNotMeasuredCap ?? 'Not measured') + : _stageName(c, stage), + valueColor: stageInk ?? p.ink2), + if (recorded('hr')) + ChartPanelCell(l?.sleepDetailHeartRate ?? 'Heart rate', + color: p.on(C.red), value: reading('hr', 0, 'bpm')), + if (recorded('hrv')) + ChartPanelCell(l?.sleepDetailHrv ?? 'HRV', + color: p.on(C.green), value: reading('hrv', 0, 'ms')), + if (recorded('resp')) + ChartPanelCell(l?.sleepDetailBreathing ?? 'Breathing', + color: p.on(C.teal), value: reading('resp', 1, 'br/min')), + if (recorded('skin_temp')) + ChartPanelCell(l?.sleepDetailTempRelative ?? 'Temp (rel.)', + color: p.on(C.orange), + value: at('skin_temp') == null + ? null + : sleepScrubTempDelta(at('skin_temp')!)), + ]; + if (cells.isEmpty) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(top: S.x3), - child: Surface( - color: p.card2, - elevation: 0, - child: Column(children: [ - Row(children: [ - Text(clockOfTs(t), - style: F.body.copyWith(color: p.ink, fontWeight: FontWeight.w600)), - const Spacer(), - if (stage != null) - Pill(_stageName(c, stage), Hypnogram.pigment[stage] ?? C.blue) - else if (stages.isNotEmpty) - // Not a stage, so not a Pill: this instant has no colour because - // the band was not recording it. - Text(l?.sleepDetailNotMeasuredCap ?? 'Not measured', - style: F.cap.copyWith(color: p.ink3)), - ]), - if (items.isEmpty) ...[ - const SizedBox(height: S.x3), - Text(l?.sleepDetailNoSignalAtMoment ?? 'No signal recorded at this moment.', - style: F.cap.copyWith(color: p.ink3)), - ] else ...[ - const SizedBox(height: S.x4), - InlineMetrics(items), - ], - ]), + child: ChartPanel( + time: clockOfTs(t), + // Every half hour and the wake itself: the caption reserves the + // tallest of these, so a scrub never reflows the panel. + timeSpan: [ + for (var s = t0; s < t1; s += 1800) clockOfTs(s), + clockOfTs(t1), + ], + scrubbed: scrubbed, + color: stageInk ?? p.ink2, + cells: cells, ), ); } @@ -2313,7 +2354,7 @@ class _SleepDetailState extends State with RevisionReload { size: Size.infinite, painter: NightStack(series, colors, axes: axes)), ), - if (linked && _scrub != null) _scrubCard(c, p, d), + if (linked) _scrubPanel(c, p, d, n), ]), ); } diff --git a/test/russian_review_findings_test.dart b/test/russian_review_findings_test.dart index 59ac92395..144d935f6 100644 --- a/test/russian_review_findings_test.dart +++ b/test/russian_review_findings_test.dart @@ -505,6 +505,10 @@ void main() { ? ru.metricDetailRankFromBand(axisDay(ts), 12, band) : ru.metricDetailRankFrom(axisDay(ts), 12)); expect(expected, contains('выше, чем в 12% ваших дней')); + // The scrub panel under the chart is always on screen now, which moves + // this line below the 600 px test viewport; bring it into view. + await t.ensureVisible(find.text(expected, skipOffstage: false)); + await t.pumpAndSettle(); expect(find.text(expected), findsOneWidget); expect(t.takeException(), isNull); }); diff --git a/test/ui2_trend_reading_test.dart b/test/ui2_trend_reading_test.dart index cf6664252..6261ff1af 100644 --- a/test/ui2_trend_reading_test.dart +++ b/test/ui2_trend_reading_test.dart @@ -134,7 +134,14 @@ void main() { (w) => w is Scrubber && w.label == 'Recovery'); await t.tapAt(t.getBottomRight(strip) - const Offset(1, 1)); await t.pumpAndSettle(); - expect(find.textContaining('· 20 · Rest today'), findsOneWidget); + // The panel under the chart replaced the pop-in line: the touched day + // (the last bar is today) and its score and band. + final panel = find.byKey(const ValueKey('chart-panel')); + expect(find.descendant(of: panel, matching: find.text('SELECTED · Today')), + findsOneWidget); + expect( + find.descendant(of: panel, matching: find.text('20 · Rest today')), + findsOneWidget); }); testWidgets('under two weeks there is no band, and one line says why', From c3cf5a492c4238d2c3890581a8688ed1d9b42ade Mon Sep 17 00:00:00 2001 From: MikenRafter Date: Sat, 7 Nov 2026 06:08:47 -0700 Subject: [PATCH 07/23] docs(ui2): gallery cases and README for the chart panel and annotation lane --- lib/ui2/README.md | 41 +++++++++++++++++ lib/ui2/profile/gallery.dart | 87 ++++++++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+) diff --git a/lib/ui2/README.md b/lib/ui2/README.md index ae6e012e2..07ed6e824 100644 --- a/lib/ui2/README.md +++ b/lib/ui2/README.md @@ -121,6 +121,47 @@ that did not exist. `Scrubber` is the only drag: it carries the slider role, so increase/decrease reach it without a pointer, and `describe` is what those steps say out loud. +### ChartPanel — what a chart reads, always on screen + +```dart +ChartPanel({required String? time, required bool scrubbed, required Color color, + required List cells, List stats = const [], + List timeSpan = const [], VoidCallback? onOpen, String? openLabel}) +ChartPanelCell(String label, {Color? color, String? value, Color? valueColor}) +``` + +Put it under a chart that has a `Scrubber`, in the same card. It is there at +rest and while a finger is on the chart, so the chart never changes height under +the thumb and nothing is readable only while it is held. There is no pop-in. + +- **Caption.** `LATEST ·