From 8a7825fd884452225d1e333518d1223bc79f0746 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 27 Jul 2026 21:37:14 +0530 Subject: [PATCH 1/8] refactor(design): prune the design system to what actually ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gallery had drifted into a catalogue of things nothing used. Scanned every public type in ui/design + ui/kit for call sites outside its own file and the gallery, then removed what had none: RadialHeatmap, RecapCard, AreaSpark, DotMatrix, CalendarHeatmap, StatTile, BaselineProgress, NightCard, NavPillAction, StateChips, OrbitScore's satellite layer, and the dead TimelineScreen wrapper (Journey embeds TimelineContent and loads its own bundle). Kept ArcGaugePainter/SkelBox/RouteMapScreen/RouteZoneLegend/MetricInfo — they scan as unused but are consumed inside their own files. Where a deleted component carried a real invariant, the test moved rather than went: RecapCard's "a missing day holds its slot instead of sliding the week left" now runs against MiniBars, whose invariant it actually was. Also: the Today readiness ring's status word is now a StateChipView pill (Push / Focus / Recover) instead of loose text. Still derived from the SAME readinessBand() cuts the AI briefing uses, so the ring and the briefing cannot disagree. Presentation only — no kAlgoVersion bump. ToggleChip had 5 live call sites and no gallery section at all; it has one now. The gallery covers what ships, in both directions. -1060 lines. --- lib/ai/briefing_engine.dart | 2 +- lib/ui/design/design.dart | 1 - lib/ui/design/gallery_screen.dart | 226 +++++++++----- lib/ui/design/nav_pill.dart | 46 --- lib/ui/design/orbit_score.dart | 234 +++------------ lib/ui/design/radial_heatmap.dart | 164 ----------- lib/ui/design/recap_card.dart | 125 +------- lib/ui/design/state_chips.dart | 183 ++++++------ lib/ui/kit/charts.dart | 422 +-------------------------- lib/ui/kit/kit.dart | 21 -- lib/ui/timeline/timeline_screen.dart | 79 +---- lib/ui/today/today_screen.dart | 14 +- test/absent_not_zero_test.dart | 15 +- test/ai_briefing_test.dart | 10 +- test/core_screens_test.dart | 2 +- test/design_redesign_test.dart | 116 +++----- test/ui_kit_new_widgets_test.dart | 19 -- 17 files changed, 353 insertions(+), 1326 deletions(-) delete mode 100644 lib/ui/design/radial_heatmap.dart diff --git a/lib/ai/briefing_engine.dart b/lib/ai/briefing_engine.dart index bf1c5a7d..ff49e9a6 100644 --- a/lib/ai/briefing_engine.dart +++ b/lib/ai/briefing_engine.dart @@ -153,7 +153,7 @@ String partOfDay(DateTime now) { /// /// THE single source of truth for readiness-score banding — also used by /// the Today ring's status word (`TodayVitals._orbitHero` in -/// today_screen.dart maps good/moderate/low → Primed/Steady/Run easy). +/// today_screen.dart maps good/moderate/low → Push/Focus/Recover). /// These cuts (40/66) MUST match the ring's own thresholds: a briefing band /// computed from different cuts than the ring's word is exactly the /// tone-vs-score contradiction this function exists to prevent, just moved diff --git a/lib/ui/design/design.dart b/lib/ui/design/design.dart index 9f89ba40..b476e5be 100644 --- a/lib/ui/design/design.dart +++ b/lib/ui/design/design.dart @@ -26,7 +26,6 @@ export 'motion.dart'; export 'nav_pill.dart'; export 'orbit_score.dart'; export 'pressable.dart'; -export 'radial_heatmap.dart'; export 'recap_card.dart'; export 'ring_week.dart'; export 'rows.dart'; diff --git a/lib/ui/design/gallery_screen.dart b/lib/ui/design/gallery_screen.dart index faf2a2ce..860d9980 100644 --- a/lib/ui/design/gallery_screen.dart +++ b/lib/ui/design/gallery_screen.dart @@ -9,8 +9,11 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../gps/route_math.dart' as rmath; +import '../../gps/route_models.dart' show RouteVertex, WorkoutRoute; import '../../theme/theme_controller.dart'; import '../../theme/theme_switcher.dart'; +import '../../state/units_controller.dart'; +import '../activity/workout_share_card.dart'; import '../activity/live_session_screen.dart' show GpsLiveMapView, WorkoutFinishScreen, WorkoutFinishSnapshot; import 'design.dart'; @@ -28,6 +31,9 @@ class _DesignGalleryScreenState extends State { int _nav = 0; int _chip = 0; + static const _tags = ['Caffeine', 'Alcohol', 'Late meal', 'Travel']; + final _tagsOn = {1}; + static const _spark = [ 62, 58, @@ -45,6 +51,75 @@ class _DesignGalleryScreenState extends State { 51, ]; + // The fake route and its vertices are built ONCE. `fakeRunRoute()` generates + // 140 points and `buildVertices` is O(n) with a binary search and a haversine + // per point — cheap individually, but the gallery rebuilds on every theme + // toggle and this is the exact per-build recomputation that made the finish + // screen janky. Only the formatted strings below depend on units, and those + // are just string formatting. + late final WorkoutRoute _shareRoute = fakeRunRoute(); + late final List _shareVertices = + rmath.buildVertices(_shareRoute.points, _shareRoute.hr, 190); + + /// Build the share composition from the fake run, exactly the way the finish + /// screen does — same units controller, same three stats — so what the + /// gallery shows is what ships, not a hand-written mock that can drift. + WorkoutShareData _fakeShareData(BuildContext context) { + final units = context.watch(); + final route = _shareRoute; + final parts = units.distance(route.distanceMeters).split(' '); + return WorkoutShareData( + title: 'Morning Run', + subtitle: '27 Jul 2026', + vertices: _shareVertices, + heroValue: parts.first, + heroUnit: parts.length > 1 ? parts.sublist(1).join(' ') : '', + stats: [ + ('20:06', 'Time'), + (units.pace(route.distanceMeters, route.movingSec), 'Pace'), + ('11.6', 'Strain'), + ], + accent: AppColors.coral, + ); + } + + /// Both formats side by side, scaled to fit the gallery column. Scaled — not + /// re-laid-out at a smaller width — so the proportions and type hierarchy are + /// exactly what a real post gets. + Widget _shareCardDemo(BuildContext context) { + final data = _fakeShareData(context); + Widget shrunk(ShareFormat f) => Expanded( + child: Column( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => WorkoutSharePreviewScreen(data: data), + ), + ), + child: FittedBox( + fit: BoxFit.contain, + child: WorkoutShareCard(data: data, format: f), + ), + ), + const SizedBox(height: Sp.x2), + Text( + '${f.label} · ${f == ShareFormat.feed ? '4:5' : '9:16'}', + style: AppText.captionMuted, + ), + ], + ), + ); + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + shrunk(ShareFormat.feed), + const SizedBox(width: Sp.x4), + shrunk(ShareFormat.story), + ], + ); + } + void _toggleTheme(ThemeController ctrl) { final next = ctrl.isDark ? AppThemeChoice.light : AppThemeChoice.dark; final overlay = themeSwitchKey.currentState; @@ -198,39 +273,11 @@ class _DesignGalleryScreenState extends State { OrbitScore( score: 82, label: 'Readiness', - word: 'Primed', + word: 'Push', + wordIcon: OsIcon.intensity, color: AppColors.scoreColor(0.82), + glow: true, onTap: () {}, - satellites: [ - OrbitSatellite( - icon: OsIcon.sleep, - label: 'Sleep', - value: '7h 42m', - color: DomainAccent.sleep, - onTap: () {}, - ), - OrbitSatellite( - icon: OsIcon.heart, - label: 'Heart', - value: '48 ms', - color: DomainAccent.heart, - onTap: () {}, - ), - OrbitSatellite( - icon: OsIcon.bodyStrain, - label: 'Strain', - value: '12.4', - color: DomainAccent.strain, - onTap: () {}, - ), - OrbitSatellite( - icon: OsIcon.stress, - label: 'Stress', - value: '34', - color: DomainAccent.stress, - onTap: () {}, - ), - ], ), const SizedBox(height: Sp.x6), @@ -363,29 +410,6 @@ class _DesignGalleryScreenState extends State { ), const SizedBox(height: Sp.x6), - // ── RadialHeatmap ───────────────────────────────────────────── - const SectionHeader('RadialHeatmap'), - SurfaceCard( - child: Column( - children: [ - Text('STRAIN BY HOUR', style: AppText.overline), - const SizedBox(height: Sp.x3), - RadialHeatmap( - values: const [ - 0.05, 0.02, 0.0, null, 0.0, 0.1, 0.35, 0.8, - 0.95, 0.6, 0.3, 0.4, 0.5, 0.3, 0.2, 0.25, - 0.45, 0.85, 0.7, 0.4, 0.2, 0.1, 0.05, 0.02, - ], - color: DomainAccent.strain, - size: 190, - labels: const ['12a', '6a', '12p', '6p'], - startAngle: -1.5707963267948966, - ), - ], - ), - ), - const SizedBox(height: Sp.x6), - // ── RingWeek ────────────────────────────────────────────────── const SectionHeader('RingWeek'), SurfaceCard( @@ -397,33 +421,66 @@ class _DesignGalleryScreenState extends State { ), const SizedBox(height: Sp.x6), - // ── StateChips ──────────────────────────────────────────────── - const SectionHeader('StateChips'), - StateChips( - chips: const [ - StateChip('Energize', emoji: '⚡'), - StateChip('Recover', emoji: '🛌'), - StateChip('Focus', emoji: '🎯'), - StateChip('Calm', emoji: '🫧'), - StateChip('Push', emoji: '🔥'), + // ── StateChipView + ToggleChip ──────────────────────────────── + const SectionHeader('StateChipView · ToggleChip'), + // Display-only, accent-tinted — the exact pill the Today readiness + // ring puts under the score, at each of the three bands. + Row( + children: [ + for (final (w, i, t) in const [ + ('Push', OsIcon.intensity, 0.82), + ('Focus', OsIcon.activity, 0.55), + ('Recover', OsIcon.calm, 0.28), + ]) ...[ + StateChipView( + StateChip(w, icon: i), + selected: true, + accent: AppColors.scoreColor(t), + dense: true, + ), + const SizedBox(width: Sp.x2), + ], ], - selected: _chip, - onSelect: (i) => setState(() => _chip = i), ), - const SizedBox(height: Sp.x6), - - // ── RecapCard + MedalCard ───────────────────────────────────── - const SectionHeader('RecapCard · MedalCard'), - RecapCard( - title: 'Weekly recap', - highlight: 'You slept 40 min more than your usual this week.', - value: '7h 12m', - caption: 'daily average', - bars: const [6.2, 7.5, 8.1, 6.9, 7.2, 8.4, 7.1], - accent: DomainAccent.sleep, - onTap: () {}, + const SizedBox(height: Sp.x3), + // Interactive variant (onTap) — a Wrap of chips, single-select. + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final (i, c) in const [ + (0, StateChip('Push', icon: OsIcon.intensity)), + (1, StateChip('Focus', icon: OsIcon.activity)), + (2, StateChip('Recover', icon: OsIcon.calm)), + (3, StateChip('Sleep', icon: OsIcon.sleep)), + ]) + StateChipView( + c, + selected: _chip == i, + onTap: () => setState(() => _chip = i), + ), + ], ), const SizedBox(height: Sp.x3), + // ToggleChip — the multi-select sibling (journal tags, cycle symptoms). + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (var i = 0; i < _tags.length; i++) + ToggleChip( + _tags[i], + selected: _tagsOn.contains(i), + onTap: () => setState( + () => _tagsOn.contains(i) ? _tagsOn.remove(i) : _tagsOn.add(i), + ), + ), + ], + ), + const SizedBox(height: Sp.x6), + + // ── MedalCard ───────────────────────────────────────────────── + const SectionHeader('MedalCard'), MedalCard( medal: '5K', overline: 'Personal record', @@ -789,6 +846,21 @@ class _DesignGalleryScreenState extends State { ), const SizedBox(height: Sp.x6), + // ── Share card ──────────────────────────────────────────────── + const SectionHeader('Share card'), + const SizedBox(height: Sp.x2), + Text( + 'What actually gets posted — composed for a feed, not a capture of ' + 'the finish screen. Map full-bleed, one headline figure, three ' + 'supporting stats, nothing else. Both cards below are the REAL ' + 'widget with the fake run\'s data and your current units; tap either ' + 'to open the live preview screen with its format switcher.', + style: AppText.captionMuted, + ), + const SizedBox(height: Sp.x4), + _shareCardDemo(context), + const SizedBox(height: Sp.x6), + // ── Nav pill ────────────────────────────────────────────────── // Mirrors the shipped shell: five even tabs, no center action. const SectionHeader('FloatingNavPill'), diff --git a/lib/ui/design/nav_pill.dart b/lib/ui/design/nav_pill.dart index 756361f1..856a970b 100644 --- a/lib/ui/design/nav_pill.dart +++ b/lib/ui/design/nav_pill.dart @@ -12,7 +12,6 @@ import 'package:flutter/services.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; import '../kit/os_icons.dart'; -import 'pressable.dart'; class NavPillItem { /// Illustrated tab icon (full-colour, theme-aware). Always rendered at full @@ -130,51 +129,6 @@ class FloatingNavPill extends StatelessWidget { } } -/// The standard ember circle for [FloatingNavPill.centerAction] — press -/// feedback + haptic + semantics come free. Kept public for custom shells; -/// the app shell itself no longer renders a center action. -class NavPillAction extends StatelessWidget { - /// Glyph shown on the ember circle (e.g. [OsIcon.add]). - final OsIcon? icon; - - final VoidCallback onTap; - final String semanticLabel; - - const NavPillAction({ - super.key, - this.icon, - required this.onTap, - this.semanticLabel = 'Start', - }) : assert(icon != null); - - @override - Widget build(BuildContext context) { - return Semantics( - button: true, - label: semanticLabel, - child: Pressable( - // Pressable fires the selection haptic itself. - pressedScale: 0.9, - onTap: onTap, - // The old illustrated art was itself a rendered "soft-3D button - // coin" and needed no circle behind it. Plain vector glyphs do — - // draw the ember circle explicitly, same 46px footprint as before. - child: Container( - width: 46, - height: 46, - decoration: BoxDecoration( - color: AppColors.accent, - shape: BoxShape.circle, - ), - child: Center( - child: OsAppIcon(icon!, size: 22, color: Colors.white), - ), - ), - ), - ); - } -} - /// Internal: keeps item hit-targets comfortable inside the tight pill. class _NavPad extends StatelessWidget { final Widget child; diff --git a/lib/ui/design/orbit_score.dart b/lib/ui/design/orbit_score.dart index ae282b88..28396e1d 100644 --- a/lib/ui/design/orbit_score.dart +++ b/lib/ui/design/orbit_score.dart @@ -1,18 +1,18 @@ -// OrbitScore — the whole-health hero: one radial score with the health -// domains orbiting it as tappable satellite chips (the image-4 pattern). -// The center carries the big number + status word; faint concentric orbit -// rings give structure; each satellite sits on the outer orbit and routes to -// its domain screen. Restrained by design: hairline rings, no glow, no -// particles — presence comes from scale and composition. +// OrbitScore — the whole-health hero: one radial score, and nothing else +// competing with it. The center carries the label, the big number, and the +// status chip. Restrained by design: no glow by default, no particles — +// presence comes from scale and negative space. +// +// It used to float up-to-four tappable domain "satellites" on concentric +// orbits around the core. Those were cut from the Today hero (that data +// already lives one tap away on its own tab, and at near-equal visual weight +// they fought the score), and with no caller left the whole orbit/satellite +// layer went with them. // // OrbitScore( // score: 82, // null → honest baseline/empty center -// word: 'Primed', +// word: 'Push', wordIcon: OsIcon.intensity, // rendered as a state chip // color: AppColors.scoreColor(0.82), -// satellites: [ -// OrbitSatellite(icon: OsIcon.sleep, label: 'Sleep', onTap: …), -// …up to 4, rendered at staggered orbit anchors… -// ], // ) import 'dart:math' as math; @@ -21,38 +21,24 @@ import 'package:flutter/material.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; -import '../kit/kit.dart' show OsAppIcon, OsIcon; +import '../kit/kit.dart' show OsIcon; import 'arc_gauge.dart'; import 'motion.dart'; import 'pressable.dart'; - -class OrbitSatellite { - final OsIcon icon; - - /// Optional illustrated icon — replaces the tinted [icon] glyph when the - /// domain has full-colour art (rendered as-is, never tinted). - final String label; - - /// Optional tiny value shown after the label ('48 ms'). - final String? value; - final Color? color; - final VoidCallback? onTap; - const OrbitSatellite({ - required this.icon, - required this.label, - this.value, - this.color, - this.onTap, - }); -} +import 'state_chips.dart'; class OrbitScore extends StatelessWidget { /// 0–100 score. Null renders [center] (the honest building/empty state). final int? score; - /// Status word under the number ('Primed', 'Steady', 'Run easy'). + /// Status word under the number ('Push', 'Focus', 'Recover'). Rendered as a + /// calm [StateChipView] pill tinted with [color], so the ring's verdict + /// reads as a state you're *in* rather than a loose caption. final String? word; + /// Optional glyph for the [word] chip. Null renders the word alone. + final OsIcon? wordIcon; + /// Whispered overline above the number ('READINESS'). final String? label; @@ -70,9 +56,6 @@ class OrbitScore extends StatelessWidget { /// (2 of 5 nights = 0.4) while the honest center explains it. final double? ringFill; - /// Up to four satellites, anchored NE / SE / SW / NW around the orbit. - final List satellites; - /// Tap on the score core itself. final VoidCallback? onTap; @@ -86,12 +69,12 @@ class OrbitScore extends StatelessWidget { super.key, required this.score, this.word, + this.wordIcon, this.label, this.color, this.confidence = 1.0, this.center, this.ringFill, - this.satellites = const [], this.onTap, this.height = 280, this.glow = false, @@ -100,7 +83,6 @@ class OrbitScore extends StatelessWidget { @override Widget build(BuildContext context) { final c = color ?? AppColors.accent; - final hasSatellites = satellites.isNotEmpty; final reduceMotion = MediaQuery.maybeDisableAnimationsOf(context) ?? false; return SizedBox( @@ -109,16 +91,9 @@ class OrbitScore extends StatelessWidget { builder: (context, box) { final w = box.maxWidth; final side = math.min(w, height); - // Core gauge ≈ half the shorter side when satellites orbit it; - // with no satellites to make room for, the ring itself is the - // whole composition — let it fill far more of the space and give - // it generous surrounding negative space instead of chips. - final coreSize = hasSatellites - ? (side * 0.52).clamp(120.0, 168.0) - : (side * 0.72).clamp(160.0, 224.0); - final orbitR = coreSize / 2 + side * 0.16; - - final coreCenter = Offset(w / 2, height / 2); + // The ring IS the composition — let it fill most of the shorter + // side and give it generous surrounding negative space. + final coreSize = (side * 0.72).clamp(160.0, 224.0); Widget core = ArcGauge( value: score == null @@ -126,7 +101,7 @@ class OrbitScore extends StatelessWidget { : (score! / 100).clamp(0.0, 1.0), color: c, size: coreSize, - stroke: hasSatellites ? 10 : 13, + stroke: 13, sweepFraction: 0.78, confidence: confidence, glow: glow, @@ -154,14 +129,15 @@ class OrbitScore extends StatelessWidget { color: score == null ? AppColors.inkMuted : null, ), ), - if (word != null) - Text( - word!, - style: AppText.caption.copyWith( - color: c, - fontWeight: FontWeight.w800, - ), + if (word != null) ...[ + const SizedBox(height: Sp.x1), + StateChipView( + StateChip(word!, icon: wordIcon), + selected: true, + accent: c, + dense: true, ), + ], ], ), ); @@ -185,156 +161,12 @@ class OrbitScore extends StatelessWidget { ); } - return Stack( - clipBehavior: Clip.none, - children: [ - // Faint concentric orbits (hairline; structure, not decoration) - // — only drawn when satellites actually anchor to them; with - // no satellites the ring is the whole composition and gets - // pure negative space instead of rings around nothing. - if (hasSatellites) - Positioned.fill( - child: RepaintBoundary( - child: CustomPaint( - painter: _OrbitRingsPainter( - center: coreCenter, - radii: [orbitR * 0.82, orbitR], - color: AppColors.inkMuted.withValues( - alpha: AppColors.isDark ? 0.22 : 0.28, - ), - ), - ), - ), - ), - Positioned( - left: coreCenter.dx - coreSize / 2, - top: coreCenter.dy - coreSize / 2, - child: animatedCore.dsEnter(), - ), - ..._placeSatellites(w, coreCenter, orbitR), - ], - ); + return Center(child: animatedCore.dsEnter()); }, ), ); } - /// Anchor the (up to 4) satellites at staggered angles on the outer orbit, - /// clamped into the box so chips never overflow the screen edge. - List _placeSatellites(double w, Offset c, double r) { - // NE, SW, SE, NW — alternating sides reads balanced with any count. - const angles = [-0.30 * math.pi, 0.72 * math.pi, 0.28 * math.pi, -0.72 * math.pi]; - final out = []; - for (var i = 0; i < satellites.length && i < 4; i++) { - final s = satellites[i]; - final ang = angles[i]; - final p = c + Offset(math.cos(ang), math.sin(ang)) * r; - out.add( - Positioned( - left: p.dx < w / 2 ? math.max(0, p.dx - 76) : null, - right: p.dx >= w / 2 ? math.max(0, w - p.dx - 76) : null, - // Half the chip height (6+6 padding + 34 icon = 46) keeps the pill - // vertically centred on its orbit anchor. - top: p.dy - 23, - child: _SatelliteChip(s).dsEnter(index: i + 2), - ), - ); - } - return out; - } -} - -class _SatelliteChip extends StatelessWidget { - final OrbitSatellite s; - const _SatelliteChip(this.s); - - @override - Widget build(BuildContext context) { - return Pressable( - pressedScale: 0.92, - onTap: s.onTap, - child: Container( - constraints: const BoxConstraints(maxWidth: 152), - padding: const EdgeInsets.symmetric(horizontal: Sp.x3, vertical: 6), - decoration: BoxDecoration( - color: Elevation.surfaceAt(2), - borderRadius: BorderRadius.circular(R.pill), - border: Elevation.border(2), - boxShadow: Elevation.shadows(1), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - // The illustrations carry built-in transparent padding, so they - // need a larger canvas (34) than the 28px glyph disc to read at - // the same visual weight inside the pill. - OsAppIcon(s.icon, size: 34), - const SizedBox(width: Sp.x2), - Flexible( - child: Text( - s.label, - style: AppText.caption.copyWith( - color: AppColors.ink, - fontWeight: FontWeight.w800, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (s.value != null) ...[ - const SizedBox(width: Sp.x1 + 2), - Text( - s.value!, - style: AppText.caption.copyWith(color: AppColors.inkSoft), - maxLines: 1, - ), - ], - ], - ), - ), - ); - } } -class _OrbitRingsPainter extends CustomPainter { - final Offset center; - final List radii; - final Color color; - _OrbitRingsPainter({ - required this.center, - required this.radii, - required this.color, - }); - @override - void paint(Canvas canvas, Size size) { - final p = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 1 - ..color = color; - for (final r in radii) { - canvas.drawCircle(center, r, p); - } - // Four quiet anchor ticks on the outer orbit (N/E/S/W) — a compass, not - // decoration; they make the orbit read as a measured instrument. - final tick = Paint() - ..style = PaintingStyle.stroke - ..strokeWidth = 2 - ..strokeCap = StrokeCap.round - ..color = color; - final r = radii.last; - for (var k = 0; k < 4; k++) { - final a = k * math.pi / 2; - final dir = Offset(math.cos(a), math.sin(a)); - canvas.drawLine( - center + dir * (r - 3), - center + dir * (r + 3), - tick, - ); - } - } - - @override - bool shouldRepaint(_OrbitRingsPainter old) => - old.center != center || old.color != color || old.radii != radii; -} diff --git a/lib/ui/design/radial_heatmap.dart b/lib/ui/design/radial_heatmap.dart deleted file mode 100644 index b5ce5e7c..00000000 --- a/lib/ui/design/radial_heatmap.dart +++ /dev/null @@ -1,164 +0,0 @@ -// RadialHeatmap — the radial segmented heatmap from the refs' muscle map: a -// disc of sectors × rings where each sector is a category (muscle group, -// hour-of-day, domain) and fill intensity encodes 0..1 load. Meaningful, not -// decorative: sectors with no data stay honest track-grey, and the strongest -// sector can carry a label callout. -// -// RadialHeatmap( -// values: strainByHour, // one 0..1 (or null) per sector -// rings: 3, // intensity quantized across rings -// color: DomainAccent.strain, -// labels: ['12a', '6a', '12p', '6p'], // quiet compass labels (optional) -// ) - -import 'dart:math' as math; - -import 'package:flutter/material.dart'; - -import '../../theme/theme.dart'; -import '../../theme/tokens.dart'; - -class RadialHeatmap extends StatelessWidget { - /// One intensity per sector, 0..1; null = no data (honest empty sector). - final List values; - - /// Concentric intensity rings (inner fills first — like the refs' map). - final int rings; - - final Color? color; - final double size; - - /// Quiet labels. Pass exactly one per sector to label every sector at its - /// own mid-angle (e.g. seven weekday names); any other count falls back to - /// up to four compass labels at N/E/S/W. - final List? labels; - - /// Start angle of sector 0 (default: 12 o'clock). - final double startAngle; - - const RadialHeatmap({ - super.key, - required this.values, - this.rings = 3, - this.color, - this.size = 168, - this.labels, - this.startAngle = -math.pi / 2, - }); - - @override - Widget build(BuildContext context) { - final c = color ?? AppColors.accent; - return RepaintBoundary( - child: SizedBox( - width: size, - height: size, - child: TweenAnimationBuilder( - duration: Motion.ring, - curve: Motion.emphatic, - tween: Tween(begin: 0, end: 1), - builder: (_, t, _) => CustomPaint( - painter: _RadialHeatmapPainter( - values: values, - rings: rings.clamp(1, 6), - color: c, - track: AppColors.surfaceAlt, - labelColor: AppColors.inkMuted, - labelStyle: AppText.captionMuted.copyWith(fontSize: 9), - labels: labels, - startAngle: startAngle, - reveal: t, - ), - ), - ), - ), - ); - } -} - -class _RadialHeatmapPainter extends CustomPainter { - final List values; - final int rings; - final Color color; - final Color track; - final Color labelColor; - final TextStyle labelStyle; - final List? labels; - final double startAngle; - final double reveal; - - _RadialHeatmapPainter({ - required this.values, - required this.rings, - required this.color, - required this.track, - required this.labelColor, - required this.labelStyle, - required this.labels, - required this.startAngle, - required this.reveal, - }); - - @override - void paint(Canvas canvas, Size size) { - if (values.isEmpty) return; - final c = size.center(Offset.zero); - final outerR = size.shortestSide / 2 - (labels == null ? 2 : 12); - final innerR = outerR * 0.30; - final ringW = (outerR - innerR) / rings; - final n = values.length; - final sweep = 2 * math.pi / n; - const gap = 0.035; // radians between sectors - - final paintSeg = Paint()..style = PaintingStyle.stroke; - - for (var i = 0; i < n; i++) { - final v = values[i]; - final a0 = startAngle + sweep * i + gap / 2; - final sw = sweep - gap; - final level = v == null ? 0 : (v.clamp(0.0, 1.0) * rings * reveal); - for (var r = 0; r < rings; r++) { - final radius = innerR + ringW * r + ringW / 2; - paintSeg.strokeWidth = ringW - 2.5; - // Ring r is "on" when intensity reaches it; partial top ring fades in. - final fill = (level - r).clamp(0.0, 1.0); - paintSeg.color = fill <= 0 - ? track - : Color.lerp(track, color, 0.25 + 0.75 * fill)!; - canvas.drawArc( - Rect.fromCircle(center: c, radius: radius), - a0, - sw, - false, - paintSeg, - ); - } - } - - // Quiet labels: one per sector (drawn at its mid-angle) when the counts - // match, else the classic ≤4 compass labels at N/E/S/W. - final ls = labels; - if (ls != null && ls.isNotEmpty) { - final perSector = ls.length == n; - final count = perSector ? n : math.min(ls.length, 4); - for (var k = 0; k < count; k++) { - final a = perSector - ? startAngle + sweep * (k + 0.5) - : startAngle + (2 * math.pi / math.min(ls.length, 4)) * k; - final p = c + Offset(math.cos(a), math.sin(a)) * (outerR + 7); - final tp = TextPainter( - text: TextSpan(text: ls[k], style: labelStyle), - textDirection: TextDirection.ltr, - )..layout(); - tp.paint(canvas, p - Offset(tp.width / 2, tp.height / 2)); - } - } - } - - @override - bool shouldRepaint(_RadialHeatmapPainter old) => - old.values != values || - old.color != color || - old.reveal != reveal || - old.rings != rings; -} diff --git a/lib/ui/design/recap_card.dart b/lib/ui/design/recap_card.dart index 3f4e5555..a94c763e 100644 --- a/lib/ui/design/recap_card.dart +++ b/lib/ui/design/recap_card.dart @@ -1,131 +1,16 @@ -// RecapCard + MedalCard — the "weekly recap" and "achievement medal" -// compositions from the refs. +// MedalCard — an inverted (ink) achievement card with an engraved medal disc: +// personal records, streak milestones. Restrained metal, no confetti. // -// • [RecapCard] — a headline period recap: title, one highlight sentence in -// a soft banner, a big average figure, and a quiet bar strip of the week. -// The whole card taps through to the full recap screen. -// • [MedalCard] — an inverted (ink) achievement card with an engraved medal -// disc: personal records, streak milestones. Restrained metal, no confetti. +// This file also held RecapCard (a headline period recap with a bar strip). +// The recap screen builds its own composition, so RecapCard never had a call +// site outside the gallery and was removed. import 'package:flutter/material.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; -import '../kit/charts.dart' show MiniBars; import '../kit/kit.dart' show AppIcon, OsIcon; import 'bento.dart'; -import 'big_stat.dart'; - -class RecapCard extends StatelessWidget { - /// 'Weekly recap', 'January'… - final String title; - - /// One highlight sentence ('You slept 40 min more than usual'). - final String? highlight; - - /// The headline figure ('7h 12m', '11 840'). - final String? value; - final String? unit; - - /// Label under the value ('daily average'). - final String? caption; - - /// A small bar strip (e.g. 7 daily values; nulls = gaps). - final List? bars; - - final Color? accent; - final VoidCallback? onTap; - - const RecapCard({ - super.key, - required this.title, - this.highlight, - this.value, - this.unit, - this.caption, - this.bars, - this.accent, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - final a = accent ?? AppColors.accent; - // Nulls go THROUGH to MiniBars, which keeps their slots empty. Stripping - // them here compacted the strip: a week missing Wednesday drew six bars - // with Thu–Sun shifted a day left, silently re-dating every value after - // the gap. - final barStrip = bars ?? const []; - return BentoTile( - tone: BentoTone.paper, - accent: a, - padding: const EdgeInsets.all(Sp.x4), - onTap: onTap, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - TileHeader( - title, - trailing: onTap == null - ? null - : AppIcon(OsIcon.arrowRight, size: 14, color: AppColors.inkMuted), - ), - if (highlight != null) ...[ - const SizedBox(height: Sp.x3), - Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: Sp.x3, - vertical: Sp.x2 + 2, - ), - decoration: BoxDecoration( - color: a.withValues(alpha: AppColors.isDark ? 0.16 : 0.10), - borderRadius: BorderRadius.circular(R.chip), - ), - child: Text( - highlight!, - style: AppText.caption.copyWith( - color: AppColors.ink, - fontWeight: FontWeight.w700, - height: 1.35, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ), - ], - if (value != null) ...[ - const SizedBox(height: Sp.x3), - Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: BigStat( - value: value, - unit: unit, - caption: caption, - size: BigStatSize.md, - ), - ), - // Gate on how many slots actually CARRY a value (a strip of - // one real bar plus six gaps isn't a trend), but draw the - // full-length strip so the bars keep their days. - if (barStrip.whereType().length >= 2) ...[ - const SizedBox(width: Sp.x3), - SizedBox( - width: 96, - child: MiniBars(barStrip, color: a, height: 34), - ), - ], - ], - ), - ], - ], - ), - ); - } -} /// An inverted achievement card with an engraved medal disc. class MedalCard extends StatelessWidget { diff --git a/lib/ui/design/state_chips.dart b/lib/ui/design/state_chips.dart index b7e062bb..5b20fe04 100644 --- a/lib/ui/design/state_chips.dart +++ b/lib/ui/design/state_chips.dart @@ -1,13 +1,18 @@ -// StateChips — the mood/state chip row from the refs: a horizontally -// scrollable set of pill chips (emoji or icon + word), single-select, calm. -// Used for journal moods, coach intents ('Energize', 'Recover', 'Focus'), -// filter rows. Selection is a soft accent fill — never a colour explosion. +// State chips — the calm pill vocabulary: emoji-or-icon + word, soft accent +// fill when on, never a colour explosion. // -// StateChips( -// chips: [StateChip('Energize', emoji: '⚡'), StateChip('Recover', …)], -// selected: 1, // null = nothing selected -// onSelect: (i) => …, -// ) +// • [StateChipView] — ONE pill. Display-only (no onTap) or interactive. +// The Today readiness ring puts one under the score ('Push' / 'Focus' / +// 'Recover'), tinted with the score colour. +// • [ToggleChip] — an independent on/off pill for multi-select rows +// (journal tags, cycle symptoms, notification kinds), painted from +// [StateChipView]'s tokens so both stay one look. +// +// There used to be a single-select `StateChips` row here too. Nothing in the +// app single-selects a chip row (journal/cycle/profile all multi-select via +// ToggleChip), so it was removed rather than kept as gallery-only scaffolding +// — a Wrap of [StateChipView]s is the same thing in six lines if it's ever +// wanted back. import 'package:flutter/material.dart'; import '../kit/os_icons.dart'; @@ -24,35 +29,49 @@ class StateChip { const StateChip(this.label, {this.emoji, this.icon}); } -/// ToggleChip — the multi-select sibling of [StateChips]: one independent -/// on/off pill (journal tags, cycle symptoms). Soft accent fill + tinted -/// hairline when on; calm surface otherwise. Never a colour explosion. -class ToggleChip extends StatelessWidget { - final String label; +/// Soft fill for a chip in its ON state. With no [accent] this is the brand +/// accent-soft token; with one, the accent is blended into the surface so a +/// domain- or score-tinted chip reads as the same material, not as a second +/// colour system. +Color _chipFill(Color? accent) => accent == null + ? AppColors.accentSoft + : Color.alphaBlend( + accent.withValues(alpha: AppColors.isDark ? 0.18 : 0.13), + Elevation.surfaceAt(1), + ); + +/// Ink for a chip in its ON state — the readable on-accent token by default, +/// the accent itself when the caller tinted the chip. +Color _chipInk(Color? accent) => accent ?? AppColors.onAccentSoft; + +/// StateChipView — ONE calm pill: icon/emoji + word, soft accent fill when on. +/// +/// The single shared renderer behind every chip in the system, [ToggleChip] +/// included. Pass [onTap] for an interactive chip; leave it null for a +/// display-only badge (the Today readiness ring's status word). +class StateChipView extends StatelessWidget { + final StateChip chip; final bool selected; + final Color? accent; final VoidCallback? onTap; - /// Domain accent; defaults to the brand accent (soft fill + accent ink). - final Color? accent; + /// Slightly tighter padding for chips that sit inside a constrained + /// container (e.g. within the readiness ring). + final bool dense; - const ToggleChip( - this.label, { + const StateChipView( + this.chip, { super.key, - required this.selected, - this.onTap, + this.selected = false, this.accent, + this.onTap, + this.dense = false, }); @override Widget build(BuildContext context) { final a = accent ?? AppColors.accent; - final ink = accent == null ? AppColors.onAccentSoft : a; - final fill = accent == null - ? AppColors.accentSoft - : Color.alphaBlend( - a.withValues(alpha: AppColors.isDark ? 0.18 : 0.13), - Elevation.surfaceAt(1), - ); + final ink = selected ? _chipInk(accent) : AppColors.inkSoft; return Pressable( pressedScale: 0.94, borderRadius: BorderRadius.circular(R.pill), @@ -60,98 +79,84 @@ class ToggleChip extends StatelessWidget { child: AnimatedContainer( duration: Motion.fast, curve: Motion.curve, - padding: const EdgeInsets.symmetric(horizontal: Sp.x3, vertical: Sp.x2), + padding: EdgeInsets.symmetric( + horizontal: dense ? Sp.x2 + 2 : Sp.x3 + 2, + vertical: dense ? 5 : 8, + ), decoration: BoxDecoration( - color: selected ? fill : Elevation.surfaceAt(1), + color: selected ? _chipFill(accent) : Elevation.surfaceAt(1), borderRadius: BorderRadius.circular(R.pill), border: Border.all( color: selected ? a.withValues(alpha: 0.55) : AppColors.divider, ), ), - child: Text( - label, - style: AppText.label.copyWith( - color: selected ? ink : AppColors.inkSoft, - fontWeight: FontWeight.w700, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (chip.emoji != null) ...[ + Text(chip.emoji!, style: const TextStyle(fontSize: 13)), + const SizedBox(width: Sp.x1 + 2), + ] else if (chip.icon != null) ...[ + AppIcon(chip.icon!, size: dense ? 13 : 14, color: ink), + const SizedBox(width: Sp.x1 + 2), + ], + Text( + chip.label, + style: AppText.caption.copyWith( + fontWeight: FontWeight.w700, + color: ink, + ), + ), + ], ), ), ); } } -class StateChips extends StatelessWidget { - final List chips; - final int? selected; - final ValueChanged? onSelect; - final Color? accent; +/// ToggleChip — the multi-select pill: one independent on/off chip (journal +/// tags, cycle symptoms). Soft accent fill + tinted hairline when on; calm +/// surface otherwise. Same tokens as [StateChipView], label-only (no glyph). +class ToggleChip extends StatelessWidget { + final String label; + final bool selected; + final VoidCallback? onTap; - /// Scroll horizontally (default) or wrap to multiple lines. - final bool wrap; + /// Domain accent; defaults to the brand accent (soft fill + accent ink). + final Color? accent; - const StateChips({ + const ToggleChip( + this.label, { super.key, - required this.chips, - this.selected, - this.onSelect, + required this.selected, + this.onTap, this.accent, - this.wrap = false, }); @override Widget build(BuildContext context) { - final children = [ - for (var i = 0; i < chips.length; i++) - _chip(context, i, chips[i], i == selected), - ]; - if (wrap) { - return Wrap(spacing: Sp.x2, runSpacing: Sp.x2, children: children); - } - return SizedBox( - height: 40, - child: ListView.separated( - scrollDirection: Axis.horizontal, - physics: const BouncingScrollPhysics(), - itemCount: children.length, - separatorBuilder: (_, _) => const SizedBox(width: Sp.x2), - itemBuilder: (_, i) => Center(child: children[i]), - ), - ); - } - - Widget _chip(BuildContext context, int i, StateChip c, bool on) { final a = accent ?? AppColors.accent; return Pressable( pressedScale: 0.94, - onTap: onSelect == null ? null : () => onSelect!(i), + borderRadius: BorderRadius.circular(R.pill), + onTap: onTap, child: AnimatedContainer( duration: Motion.fast, - padding: const EdgeInsets.symmetric(horizontal: Sp.x3 + 2, vertical: 8), + curve: Motion.curve, + padding: const EdgeInsets.symmetric(horizontal: Sp.x3, vertical: Sp.x2), decoration: BoxDecoration( - color: on ? AppColors.accentSoft : Elevation.surfaceAt(1), + color: selected ? _chipFill(accent) : Elevation.surfaceAt(1), borderRadius: BorderRadius.circular(R.pill), border: Border.all( - color: on ? a.withValues(alpha: 0.55) : AppColors.divider, + color: selected ? a.withValues(alpha: 0.55) : AppColors.divider, ), ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (c.emoji != null) ...[ - Text(c.emoji!, style: const TextStyle(fontSize: 13)), - const SizedBox(width: Sp.x1 + 2), - ] else if (c.icon != null) ...[ - AppIcon(c.icon!, size: 14, color: on ? AppColors.onAccentSoft : AppColors.inkSoft), - const SizedBox(width: Sp.x1 + 2), - ], - Text( - c.label, - style: AppText.caption.copyWith( - fontWeight: FontWeight.w700, - color: on ? AppColors.onAccentSoft : AppColors.inkSoft, - ), - ), - ], + child: Text( + label, + style: AppText.label.copyWith( + color: selected ? _chipInk(accent) : AppColors.inkSoft, + fontWeight: FontWeight.w700, + ), ), ), ); diff --git a/lib/ui/kit/charts.dart b/lib/ui/kit/charts.dart index 67c36040..859fac23 100644 --- a/lib/ui/kit/charts.dart +++ b/lib/ui/kit/charts.dart @@ -1,14 +1,14 @@ -// OpenStrap chart kit — rings, sparkline bars, labeled week bars, area sparks, -// the coral dot-matrix, and the composite StatTile. All paper-on-coral styled. +// OpenStrap chart kit — rings, gauges, sparkline bars, labeled week bars, the +// time-series chart family and the workout HR/zone strips. All paper-on-coral +// styled. Every widget here has at least one live call site in the app; the +// dead ornamental ones (area spark, dot-matrix, calendar heatmap, composite +// stat tile, baseline progress) were removed rather than kept "just in case". import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:fl_chart/fl_chart.dart'; -import '../../models/metric.dart'; import '../../theme/theme.dart'; import '../../theme/tokens.dart'; -import 'kit.dart'; import '../design/arc_gauge.dart'; import '../design/controls.dart' show StatusChip, ChipTone; import '../design/domains.dart' show DomainAccent; @@ -87,94 +87,6 @@ class RingStat extends StatelessWidget { ); } -/// BaselineProgress — the honest "still learning you" state, rendered as a -/// partially-filled [Gauge] (nights collected / nights needed) with the count -/// remaining in the centre and a line saying what it unlocks. Replaces the bare -/// "Need N more nights" text where a baseline is still filling in. -class BaselineProgress extends StatelessWidget { - final int collected; - final int needed; - final String unlocks; // e.g. 'to unlock Readiness' - final Color? color; - final double size; - const BaselineProgress({ - super.key, - required this.collected, - required this.needed, - this.unlocks = '', - this.color, - this.size = 150, - }); - - /// Build from a baseline-gated [Metric] (`need_baseline:have=H,need=N`). - /// Returns null if the metric isn't a baseline abstention. - static BaselineProgress? fromMetric( - Metric m, { - String unlocks = '', - Color? color, - double size = 150, - Key? key, - }) { - final note = m.note; - if (note == null || !note.contains('need_baseline:')) return null; - final match = RegExp(r'have=(\d+),need=(\d+)').firstMatch(note); - if (match == null) return null; - final have = int.tryParse(match.group(1)!) ?? 0; - final need = int.tryParse(match.group(2)!) ?? 0; - if (need <= 0) return null; - return BaselineProgress( - key: key, - collected: have.clamp(0, need), - needed: need, - unlocks: unlocks, - color: color, - size: size, - ); - } - - @override - Widget build(BuildContext context) { - final c = color ?? AppColors.coral; - final remaining = (needed - collected).clamp(0, needed); - final frac = needed == 0 ? 0.0 : (collected / needed).clamp(0.0, 1.0); - final numSize = (size * 0.28).clamp(20.0, 44.0); - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Gauge( - t: frac, - color: c, - size: size, - stroke: size < 110 ? 10 : 12, - center: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('$remaining', style: AppText.display.copyWith(fontSize: numSize)), - Text( - remaining == 1 ? 'night to go' : 'nights to go', - style: AppText.caption.copyWith(fontSize: size < 110 ? 9.5 : 12), - ), - ], - ), - ), - if (unlocks.isNotEmpty) ...[ - const SizedBox(height: Sp.x4), - Text( - unlocks, - style: AppText.bodySoft, - textAlign: TextAlign.center, - ), - const SizedBox(height: Sp.x2), - Text( - '$collected of $needed nights', - style: AppText.captionMuted, - ), - ], - ], - ); - } -} - /// Tiny sparkline bars (for inside cards). Values normalized to their own max. class MiniBars extends StatelessWidget { /// Bar values. A NULL entry is a documented gap (nothing was measured for @@ -359,62 +271,6 @@ class LabeledBars extends StatelessWidget { } } -/// Smooth area spark (HR / strain over a window) using fl_chart. -class AreaSpark extends StatelessWidget { - final List values; - final Color? color; - final double height; - const AreaSpark(this.values, {super.key, this.color, this.height = 90}); - @override - Widget build(BuildContext context) { - final color = this.color ?? AppColors.coral; - if (values.length < 2) { - return SizedBox( - height: height, - child: Center( - child: Text('Not enough data yet', style: AppText.captionMuted), - ), - ); - } - final spots = [ - for (int i = 0; i < values.length; i++) FlSpot(i.toDouble(), values[i]), - ]; - final minY = values.reduce(math.min); - final maxY = values.reduce(math.max); - return SizedBox( - height: height, - child: LineChart( - LineChartData( - minY: minY - (maxY - minY) * 0.15 - 0.5, - maxY: maxY + (maxY - minY) * 0.15 + 0.5, - gridData: const FlGridData(show: false), - titlesData: const FlTitlesData(show: false), - borderData: FlBorderData(show: false), - lineTouchData: const LineTouchData(enabled: false), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: true, - curveSmoothness: 0.3, - color: color, - barWidth: 3, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [color.withValues(alpha: 0.28), Colors.transparent], - ), - ), - ), - ], - ), - ), - ); - } -} - class TimeSeriesPoint { final double x; final double y; @@ -1158,63 +1014,6 @@ class ZoneTimelineBar extends StatelessWidget { } } -/// Coral dot-matrix column chart (ref #2 Stats). Each column is a stack of -/// rounded squares; filled count ∝ value. Great for week/month step-like data. -class DotMatrix extends StatelessWidget { - final List values; - final int rows; - final Color? color; - final double cell; - const DotMatrix( - this.values, { - super.key, - this.rows = 12, - this.color, - this.cell = 12, - }); - @override - Widget build(BuildContext context) { - final color = this.color ?? AppColors.coral; - if (values.isEmpty) return const SizedBox.shrink(); - final maxV = math.max(1.0, values.reduce(math.max)); - return LayoutBuilder( - builder: (context, c) { - return SizedBox( - height: rows * (cell + 4), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - for (final v in values) - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - for (int r = rows - 1; r >= 0; r--) - Padding( - padding: const EdgeInsets.all(2), - child: Container( - height: cell, - decoration: BoxDecoration( - color: ((v / maxV) * rows) > r - ? color.withValues( - alpha: 0.45 + 0.55 * (r / rows), - ) - : color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(4), - ), - ), - ), - ], - ), - ), - ], - ), - ); - }, - ); - } -} - /// Horizontal multi-segment bar (HR zones z1..z5). class SegmentBar extends StatelessWidget { final List values; @@ -1255,150 +1054,6 @@ class SegmentBar extends StatelessWidget { } } -/// Composite stat tile: icon + label, big number + unit, optional delta + spark. -/// Renders "—" muted when [value] is null. Confidence dot + honesty tag optional. -class StatTile extends StatelessWidget { - final OsIcon icon; - final String label; - final String? value; - final String? unit; - final num? deltaPct; - final bool deltaGoodIsUp; - final List? spark; - final Color? accent; - final double? confidence; - final Widget? tag; - final VoidCallback? onTap; - const StatTile({ - super.key, - required this.icon, - required this.label, - required this.value, - this.unit, - this.deltaPct, - this.deltaGoodIsUp = true, - this.spark, - this.accent, - this.confidence, - this.tag, - this.onTap, - }); - @override - Widget build(BuildContext context) { - final accent = this.accent ?? AppColors.coral; - return ConstrainedBox( - constraints: const BoxConstraints(minHeight: 110), - child: ProCard( - onTap: onTap == null - ? null - : () { - HapticFeedback.selectionClick(); - onTap!(); - }, - pressScale: onTap != null, - padding: const EdgeInsets.all(Sp.x3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - // Tonal fill so the full-saturation icon on top - // doesn't sit on an equally-bright wash of itself. - color: AppColors.tonalFill(accent), - borderRadius: BorderRadius.circular(R.chip), - ), - child: AppIcon(icon, size: 16, color: accent), - ), - const SizedBox(width: Sp.x2), - Expanded( - child: Text( - label, - style: AppText.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (confidence != null) ConfDot(confidence!), - ], - ), - const SizedBox(height: Sp.x3), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - if (value == null) - metricDash(24) - else - Flexible( - child: Text( - value!, - style: AppText.metric.copyWith(fontSize: 22), - overflow: TextOverflow.ellipsis, - ), - ), - if (unit != null && value != null) ...[ - const SizedBox(width: 4), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Text( - unit!, - style: AppText.caption.copyWith( - color: AppColors.inkMuted, - fontSize: 11, - ), - ), - ), - ], - ], - ), - ], - ), - if (deltaPct != null || tag != null || spark != null) ...[ - const SizedBox(height: Sp.x2), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (deltaPct != null) - Flexible( - child: DeltaChip(deltaPct, goodIsUp: deltaGoodIsUp), - ), - if (tag != null) ...[ - if (deltaPct != null) const SizedBox(width: Sp.x2), - Flexible(child: tag!), - ], - ], - ), - ), - if (spark != null && spark!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(left: Sp.x2), - child: SizedBox( - width: 48, - child: MiniBars(spark!, color: accent, height: 22), - ), - ), - ], - ), - ], - ], - ), - ), - ); - } -} - /// FormChart — Banister Fitness vs Fatigue dual line (with a soft band between), /// for the Body tab. Pass aligned series (oldest→newest); nulls are skipped. class FormChart extends StatelessWidget { @@ -1471,70 +1126,3 @@ class FormChart extends StatelessWidget { ); } } - -/// CalendarHeatmap — a month grid (weeks × 7) of cells colored by a metric. Pass -/// day entries with a 0..1 intensity `t` and a base color; null `t` = no data. -class CalendarHeatmap extends StatelessWidget { - final List<({DateTime date, double? t})> days; - final Color? color; - final double cell; - const CalendarHeatmap({ - super.key, - required this.days, - this.color, - this.cell = 16, - }); - @override - Widget build(BuildContext context) { - final color = this.color ?? AppColors.good; - if (days.isEmpty) return const SizedBox.shrink(); - const wd = ['M', 'T', 'W', 'T', 'F', 'S', 'S']; - // Pad the front so the first day lands on its weekday column (Mon=0). - final first = days.first.date; - final lead = (first.weekday + 6) % 7; // Mon=0 - final cells = <({DateTime? date, double? t})>[ - for (int i = 0; i < lead; i++) (date: null, t: null), - for (final d in days) (date: d.date, t: d.t), - ]; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - for (final l in wd) - SizedBox( - width: cell + 4, - child: Text( - l, - textAlign: TextAlign.center, - style: AppText.captionMuted, - ), - ), - ], - ), - const SizedBox(height: 4), - Wrap( - spacing: 4, - runSpacing: 4, - children: [ - for (final c in cells) - Container( - width: cell, - height: cell, - decoration: BoxDecoration( - color: c.date == null - ? Colors.transparent - : (c.t == null - ? AppColors.surfaceSunk - : color.withValues( - alpha: (0.18 + 0.82 * c.t!.clamp(0, 1)), - )), - borderRadius: BorderRadius.circular(4), - ), - ), - ], - ), - ], - ); - } -} diff --git a/lib/ui/kit/kit.dart b/lib/ui/kit/kit.dart index 4b832eb8..aac83165 100644 --- a/lib/ui/kit/kit.dart +++ b/lib/ui/kit/kit.dart @@ -223,27 +223,6 @@ class GlowCard extends StatelessWidget { } } -/// Dark hero card (device, splash overlays). -class NightCard extends StatelessWidget { - final Widget child; - final EdgeInsetsGeometry padding; - final VoidCallback? onTap; - const NightCard({ - super.key, - required this.child, - this.padding = const EdgeInsets.all(Sp.x6), - this.onTap, - }); - @override - Widget build(BuildContext context) => ProCard( - padding: padding, - onTap: onTap, - color: AppColors.night, - shadow: Shadows.lift, - child: child, - ); -} - /// Wrap each non-spacer widget in a hand-built list with a staggered [Entrance] /// (delay by list position), for a one-time fade-up reveal of a ListView's /// children. Bare [SizedBox] spacers pass through untouched so gaps don't move. diff --git a/lib/ui/timeline/timeline_screen.dart b/lib/ui/timeline/timeline_screen.dart index 999767a8..ce0503be 100644 --- a/lib/ui/timeline/timeline_screen.dart +++ b/lib/ui/timeline/timeline_screen.dart @@ -17,12 +17,14 @@ // HONESTY: only continuously-recorded vitals are drawn — HR, HRV, resp (rolling // RSA) and a RELATIVE skin-temp trend (no absolute °C). HRV/resp are movement- // confounded by day (explained behind the (i)). +// +// This file is presentation only: [TimelineContent] takes an already-loaded day +// bundle. It used to also carry a standalone `TimelineScreen` that fetched the +// bundle itself, but the timeline is only ever reached embedded in the Journey +// screen (which does its own loading), so that wrapper was removed. import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import '../../data/local_repository.dart'; -import '../../state/app_state.dart'; import '../design/design.dart'; // Strong, opposite, nature-matched vital colours (deliberately NOT the light @@ -111,77 +113,6 @@ class _Band { const _Band(this.label, this.color, this.start, this.end, this.icon); } -class TimelineScreen extends StatefulWidget { - final String date; - const TimelineScreen({super.key, required this.date}); - @override - State createState() => _TimelineScreenState(); -} - -enum _Phase { loading, ready, empty, error } - -class _TimelineScreenState extends State { - _Phase _phase = _Phase.loading; - Map _data = const {}; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() => _phase = _Phase.loading); - try { - final LocalRepository? repo = context.read().repo; - final d = await repo?.getDayTimeline(widget.date); - if (!mounted) return; - setState(() { - _data = d ?? const {}; - _phase = TimelineContent.hasVitals(_data) - ? _Phase.ready - : _Phase.empty; - }); - } catch (_) { - if (mounted) setState(() => _phase = _Phase.error); - } - } - - @override - Widget build(BuildContext context) { - return AppScaffold( - title: 'Your timeline', - subtitle: 'Every vital, one day', - children: [ - if (_phase == _Phase.loading) ...[ - Skeleton.tileRow(rows: 1), - const SizedBox(height: Sp.x4), - Skeleton.chart(height: 280), - ] else if (_phase == _Phase.empty) - StateCard( - icon: OsIcon.heartRate, - title: 'No timeline yet', - message: - 'Wear the strap through the day and your merged vitals ' - 'timeline will appear here.', - actionLabel: 'Try again', - onAction: _load, - ) - else if (_phase == _Phase.error) - StateCard( - icon: OsIcon.sync, - title: "Couldn't load your timeline", - message: 'Please try again.', - actionLabel: 'Try again', - onAction: _load, - ) - else - TimelineContent(data: _data), - ], - ); - } -} - /// Pure presentation for the merged-vitals board (render-testable without a /// repo): selector chips, the merged normalized chart, peak/low BigStats for /// the active vital, and the day's event list. diff --git a/lib/ui/today/today_screen.dart b/lib/ui/today/today_screen.dart index a6d22cbc..7d450e0a 100644 --- a/lib/ui/today/today_screen.dart +++ b/lib/ui/today/today_screen.dart @@ -960,12 +960,15 @@ class TodayVitals extends StatelessWidget { // uses (40/66) — the ring's word and the AI briefing's band must always // agree, or the app can tell the user two different things about the // same score again (exactly the bug this shared source of truth fixes). - final word = score == null - ? null + // + // The word is a state you're in, phrased as what today's training should + // be, and renders as a state chip inside the ring (see OrbitScore.word). + final (word, wordIcon) = score == null + ? (null, null) : switch (readinessBand(score)) { - 'good' => 'Primed', - 'moderate' => 'Steady', - _ => 'Run easy', + 'good' => ('Push', OsIcon.intensity), + 'moderate' => ('Focus', OsIcon.activity), + _ => ('Recover', OsIcon.calm), }; // Honest "still learning you" center: nights-to-go over a dashed @@ -997,6 +1000,7 @@ class TodayVitals extends StatelessWidget { score: score, label: 'Readiness', word: word, + wordIcon: wordIcon, color: accent, confidence: score == null ? 0.3 : r.confidence, ringFill: (score == null && fill != null) ? fill.$1 / fill.$2 : null, diff --git a/test/absent_not_zero_test.dart b/test/absent_not_zero_test.dart index 79487172..3e4d80d6 100644 --- a/test/absent_not_zero_test.dart +++ b/test/absent_not_zero_test.dart @@ -11,7 +11,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:openstrap_edge/theme/theme.dart'; import 'package:openstrap_edge/theme/tokens.dart'; -import 'package:openstrap_edge/ui/design/recap_card.dart' show RecapCard; import 'package:openstrap_edge/ui/kit/charts.dart' show HrReplayOverlay, LabeledBars, MiniBars, TimeSeriesPoint; import 'package:openstrap_edge/ui/kit/kit.dart' show OsIcon; @@ -435,16 +434,16 @@ void main() { }); }); - // ── recap strip gaps ────────────────────────────────────────────────────── - group('RecapCard week strip', () { + // ── week-strip gaps ─────────────────────────────────────────────────────── + // This used to go through RecapCard (now deleted — it had no call site + // outside the gallery). The invariant it guarded is MiniBars' own: a null + // day holds its slot instead of sliding the rest of the week left. + group('MiniBars week strip', () { testWidgets('keeps a missing day in place instead of shifting the week ' 'left', (t) async { _phone(t); - await t.pumpWidget(_host(const RecapCard( - title: 'Weekly recap', - value: '7h 12m', - caption: 'daily average', - bars: [420.0, 430.0, null, 445.0, 455.0, 460.0, 470.0], + await t.pumpWidget(_host(const MiniBars( + [420.0, 430.0, null, 445.0, 455.0, 460.0, 470.0], ))); await t.pump(const Duration(milliseconds: 700)); final bars = t.widget(find.byType(MiniBars)); diff --git a/test/ai_briefing_test.dart b/test/ai_briefing_test.dart index 9d536768..2eee28dd 100644 --- a/test/ai_briefing_test.dart +++ b/test/ai_briefing_test.dart @@ -148,13 +148,13 @@ void main() { test( 'readinessBand cuts at 40/66 — MUST match the Today ring\'s own ' - 'word-thresholds (score>=66 Primed, >=40 Steady, else Run easy) or ' + 'word-thresholds (score>=66 Push, >=40 Focus, else Recover) or ' 'the briefing and the ring can disagree again', () { // Just below/at each ring boundary. - expect(readinessBand(39), 'low'); // ring: "Run easy" - expect(readinessBand(40), 'moderate'); // ring: "Steady" - expect(readinessBand(65), 'moderate'); // ring: "Steady" - expect(readinessBand(66), 'good'); // ring: "Primed" + expect(readinessBand(39), 'low'); // ring: "Recover" + expect(readinessBand(40), 'moderate'); // ring: "Focus" + expect(readinessBand(65), 'moderate'); // ring: "Focus" + expect(readinessBand(66), 'good'); // ring: "Push" expect(readinessBand(100), 'good'); expect(readinessBand(0), 'low'); }); diff --git a/test/core_screens_test.dart b/test/core_screens_test.dart index 157a5dc5..f487d064 100644 --- a/test/core_screens_test.dart +++ b/test/core_screens_test.dart @@ -112,7 +112,7 @@ void main() { ); await t.pump(const Duration(milliseconds: 1200)); expect(find.text('READINESS'), findsOneWidget); - expect(find.text('Primed'), findsOneWidget); // 82 → primed + expect(find.text('Push'), findsOneWidget); // 82 → good band → 'Push' chip expect(find.text('48'), findsWidgets); // HRV value expect(find.text('52'), findsWidgets); // RHR value expect(find.text('12.4'), findsOneWidget); // strain — quick-stats row diff --git a/test/design_redesign_test.dart b/test/design_redesign_test.dart index 72897f7b..f1121495 100644 --- a/test/design_redesign_test.dart +++ b/test/design_redesign_test.dart @@ -14,10 +14,8 @@ import 'package:openstrap_edge/theme/tokens.dart'; import 'package:openstrap_edge/ui/design/ai_hero.dart'; import 'package:openstrap_edge/ui/design/bento.dart'; import 'package:openstrap_edge/ui/design/big_stat.dart'; -import 'package:openstrap_edge/ui/design/domains.dart'; import 'package:openstrap_edge/ui/design/hypnogram.dart'; import 'package:openstrap_edge/ui/design/orbit_score.dart'; -import 'package:openstrap_edge/ui/design/radial_heatmap.dart'; import 'package:openstrap_edge/ui/design/recap_card.dart'; import 'package:openstrap_edge/ui/design/ring_week.dart'; import 'package:openstrap_edge/ui/design/state_chips.dart'; @@ -62,47 +60,33 @@ void main() { tearDown(() => AppColors.active = kLightPalette); group('OrbitScore', () { - testWidgets('score, word, label render; core + satellite taps fire', ( + testWidgets('score, label + word chip render; core tap fires', ( t, ) async { _phone(t); var core = 0; - final opened = []; await t.pumpWidget( _host( OrbitScore( score: 82, label: 'Readiness', - word: 'Primed', + word: 'Push', + wordIcon: OsIcon.intensity, onTap: () => core++, - satellites: [ - OrbitSatellite( - icon: OsIcon.sleep, - label: 'Sleep', - onTap: () => opened.add('sleep'), - ), - OrbitSatellite( - icon: OsIcon.heart, - label: 'Heart', - onTap: () => opened.add('heart'), - ), - ], ), ), ); await t.pump(const Duration(milliseconds: 1200)); expect(find.text('READINESS'), findsOneWidget); expect(find.text('82'), findsOneWidget); - expect(find.text('Primed'), findsOneWidget); - expect(find.text('Sleep'), findsOneWidget); + // The status word renders as a StateChipView pill, not bare text. + expect(find.text('Push'), findsOneWidget); + expect(find.byType(StateChipView), findsOneWidget); expect(t.takeException(), isNull); await t.tap(find.text('82')); await t.pump(const Duration(milliseconds: 250)); expect(core, 1); - await t.tap(find.text('Sleep')); - await t.pump(const Duration(milliseconds: 250)); - expect(opened, ['sleep']); }); testWidgets('null score with ringFill + custom center stays honest', ( @@ -282,24 +266,7 @@ void main() { }); }); - group('RadialHeatmap + RingWeek', () { - testWidgets('RadialHeatmap handles nulls + labels without throwing', ( - t, - ) async { - _phone(t); - await t.pumpWidget( - _host( - RadialHeatmap( - values: const [0.1, null, 0.8, 1.0, 0.4, 0.0, null, 0.6], - color: DomainAccent.strain, - labels: const ['12a', '6a', '12p', '6p'], - ), - ), - ); - await t.pump(const Duration(milliseconds: 1100)); - expect(t.takeException(), isNull); - }); - + group('RingWeek', () { testWidgets('RingWeek renders custom labels + null days', (t) async { _phone(t); await t.pumpWidget( @@ -318,65 +285,60 @@ void main() { }); }); - group('StateChips + RecapCard + MedalCard + AiHero', () { - testWidgets('StateChips selects on tap', (t) async { + group('StateChipView + MedalCard + AiHero', () { + testWidgets('StateChipView fires onTap; display-only chip does not', ( + t, + ) async { _phone(t); - var sel = 0; + var taps = 0; await t.pumpWidget( _host( - StatefulBuilder( - builder: (context, setState) => StateChips( - chips: const [ - StateChip('Energize', emoji: '⚡'), - StateChip('Recover', emoji: '🛌'), - ], - selected: sel, - onSelect: (i) => setState(() => sel = i), - ), + Column( + children: [ + StateChipView( + const StateChip('Recover', icon: OsIcon.calm), + selected: true, + onTap: () => taps++, + ), + // No onTap → a display badge. Tapping it must stay inert. + const StateChipView( + StateChip('Push', icon: OsIcon.intensity), + selected: true, + ), + ], ), ), ); await t.pump(const Duration(milliseconds: 300)); await t.tap(find.text('Recover')); await t.pump(const Duration(milliseconds: 300)); - expect(sel, 1); + expect(taps, 1); + await t.tap(find.text('Push')); + await t.pump(const Duration(milliseconds: 300)); + expect(taps, 1); + expect(t.takeException(), isNull); }); - testWidgets('RecapCard + MedalCard render and tap through', (t) async { + testWidgets('MedalCard renders and taps through', (t) async { for (final p in [kLightPalette, kDarkPalette]) { _phone(t); var taps = 0; await t.pumpWidget( _host( - Column( - children: [ - RecapCard( - title: 'Weekly recap', - highlight: 'You slept 40 min more than usual.', - value: '7h 12m', - caption: 'daily average', - bars: const [6.2, 7.5, 8.1, 6.9, 7.2, 8.4, 7.1], - onTap: () => taps++, - ), - const SizedBox(height: Sp.x3), - MedalCard( - medal: '5K', - overline: 'Personal record', - title: 'Fastest 5k — 24:31', - subtitle: 'Tuesday morning run', - onTap: () {}, - ), - ], + MedalCard( + medal: '5K', + overline: 'Personal record', + title: 'Fastest 5k — 24:31', + subtitle: 'Tuesday morning run', + onTap: () => taps++, ), palette: p, ), ); await t.pump(const Duration(milliseconds: 500)); - expect(find.text('WEEKLY RECAP'), findsOneWidget); - expect(find.text('7h 12m'), findsOneWidget); expect(find.text('Fastest 5k — 24:31'), findsOneWidget); expect(t.takeException(), isNull); - await t.tap(find.text('7h 12m')); + await t.tap(find.text('Fastest 5k — 24:31')); await t.pump(const Duration(milliseconds: 250)); expect(taps, 1); } @@ -451,7 +413,7 @@ void main() { // Hero (no floating satellites anymore) + the demoted quick-stats // row underneath it. expect(find.text('READINESS'), findsOneWidget); - expect(find.text('Primed'), findsOneWidget); + expect(find.text('Push'), findsOneWidget); expect(find.text('Sleep'), findsOneWidget); // quick-stats row route // Bento numbers. RHR also appears in the quick-stats row (Heart), // so it matches >1; HRV is bento-only (shown as an AI-briefing diff --git a/test/ui_kit_new_widgets_test.dart b/test/ui_kit_new_widgets_test.dart index e40d1ed8..5a1e3664 100644 --- a/test/ui_kit_new_widgets_test.dart +++ b/test/ui_kit_new_widgets_test.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:openstrap_edge/models/metric.dart'; import 'package:openstrap_edge/ui/kit/skeleton.dart'; import 'package:openstrap_edge/ui/kit/state_card.dart'; import 'package:openstrap_edge/ui/kit/os_icons.dart'; @@ -80,22 +79,4 @@ void main() { await t.pump(const Duration(milliseconds: 500)); expect(find.text('x'), findsOneWidget); }); - - testWidgets('BaselineProgress.fromMetric parses need_baseline note', (t) async { - const m = Metric(note: 'need_baseline:have=2,need=5'); - final w = BaselineProgress.fromMetric(m, unlocks: 'to unlock Readiness'); - expect(w, isNotNull); - await t.pumpWidget(_host(w!)); - await t.pump(const Duration(milliseconds: 500)); - // remaining = 5 - 2 = 3 - expect(find.text('3'), findsOneWidget); - expect(find.text('nights to go'), findsOneWidget); - expect(find.text('to unlock Readiness'), findsOneWidget); - expect(find.text('2 of 5 nights'), findsOneWidget); - }); - - testWidgets('BaselineProgress.fromMetric returns null for non-baseline note', (t) async { - const m = Metric(note: 'something_else'); - expect(BaselineProgress.fromMetric(m), isNull); - }); } From f5cda0bc0ea754c93c2fd09fc2084c8a6ba6fe79 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 27 Jul 2026 21:37:37 +0530 Subject: [PATCH 2/8] fix(workout): stop losing runs and rides mid-session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five independent ways a workout could die, found while chasing "the app closes in the middle" reports. Each is separately sufficient: 1. Android FGS type silently stripped. EdgeTrackingService.start(Context) builds its Intent with no EXTRA_LOCATION, defaulting to false — and CompanionBridge.onDeviceAppeared calls it whenever the band re-enters BLE range, which happens routinely mid-run from arm-swing dropouts. That re-startForeground'd as connectedDevice only, dropping `location` from a live session. The extra is now tri-state: present => that's the mode and it latches, absent => inherit what the live session asked for. The Dart-side sticky flag could never protect this; these callers never go through Dart. 2. Heavy derivation fired mid-ride. The foreground/background gate is INVERTED for this case: phone on the bars with the screen awake means the app IS foreground, so an isolate spawn (roughly doubling peak heap) landed at the worst possible moment, competing with GPS, the live map and the BLE drain. New DeriveScheduler.setWorkoutActive gate; a workout is minutes long and derives at the end anyway. 3. The screen slept, with no wakelock anywhere. Held now via the existing method channels — FLAG_KEEP_SCREEN_ON / isIdleTimerDisabled, both window-scoped, no new dependency. Released on every teardown path. 4. After a process restart, _reconcileOrphanedLiveWorkout restored the timer, calories and strain but never restarted route tracking — the map was silently dead for the rest of the session and you only found out at the finish screen. 5. notifyListeners() after dispose. Re-arming route tracking exposed the hazard dispose()'s own comment already warns about for timers: an in-flight await cannot be cancelled. Added a _disposed guard rather than weakening the test that caught it. Also caps the decoded-image cache at 40 MiB. Flutter's 100 MiB default is sized for a photo feed; retina map tiles are ~1 MiB decoded each, so a long ride could sit on ~100 MiB of tile bitmaps on top of the persistent pre-warmed engine, which is LMK/jetsam territory on a 3-4 GB device. Worth knowing: telemetry is opt-in and defaults OFF, so none of this necessarily produced a crash report. Worth querying Crashlytics for jank_watchdog on LiveSessionScreen from consenting users. --- .../openstrap_edge/EdgeTrackingService.kt | 40 +++- .../openstrap_edge/NativeChannels.kt | 27 +++ ios/Runner/AppDelegate.swift | 9 + lib/compute/derive_scheduler.dart | 35 ++- lib/gps/screen_wake.dart | 61 ++++++ lib/main.dart | 12 ++ lib/state/app_state.dart | 44 ++++ test/workout_reliability_test.dart | 202 ++++++++++++++++++ 8 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 lib/gps/screen_wake.dart create mode 100644 test/workout_reliability_test.dart diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt index c0223ef3..06088bd1 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/EdgeTrackingService.kt @@ -41,6 +41,31 @@ class EdgeTrackingService : Service() { */ const val EXTRA_LOCATION = "location" + /** + * Sticky "a GPS route session is live in this process" flag. + * + * WHY THIS EXISTS: several native callers restart the service WITHOUT + * going through Dart — CompanionBridge.onDeviceAppeared (fires whenever + * the band re-enters BLE range, which happens routinely mid-run from + * arm-swing/body-block dropouts), KeepAliveWorker and BootReceiver. + * They use [start] below, whose Intent carries no EXTRA_LOCATION, so + * onStartCommand used to read `false` and re-call startForeground() + * with CONNECTED_DEVICE only — silently STRIPPING the location type off + * a live workout. On Android 14+ that ends location delivery the next + * time the app is backgrounded and the route just stops mid-ride, with + * no crash and no log. + * + * So the extra is now tri-state: present ⇒ authoritative (and latched + * here), absent ⇒ inherit whatever the live session last asked for. + * A process kill resets this to false, which is correct — Dart re-arms + * it via EdgeTracking.start(location: true) when it rehydrates the + * orphaned workout. + */ + @Volatile + @JvmStatic + var locationSessionActive: Boolean = false + private set + /** * True while the service is alive IN THIS PROCESS. The KeepAliveWorker runs * in the same process, so this is an exact "is my service running" check — @@ -72,12 +97,25 @@ class EdgeTrackingService : Service() { override fun onDestroy() { running = false + // The latch is per-process and per-service-lifetime; a fresh service + // must not inherit a stale "route session live" claim. + locationSessionActive = false super.onDestroy() } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val notif = buildNotification() - val withLocation = intent?.getBooleanExtra(EXTRA_LOCATION, false) == true + // Tri-state (see [locationSessionActive]): only an intent that actually + // carries the extra may change the mode. A bare start() from CDM / + // KeepAliveWorker / boot inherits the live session's type instead of + // downgrading it. + val withLocation = if (intent?.hasExtra(EXTRA_LOCATION) == true) { + intent.getBooleanExtra(EXTRA_LOCATION, false).also { + locationSessionActive = it + } + } else { + locationSessionActive + } try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { var type = ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE diff --git a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt index eecaae8e..ecbe0e45 100644 --- a/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt +++ b/android/app/src/main/kotlin/wtf/openstrap/openstrap_edge/NativeChannels.kt @@ -20,6 +20,7 @@ import android.os.Vibrator import android.os.VibratorManager import android.provider.Settings import android.view.KeyEvent +import android.view.WindowManager import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -69,6 +70,32 @@ object NativeChannels { app.stopService(Intent(app, EdgeTrackingService::class.java)) result.success(null) } + // Hold the screen on for the duration of a live workout, the + // way every run/ride app does — the athlete is glancing at a + // handlebar/armband, not tapping to keep the display awake. + // FLAG_KEEP_SCREEN_ON is scoped to this window and released + // automatically if the activity goes away, so it can never + // leak into a permanent wakelock. + "keepAwake" -> { + val on = call.argument("on") == true + val activity = CompanionBridge.currentActivity + if (activity == null) { + result.success(false) + } else { + activity.runOnUiThread { + if (on) { + activity.window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + ) + } else { + activity.window.clearFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + ) + } + } + result.success(true) + } + } "consumeHeadlessBootPending" -> { val prefs = app.getSharedPreferences( "openstrap_runtime", diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 08ebad26..56d3e815 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -111,6 +111,15 @@ enum ConfigBridge { // the paired Apple Watch. Best-effort, never fails the Dart caller. WatchBridge.shared.pushCurrentState() result(true) + case "keepAwake": + // Hold the display awake for a live workout, the way every run/ride app + // does. Scoped strictly to the session: Dart clears it on finish, and + // iOS drops it anyway if the app is terminated, so it cannot leak into + // a permanently-awake screen. + let args = call.arguments as? [String: Any] ?? [:] + let on = args["on"] as? Bool ?? false + UIApplication.shared.isIdleTimerDisabled = on + result(true) default: result(FlutterMethodNotImplemented) } diff --git a/lib/compute/derive_scheduler.dart b/lib/compute/derive_scheduler.dart index 87c8a4a7..b4e1001c 100644 --- a/lib/compute/derive_scheduler.dart +++ b/lib/compute/derive_scheduler.dart @@ -26,6 +26,19 @@ class DeriveScheduler { final Duration heavySettle; bool _offloadActive = false; + + /// True while a live workout is running. Held exactly like [_offloadActive]. + /// + /// Heavy derivation spawns an isolate (roughly doubling peak heap) and hits + /// the DB hard. Nothing used to stop that landing in the middle of a run or + /// ride — and the existing foreground/background gate is INVERTED for this + /// case: with the phone mounted on the bars and the screen awake the app IS + /// foregrounded, so derives ran at their most expensive possible moment, + /// competing with the GPS stream, the live map and the BLE drain. A workout + /// is minutes long and its own results are derived at the end anyway, so + /// deferring costs nothing. + bool _workoutActive = false; + // While the app is backgrounded we must NOT run derivation: a derive pass // decodes the whole retained substrate + runs the metric compute, and doing // that on a short background BLE wake trips iOS's CPU watchdog @@ -54,6 +67,7 @@ class DeriveScheduler { Map snapshot() => { 'offload_active': _offloadActive, + 'workout_active': _workoutActive, 'background': _background, 'running': _running, 'pending_light': _pendingLight, @@ -68,6 +82,23 @@ class DeriveScheduler { unawaited(_enqueue(type: 'derive_heavy', reason: 'capture_settled')); } + /// Hold derivation for the duration of a live workout (see [_workoutActive]). + /// Queued jobs stay durable and drain the moment the session ends. + void setWorkoutActive(bool active) { + if (_workoutActive == active) return; + _workoutActive = active; + if (active) { + _timer?.cancel(); + _timer = null; + log('[derive-scheduler] workout live — holding derive work'); + onChanged(); + return; + } + log('[derive-scheduler] workout ended — derive may run'); + onChanged(); + _arm(); + } + void setOffloadActive(bool active) { if (_offloadActive == active) return; _offloadActive = active; @@ -118,7 +149,7 @@ class DeriveScheduler { } void _arm() { - if (_running || _offloadActive || _background) return; + if (_running || _offloadActive || _background || _workoutActive) return; if (!_pendingLight && !_pendingHeavy) { unawaited(_refreshSnapshot()); return; @@ -131,7 +162,7 @@ class DeriveScheduler { } Future _drain() async { - if (_running || _offloadActive || _background) return; + if (_running || _offloadActive || _background || _workoutActive) return; _timer?.cancel(); _timer = null; final job = await LocalDb.takeNextComputeJob(); diff --git a/lib/gps/screen_wake.dart b/lib/gps/screen_wake.dart new file mode 100644 index 00000000..17d3c4f5 --- /dev/null +++ b/lib/gps/screen_wake.dart @@ -0,0 +1,61 @@ +// ScreenWake — hold the display awake for the duration of a live workout. +// +// Every serious run/ride app does this: the athlete has the phone on a bar +// mount or an armband and glances at it, they do not tap it every 30 s to stop +// the screen sleeping. Before this, the live session screen carried a "Keep the +// screen on to map your route" hint — asking the user to work around the app. +// +// Deliberately NOT a new dependency. Both platforms already have a registered +// method channel, and the native primitive is one line each: +// • Android — FLAG_KEEP_SCREEN_ON on the activity window (window-scoped, so +// it is released automatically when the activity goes away). +// • iOS — UIApplication.isIdleTimerDisabled. +// Neither is a true CPU wakelock: they keep the DISPLAY on while the app is +// frontmost and nothing more, so a leaked flag can never drain the battery in +// the background. Background *recording* is a separate mechanism entirely (the +// location background mode / FGS location type — see gps_source.dart). +// +// Failure is always silent: a screen that sleeps is a papercut, never a reason +// to interrupt a workout. + +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class ScreenWake { + static const _android = MethodChannel('openstrap/edge_tracking'); + static const _ios = MethodChannel('openstrap/ios_config'); + + /// Tracks what we last asked for, so repeated `enable()` calls from a 1 Hz + /// tick don't hit the platform channel every second. + static bool _on = false; + + @visibleForTesting + static bool get isHeld => _on; + + /// Keep the display awake. Safe to call repeatedly. + static Future enable() => _set(true); + + /// Release the display. MUST be called when the session ends — including on + /// the error/abort paths, or the screen stays awake until the app is killed. + static Future release() => _set(false); + + static Future _set(bool on) async { + if (on == _on) return; + _on = on; + try { + if (Platform.isAndroid) { + await _android.invokeMethod('keepAwake', {'on': on}); + } else if (Platform.isIOS) { + await _ios.invokeMethod('keepAwake', {'on': on}); + } + } catch (e) { + // Never surface: losing the wake flag degrades to "screen sleeps". + debugPrint('[screen-wake] ${on ? 'enable' : 'release'} failed: $e'); + } + } + + @visibleForTesting + static void resetForTest() => _on = false; +} diff --git a/lib/main.dart b/lib/main.dart index bd57c11b..dccd2162 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -62,6 +62,18 @@ Future main() async { // freezing isn't a crash. See installJankWatchdog's doc for the threshold. TelemetryService.instance.installJankWatchdog(); + // Cap the decoded-image cache. Flutter's default is 1000 entries / 100 MiB of + // DECODED bitmaps, which is sized for a photo feed, not for us. The only thing + // in this app that can fill it is map tiles: a retina 512² tile costs ~1 MiB + // decoded, so a long ride that pans continuously could sit on ~100 MiB of + // resident tile bitmaps — on top of the deliberately-persistent pre-warmed + // FlutterEngine — and push a 3–4 GB device into LMK/jetsam territory mid- + // workout. 40 MiB still covers several screens of tiles either side of the + // route; evicted tiles simply re-decode from the network layer. + PaintingBinding.instance.imageCache + ..maximumSizeBytes = 40 << 20 + ..maximumSize = 200; + // Android: Cancel the two legacy WorkManager tasks by unique name. A previous // version scheduled heavy derivation passes in the background, but they were // pulled due to isolate collisions/deadlocks with the main UI's database access. diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 68e57f98..7472e518 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -44,6 +44,7 @@ import '../data/live_coverage_policy.dart'; import '../data/local_repository.dart'; import '../gps/gps_source.dart'; import '../gps/route_tracker.dart'; +import '../gps/screen_wake.dart'; import '../data/local_repository_impl.dart'; import '../notify/notification_center.dart'; import '../notify/notification_event.dart'; @@ -750,6 +751,7 @@ class AppState extends ChangeNotifier { @override void dispose() { + _disposed = true; // EVERY timer this object owns, not just three of them. _spotTimer, // _breathingRecomputeTimer and _workoutTimer used to survive dispose, and // each of their callbacks ends in notifyListeners() on a disposed @@ -3203,6 +3205,10 @@ class AppState extends ChangeNotifier { unawaited(engine.retryFullLiveStreams()); } _workoutRawBase = _liveRaw; + // Hold heavy derivation for the session — an isolate spawn mid-ride + // competes with GPS, the live map and the BLE drain (see + // DeriveScheduler.setWorkoutActive). + _deriveScheduler.setWorkoutActive(true); activeWorkout = LiveWorkoutState( startTime: start, targetKcal: targetKcal, @@ -3248,6 +3254,13 @@ class AppState extends ChangeNotifier { /// off" affordance instead of silently skipping the map. GpsPermissionStatus? routeLocationIssue; + /// Set in [dispose]. Async work that resumes AFTER teardown must not touch + /// state or call notifyListeners() — see dispose()'s own note about + /// notifying a disposed ChangeNotifier (which throws in release). Timers are + /// cancelled there, but an already-suspended `await` cannot be, so every + /// continuation past an await in this class needs to re-check this. + bool _disposed = false; + /// Start recording the route if the type is eligible and location permission /// is granted. Denial is surfaced (routeLocationIssue) — the workout still /// runs without a map, but the user is told why and how to fix it. @@ -3261,6 +3274,9 @@ class AppState extends ChangeNotifier { } catch (_) { perm = GpsPermissionStatus.error; } + // The permission round-trip can outlive the whole AppState (a resumed + // workout kicks this off unawaited during startup), so re-check both. + if (_disposed) return; // The session may have ended while we awaited the permission dialog. if (activeWorkout?.workoutId != id) return; if (perm != GpsPermissionStatus.granted) { @@ -3288,6 +3304,8 @@ class AppState extends ChangeNotifier { // Android: retype the already-running FGS to connectedDevice|location so // the OS keeps delivering fixes while a route session is live. EdgeTracking.start(location: true); + // Hold the display for the session (released on every teardown path below). + ScreenWake.enable(); notifyListeners(); _log('Route tracking started for $type.'); } @@ -3388,6 +3406,15 @@ class AppState extends ChangeNotifier { (_) => _tickWorkout(), ); _log('[workout] resumed a live session still running after restart (id=$id).'); + // Re-arm GPS for the REST of the session. Without this a resumed + // workout recorded no further route at all: the timer/calories/strain + // all came back, the map silently never did, and the athlete only + // found out at the finish screen. `_maybeStartRouteTracking` is a + // no-op for non-route types and re-appends to the SAME workout_route + // rows (`id` is unchanged), so the pre-restart part of the route is + // kept and the gap shows honestly as a segment break. + unawaited(_maybeStartRouteTracking(id, activeWorkout!.type)); + _deriveScheduler.setWorkoutActive(true); } else { await LocalDb.putSession({...row, 'status': 'done'}); _log('[workout] finalized a stale live-session row from a previous run (id=${row['id']}).'); @@ -3416,6 +3443,12 @@ class AppState extends ChangeNotifier { // Android: drop the FGS back to connectedDevice-only now the route ended. EdgeTracking.start(location: false); } + // Release the display unconditionally — not inside the `rt != null` branch. + // A session that never got a tracker (permission denied) still armed + // nothing, but a session whose tracker was already cleared by another path + // would otherwise leave the screen pinned awake until the app is killed. + ScreenWake.release(); + _deriveScheduler.setWorkoutActive(false); final w = activeWorkout!; final finalKcal = w.calories.round(); final wSteps = workoutSteps; // real steps taken during this workout @@ -3479,6 +3512,8 @@ class AppState extends ChangeNotifier { } catch (_) {} EdgeTracking.start(location: false); } + ScreenWake.release(); + _deriveScheduler.setWorkoutActive(false); activeWorkout = null; _workoutRawBase = null; LiveActivity.end(); @@ -3649,6 +3684,15 @@ class LiveWorkoutState { int currentHr = 0; int maxHrSeen = 0; // spike-suppressed peak live HR this session (issue #127) + /// Milestone keys already announced this SESSION ("t5", "k200", "mhr178"…). + /// + /// Lives here, not on the live-session screen's State, because the screen is + /// disposed and rebuilt every time the athlete navigates away and back — so + /// a screen-local set forgot everything and re-fired the same milestone + /// (banner, haptic and confetti) on every return. The workout is the thing + /// a milestone belongs to, so the workout remembers it. + final Set firedMilestones = {}; + /// Rolling-median accumulator behind [maxHrSeen] — smooths the live 1 Hz HR /// at accrual so a transient PPG motion spike can't set the session max (or /// fire a spurious "new max!"). Same window + reject as the on-read recompute. diff --git a/test/workout_reliability_test.dart b/test/workout_reliability_test.dart new file mode 100644 index 00000000..70031068 --- /dev/null +++ b/test/workout_reliability_test.dart @@ -0,0 +1,202 @@ +// Regressions for the "app closed mid-ride" class of failure. +// +// The live-workout path had several independent ways to lose a run or ride: +// heavy derivation firing an isolate mid-session, the display sleeping, and +// (platform-side) the foreground-service location type being stripped by an +// unrelated restart. These cover the parts that are testable in pure Dart — +// the two platform-channel behaviours are asserted at the seam. + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/derive_scheduler.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/gps/screen_wake.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + // Releasing the gate re-arms the scheduler, which reads the durable + // compute_jobs queue — so this needs a real (in-memory-ish) DB. + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_workout_reliability_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + }); + + group('DeriveScheduler — live-workout gate', () { + late List logs; + late int runs; + late DeriveScheduler s; + + setUp(() { + logs = []; + runs = 0; + s = DeriveScheduler( + run: ({required DeriveJobKind kind}) async => runs++, + log: logs.add, + onChanged: () {}, + lightSettle: const Duration(milliseconds: 10), + heavySettle: const Duration(milliseconds: 10), + ); + }); + + tearDown(() => s.dispose()); + + test('holding is idempotent — repeated starts log once', () { + s.setWorkoutActive(true); + s.setWorkoutActive(true); + expect( + logs.where((l) => l.contains('workout live')).length, + 1, + reason: 'a re-entrant start must not re-log or re-arm', + ); + }); + + test('releasing after a hold logs the drain exactly once', () { + s.setWorkoutActive(true); + s.setWorkoutActive(false); + s.setWorkoutActive(false); + expect(logs.where((l) => l.contains('workout ended')).length, 1); + }); + + test('a release without a preceding hold is a no-op', () { + s.setWorkoutActive(false); + expect(logs, isEmpty); + }); + + test('the gate is visible in the snapshot', () { + expect(s.snapshot()['workout_active'], isFalse); + s.setWorkoutActive(true); + expect(s.snapshot()['workout_active'], isTrue); + s.setWorkoutActive(false); + expect(s.snapshot()['workout_active'], isFalse); + }); + + test( + 'a live workout never runs a derive pass, even once the settle elapses', + () async { + s.setWorkoutActive(true); + // Long enough that an unheld scheduler would have drained twice over. + await Future.delayed(const Duration(milliseconds: 60)); + expect(runs, 0, reason: 'derivation must stay parked for the session'); + }, + ); + }); + + group('ScreenWake', () { + final calls = []; + + setUp(() { + calls.clear(); + ScreenWake.resetForTest(); + for (final ch in const [ + MethodChannel('openstrap/edge_tracking'), + MethodChannel('openstrap/ios_config'), + ]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(ch, (c) async { + calls.add(c); + return true; + }); + } + }); + + test('enable then release round-trips the flag', () async { + expect(ScreenWake.isHeld, isFalse); + await ScreenWake.enable(); + expect(ScreenWake.isHeld, isTrue); + await ScreenWake.release(); + expect(ScreenWake.isHeld, isFalse); + }); + + test( + 'repeated enables do not spam the platform channel', + () async { + await ScreenWake.enable(); + final afterFirst = calls.length; + await ScreenWake.enable(); + await ScreenWake.enable(); + expect( + calls.length, + afterFirst, + reason: 'the 1 Hz session tick must not hit the channel every second', + ); + }, + ); + + test('a release with nothing held is a no-op', () async { + await ScreenWake.release(); + expect(calls, isEmpty); + }); + + test('a channel failure never throws into the workout path', () async { + for (final ch in const [ + MethodChannel('openstrap/edge_tracking'), + MethodChannel('openstrap/ios_config'), + ]) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + ch, (c) async => throw PlatformException(code: 'boom')); + } + // Losing the wake flag degrades to "the screen sleeps" — it must never + // propagate and interrupt a session. + await expectLater(ScreenWake.enable(), completes); + }); + }); + + group('live milestones', () { + test( + 'a milestone fires once per SESSION, surviving screen re-entry', + () { + // The live screen is disposed and rebuilt every time the athlete + // navigates away and back. The dedup set therefore lives on the + // workout, not the screen — a screen-local set re-fired "5 MINUTES" + // (banner + haptic + confetti) on every single return. + final w = LiveWorkoutState( + startTime: DateTime.now().subtract(const Duration(minutes: 6)), + targetKcal: 300, + workoutId: 'w1', + type: 'run', + ); + expect(w.firedMilestones.add('t5'), isTrue, reason: 'first announce'); + expect(w.firedMilestones.add('t5'), isFalse, + reason: 're-entering the screen must not re-fire it'); + // A genuinely new milestone still gets through. + expect(w.firedMilestones.add('t10'), isTrue); + }, + ); + }); + + group('pace is MOVING pace', () { + final units = UnitsController.seed(UnitSystem.metric); + + test( + 'standing still after a short walk does not invent an absurd pace', + () { + // The reported bug: ~250 m covered, then a long stationary spell. + // Averaging over ELAPSED time produced "40:32 /km" for someone who had + // barely moved. Over MOVING time it is a real walking pace. + const meters = 250.0; + const movingSec = 200; // ~3.6 km/h — a slow walk + const elapsedSec = 608; // most of it spent standing + + expect( + units.pace(meters, elapsedSec), + '40:32 /km', + reason: 'this is the wrong number the old code showed', + ); + expect(units.pace(meters, movingSec), '13:20 /km'); + }, + ); + + test('no moving time yet reports "—" rather than dividing by elapsed', () { + expect(units.pace(120.0, 0), '—'); + }); + }); +} From 10afbe3cdcca5735fd3a52b36cb07e326a9e197c Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 27 Jul 2026 21:37:58 +0530 Subject: [PATCH 3/8] feat(ios): record routes in the background, and say so honestly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REVIEW THIS ONE ON ITS OWN — it changes a user-facing privacy promise. Without the `location` UIBackgroundMode, iOS suspends the process within seconds of a screen lock or an app switch. The fix stream dies, the route is lost, and a suspended app is first in line for jetsam. That was the single largest cause of "the app closed mid-ride". The previous v1 stance — while-in-use only plus a "Keep the screen on to map your route" hint in the UI — was the architecture admitting the gap in UI copy, and it could not survive a real 40-minute workout. Scope is deliberately tight, and each of these was verified rather than assumed: - Authorization stays WHEN-IN-USE. geolocator_apple's PermissionHandler is an if/else if: because we ship NSLocationWhenInUseUsageDescription it calls requestWhenInUseAuthorization and the Always branch is unreachable. We never ask for always-on location. - Session-scoped. _settings() has exactly one caller (stream()), which has exactly one caller (_maybeStartRouteTracking), torn down on every workout-end path. - showBackgroundLocationIndicator is ON, so the blue pill is visible the entire time we read location in the background. - Routes still go only to the on-device workout_route table. This changes WHEN we can read GPS, not where any of it goes. The old usage string said "OpenStrap does not track your location in the background." That becomes false the moment this ships, so it could not stay. Both strings rewritten to describe what actually happens. Both privacy documents also gain a "Location and workout routes" section — neither mentioned location AT ALL, which was already a gap since the app has recorded routes for a while. The claim that the AI Coach cannot read route data is literal: coach_db derives its allowed root-page set by EXPLAINing the permitted views, so workout_route is unreachable at the btree level on a read-only handle, not merely absent from a name list. No App Store / Play privacy-label change is needed: Apple's definition of "collect" is transmission off device, and data processed only on device is explicitly excluded. Android uses FOREGROUND_SERVICE_LOCATION, not ACCESS_BACKGROUND_LOCATION, so no Play background-location declaration is triggered either. App Review (guideline 2.5.4) is the thing to satisfy, via the purpose strings above. --- PRIVACY.md | 30 ++++++++++++++++++++++++++++- docs/privacy.html | 33 +++++++++++++++++++++++++++++++- ios/Runner/Info.plist | 42 ++++++++++++++++++++++++++++------------- lib/gps/gps_source.dart | 31 ++++++++++++++++++++---------- 4 files changed, 111 insertions(+), 25 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 86c16199..ea2e3406 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy — Edge / OpenStrap -_Last updated: July 20, 2026_ +_Last updated: July 27, 2026_ Edge ("the App") is an independent, open-source project. It is not affiliated with, sponsored by, or endorsed by WHOOP, Inc. @@ -43,6 +43,34 @@ is handled under Firebase's own privacy and security practices, not a system we built or operate ourselves — see Google's Firebase privacy & security documentation: https://firebase.google.com/support/privacy. +**Location and workout routes** +If you record a run, ride or walk, the App uses your device's location to draw +that workout's route. This is the most sensitive permission the App asks for, +so to be specific about it: + +- **Only during a workout.** Location is read only while a run, ride or walk is + actively recording. It stops the moment you finish. The App never reads your + location in the background at any other time. +- **We never ask for "always" access.** The App requests *while-in-use* + location only. Recording does continue while your screen is locked or you + switch apps — otherwise a workout would stop being recorded the moment you + put your phone in your pocket — but that is scoped to the active workout, not + a standing permission to follow you. +- **It is visible while it happens.** On iOS the system's blue location + indicator is shown for the whole time the App is reading location in the + background. On Android the workout runs as a foreground service with a + visible, persistent notification. +- **Routes never leave your device.** A route is written to a local database + table on your phone and nowhere else. It is not uploaded, not included in + anonymous diagnostics, and not sent to your AI Coach provider — the coach is + technically prevented from reading route data, not merely asked not to. +- **You can delete it.** Deleting a workout deletes its route with it, and + uninstalling the App removes all of it immediately. + +You can decline or revoke location access at any time in your device settings. +The App still records the workout — heart rate, duration, strain and the rest — +it simply has no map for it. + **Optional, user-initiated integrations** If you choose to enable them, the App can also send data to services *you* configure: diff --git a/docs/privacy.html b/docs/privacy.html index 2a87c3d0..c6bb702c 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -22,7 +22,7 @@

Privacy Policy — Edge / OpenStrap

-

Last updated: July 20, 2026

+

Last updated: July 27, 2026

Edge ("the App") is an independent, open-source project. It is not affiliated with, sponsored by, or endorsed by WHOOP, Inc.

@@ -66,6 +66,37 @@

Anonymous diagnostics

Google's Firebase privacy & security documentation: firebase.google.com/support/privacy.

+

Location and workout routes

+

If you record a run, ride or walk, the App uses your device's location to + draw that workout's route. This is the most sensitive permission the App + asks for, so to be specific about it:

+
    +
  • Only during a workout. Location is read only while a + run, ride or walk is actively recording. It stops the moment you finish. + The App never reads your location in the background at any other time.
  • +
  • We never ask for "always" access. The App requests + while-in-use location only. Recording does continue while your + screen is locked or you switch apps — otherwise a workout would stop + being recorded the moment you put your phone in your pocket — but that + is scoped to the active workout, not a standing permission to follow + you.
  • +
  • It is visible while it happens. On iOS the system's + blue location indicator is shown for the whole time the App is reading + location in the background. On Android the workout runs as a foreground + service with a visible, persistent notification.
  • +
  • Routes never leave your device. A route is written to + a local database table on your phone and nowhere else. It is not + uploaded, not included in anonymous diagnostics, and not sent to your AI + Coach provider — the coach is technically prevented from reading route + data, not merely asked not to.
  • +
  • You can delete it. Deleting a workout deletes its + route with it, and uninstalling the App removes all of it + immediately.
  • +
+

You can decline or revoke location access at any time in your device + settings. The App still records the workout — heart rate, duration, strain + and the rest — it simply has no map for it.

+

Optional, user-initiated integrations

If you choose to enable them, the App can also send data to services you configure:

diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3cfe2647..bbbd94d6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -68,15 +68,19 @@ NSBluetoothPeripheralUsageDescription OpenStrap connects to your WHOOP band over Bluetooth to sync your health data. NSLocationWhenInUseUsageDescription - OpenStrap records your route on a map during a run, ride or walk so you can see it, colored by heart-rate zone, when the workout ends. Your location stays on this device and is never uploaded. - + OpenStrap records your route during a run, ride or walk so you can see it, colored by heart-rate zone, when the workout ends. Recording continues while your screen is locked or you switch apps, but only while a workout is running — it stops the moment you finish. Your route stays on this device and is never uploaded. + NSLocationAlwaysAndWhenInUseUsageDescription - OpenStrap does not track your location in the background. This permission is linked by a dependency but unused — the app only ever asks for location access while you're actively viewing a workout route. + OpenStrap never needs always-on location and does not ask for it. It records your route only while a run, ride or walk is actively running — including when your screen is locked — and stops as soon as you finish. Your route stays on this device and is never uploaded. BGTaskSchedulerPermittedIdentifiers @@ -129,6 +144,7 @@ UIBackgroundModes bluetooth-central + location processing fetch diff --git a/lib/gps/gps_source.dart b/lib/gps/gps_source.dart index 0c8809b2..fb3bd261 100644 --- a/lib/gps/gps_source.dart +++ b/lib/gps/gps_source.dart @@ -3,10 +3,15 @@ // `GpsSample`s. Nothing here is uploaded: fixes flow only into the local // RouteTracker → workout_route table. // -// v1 uses WHILE-IN-USE location. Continuous background ("always") location for -// screen-off tracking is a documented follow-up (see Info.plist / manifest -// notes); during a session the app is kept alive by the existing foreground -// service, so fixes keep flowing while the app is foregrounded. +// Authorization stays WHILE-IN-USE — "always" is never requested. Background +// delivery during a workout does not need it: on iOS the "location" +// UIBackgroundMode + allowsBackgroundLocationUpdates is enough (blue indicator +// shown), and on Android the existing EdgeTrackingService claims the `location` +// foreground-service type for the duration (EdgeTracking.start(location: true)). +// +// Both are armed ONLY while a route session is live. That is the difference +// between a workout that survives a pocketed phone and one that silently dies +// the moment the screen locks. import 'dart:io' show Platform; @@ -92,12 +97,18 @@ class GpsSource { distanceFilter: distanceFilter, activityType: ActivityType.fitness, pauseLocationUpdatesAutomatically: false, - // Deliberately NOT enabling background location updates in v1 — the - // "location" UIBackgroundMode is intentionally absent. The live map UI - // shows a "keep the screen on" hint; RouteTracker's gap recovery starts - // a fresh segment when fixes resume after an unlock. - allowBackgroundLocationUpdates: false, - showBackgroundLocationIndicator: false, + // Background updates are the ONLY thing that keeps a workout alive + // across a screen lock or an app switch. Without this (and the + // matching "location" UIBackgroundMode) iOS suspends the process + // within seconds, the fix stream stops, and a suspended app is first + // in line for jetsam — which is what "the app closed mid-ride" was. + // + // Armed only for [stream], i.e. only while a route session is live, + // and torn down with the subscription when the workout ends. The blue + // background-location indicator stays ON for the whole session: if we + // are reading location with the app backgrounded, the user sees it. + allowBackgroundLocationUpdates: true, + showBackgroundLocationIndicator: true, ); } return const LocationSettings( From 15d3f2ebfa6e0cbefafb40f6e7e5e02f3a20b0a1 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 27 Jul 2026 21:38:20 +0530 Subject: [PATCH 4/8] feat(activity): rebuild the live session screen; fix map and contrast bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlapping elements were structural, not styling. The screen was one flat Stack of absolutely-positioned layers with no layout relationship between them, so collisions were guaranteed on some device: the map's re-centre button was pinned `bottom: 96` while the control panel is far taller than that, so it rendered UNDERNEATH it; the centred recording pill ran under the 44 px map toggle; the fixed 270 px core had nothing stopping it colliding with the clock above and the panel below on a short phone. Now a bounded hero and a metric sheet as SIBLINGS in a Column — overlap is impossible rather than merely unlikely. Things that genuinely float are Positioned inside the hero's own Stack, anchored to the hero's edges. The ring takes its size from a LayoutBuilder instead of a hard 270. Sheet follows the hierarchy every production run/ride app converges on: one primary figure readable from a bar mount, a zone-tinted HR pill, three evenly-weighted secondary stats. The old panel gave six stats identical weight and tagged every one with the SAME generic pulse icon, so nothing read first and the icons carried no information. Bugs fixed alongside: - Pace read "40:32 /km" after barely moving: average pace divided distance by ELAPSED time, so every second spent standing still made it worse. Moving time only now; "—" when there is none. The test reproduces the exact reported number. - The 5-minute confetti re-fired on every return to the screen — the fired-milestone set lived on the screen's State, which is disposed and rebuilt on navigation. It belongs to the workout, so the workout holds it now. - The map greyed out past a zoom: TileLayer.maxZoom means "above this, draw NOTHING" (its docs say leave it infinite); maxNativeZoom is the one that describes a tile source and scales instead. Swapped. Also enabled retinaMode — the URL carried the {r} placeholder but the flag was never set, so we fetched standard-res tiles and upscaled them on every high-DPI device. - Zone colours were invisible. Measured, not eyeballed: the ramp resolved the ACTIVE palette while this screen is always dark, and Z0 mapped to `cool` — a SURFACE token — measuring 1.03:1 against nightAlt. Z0 is the RESTING zone, so that is what is on screen at the start of every workout. New zoneOnDark ramp, Z0 at 9.76:1, guarded by a test asserting all six clear 3:1. - Label text used Colors.white30 (2.72:1) where small text needs 4.5:1. onNight/onNightSoft already existed and already passed — the screen just wasn't using them. Added the missing onNightMuted step so there is a token for every role and no reason to reach for a raw whiteNN. The layout test asserts against the BOTTOM-most hero element. An earlier version asserted on the clock and passed with the bug deliberately reintroduced; this one fails by 15.5 px, verified both ways. --- lib/theme/tokens.dart | 71 +- lib/ui/activity/live_session_screen.dart | 2178 ++++++++++++++-------- lib/ui/kit/route_map.dart | 204 +- test/design_system_test.dart | 34 +- test/live_session_layout_test.dart | 181 ++ test/zone_contrast_test.dart | 88 + 6 files changed, 1988 insertions(+), 768 deletions(-) create mode 100644 test/live_session_layout_test.dart create mode 100644 test/zone_contrast_test.dart diff --git a/lib/theme/tokens.dart b/lib/theme/tokens.dart index acf107e5..f04b5532 100644 --- a/lib/theme/tokens.dart +++ b/lib/theme/tokens.dart @@ -195,7 +195,35 @@ class AppColors { // device card, the live-workout screen, splash overlays). ── static const night = Color(0xFF181613); static const nightAlt = Color(0xFF24211D); + + // ── Ink ramp for permanently-dark surfaces (the live session screen) ── + // + // These exist because that screen was written with ad-hoc `Colors.white30` / + // `white38` values, and MEASURED against [nightAlt] they do not clear the + // WCAG AA floor for small text (4.5:1): + // + // white24 → 2.20:1 white30 → 2.72:1 white38 → 3.49:1 + // + // Its labels are 9 px overlines, so that is squarely small text — the + // "labels are invisible on the dark panel" report. Opacity is a convenient + // knob but it is not a contrast decision; picking one requires knowing the + // backdrop, which is exactly what a token can encode and a call site cannot. + // + // Ratios below are against [nightAlt] (the sheet); every one is higher + // against the darker [night]. Guarded by test/zone_contrast_test.dart. + /// Muted ink (overline labels, units) — 5.77:1. The FLOOR for small text on + /// this surface; do not reach for a lower opacity instead. + /// + /// [onNight] (14.23:1) and [onNightSoft] (6.21:1) below already existed and + /// already pass — the live session screen simply wasn't using them, and + /// reached for raw `Colors.whiteNN` instead. This adds the third step that + /// was missing so there is a token for every role and no reason to. + static const onNightMuted = Color(0xFF9C9B99); + + /// Primary ink on a dark session surface — 14.23:1. static const onNight = Color(0xFFF4F1EC); + + /// Secondary ink (values, unselected controls) — 6.21:1. static const onNightSoft = Color(0xFFA8A096); // ── Accent — ember coral (mode-varying). Alert/urgent semantics ONLY — @@ -253,23 +281,52 @@ class AppColors { // ── HR zone palette (Z0..Z5) — the single source for zone colours. Reads the // active palette at call time, so it re-themes for free. Both the live // session ladder and the workouts zone bars source their colours here. ── - static Color zone(int z) { + static Color zone(int z) => zoneIn(active, z); + + /// The zone ramp resolved against a SPECIFIC palette rather than whatever is + /// active. Needed because not every surface follows the app theme. + static Color zoneIn(Palette p, int z) { switch (z.clamp(0, 5)) { case 0: - return cool; // resting / below zone 1 + return p.cool; // resting / below zone 1 case 1: - return loadDetraining; // warm-up + return p.loadDetraining; // warm-up case 2: - return good; // fat burn + return p.good; // fat burn case 3: - return warn; // aerobic + return p.warn; // aerobic case 4: - return coral; // threshold + return p.coral; // threshold default: - return coralDeep; // max effort (Z5) + return p.coralDeep; // max effort (Z5) } } + /// Zone colour for a surface that is ALWAYS dark, regardless of the user's + /// theme — today that means the live workout session screen, which paints on + /// [night]/[nightAlt] whether the app is in light or dark mode. + /// + /// Two separate legibility bugs are fixed here, both measured rather than + /// eyeballed (WCAG relative-luminance contrast against [nightAlt]): + /// + /// 1. Plain [zone] resolves the ACTIVE palette. With the app in LIGHT mode + /// that returned hues tuned for contrast against white and painted them + /// on near-black. + /// 2. Even on the dark palette, Z0 mapped to `cool` — which is a SURFACE + /// token (a dark cool-grey panel), not an ink. As a foreground it + /// measured **1.03:1** against nightAlt: literally invisible. And Z0 is + /// the resting zone, i.e. exactly what is on screen at the start of + /// every workout and whenever heart rate is low or absent. + /// + /// Z0 therefore uses `coolInk` — the token that already exists as "ink on + /// the cool surface" — measuring 9.76:1. The rest of the ramp was already + /// clear (5.7:1 – 8.9:1) and is unchanged. + /// + /// Guarded by a test that asserts every zone clears 3:1 on this surface, so + /// a future palette edit cannot silently reintroduce an invisible zone. + static Color zoneOnDark(int z) => + z.clamp(0, 5) == 0 ? kDarkPalette.coolInk : zoneIn(kDarkPalette, z); + /// A soft tint of a zone colour — for faint backfills / legend swatches. static Color zoneSoft(int z) => zone(z).withValues(alpha: 0.16); diff --git a/lib/ui/activity/live_session_screen.dart b/lib/ui/activity/live_session_screen.dart index 396fe28c..b62b9969 100644 --- a/lib/ui/activity/live_session_screen.dart +++ b/lib/ui/activity/live_session_screen.dart @@ -4,16 +4,10 @@ // an "in the red" streak, milestone bursts, and a playful line engine. Code-drawn // (CustomPaint), haptics-only, open-ended. Long-press to finish → breakdown. -import 'dart:io'; import 'dart:math' as math; -import 'dart:ui'; -import 'dart:ui' as ui; import 'package:flutter/material.dart'; -import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import 'package:path_provider/path_provider.dart'; import 'package:provider/provider.dart'; -import 'package:share_plus/share_plus.dart'; import '../../models/payloads.dart'; import '../../state/app_state.dart'; @@ -21,6 +15,7 @@ import '../../state/units_controller.dart'; import '../../theme/theme.dart'; import '../../theme/theme_switcher.dart'; import '../../theme/tokens.dart'; +import 'workout_share_card.dart'; import '../kit/kit.dart'; import '../kit/charts.dart'; import '../kit/route_map.dart'; @@ -52,14 +47,41 @@ class _ZoneMeta { const _ZoneMeta(this.label, this.name, this.color); } -final List<_ZoneMeta> _zones = [ - _ZoneMeta('Z0', 'Resting', AppColors.zone(0)), - _ZoneMeta('Z1', 'Warm-up', AppColors.zone(1)), - _ZoneMeta('Z2', 'Fat burn', AppColors.zone(2)), - _ZoneMeta('Z3', 'Aerobic', AppColors.zone(3)), - _ZoneMeta('Z4', 'Threshold', AppColors.zone(4)), - _ZoneMeta('Z5', 'Max effort', AppColors.zone(5)), +/// Zone labels. Colours are NOT baked in here — see [_zones]. +const List<(String, String)> _zoneNames = [ + ('Z0', 'Resting'), + ('Z1', 'Warm-up'), + ('Z2', 'Fat burn'), + ('Z3', 'Aerobic'), + ('Z4', 'Threshold'), + ('Z5', 'Max effort'), ]; + +/// Zone metadata for the live session screen. +/// +/// TWO bugs lived in the old `final List<_ZoneMeta> _zones = [...]` here: +/// +/// 1. It resolved `AppColors.zone(z)` from the ACTIVE palette, but this +/// screen always paints on [AppColors.night] regardless of the app theme. +/// In light mode that handed back hues tuned for a white background and +/// painted them on near-black — the low zones were effectively invisible. +/// 2. Being a top-level `final`, it was initialised ONCE at first access and +/// then never re-themed, so even switching themes could not fix it. +/// +/// Now it is a function over the dark ramp, evaluated per build. +_ZoneMeta _zoneAt(int z) { + final i = z.clamp(0, 5); + return _ZoneMeta( + _zoneNames[i].$1, _zoneNames[i].$2, AppColors.zoneOnDark(i)); +} + +/// Indexable shim so existing `_zones[z]` call sites keep reading naturally. +class _ZoneTable { + const _ZoneTable(); + _ZoneMeta operator [](int z) => _zoneAt(z); +} + +const _zones = _ZoneTable(); const _zonePct = [0.0, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]; // lower bound of z0..z5 then top // Playful + a little funny lines, by zone bucket. @@ -88,7 +110,6 @@ class _LiveSessionScreenState extends State int _lastZone = -1; DateTime? _redStart; // start of continuous time in zone ≥3 Duration _redStreak = Duration.zero; - final Set _milestones = {}; String _line = ''; int _lineSeed = 0; String? _callout; // ephemeral big banner ("ZONE 4") @@ -120,7 +141,30 @@ class _LiveSessionScreenState extends State void _onRoutePathTick() { if (_userToggledMap || !mounted) return; final hasRoute = _observedTracker?.path.value.isNotEmpty ?? false; - if (hasRoute != _showMap) setState(() => _showMap = hasRoute); + if (hasRoute != _showMap) { + setState(() => _showMap = hasRoute); + _syncDecorativeAnimations(); + } + } + + /// Park the purely decorative animations while the map is the primary view. + /// + /// `_beat` (HR pulse) and `_fx` (ember field) are `repeat()`-forever + /// controllers. Their painters are already gated behind `if (!mapOn)`, but a + /// running Ticker keeps requesting frames regardless — so a 90-minute ride + /// spent entirely on the map view still drove a 60 fps vsync loop the whole + /// time, burning battery and generating heat for pixels nobody was drawing. + /// Stopping the controllers stops the frame requests; they resume the moment + /// the athlete flips back to the ring view. + void _syncDecorativeAnimations() { + final onMap = _showMap; + if (onMap) { + if (_beat.isAnimating) _beat.stop(); + if (_fx.isAnimating) _fx.stop(); + } else { + if (!_beat.isAnimating) _beat.repeat(reverse: true); + if (!_fx.isAnimating) _fx.repeat(); + } } @override @@ -221,9 +265,15 @@ class _LiveSessionScreenState extends State _calloutUntil = DateTime.now().add(const Duration(seconds: 3)); } + /// Announce a milestone ONCE per session. + /// + /// The dedup set lives on the workout, not on this State: leaving the screen + /// and coming back disposes and rebuilds this widget, which used to reset a + /// screen-local set and re-fire "5 MINUTES" (banner + haptic + confetti) + /// every single time the athlete returned to the live screen. void _milestone(String key, String big, String sub, Color c) { - if (_milestones.contains(key)) return; - _milestones.add(key); + final fired = _app?.activeWorkout?.firedMilestones; + if (fired == null || !fired.add(key)) return; _fireCallout(big, sub); _fireConfetti(c); HapticFeedback.mediumImpact(); @@ -298,280 +348,196 @@ class _LiveSessionScreenState extends State final gapBpm = zone < 5 ? (_zonePct[zone + 1] * _maxHr).ceil() - hr : 0; final almost = zone < 5 && hr > 0 && gapBpm > 0 && gapBpm <= 5; final calloutOn = _callout != null && DateTime.now().isBefore(_calloutUntil); - - // GPS map mode is now a dedicated layout, not a small box floating over - // the ember/HR-reactive core: that layering (separate zone ladder, big - // timer, and HR circle all still rendering underneath a boxed map) is - // exactly what read as badly composed. When a route exists, the map IS - // the screen — BPM/zone/duration move into its own unified stat bar - // (GpsLiveMapView) instead of competing with a second UI system. The - // ember core stays untouched for non-GPS workouts, where it's the right - // default. final mapOn = _showMap && app.routeTracker != null; + // The map/heart view switch lives in the SHEET, next to the stats — not + // floating over the map. On the map it sat in the top-right corner, which + // is both the least reachable part of a phone one-handed mid-run and prime + // map real estate. Down here it reads as what it is: a control for what + // the panel above is showing. + final viewToggle = app.routeTracker == null + ? null + : _ViewToggle( + showingMap: _showMap, + onChanged: (wantMap) { + if (wantMap == _showMap) return; + setState(() { + _showMap = wantMap; + _userToggledMap = true; + }); + _syncDecorativeAnimations(); + }, + ); + + // ── LAYOUT CONTRACT ────────────────────────────────────────────────── + // Two regions in a Column: a bounded HERO (map or heart-rate core) and a + // METRIC SHEET. They are siblings, so the sheet can never sit on top of + // the hero and the hero can never grow under the sheet. + // + // This screen used to be one flat Stack of absolutely-positioned layers + // with no layout relationship between them, and they collided on real + // devices: the map's re-centre button was pinned `bottom: 96` while the + // control panel is far taller than that, so it rendered UNDERNEATH the + // panel; the centred recording pill ran under the 44 px map toggle; and + // in ring mode the fixed 270 px core had nothing stopping it colliding + // with the timer above and the panel below on a shorter phone. + // + // Anything that genuinely floats (re-centre, callout, confetti) is now + // Positioned INSIDE the hero's own Stack, so it is clipped to the hero + // and anchored to the hero's edges — never the screen's. return Theme( data: ThemeData.dark().copyWith(scaffoldBackgroundColor: AppColors.night), child: Scaffold( - body: Stack(children: [ - // 1. Zone-tinted studio background, intensity climbs with effort. - if (!mapOn) - Positioned.fill(child: AnimatedContainer( - duration: Motion.slow, - decoration: BoxDecoration(gradient: RadialGradient( - center: const Alignment(0, -0.15), radius: 1.4, - colors: [z.color.withValues(alpha: 0.12 + 0.30 * hrrPct), AppColors.night], - )), - )), - - // 2. Ember field rising behind the core (count/heat ∝ effort). - if (!mapOn) - Positioned.fill(child: AnimatedBuilder( - animation: _fx, - builder: (context, _) => CustomPaint(painter: _EmberPainter(t: _fx.value, intensity: hrrPct, color: z.color)), - )), - - // 3. Top: the big tabular timer (the refs' huge session clock) + - // in-the-red streak. Weight and space, no chrome. (Map mode shows - // duration in its own unified stat bar instead — see 5b.) - if (!mapOn) - SafeArea(child: Padding( - padding: const EdgeInsets.symmetric(vertical: Sp.x4), - child: Column(children: [ - Text( - _fmt(w.elapsed), - style: AppText.hero.copyWith( - fontSize: 40, - color: Colors.white, - letterSpacing: 0, - ), - ), - Text( - 'DURATION', - style: AppText.overline.copyWith( - color: Colors.white30, - fontSize: 9, - letterSpacing: 3, + body: Column( + children: [ + Expanded( + child: Stack( + children: [ + if (mapOn) + Positioned.fill( + child: _LiveRouteMap( + tracker: app.routeTracker!, + elapsed: w.elapsed, + hr: hr, + zoneIndex: zone, + showStatBar: false, + ), + ) + else + Positioned.fill( + child: _HeroCore( + hr: hr, + zone: zone, + hrrPct: hrrPct, + elapsed: w.elapsed, + redStreak: _redStreak, + line: _line, + almostText: almost + ? '\$gapBpm bpm to \${_zones[zone + 1].label} — push' + : null, + almostColor: + zone < 5 ? _zones[zone + 1].color : z.color, + beat: _beat, + fx: _fx, + fmt: _fmt, + ), + ), + + // Top rail — ONE row, space-between. The state chip and the + // map toggle are laid out against each other, so no amount + // of text can push one under the other (the chip is + // Flexible and ellipsizes instead). + Positioned( + top: 0, + left: 0, + right: 0, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB( + Sp.x5, Sp.x3, Sp.x5, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: _SessionStateChip( + locationIssue: app.routeTracker == null + ? app.routeLocationIssue + : null, + onFixLocation: () async { + final issue = app.routeLocationIssue; + if (issue == null) return; + if (issue == GpsPermissionStatus.denied) { + await app.retryRouteTracking(); + } else { + await GpsSource.openSettingsFor(issue); + } + }, + ), + ), + ], + ), + ), + ), ), - ), - if (_redStreak.inSeconds >= 5) ...[ - const SizedBox(height: Sp.x2), - _pill(AppIcon(OsIcon.calories, size: 14, color: AppColors.coral), - '${_fmt(_redStreak)} in the red', tint: AppColors.coral), - ], - ]), - )), - - // 4. The ember core (beats at your HR). Map mode shows BPM + zone - // in its own unified stat bar instead — see 5b. - if (!mapOn) - Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ - Stack(alignment: Alignment.center, children: [ - SizedBox(width: 270, height: 270, child: CustomPaint( - painter: _ZoneArcPainter(pct: hrrPct, color: z.color))), - AnimatedBuilder( - animation: _beat, - builder: (context, child) { - final v = (hr > 160 ? Curves.elasticOut : Curves.easeInOut).transform(_beat.value); - final scale = 1.0 + 0.08 * v; - final glow = 0.4 + 0.6 * v; - return Container( - width: 210, height: 210, - decoration: BoxDecoration(shape: BoxShape.circle, boxShadow: [ - BoxShadow(color: z.color.withValues(alpha: 0.4 * glow), blurRadius: 40 * scale, spreadRadius: 2), - BoxShadow(color: z.color.withValues(alpha: 0.15 * glow), blurRadius: 100 * scale, spreadRadius: 10), - ]), - child: Transform.scale(scale: scale, child: Container( - decoration: BoxDecoration(shape: BoxShape.circle, color: AppColors.night, - border: Border.all(color: z.color.withValues(alpha: 0.35), width: 1.5)), - alignment: Alignment.center, child: child, - )), - ); - }, - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Text(hr > 0 ? '$hr' : '—', style: AppText.display.copyWith( - fontSize: 88, color: Colors.white, height: 1, fontWeight: FontWeight.w900)), - Text('BPM', style: AppText.overline.copyWith( - color: Colors.white38, fontSize: 11, letterSpacing: 5, fontWeight: FontWeight.w800)), - ]), - ), - ]), - const SizedBox(height: Sp.x8), - AnimatedDefaultTextStyle( - duration: Motion.med, - style: AppText.h2.copyWith(color: z.color, letterSpacing: 3, fontWeight: FontWeight.w900, fontSize: 22), - child: Text('${z.label} · ${z.name}'.toUpperCase()), - ), - const SizedBox(height: Sp.x2), - // "Almost there" nudge or the playful line. - SizedBox(height: 22, child: AnimatedSwitcher( - duration: Motion.med, - child: almost - ? Text('$gapBpm bpm to ${_zones[zone + 1].label} — push', - key: ValueKey('almost$gapBpm'), - style: AppText.bodySoft.copyWith(color: _zones[zone + 1].color, fontWeight: FontWeight.w700)) - : Text(_line, key: ValueKey(_line), - style: AppText.bodySoft.copyWith(color: Colors.white38)), - )), - ])), - - // 5. Zone ladder (right edge). Redundant with map mode's own - // BPM/zone stat — suppressed there. - if (!mapOn) - Positioned(right: Sp.x4, top: 0, bottom: 0, child: Center(child: _zoneLadder(zone))), - - // 5b. Live route map — for a GPS workout (run/ride/walk) this IS - // the screen now, full-bleed, not a small box over the ember core. - // showStatBar: false — the merged _GpsControlPanel below shows - // these same live stats in ONE glass card instead of a second, - // competing bar stacked on the map. - if (mapOn) - Positioned.fill( - child: _LiveRouteMap( - tracker: app.routeTracker!, - elapsed: w.elapsed, - hr: hr, - zoneIndex: zone, - showStatBar: false, - ), - ), - // 6. Stat panel + hold-to-finish. ONE glass card either way now — - // in map mode it also carries the live distance/duration/pace/BPM - // readout (via _GpsControlPanel), instead of a second stat bar - // floating separately on the map. - // Bottom offset adds the system gesture-nav inset — on Android the - // fixed Sp.x8 alone let the hold-to-finish control sit under/behind - // the nav bar on devices with a gesture bar. - Positioned(left: Sp.x6, right: Sp.x6, - bottom: Sp.x8 + MediaQuery.of(context).padding.bottom, - child: mapOn - ? _GpsControlPanel( - tracker: app.routeTracker!, - elapsed: w.elapsed, - hr: hr, - zoneIndex: zone, - workout: w, - holdController: _hold, - ending: _ending, - onFinished: _finish, - ) - : _ControlPanel(workout: w, holdController: _hold, ending: _ending, onFinished: _finish)), - - // 7. Celebration confetti (one-shot). - Positioned.fill(child: IgnorePointer(child: AnimatedBuilder( - animation: _burst, - builder: (context, _) => _burst.isAnimating - ? CustomPaint(painter: _ConfettiPainter(t: _burst.value, particles: _confetti)) - : const SizedBox.shrink(), - ))), - - // 8. Big ephemeral callout (zone-up / milestone). - if (calloutOn) Positioned.fill(child: IgnorePointer(child: Center( - child: Column(mainAxisSize: MainAxisSize.min, children: [ - const Spacer(flex: 2), - Text(_callout!, style: AppText.display.copyWith( - fontSize: 46, color: Colors.white, fontWeight: FontWeight.w900, letterSpacing: 2)), - if (_calloutSub != null) - Text(_calloutSub!, style: AppText.label.copyWith(color: z.color, letterSpacing: 3)), - const Spacer(flex: 3), - ]), - ))), - - // 9b. Location denied/off for a route-eligible workout → say so and - // offer the fix, instead of silently running without a map. - if (app.routeTracker == null && app.routeLocationIssue != null) - Positioned( - top: MediaQuery.of(context).padding.top + 64, - left: Sp.x5, - right: Sp.x5, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () async { - final issue = app.routeLocationIssue!; - if (issue == GpsPermissionStatus.denied) { - // Re-prompt is still possible — retry in place. - await app.retryRouteTracking(); - } else { - await GpsSource.openSettingsFor(issue); - } - }, - child: Center( - child: _pill( - const Icon(Icons.location_off_outlined, - size: 15, color: Colors.white60), - app.routeLocationIssue == GpsPermissionStatus.serviceOff - ? 'Location off — turn it on to map your route' - : 'Location off — allow it to map your route', - tint: AppColors.warn, + // Ephemeral zone-up / milestone callout. + if (calloutOn) + Positioned.fill( + child: IgnorePointer( + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _callout!, + textAlign: TextAlign.center, + style: AppText.display.copyWith( + fontSize: 46, + color: Colors.white, + fontWeight: FontWeight.w900, + letterSpacing: 2, + ), + ), + if (_calloutSub != null) + Text( + _calloutSub!, + textAlign: TextAlign.center, + style: AppText.label.copyWith( + color: z.color, letterSpacing: 3), + ), + ], + ), + ), + ), + ), + + // Confetti stays clipped to the hero. + Positioned.fill( + child: IgnorePointer( + child: AnimatedBuilder( + animation: _burst, + builder: (context, _) => _burst.isAnimating + ? CustomPaint( + painter: _ConfettiPainter( + t: _burst.value, particles: _confetti)) + : const SizedBox.shrink(), + ), + ), ), - ), + ], ), ), - // 9. Map-mode toggle (run/ride/walk with a live route only). - if (app.routeTracker != null) - Positioned( - top: MediaQuery.of(context).padding.top + Sp.x5, - right: Sp.x5, - child: GestureDetector( - onTap: () => setState(() { - _showMap = !_showMap; - _userToggledMap = true; // respect the explicit choice now - }), - behavior: HitTestBehavior.opaque, - child: Container( - width: 44, - height: 44, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: _showMap - ? AppColors.coral.withValues(alpha: 0.9) - : Colors.white.withValues(alpha: 0.10), - border: Border.all(color: Colors.white24), - ), - child: Icon( - _showMap ? Icons.favorite : Icons.map_outlined, - size: 20, - color: Colors.white, + // The metric sheet. Owns the bottom safe area itself. + mapOn + ? _GpsControlPanel( + tracker: app.routeTracker!, + elapsed: w.elapsed, + hr: hr, + zoneIndex: zone, + workout: w, + holdController: _hold, + ending: _ending, + onFinished: _finish, + viewToggle: viewToggle, + ) + : _SessionSheet( + workout: w, + holdController: _hold, + ending: _ending, + onFinished: _finish, + hr: hr, + zoneIndex: zone, + elapsed: w.elapsed, + viewToggle: viewToggle, ), - ), - ), - ), - ]), + ], + ), ), ); } - Widget _pill(Widget icon, String text, {Color? tint}) => Container( - padding: const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), - decoration: BoxDecoration( - color: (tint ?? Colors.white).withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(R.pill), - border: Border.all(color: (tint ?? Colors.white).withValues(alpha: 0.18)), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - icon, const SizedBox(width: Sp.x2), - Text(text, style: AppText.metricSm.copyWith( - color: tint ?? Colors.white, fontSize: 15, letterSpacing: 0.5, - fontFeatures: [const FontFeature.tabularFigures()])), - ]), - ); - - Widget _zoneLadder(int zone) => Column(mainAxisSize: MainAxisSize.min, children: [ - for (int z = 5; z >= 1; z--) ...[ - AnimatedContainer( - duration: Motion.med, - width: z == zone ? 16 : 10, - height: 30, - decoration: BoxDecoration( - color: z <= zone ? _zones[z].color.withValues(alpha: z == zone ? 1 : 0.5) : Colors.white12, - borderRadius: BorderRadius.circular(6), - boxShadow: z == zone ? [BoxShadow(color: _zones[z].color.withValues(alpha: 0.6), blurRadius: 12)] : null, - ), - ), - if (z > 1) const SizedBox(height: 6), - ], - ]); } // ── Ember particle field ────────────────────────────────────────────────────── @@ -653,142 +619,9 @@ class _ConfettiPainter extends CustomPainter { } // ── Stat panel + hold-to-finish (kept from the original, lightly adapted) ───── -class _ControlPanel extends StatelessWidget { - final LiveWorkoutState workout; - final AnimationController holdController; - final bool ending; - final VoidCallback onFinished; - // GPS-mode live stats — ONE glass card with the map's live readout on top - // and the existing calories/strain/steps below, instead of two separate - // floating panels stacked on the map (that's what read as "bolted on"). - // Null (via [gpsDistance]) when this isn't a GPS-tagged workout. - final String? gpsDistance; - final String? gpsDuration; - final String? gpsPace; - final int? gpsHr; - final Color? gpsZoneColor; - final String? gpsZoneLabel; - const _ControlPanel({ - required this.workout, - required this.holdController, - required this.ending, - required this.onFinished, - this.gpsDistance, - this.gpsDuration, - this.gpsPace, - this.gpsHr, - this.gpsZoneColor, - this.gpsZoneLabel, - }); - - bool get _hasGpsStats => gpsDistance != null; - - @override - Widget build(BuildContext context) { - return Column(mainAxisSize: MainAxisSize.min, children: [ - ClipRRect( - borderRadius: BorderRadius.circular(R.card), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 25, sigmaY: 25), - child: Container( - padding: const EdgeInsets.all(Sp.x6), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(R.card), - border: Border.all(color: Colors.white10), - ), - child: Column(children: [ - if (_hasGpsStats) ...[ - // 2x2 grid, not 4-across — four full stats (icon+value+unit+ - // label each) in one row left ~70px per stat on a real phone - // width, which crowded/crammed together. Two rows of two - // gives each stat roughly double the room. - Row(children: [ - Expanded(child: _Stat(icon: OsIcon.activity, label: 'DISTANCE', value: gpsDistance!, unit: '')), - const SizedBox(width: Sp.x4), - Expanded(child: _Stat(icon: OsIcon.activity, label: 'DURATION', value: gpsDuration!, unit: '')), - ]), - const SizedBox(height: Sp.x4), - Row(children: [ - Expanded(child: _Stat(icon: OsIcon.activity, label: 'PACE', value: gpsPace!, unit: '')), - const SizedBox(width: Sp.x4), - // BPM stays white like the other three stats — zone colour - // on the number itself read as a bug ("why is heart rate - // blue?"), not a signal. The zone name in the label below - // it already conveys the zone. - Expanded(child: _Stat( - icon: OsIcon.heartRate, - label: gpsZoneLabel ?? '', - value: (gpsHr ?? 0) > 0 ? '$gpsHr' : '—', - unit: '', - )), - ]), - const SizedBox(height: Sp.x4), - const Divider(color: Colors.white10, height: 1), - const SizedBox(height: Sp.x4), - ], - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _Stat(icon: OsIcon.activity, label: 'CALORIES', value: workout.calories.round().toString(), unit: 'kcal'), - _Stat(icon: OsIcon.activity, label: 'STRAIN', value: workout.strain.toStringAsFixed(1), unit: ''), - // Real steps counted on the live 100 Hz stream, scoped to THIS - // workout (resets at start, not at connection). - _Stat(icon: OsIcon.activity, label: 'STEPS', - // select, not watch — this was subscribing the whole row to - // every AppState notifyListeners(), not just workoutSteps. - value: context.select((a) => a.workoutSteps).toString(), - unit: ''), - ]), - ]), - ), - ), - ), - const SizedBox(height: Sp.x5), - GestureDetector( - onLongPressStart: (_) { holdController.forward(); HapticFeedback.lightImpact(); }, - onLongPressEnd: (_) { - if (holdController.value >= 1.0) { onFinished(); } else { holdController.reverse(); } - }, - child: AnimatedBuilder( - animation: holdController, - builder: (context, child) { - final val = holdController.value; - return Transform.scale( - scale: 1.0 - 0.05 * val, - child: Container( - width: double.infinity, height: 72, - decoration: BoxDecoration( - color: val > 0 ? Colors.white.withValues(alpha: 0.1) : AppColors.nightAlt, - borderRadius: BorderRadius.circular(R.pill), - border: Border.all(color: Color.lerp(Colors.white10, AppColors.coral, val)!, width: 1.5), - ), - child: Stack(alignment: Alignment.center, children: [ - Positioned.fill(child: FractionallySizedBox( - alignment: Alignment.centerLeft, widthFactor: val, - child: Container(decoration: BoxDecoration( - color: AppColors.coral.withValues(alpha: 0.2 + 0.2 * val), - borderRadius: BorderRadius.circular(R.pill))), - )), - Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - AppIcon(OsIcon.cancel, size: 20, color: Color.lerp(Colors.white24, Colors.white, val)), - const SizedBox(width: Sp.x3), - Text(ending ? 'FINISHING…' : 'HOLD TO FINISH', style: AppText.label.copyWith( - color: Color.lerp(Colors.white38, Colors.white, val), - fontWeight: FontWeight.w900, letterSpacing: 3, fontSize: 13)), - ]), - ]), - ), - ); - }, - ), - ), - ]); - } -} - -/// Feeds the RouteTracker's live distance/speed into [_ControlPanel]'s merged -/// GPS stat row — so it updates live without wrapping the whole ember-core -/// Stack (confetti, callouts, etc.) in ValueListenableBuilders it doesn't -/// need. +/// Feeds the RouteTracker's live distance/speed into [_SessionSheet] without +/// wrapping the whole hero (map, callouts, confetti) in ValueListenableBuilders +/// it does not need. class _GpsControlPanel extends StatelessWidget { final RouteTracker tracker; final Duration elapsed; @@ -798,6 +631,7 @@ class _GpsControlPanel extends StatelessWidget { final AnimationController holdController; final bool ending; final VoidCallback onFinished; + final Widget? viewToggle; const _GpsControlPanel({ required this.tracker, required this.elapsed, @@ -807,43 +641,45 @@ class _GpsControlPanel extends StatelessWidget { required this.holdController, required this.ending, required this.onFinished, + this.viewToggle, }); - static const _zoneLabels = ['Rest', 'Warm', 'Fat', 'Aero', 'Thr', 'Max']; - - String _fmtDuration(Duration d) { - final h = d.inHours; - final m = (d.inMinutes % 60).toString().padLeft(2, '0'); - final s = (d.inSeconds % 60).toString().padLeft(2, '0'); - return h > 0 ? '$h:$m:$s' : '$m:$s'; - } - @override Widget build(BuildContext context) { final units = context.watch(); - final zone = zoneIndex.clamp(0, 5); return ValueListenableBuilder( valueListenable: tracker.distanceMeters, builder: (context, meters, _) => ValueListenableBuilder( valueListenable: tracker.currentSpeedMps, builder: (context, speedMps, _) { final movingSec = tracker.movingSeconds; - final avgPace = units.pace( - meters, - movingSec > 0 ? movingSec : elapsed.inSeconds, - ); + // MOVING pace, never elapsed pace. + // + // This used to fall back to `elapsed` whenever movingSec was 0, which + // produced the "40:32 /km even though I barely moved" reading: a + // couple of hundred metres divided by every second the athlete had + // also spent standing still is not a pace, it is an average of + // walking and waiting. Every serious run/ride app reports pace over + // moving time for exactly this reason. With no moving time yet, + // `units.pace` returns "—", which is the honest answer. + final avgPace = units.pace(meters, movingSec); final livePace = units.paceFromSpeed(speedMps); - return _ControlPanel( + // `units.distance` returns e.g. "2.41 km" — split it so the sheet can + // set the figure and its unit at different weights. + final distanceText = units.distance(meters); + final parts = distanceText.split(' '); + return _SessionSheet( workout: workout, holdController: holdController, ending: ending, onFinished: onFinished, - gpsDistance: units.distance(meters), - gpsDuration: _fmtDuration(elapsed), - gpsPace: livePace == '—' ? avgPace : livePace, - gpsHr: hr, - gpsZoneColor: AppColors.zone(zone), - gpsZoneLabel: _zoneLabels[zone], + hr: hr, + zoneIndex: zoneIndex, + elapsed: elapsed, + distance: parts.first, + distanceUnit: parts.length > 1 ? parts.sublist(1).join(' ') : '', + pace: livePace == '—' ? avgPace : livePace, + viewToggle: viewToggle, ); }, ), @@ -851,48 +687,6 @@ class _GpsControlPanel extends StatelessWidget { } } -class _Stat extends StatelessWidget { - final String label, value, unit; - final OsIcon icon; - const _Stat({ - required this.label, - required this.value, - required this.unit, - required this.icon, - }); - @override - Widget build(BuildContext context) { - return Column(mainAxisSize: MainAxisSize.min, children: [ - AppIcon(icon, size: 16, color: Colors.white38), - const SizedBox(height: Sp.x2), - // mainAxisSize.min + explicit centering: when this _Stat sits inside - // an Expanded (the merged GPS stat row), a bare default Row here - // fills the WIDER Expanded box and left-aligns within it — the icon - // and label above/below stay centered (plain leaf widgets), so the - // value+unit alone reads as shifted left relative to them. Shrink- - // wrapping fixes that mismatch. - Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text(value, style: AppText.metric.copyWith(color: Colors.white, fontSize: 24)), - if (unit.isNotEmpty) ...[const SizedBox(width: 4), Text(unit, style: AppText.caption.copyWith(color: Colors.white38))], - ], - ), - const SizedBox(height: 4), - Text( - label, - style: AppText.overline.copyWith(color: Colors.white30, fontSize: 9, letterSpacing: 1), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ]); - } -} - // ═══════════════════════════════════════════════════════════════════════════ // F2 — cinematic post-workout finish card, on the design system. // @@ -956,7 +750,6 @@ class _WorkoutFinishScreenState extends State ); final _rand = math.Random(); final List<_Particle> _particles = []; - final GlobalKey _cardKey = GlobalKey(); Map? _detail; List? _routeVertices; @@ -965,7 +758,6 @@ class _WorkoutFinishScreenState extends State bool _prWorkout = false; bool _prSteps = false; bool _confettiFired = false; - bool _sharing = false; @override void initState() { @@ -1082,6 +874,26 @@ class _WorkoutFinishScreenState extends State double _seg(double a, double b) => Interval(a, b, curve: Curves.easeOutCubic).transform(_reveal.value); + /// Stage a section into the reveal: fades and lifts [child] over the + /// [from]..[to] slice of the timeline. The child is built ONCE and handed to + /// AnimatedBuilder as its `child`, so per-frame work is a single Opacity + + /// Transform — never a subtree rebuild. Anything whose CONTENT counts up with + /// the animation (the hero numbers) uses its own builder instead. + Widget _reveals(double from, double to, Widget child) => AnimatedBuilder( + animation: _reveal, + child: child, + builder: (context, built) { + final p = _seg(from, to); + return Opacity( + opacity: p, + child: Transform.translate( + offset: Offset(0, 14 * (1 - p)), + child: built, + ), + ); + }, + ); + String _dur(Duration d) { final h = d.inHours; final m = d.inMinutes % 60; @@ -1108,61 +920,75 @@ class _WorkoutFinishScreenState extends State // thumbnail buried at the end among the strain/zone/PR cards. final hasRoute = _route != null && _route!.hasPath; + // PERFORMANCE: the ListView is deliberately NOT wrapped in one big + // AnimatedBuilder any more. It used to be — which meant every frame of the + // 2.6 s reveal rebuilt the entire screen, including the FlutterMap and (via + // RouteCard) a full O(N) re-derivation of the route geometry. On an hour-long + // ride that was hundreds of thousands of trig ops and allocations per second, + // and it is the single reason this screen felt broken. + // + // Now each section owns a small [_Reveal] that animates only opacity and + // offset around an already-built `child`, so the expensive subtrees are + // constructed exactly once. return Scaffold( backgroundColor: AppColors.background, body: Stack( children: [ SafeArea( - child: AnimatedBuilder( - animation: _reveal, - builder: (context, _) => ListView( - padding: const EdgeInsets.fromLTRB( - Sp.screen, Sp.x6, Sp.screen, Sp.x10), - children: [ - // Opaque background so the shared PNG never captures - // transparency. - RepaintBoundary( - key: _cardKey, - child: Container( - color: AppColors.background, - padding: const EdgeInsets.symmetric(vertical: Sp.x2), - child: Column( - children: [ - _header(s), - if (hasRoute) ...[ - const SizedBox(height: Sp.x5), - _heroRoute(), - ], - const SizedBox(height: Sp.x6), - _strainGauge(strain), - const SizedBox(height: Sp.x7), - _heroStats(peak, avg, kcal, steps), - const SizedBox(height: Sp.x7), - _zoneCard(bands), - if (curve.isNotEmpty) ...[ - const SizedBox(height: Sp.x5), - _hrrCard(curve), - ], - if (_prWorkout || _prSteps) ...[ - const SizedBox(height: Sp.x5), - _prBadges(), - ], - // The old small map thumbnail only shows for - // non-GPS workouts / no route (its own graceful - // empty state) — a real route is already the hero - // above, not duplicated down here. - if (!hasRoute) ...[ - const SizedBox(height: Sp.x5), - _mapSlot(), - ], + child: ListView( + padding: const EdgeInsets.fromLTRB( + Sp.screen, Sp.x6, Sp.screen, Sp.x10), + children: [ + // Kept as a RepaintBoundary purely to isolate this subtree's + // repaints — it is no longer a capture target. Sharing now + // composes its own image (workout_share_card.dart) instead of + // rasterising this screen. + RepaintBoundary( + child: Container( + color: AppColors.background, + padding: const EdgeInsets.symmetric(vertical: Sp.x2), + child: Column( + children: [ + _header(s), + if (hasRoute) ...[ + const SizedBox(height: Sp.x5), + _heroRoute(), + const SizedBox(height: Sp.x5), + _routeStatRow(), ], - ), + const SizedBox(height: Sp.x6), + _strainGauge(strain), + const SizedBox(height: Sp.x7), + _heroStats(peak, avg, kcal, steps), + const SizedBox(height: Sp.x7), + _zoneCard(bands), + if (hasRoute) ...[ + const SizedBox(height: Sp.x5), + _splitsCard(), + ], + if (curve.isNotEmpty) ...[ + const SizedBox(height: Sp.x5), + _hrrCard(curve), + ], + if (_prWorkout || _prSteps) ...[ + const SizedBox(height: Sp.x5), + _prBadges(), + ], + // The old small map thumbnail only shows for + // non-GPS workouts / no route (its own graceful + // empty state) — a real route is already the hero + // above, not duplicated down here. + if (!hasRoute) ...[ + const SizedBox(height: Sp.x5), + _mapSlot(), + ], + ], ), ), - const SizedBox(height: Sp.x7), - _actions(), - ], - ), + ), + const SizedBox(height: Sp.x7), + _actions(), + ], ), ), // Confetti — only after a PR pops. @@ -1189,9 +1015,10 @@ class _WorkoutFinishScreenState extends State final label = s.type.isEmpty ? 'Workout' : s.type[0].toUpperCase() + s.type.substring(1); - return Opacity( - opacity: _seg(0.0, 0.3), - child: Column( + return _reveals( + 0.0, + 0.3, + Column( children: [ Text('$label complete', style: AppText.h1), const SizedBox(height: Sp.x1), @@ -1201,58 +1028,65 @@ class _WorkoutFinishScreenState extends State ); } - Widget _strainGauge(double strain) { - final p = _seg(0.0, 0.5); - return Center( - child: ArcGauge( - value: (strain / 21).clamp(0.0, 1.0), - color: AppColors.accent, - size: 176, - stroke: 15, - sweepFraction: 0.75, - endDot: true, - center: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text((strain * p).toStringAsFixed(1), style: AppText.display), - Text('STRAIN', style: AppText.overline), - ], + Widget _strainGauge(double strain) => Center( + child: ArcGauge( + value: (strain / 21).clamp(0.0, 1.0), + color: AppColors.accent, + size: 176, + stroke: 15, + sweepFraction: 0.75, + endDot: true, + // Only the counting number rebuilds — not the gauge around it. + center: AnimatedBuilder( + animation: _reveal, + builder: (context, _) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text((strain * _seg(0.0, 0.5)).toStringAsFixed(1), + style: AppText.display), + Text('STRAIN', style: AppText.overline), + ], + ), + ), ), - ), - ); - } + ); + /// These figures COUNT UP with the reveal, so unlike the other sections they + /// legitimately rebuild per frame — but it is a handful of Text widgets, not + /// a map or a route re-derivation. Widget _heroStats(int peak, int? avg, int kcal, int steps) { - final p = _seg(0.15, 0.6); - Widget stat(String v, String label) => Expanded( - child: Column( - children: [ - Text(v, style: AppText.metric.copyWith(fontSize: 24)), - const SizedBox(height: 2), - Text(label, style: AppText.overline.copyWith(fontSize: 9)), - ], + Widget stat(String v, String label) => + Expanded(child: _FinishStat(v, label)); + return AnimatedBuilder( + animation: _reveal, + builder: (context, _) { + final p = _seg(0.15, 0.6); + return Opacity( + opacity: p, + child: Transform.translate( + offset: Offset(0, 14 * (1 - p)), + child: Row( + children: [ + stat(peak > 0 ? '${(peak * p).round()}' : '—', 'PEAK BPM'), + stat(avg != null ? '${(avg * p).round()}' : '—', 'AVG BPM'), + stat('${(kcal * p).round()}', 'KCAL'), + if (steps > 0) stat('${(steps * p).round()}', 'STEPS'), + ], + ), ), ); - return Opacity( - opacity: p, - child: Transform.translate( - offset: Offset(0, 14 * (1 - p)), - child: Row( - children: [ - stat(peak > 0 ? '${(peak * p).round()}' : '—', 'PEAK BPM'), - stat(avg != null ? '${(avg * p).round()}' : '—', 'AVG BPM'), - stat('${(kcal * p).round()}', 'KCAL'), - if (steps > 0) stat('${(steps * p).round()}', 'STEPS'), - ], - ), - ), + }, ); } - Widget _zoneCard(List bands) { + Widget _zoneCard(List bands) => AnimatedBuilder( + animation: _reveal, + builder: (context, _) => _zoneCardBody(bands, _seg(0.4, 0.7)), + ); + + Widget _zoneCardBody(List bands, double wipe) { final vals = [for (final b in bands) (b['min'] as num?)?.toDouble() ?? 0]; final colors = [for (int i = 0; i < bands.length; i++) AppColors.zone(i)]; - final wipe = _seg(0.4, 0.7); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1305,8 +1139,12 @@ class _WorkoutFinishScreenState extends State ); } - Widget _hrrCard(List curve) { - final p = _seg(0.55, 0.85); + Widget _hrrCard(List curve) => AnimatedBuilder( + animation: _reveal, + builder: (context, _) => _hrrCardBody(curve, _seg(0.55, 0.85)), + ); + + Widget _hrrCardBody(List curve, double p) { // Build normalized points: x by sec, y by drop (more drop → higher). final pts = [const Offset(0, 0)]; var maxSec = 1.0, maxDrop = 1.0; @@ -1338,7 +1176,9 @@ class _WorkoutFinishScreenState extends State ), const SizedBox(height: Sp.x2), Opacity( - opacity: _seg(0.75, 0.9), + // Same timeline slice, read from the p already threaded in. + opacity: Interval(0.75, 0.9, curve: Curves.easeOutCubic) + .transform(_reveal.value), child: Row( children: [ for (final c in curve) @@ -1362,29 +1202,35 @@ class _WorkoutFinishScreenState extends State /// PRs land as the refs' engraved medal cards (restrained metal on ink), /// with one slow celebrate pass — the confetti burst stays the only fanfare. - Widget _prBadges() { - final pop = Curves.easeOutBack.transform(_seg(0.75, 1.0).clamp(0.0, 1.0)); - return Transform.scale( - scale: pop.clamp(0.0, 1.0), - child: Column( - children: [ - if (_prWorkout) - const MedalCard( - medal: 'PR', - overline: 'Personal record', - title: 'Hardest workout yet', - subtitle: 'Your highest strain on record', - ).dsCelebrate(), - if (_prWorkout && _prSteps) const SizedBox(height: Sp.x3), - if (_prSteps) - const MedalCard( - medal: 'PR', - overline: 'Personal record', - title: 'Most steps in a workout', - subtitle: 'Your biggest step count on record', - ).dsCelebrate(), - ], - ), + Widget _prBadges() => AnimatedBuilder( + animation: _reveal, + child: _prBadgesBody(), + builder: (context, built) { + final pop = + Curves.easeOutBack.transform(_seg(0.75, 1.0).clamp(0.0, 1.0)); + return Transform.scale(scale: pop.clamp(0.0, 1.0), child: built); + }, + ); + + Widget _prBadgesBody() { + return Column( + children: [ + if (_prWorkout) + const MedalCard( + medal: 'PR', + overline: 'Personal record', + title: 'Hardest workout yet', + subtitle: 'Your highest strain on record', + ).dsCelebrate(), + if (_prWorkout && _prSteps) const SizedBox(height: Sp.x3), + if (_prSteps) + const MedalCard( + medal: 'PR', + overline: 'Personal record', + title: 'Most steps in a workout', + subtitle: 'Your biggest step count on record', + ).dsCelebrate(), + ], ); } @@ -1393,26 +1239,65 @@ class _WorkoutFinishScreenState extends State /// [RouteCard] (map + distance/pace stats) — same widget the workout /// detail screen already uses, so this stays visually consistent rather /// than reinventing the stat formatting. - Widget _heroRoute() { - return Opacity( - opacity: _seg(0.05, 0.4), - child: RouteCard(route: _route!, maxHr: _maxHr), + Widget _heroRoute() => + _reveals(0.05, 0.4, RouteCard(route: _route!, maxHr: _maxHr)); + + /// The three numbers a runner or rider looks for FIRST, given the same + /// weight as the strain ring rather than buried in RouteCard's footer: + /// distance, moving time, and pace (runs/walks) or average speed (rides). + /// Garmin/Strava lead with exactly this row; we had it nowhere on the finish + /// screen at all. + Widget _routeStatRow() { + final route = _route!; + final units = context.watch(); + final isRide = _isRideType(widget.snapshot.type); + final moving = Duration(seconds: route.movingSec); + final paceText = units.pace(route.distanceMeters, route.movingSec); + final speeds = [ + for (final p in route.points) + if (p.speed != null && p.speed! >= 0) p.speed!, + ]; + final avgSpeed = speeds.isEmpty + ? null + : speeds.reduce((a, b) => a + b) / speeds.length; + final third = isRide && avgSpeed != null + ? (units.speed(avgSpeed), 'AVG SPEED') + : (paceText, 'AVG PACE'); + return _reveals( + 0.1, + 0.45, + Row( + children: [ + Expanded( + child: _FinishStat(units.distance(route.distanceMeters), 'DISTANCE'), + ), + Expanded(child: _FinishStat(_dur(moving), 'MOVING')), + Expanded(child: _FinishStat(third.$1, third.$2)), + ], + ), ); } + static bool _isRideType(String t) => + t == 'cycling' || t == 'ride' || t == 'bike' || t == 'biking'; + + /// Per-km/mi splits — the thing every serious run/ride app shows on the + /// summary and this screen simply did not have. [SplitsTable] already + /// existed and was only reachable from the workout detail screen. + Widget _splitsCard() => + _reveals(0.55, 0.85, SplitsTable(route: _route!, maxHr: _maxHr)); + Widget _mapSlot() { final verts = _routeVertices; if (verts != null && verts.length >= 2) { // Real route recorded → static HR-zone-coloured thumbnail. - return Opacity( - opacity: _seg(0.6, 0.9), - child: RouteMapView(vertices: verts, height: 140), - ); + return _reveals(0.6, 0.9, RouteMapView(vertices: verts, height: 140)); } // No route (indoor / permission denied / non-GPS type) → graceful empty. - return Opacity( - opacity: _seg(0.6, 0.9), - child: Container( + return _reveals( + 0.6, + 0.9, + Container( height: 96, decoration: BoxDecoration( color: AppColors.surfaceAlt.withValues(alpha: 0.5), @@ -1434,72 +1319,101 @@ class _WorkoutFinishScreenState extends State } Widget _actions() { - return Opacity( - opacity: _seg(0.85, 1.0), - child: Row( + return _reveals( + 0.85, + 1.0, + // Share is the PRIMARY action here, not the secondary one it used to be. + // Finishing a workout you're proud of and wanting to post it is the most + // common thing to do from this screen; "full breakdown" is the + // considered, later action and reads fine as a quiet link. + Column( children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: _sharing ? null : _share, - icon: _sharing - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, color: AppColors.accent)) - : const Icon(Icons.ios_share_rounded, size: 18), - label: Text(_sharing ? 'Preparing…' : 'Share'), + SizedBox( + width: double.infinity, + height: 54, + child: FilledButton.icon( + onPressed: _share, + icon: const Icon(Icons.ios_share_rounded, size: 19), + label: const Text('Share workout'), ), ), - const SizedBox(width: Sp.x3), - Expanded( - child: FilledButton( - onPressed: () => Navigator.of(context).pushReplacement( - themedRoute((_) => WorkoutDetailScreen(id: widget.id), - name: 'WorkoutDetailScreen'), - ), - child: const Text('Full breakdown'), + const SizedBox(height: Sp.x3), + TextButton( + onPressed: () => Navigator.of(context).pushReplacement( + themedRoute((_) => WorkoutDetailScreen(id: widget.id), + name: 'WorkoutDetailScreen'), ), + child: const Text('Full breakdown'), ), ], ), ); } + /// Build the share composition and open the preview. + /// + /// This used to rasterise `_cardKey` — the entire finish card, header through + /// PR badges — and hand the PNG straight to the OS sheet. Two problems: it + /// was a screenshot of a dashboard rather than something worth posting, and + /// the athlete never saw it before it landed in the composer. Now the image + /// is composed for sharing (see workout_share_card.dart) and previewed first. + /// Compose the share image and open the preview. + /// + /// This used to rasterise `_cardKey` — the entire finish card, header through + /// PR badges — and hand the PNG straight to the OS sheet. Two problems: it + /// was a screenshot of a dashboard rather than something worth posting, and + /// the athlete never saw it before it landed in the composer. + /// + /// The composition itself lives in [buildWorkoutShareData] so this screen and + /// the workout DETAIL screen produce byte-identical cards for the same + /// workout — sharing the same run from two places must not give two results. Future _share() async { - setState(() => _sharing = true); - try { - final box = context.findRenderObject() as RenderBox?; - final origin = (box != null && box.hasSize) - ? (box.localToGlobal(Offset.zero) & box.size) - : null; - final boundary = - _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; - if (boundary == null) throw StateError('Card not ready'); - final ui.Image image = await boundary.toImage(pixelRatio: 3); - final ByteData? bytes = - await image.toByteData(format: ui.ImageByteFormat.png); - if (bytes == null) throw StateError('Failed to encode image'); - final dir = await getTemporaryDirectory(); - final file = File( - '${dir.path}/openstrap_workout_${DateTime.now().millisecondsSinceEpoch}.png'); - await file.writeAsBytes(bytes.buffer.asUint8List()); - await Share.shareXFiles( - [XFile(file.path)], - text: 'My OpenStrap workout', - sharePositionOrigin: origin, - ); - } catch (e) { - if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text("Couldn't share: $e"))); - } finally { - if (mounted) setState(() => _sharing = false); - } + final s = widget.snapshot; + final d = _detail; + final data = buildWorkoutShareData( + units: context.read(), + type: s.type, + duration: s.duration, + when: DateTime.now(), + maxHr: _maxHr, + strain: (d?['strain'] as num?)?.toDouble() ?? s.strain, + calories: (d?['calories'] as num?)?.toInt() ?? s.calories.round(), + route: _route, + avgHr: (d?['avg_hr'] as num?)?.toInt(), + ); + if (!mounted) return; + await Navigator.of(context).push( + themedRoute((_) => WorkoutSharePreviewScreen(data: data), + name: 'WorkoutSharePreviewScreen'), + ); } + } /// Self-drawing HR-recovery polyline — draws up to [progress] of its length. +/// One headline figure on the finish summary: the number in tabular metric +/// type, the label whispered underneath. Same rhythm as the design system's +/// [BigStat]/overline pairing, sized for a three-across row. +class _FinishStat extends StatelessWidget { + final String value; + final String label; + const _FinishStat(this.value, this.label); + + @override + Widget build(BuildContext context) => Column( + children: [ + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.metric.copyWith(fontSize: 22), + ), + const SizedBox(height: 3), + Text(label, style: AppText.overline.copyWith(fontSize: 9)), + ], + ); +} + class _HrrCurvePainter extends CustomPainter { final List points; // normalized 0..1 (y already screen-oriented) final double progress; // 0..1 @@ -1557,10 +1471,11 @@ class _LiveRouteMap extends StatelessWidget { final Duration elapsed; final int hr; final int zoneIndex; - // False in the real live session — the merged _ControlPanel/_GpsControlPanel - // shows these same live stats in ONE glass card instead. True (the - // default) for the Design Gallery's standalone preview, which has no - // control panel of its own. + // False in the real live session — the metric sheet below the map shows + // these same live stats, so a second bar on the map would duplicate them. + // True (the default) for the Design Gallery's standalone preview, which has + // no sheet of its own; it also switches on the map's own recording pill and + // shifts the re-centre button up to clear the bar. final bool showStatBar; const _LiveRouteMap({ required this.tracker, @@ -1736,12 +1651,9 @@ class _GpsLiveMapViewState extends State { final path = widget.vertices; if (path.isNotEmpty) _follow(path); final zone = widget.zoneIndex.clamp(0, 5); - final zoneColor = AppColors.zone(zone); - final movingSec = widget.movingSeconds; - final avgPace = units.pace( - widget.distanceMeters, - movingSec > 0 ? movingSec : widget.elapsed.inSeconds, - ); + final zoneColor = AppColors.zoneOnDark(zone); + // Moving pace, not elapsed pace — see the note in _GpsControlPanel. + final avgPace = units.pace(widget.distanceMeters, widget.movingSeconds); final livePace = units.paceFromSpeed(widget.currentSpeedMps); return ClipRRect( @@ -1792,9 +1704,12 @@ class _GpsLiveMapViewState extends State { // design — it's a full-bleed background), so its OWN overlays // must add the safe-area top inset themselves or they render // under/behind the status bar / notch, unreadable. + // Sits BELOW the session screen's top rail (state chip + map + // toggle), which occupies roughly the first 56 px of safe area. + // At the rail's own offset these overlapped. if (path.isNotEmpty && (widget.stalled || widget.error != null)) Positioned( - top: MediaQuery.of(context).padding.top + Sp.x3, + top: MediaQuery.of(context).padding.top + 64, left: Sp.x3, right: Sp.x3, child: Center( @@ -1813,37 +1728,27 @@ class _GpsLiveMapViewState extends State { ), ), ), - // iOS v1 is while-in-use location only — fixes stop when the screen - // locks. Say so instead of silently producing a gappy route. + // Recording state. This slot used to carry "Keep the screen on to + // map your route" — an instruction that only existed because iOS + // suspended us the moment the screen locked. That is fixed (the + // `location` background mode + a session-scoped wake flag), so the + // banner would now be actively FALSE. It is replaced by a plain + // statement of what is happening, which is what the athlete + // actually wants to know at a glance. // Mutually exclusive with the stall/error banner above — they used - // to share the exact same top position unconditionally, so on iOS - // whenever BOTH applied (a stall, which is common while genuinely - // stationary/idle) they rendered directly on top of each other, - // unreadable. - if (Platform.isIOS && !(path.isNotEmpty && (widget.stalled || widget.error != null))) - Positioned( - top: MediaQuery.of(context).padding.top + Sp.x3, - left: Sp.x3, - right: Sp.x3, - child: Center( - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Sp.x3, vertical: 4), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(R.chip), - ), - child: Text('Keep the screen on to map your route', - style: AppText.captionMuted - .copyWith(color: Colors.white70)), - ), - ), - ), + // to share the exact same top position unconditionally, so whenever + // BOTH applied (a stall is common while genuinely stationary) they + // rendered directly on top of each other, unreadable. // Re-centre button (appears once the user pans away). if (_userPanned) Positioned( right: Sp.x3, - bottom: 96, + // The map is bounded by the hero region now and the metric sheet + // is a SIBLING beneath it, not an overlay — so this only has to + // clear the map's own bottom edge. It used to be pinned at + // `bottom: 96` against the whole screen while the control panel + // was far taller than that, which put this button underneath it. + bottom: widget.showStatBar ? 96 : Sp.x4, child: GestureDetector( onTap: () { setState(() { @@ -1853,13 +1758,14 @@ class _GpsLiveMapViewState extends State { _follow(path); }, child: Container( - padding: const EdgeInsets.all(Sp.x2), + padding: const EdgeInsets.all(Sp.x3), decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.55), + color: Colors.black.withValues(alpha: 0.62), shape: BoxShape.circle, + border: Border.all(color: Colors.white24), ), - child: const Icon(Icons.my_location, - size: 18, color: Colors.white), + child: const Icon(Icons.my_location_rounded, + size: 20, color: Colors.white), ), ), ), @@ -1886,36 +1792,51 @@ class _GpsLiveMapViewState extends State { ], ), ), - child: Row( + // HIERARCHY: distance is the primary figure and is set two + // steps larger than the rest. Four equal-weight numbers (the + // old layout) give the eye nothing to land on at a glance — + // and a glance, mid-stride or on a bar mount, is all this + // screen ever gets. Heart rate is a zone-tinted pill rather + // than a fourth column, so the zone reads as colour before + // the number is even parsed. + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: _RouteLiveStat( - value: units.distance(widget.distanceMeters), - label: 'distance', - ), + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: _RouteLiveStat( + value: units.distance(widget.distanceMeters), + label: 'distance', + primary: true, + ), + ), + _LiveHrPill(hr: widget.hr, zoneColor: zoneColor, + zoneLabel: _zoneLabels[zone]), + ], ), - Expanded( - child: _RouteLiveStat( - value: _fmtDuration(widget.elapsed), - label: 'duration', - ), - ), - Expanded( - child: _RouteLiveStat( - // Live (instantaneous) pace when we have a fresh - // speed reading; falls back to the run's average - // pace so the field is never blank. - value: livePace == '—' ? avgPace : livePace, - label: 'pace', - valueColor: AppColors.coral, - ), - ), - Expanded( - child: _RouteLiveStat( - value: widget.hr > 0 ? '${widget.hr}' : '—', - label: _zoneLabels[zone], - valueColor: zoneColor, - ), + const SizedBox(height: Sp.x3), + Row( + children: [ + Expanded( + child: _RouteLiveStat( + value: _fmtDuration(widget.elapsed), + label: 'duration', + ), + ), + Expanded( + child: _RouteLiveStat( + // Live (instantaneous) pace when we have a fresh + // speed reading; falls back to the run's average + // pace so the field is never blank. + value: livePace == '—' ? avgPace : livePace, + label: 'pace', + valueColor: AppColors.coral, + ), + ), + ], ), ], ), @@ -1933,10 +1854,16 @@ class _RouteLiveStat extends StatelessWidget { final String value; final String label; final Color? valueColor; + + /// The one figure that carries the glance (distance) — set larger so the + /// bar has a clear first read instead of four equal numbers. + final bool primary; + const _RouteLiveStat({ required this.value, required this.label, this.valueColor, + this.primary = false, }); @override @@ -1946,19 +1873,814 @@ class _RouteLiveStat extends StatelessWidget { children: [ Text( value, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: AppText.metric.copyWith( color: valueColor ?? Colors.white, - fontSize: 20, + fontSize: primary ? 34 : 20, + height: primary ? 1.05 : null, fontFeatures: [const FontFeature.tabularFigures()], ), ), const SizedBox(height: 2), Text( label.toUpperCase(), - style: AppText.overline - .copyWith(color: Colors.white38, fontSize: 9, letterSpacing: 1), + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, fontSize: 9, letterSpacing: 1), ), ], ); } } + +/// Live heart rate as a zone-tinted pill on the map's stat bar. +/// +/// The zone is carried by COLOUR first — on a bar mount at speed the tint +/// registers before any digit does, which is the whole point of the app's +/// zone language. Sits beside the primary distance figure rather than as a +/// fourth equal column. +class _LiveHrPill extends StatelessWidget { + final int hr; + final Color zoneColor; + final String zoneLabel; + const _LiveHrPill({ + required this.hr, + required this.zoneColor, + required this.zoneLabel, + }); + + @override + Widget build(BuildContext context) { + final has = hr > 0; + return Container( + padding: const EdgeInsets.symmetric(horizontal: Sp.x3, vertical: 6), + decoration: BoxDecoration( + color: zoneColor.withValues(alpha: has ? 0.22 : 0.10), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all( + color: zoneColor.withValues(alpha: has ? 0.75 : 0.30), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + has ? '$hr' : '—', + style: AppText.metric.copyWith( + color: has ? zoneColor : AppColors.onNightSoft, + fontSize: 22, + height: 1.05, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + Text( + has ? zoneLabel.toUpperCase() : 'NO BPM', + style: AppText.overline.copyWith( + color: has ? zoneColor : AppColors.onNightMuted, + fontSize: 8, + letterSpacing: 1, + ), + ), + ], + ), + ); + } +} + +// ── Live-session chrome ────────────────────────────────────────────────────── +// +// These four pieces replace what used to be a flat pile of absolutely- +// positioned Stack layers on the session screen. Each one now owns a defined +// slot in a real layout, which is what makes overlap impossible rather than +// merely unlikely. + +/// The heart-rate hero for non-GPS sessions: session clock, the beating core, +/// the zone name, and the zone ladder — composed as a COLUMN inside the +/// bounded hero area, and scaled to whatever room it actually has. +/// +/// The core used to be a fixed 270 px circle in a `Center` inside the screen's +/// root Stack, with the clock absolutely positioned above it and the control +/// panel absolutely positioned below. On a shorter phone all three collided. +/// Here the ring takes its size from a LayoutBuilder, so it shrinks instead. +class _HeroCore extends StatelessWidget { + final int hr; + final int zone; + final double hrrPct; + final Duration elapsed; + final Duration redStreak; + final String line; + final String? almostText; + final Color almostColor; + final AnimationController beat; + final AnimationController fx; + final String Function(Duration) fmt; + + const _HeroCore({ + required this.hr, + required this.zone, + required this.hrrPct, + required this.elapsed, + required this.redStreak, + required this.line, + required this.almostText, + required this.almostColor, + required this.beat, + required this.fx, + required this.fmt, + }); + + @override + Widget build(BuildContext context) { + final z = _zones[zone]; + return LayoutBuilder( + builder: (context, box) { + // The ring is whatever the hero can spare, never a fixed 270. + final ring = math.min(box.maxWidth * 0.62, box.maxHeight * 0.46) + .clamp(150.0, 260.0); + final bpmSize = ring * 0.34; + return Stack( + children: [ + // Zone-tinted studio wash, intensity climbing with effort. + Positioned.fill( + child: AnimatedContainer( + duration: Motion.slow, + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(0, -0.15), + radius: 1.4, + colors: [ + z.color.withValues(alpha: 0.12 + 0.30 * hrrPct), + AppColors.night, + ], + ), + ), + ), + ), + Positioned.fill( + child: RepaintBoundary( + child: AnimatedBuilder( + animation: fx, + builder: (context, _) => CustomPaint( + painter: _EmberPainter( + t: fx.value, intensity: hrrPct, color: z.color), + ), + ), + ), + ), + // The zone ladder gets its own reserved gutter on the right, so it + // can no longer sit on top of the ring on a narrow screen. + Positioned( + right: Sp.x4, + top: 0, + bottom: 0, + child: Center(child: _zoneLadder(zone)), + ), + Positioned.fill( + child: Padding( + // Left inset mirrors the ladder gutter so the ring stays + // optically centred between them. + padding: const EdgeInsets.fromLTRB(Sp.x8, Sp.x10, Sp.x8, Sp.x4), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + fmt(elapsed), + style: AppText.hero.copyWith( + fontSize: 40, + color: Colors.white, + letterSpacing: 0, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + Text( + 'DURATION', + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 9, + letterSpacing: 3, + ), + ), + if (redStreak.inSeconds >= 5) ...[ + const SizedBox(height: Sp.x2), + _LivePill( + icon: AppIcon(OsIcon.calories, + size: 14, color: AppColors.coral), + text: '${fmt(redStreak)} in the red', + tint: AppColors.coral, + ), + ], + const Spacer(), + SizedBox( + width: ring, + height: ring, + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: CustomPaint( + painter: + _ZoneArcPainter(pct: hrrPct, color: z.color), + ), + ), + AnimatedBuilder( + animation: beat, + builder: (context, child) { + final v = (hr > 160 + ? Curves.elasticOut + : Curves.easeInOut) + .transform(beat.value); + final scale = 1.0 + 0.08 * v; + final glow = 0.4 + 0.6 * v; + return Container( + width: ring * 0.78, + height: ring * 0.78, + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: z.color + .withValues(alpha: 0.4 * glow), + blurRadius: 40 * scale, + spreadRadius: 2, + ), + BoxShadow( + color: z.color + .withValues(alpha: 0.15 * glow), + blurRadius: 100 * scale, + spreadRadius: 10, + ), + ], + ), + child: Transform.scale( + scale: scale, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: AppColors.night, + border: Border.all( + color: z.color + .withValues(alpha: 0.35), + width: 1.5, + ), + ), + alignment: Alignment.center, + child: child, + ), + ), + ); + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + hr > 0 ? '$hr' : '—', + style: AppText.display.copyWith( + fontSize: bpmSize, + color: Colors.white, + height: 1, + fontWeight: FontWeight.w900, + ), + ), + Text( + 'BPM', + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 11, + letterSpacing: 5, + fontWeight: FontWeight.w800, + ), + ), + ], + ), + ), + ], + ), + ), + const Spacer(), + AnimatedDefaultTextStyle( + duration: Motion.med, + style: AppText.h2.copyWith( + color: z.color, + letterSpacing: 3, + fontWeight: FontWeight.w900, + fontSize: 20, + ), + child: Text('${z.label} · ${z.name}'.toUpperCase()), + ), + const SizedBox(height: Sp.x2), + SizedBox( + height: 22, + child: AnimatedSwitcher( + duration: Motion.med, + child: almostText != null + ? Text( + almostText!, + key: ValueKey(almostText), + textAlign: TextAlign.center, + style: AppText.bodySoft.copyWith( + color: almostColor, + fontWeight: FontWeight.w700), + ) + : Text( + line, + key: ValueKey(line), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.bodySoft + .copyWith(color: AppColors.onNightSoft), + ), + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } +} + +Widget _zoneLadder(int zone) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (int z = 5; z >= 1; z--) ...[ + AnimatedContainer( + duration: Motion.med, + width: z == zone ? 16 : 10, + height: 26, + decoration: BoxDecoration( + color: z <= zone + ? _zones[z].color.withValues(alpha: z == zone ? 1 : 0.5) + : Colors.white12, + borderRadius: BorderRadius.circular(6), + boxShadow: z == zone + ? [ + BoxShadow( + color: _zones[z].color.withValues(alpha: 0.6), + blurRadius: 12) + ] + : null, + ), + ), + if (z > 1) const SizedBox(height: 6), + ], + ], + ); + +/// Small translucent pill used for in-hero status text. +class _LivePill extends StatelessWidget { + final Widget icon; + final String text; + final Color? tint; + const _LivePill({required this.icon, required this.text, this.tint}); + + @override + Widget build(BuildContext context) { + final c = tint ?? Colors.white; + return Container( + padding: + const EdgeInsets.symmetric(horizontal: Sp.x4, vertical: Sp.x2), + decoration: BoxDecoration( + color: c.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all(color: c.withValues(alpha: 0.18)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + const SizedBox(width: Sp.x2), + Flexible( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.metricSm.copyWith( + color: c, + fontSize: 15, + letterSpacing: 0.5, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + ), + ], + ), + ); + } +} + +/// The top rail's only occupant: a tappable warning when location is denied +/// or off for a route-eligible workout. +/// +/// There used to be a "Recording" pill here in the healthy case. It was +/// removed — it stated something the athlete already knows (they started the +/// workout, the timer is running) and cost a permanent breathing animation +/// plus a chunk of the map's most valuable screen area to say it. +class _SessionStateChip extends StatelessWidget { + final GpsPermissionStatus? locationIssue; + final Future Function() onFixLocation; + const _SessionStateChip({ + required this.locationIssue, + required this.onFixLocation, + }); + + @override + Widget build(BuildContext context) { + if (locationIssue == null) return const SizedBox.shrink(); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onFixLocation, + child: _LivePill( + icon: const Icon(Icons.location_off_outlined, + size: 15, color: Colors.white60), + text: locationIssue == GpsPermissionStatus.serviceOff + ? 'Location off — turn it on' + : 'Location off — allow it', + tint: AppColors.warn, + ), + ); + } +} + +/// Switch between the map and the heart-rate core, centred in the metric sheet. +/// +/// A two-up segmented control rather than a single circular icon button +/// floating on the map: a segmented pair states BOTH available views and which +/// one you are on, where a lone icon button only hinted at the other one. +/// +/// Icons only. The words "Heart" and "Map" beside a heart and a map glyph were +/// pure redundancy — the icons already say it, and dropping the labels keeps +/// the control compact enough to centre without dominating the row above it. +/// The accessible names live in [Semantics] instead, where screen readers need +/// them and sighted users don't. +class _ViewToggle extends StatelessWidget { + final bool showingMap; + final ValueChanged onChanged; + const _ViewToggle({required this.showingMap, required this.onChanged}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all(color: Colors.white12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _seg( + icon: Icons.favorite_rounded, + label: 'Heart', + selected: !showingMap, + onTap: () => onChanged(false), + ), + _seg( + icon: Icons.map_rounded, + label: 'Map', + selected: showingMap, + onTap: () => onChanged(true), + ), + ], + ), + ); + + Widget _seg({ + required IconData icon, + required String label, + required bool selected, + required VoidCallback onTap, + }) => + Semantics( + button: true, + selected: selected, + label: label, + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: Motion.fast, + curve: Motion.curve, + padding: const EdgeInsets.symmetric( + horizontal: Sp.x5, vertical: 8), + decoration: BoxDecoration( + color: selected + ? Colors.white.withValues(alpha: 0.14) + : Colors.transparent, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Icon( + icon, + size: 18, + color: selected ? AppColors.onNight : AppColors.onNightSoft, + ), + ), + ), + ); +} + +/// The metric sheet — the bottom half of the session screen. +/// +/// Production run/ride apps all converge on the same hierarchy, and this now +/// follows it: ONE primary figure large enough to read at a glance mid-stride, +/// a zone-tinted heart-rate pill beside it, then a row of secondary stats, then +/// the finish control. The previous panel gave six stats identical weight in a +/// 2×2-plus-3 grid, every one of them tagged with the SAME generic pulse icon, +/// so nothing read first and the icons carried no information. +/// +/// It is a sibling of the hero in a Column, so it cannot overlap anything. +class _SessionSheet extends StatelessWidget { + final LiveWorkoutState workout; + final AnimationController holdController; + final bool ending; + final VoidCallback onFinished; + final int hr; + final int zoneIndex; + final Duration elapsed; + + /// GPS sessions lead with distance and show pace; non-GPS lead with the + /// clock. Null distance ⇒ not a route workout. + final String? distance; + final String? distanceUnit; + final String? pace; + + /// The map/heart switch, rendered in the sheet's footer beside the stats. + /// Null for a workout with no route to switch to. + final Widget? viewToggle; + + const _SessionSheet({ + required this.workout, + required this.holdController, + required this.ending, + required this.onFinished, + required this.hr, + required this.zoneIndex, + required this.elapsed, + this.distance, + this.distanceUnit, + this.pace, + this.viewToggle, + }); + + static String _fmtClock(Duration d) { + final m = (d.inMinutes % 60).toString().padLeft(2, '0'); + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return d.inHours > 0 ? '${d.inHours}:$m:$s' : '$m:$s'; + } + + @override + Widget build(BuildContext context) { + final zone = zoneIndex.clamp(0, 5); + final zoneColor = AppColors.zoneOnDark(zone); + final isRoute = distance != null; + final steps = context.select((a) => a.workoutSteps); + + return Container( + decoration: BoxDecoration( + color: AppColors.nightAlt, + border: Border(top: BorderSide(color: Colors.white10)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(R.card)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(Sp.x6, Sp.x5, Sp.x6, Sp.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // The primary figure and the heart-rate pill appear ONLY on the + // map, where the hero is a map and carries neither. In heart + // mode the hero already shows the session clock at 40 px and the + // BPM at ring scale with its zone name — repeating both down + // here was the same number printed twice on one screen. + if (isRoute) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: _PrimaryMetric( + value: distance!, + unit: distanceUnit ?? '', + ), + ), + _LiveHrPill( + hr: hr, + zoneColor: zoneColor, + zoneLabel: _zones[zone].label, + ), + ], + ), + const SizedBox(height: Sp.x5), + const Divider(color: Colors.white10, height: 1), + const SizedBox(height: Sp.x4), + ], + // Secondary stats — three across, evenly weighted and centred. + Row( + children: [ + Expanded( + child: _SheetStat( + isRoute ? _fmtClock(elapsed) : '${workout.calories.round()}', + isRoute ? 'TIME' : 'KCAL', + ), + ), + Expanded( + child: _SheetStat( + isRoute ? (pace ?? '—') : workout.strain.toStringAsFixed(1), + isRoute ? 'PACE' : 'STRAIN', + ), + ), + Expanded( + child: _SheetStat( + isRoute ? '${workout.calories.round()}' : '$steps', + isRoute ? 'KCAL' : 'STEPS', + ), + ), + ], + ), + if (viewToggle != null) ...[ + const SizedBox(height: Sp.x4), + Center(child: viewToggle!), + ], + const SizedBox(height: Sp.x5), + _HoldToFinish( + holdController: holdController, + ending: ending, + onFinished: onFinished, + ), + ], + ), + ), + ), + ); + } +} + +/// The one figure the athlete reads at a glance. Sized so it survives being +/// looked at from a bar mount at speed. +class _PrimaryMetric extends StatelessWidget { + final String value; + final String unit; + const _PrimaryMetric({required this.value, required this.unit}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + value, + maxLines: 1, + style: AppText.display.copyWith( + fontSize: 52, + height: 1.0, + color: AppColors.onNight, + fontWeight: FontWeight.w900, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + ), + const SizedBox(height: 2), + Text( + unit.toUpperCase(), + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 9, + letterSpacing: 3, + ), + ), + ], + ); +} + +/// One secondary stat in the sheet. No icon — six stats all carrying the same +/// generic pulse glyph was noise, not information. +class _SheetStat extends StatelessWidget { + final String value; + final String label; + const _SheetStat(this.value, this.label); + + @override + Widget build(BuildContext context) => Column( + children: [ + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.metric.copyWith( + color: AppColors.onNight, + fontSize: 22, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 2), + Text( + label, + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 9, + letterSpacing: 2, + ), + ), + ], + ); +} + +/// Hold-to-finish. Deliberately kept as a HOLD rather than a tap: ending a +/// session is destructive and a mis-tap mid-run costs the workout. +class _HoldToFinish extends StatelessWidget { + final AnimationController holdController; + final bool ending; + final VoidCallback onFinished; + const _HoldToFinish({ + required this.holdController, + required this.ending, + required this.onFinished, + }); + + @override + Widget build(BuildContext context) => Semantics( + button: true, + label: 'Hold to finish workout', + child: GestureDetector( + onLongPressStart: (_) { + holdController.forward(); + HapticFeedback.lightImpact(); + }, + onLongPressEnd: (_) { + if (holdController.value >= 1.0) { + onFinished(); + } else { + holdController.reverse(); + } + }, + child: AnimatedBuilder( + animation: holdController, + builder: (context, child) { + final val = holdController.value; + return Transform.scale( + scale: 1.0 - 0.05 * val, + child: Container( + width: double.infinity, + height: 64, + decoration: BoxDecoration( + color: val > 0 + ? Colors.white.withValues(alpha: 0.1) + : Colors.white.withValues(alpha: 0.04), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all( + color: Color.lerp(Colors.white10, AppColors.coral, val)!, + width: 1.5, + ), + ), + child: Stack( + alignment: Alignment.center, + children: [ + Positioned.fill( + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: val, + child: Container( + decoration: BoxDecoration( + color: AppColors.coral + .withValues(alpha: 0.2 + 0.2 * val), + borderRadius: BorderRadius.circular(R.pill), + ), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppIcon(OsIcon.cancel, + size: 20, + color: Color.lerp(AppColors.onNightSoft, + AppColors.onNight, val)), + const SizedBox(width: Sp.x3), + Text( + ending ? 'FINISHING…' : 'HOLD TO FINISH', + style: AppText.label.copyWith( + color: Color.lerp( + AppColors.onNightSoft, AppColors.onNight, val), + fontWeight: FontWeight.w900, + letterSpacing: 3, + fontSize: 13, + ), + ), + ], + ), + ], + ), + ), + ); + }, + ), + ), + ); +} diff --git a/lib/ui/kit/route_map.dart b/lib/ui/kit/route_map.dart index e03dafee..6ef914cb 100644 --- a/lib/ui/kit/route_map.dart +++ b/lib/ui/kit/route_map.dart @@ -68,6 +68,35 @@ const String _kUserAgent = 'wtf.openstrap.edge'; /// in" even though it's technically "fitting" correctly. const double kRouteMapMaxAutoZoom = 17.0; +/// Deepest zoom the tile SOURCE serves, and the camera's hard ceiling. +/// +/// The grey-map bug lived here, and the first fix treated the symptom. The +/// distinction that matters is flutter_map's own: +/// +/// • `TileLayer.maxZoom` — above this the layer is NOT DRAWN AT ALL. +/// Its docs say to leave it infinite "so that there are tiles always +/// displayed"; it exists for swapping in a different layer when zoomed in. +/// • `TileLayer.maxNativeZoom` — above this, tiles at THIS level are +/// displayed and SCALED. This is the one that describes a tile source. +/// +/// The layer was setting `maxZoom: 19`, so past z19 it drew nothing and our +/// luminance-inverting ColorFilter rendered that void as a flat grey slab +/// rather than something obviously blank. Capping the camera hid it at the +/// extreme but left the same cliff in place. Now `maxZoom` is left at its +/// default (infinite) and `maxNativeZoom` describes the source, so every +/// reachable zoom has pixels — slightly soft past the native level, never +/// blank. +/// +/// Note flutter_map subtracts 1 from `maxNativeZoom` when it SIMULATES retina +/// (tile_layer.dart:312). We request native retina tiles instead (see +/// `retinaMode` below), so that subtraction does not apply here. +const double kRouteMapMaxTileZoom = 19.0; + +/// Floor for the camera. Below roughly this the basemap is continents and the +/// route is a dot — zooming further out is never useful here and only risks +/// the same empty-tile washout at the other end. +const double kRouteMapMinZoom = 3.0; + /// Desaturate + invert-luminance + warm-tint every tile pixel in one pass: /// each output channel is `-(0.2126R + 0.7152G + 0.0722B) + offset`, with the /// offset solved so a typical OSM land/background luminance (~240) lands on @@ -82,7 +111,7 @@ const List _kMapTileMatrix = [ 0, 0, 0, 1, 0, ]; -class RouteMapView extends StatelessWidget { +class RouteMapView extends StatefulWidget { /// Route vertices in order, each already tagged with its HR zone (0..5) or /// null (drawn neutral). final List vertices; @@ -116,7 +145,53 @@ class RouteMapView extends StatelessWidget { this.borderRadius = const BorderRadius.all(Radius.circular(R.cardSm)), }); - List get _points => [for (final v in vertices) v.pos]; + @override + State createState() => _RouteMapViewState(); +} + +/// PERFORMANCE — why this is stateful and memoized. +/// +/// Polyline building is O(N) over the whole route AND allocates fresh +/// `Polyline` + `List` objects. flutter_map keys its projection and +/// simplification caches on element identity, so handing it new instances +/// forces a full LatLng→Mercator projection plus a Douglas-Peucker pass over +/// every point, for BOTH the glow and the crisp layer, on the UI thread. +/// +/// As a StatelessWidget this ran on every single build. On the live map that +/// meant once per 1 Hz session tick (~3,600 vertices an hour ⇒ ~10k point +/// projections/second by minute 90), and on the finish screen it ran once per +/// FRAME of the reveal animation. That is the jank, the heat, and a large part +/// of the "app closed mid-ride" ANR. +/// +/// Now the work is redone only when the path actually changes. +class _RouteMapViewState extends State { + List? _builtFrom; + List _points = const []; + List _glow = const []; + List _crisp = const []; + + @override + void initState() { + super.initState(); + _rebuildIfNeeded(); + } + + @override + void didUpdateWidget(RouteMapView old) { + super.didUpdateWidget(old); + _rebuildIfNeeded(); + } + + /// Identity check, not a deep compare: RouteTracker publishes a NEW + /// unmodifiable list per accepted fix, so identity changes exactly when the + /// path really grew. A deep compare would cost as much as the rebuild. + void _rebuildIfNeeded() { + if (identical(_builtFrom, widget.vertices)) return; + _builtFrom = widget.vertices; + _points = [for (final v in widget.vertices) v.pos]; + _glow = _polylines(glow: true); + _crisp = _polylines(); + } Color _colorFor(int? zone) => zone == null ? AppColors.inkMuted : AppColors.zone(zone); @@ -140,11 +215,11 @@ class RouteMapView extends StatelessWidget { } List _polylines({bool glow = false}) { - final v = vertices; + final v = widget.vertices; if (v.length < 2) return const []; final out = []; Color edgeColor(int i) => _colorFor(v[i + 1].zone); - final width = interactive ? 5.0 : 4.0; + final width = widget.interactive ? 5.0 : 4.0; var i = 0; while (i < v.length - 1) { if (v[i + 1].gapBefore || !_validPos(v, i) || !_validPos(v, i + 1)) { @@ -188,7 +263,7 @@ class RouteMapView extends StatelessWidget { // Detect gesture-driven camera moves so a live map can stop auto-following. void onPositionChanged(MapCamera camera, bool hasGesture) { - if (hasGesture) onUserPan?.call(); + if (hasGesture) widget.onUserPan?.call(); } // Only bounds-fit when the box has real area. A zero-area box (every point @@ -216,21 +291,27 @@ class RouteMapView extends StatelessWidget { maxZoom: kRouteMapMaxAutoZoom, ), onPositionChanged: onPositionChanged, + minZoom: kRouteMapMinZoom, + maxZoom: kRouteMapMaxTileZoom, interactionOptions: InteractionOptions( - flags: interactive ? InteractiveFlag.all : InteractiveFlag.none, + flags: + widget.interactive ? InteractiveFlag.all : InteractiveFlag.none, ), ) : MapOptions( initialCenter: pts.first, initialZoom: 15, onPositionChanged: onPositionChanged, + minZoom: kRouteMapMinZoom, + maxZoom: kRouteMapMaxTileZoom, interactionOptions: InteractionOptions( - flags: interactive ? InteractiveFlag.all : InteractiveFlag.none, + flags: + widget.interactive ? InteractiveFlag.all : InteractiveFlag.none, ), ); final map = FlutterMap( - mapController: controller, + mapController: widget.controller, options: options, children: [ // Our own look, not stock OSM — see the file header + _kMapTileMatrix. @@ -240,19 +321,30 @@ class RouteMapView extends StatelessWidget { urlTemplate: _kOsmTileUrl, subdomains: _kTileSubdomains, userAgentPackageName: _kUserAgent, - maxZoom: 19, + // NOT `maxZoom` — see kRouteMapMaxTileZoom. Leaving the display + // ceiling at its default keeps tiles on screen at every zoom. + maxNativeZoom: kRouteMapMaxTileZoom.round(), + // The URL carries the `{r}` retina placeholder, but flutter_map + // only substitutes it when retinaMode is ON — left unset (the + // default is false) it silently resolved to "" and we fetched + // standard-resolution tiles on every device, then scaled them up + // on a 3× display. That is a soft, slightly muddy basemap + // everywhere, and it shows most in the shared image where the map + // is the whole point. flutter_map logs a warning about exactly + // this; it was being missed in the test noise. + retinaMode: RetinaMode.isHighDensity(context), ), ), // Glow pass BEHIND the crisp line — the route is the only colour // against the monochrome basemap; it needs to read unmistakably as // "the route" at a glance, not a thin GPS-app line. - PolylineLayer(polylines: _polylines(glow: true)), - PolylineLayer(polylines: _polylines()), - if (current != null) + PolylineLayer(polylines: _glow), + PolylineLayer(polylines: _crisp), + if (widget.current != null) MarkerLayer( markers: [ Marker( - point: current!, + point: widget.current!, width: 34, height: 34, child: const _PulseDot(), @@ -264,10 +356,15 @@ class RouteMapView extends StatelessWidget { ); final clipped = ClipRRect( - borderRadius: borderRadius, - child: map, + borderRadius: widget.borderRadius, + // The map paints continuously (tile fades, the live pulse dot) and sits + // inside screens that rebuild at 1 Hz — isolate it so those repaints + // never dirty the surrounding stats/cards. + child: RepaintBoundary(child: map), ); - return height == null ? clipped : SizedBox(height: height, child: clipped); + return widget.height == null + ? clipped + : SizedBox(height: widget.height, child: clipped); } /// A minimal, tucked-away credit — required by both the OSM data licence @@ -369,15 +466,70 @@ class RouteZoneLegend extends StatelessWidget { /// A ProCard with a static route thumbnail + distance / pace summary; tapping /// opens the full interactive map. Render only when `route.hasPath`. -class RouteCard extends StatelessWidget { +class RouteCard extends StatefulWidget { final WorkoutRoute route; final int maxHr; const RouteCard({super.key, required this.route, required this.maxHr}); + @override + State createState() => _RouteCardState(); +} + +/// PERFORMANCE — see the note on [_RouteMapViewState] too. +/// +/// `buildVertices` is O(N) over the route with a binary search into the HR +/// series and a haversine per point, and it allocates a `RouteVertex` for each +/// one. The per-point speed scan below is another full pass. +/// +/// This used to sit directly in a StatelessWidget's build. The workout finish +/// screen wraps its whole list in an `AnimatedBuilder`, so for a 60-minute ride +/// (~3,600 points) that was roughly 200k trig ops and allocations PER SECOND +/// for the duration of the reveal animation — which is exactly why the finish +/// screen felt broken. Hoisted into state and recomputed only when the route or +/// max-HR actually changes. +class _RouteCardState extends State { + WorkoutRoute? _builtFrom; + int? _builtMaxHr; + List _vertices = const []; + double? _avgSpeedMps; + double? _maxSpeedMps; + + @override + void initState() { + super.initState(); + _rebuildIfNeeded(); + } + + @override + void didUpdateWidget(RouteCard old) { + super.didUpdateWidget(old); + _rebuildIfNeeded(); + } + + void _rebuildIfNeeded() { + final route = widget.route; + if (identical(_builtFrom, route) && _builtMaxHr == widget.maxHr) return; + _builtFrom = route; + _builtMaxHr = widget.maxHr; + _vertices = rmath.buildVertices(route.points, route.hr, widget.maxHr); + // Avg/max speed from the per-point recorded speeds — a Strava-style stat + // distinct from pace (more natural for cycling, and "max speed" a + // descent/sprint peak that avg-pace/best-split don't surface at all). + final speeds = [ + for (final p in route.points) + if (p.speed != null && p.speed! >= 0) p.speed!, + ]; + _avgSpeedMps = speeds.isEmpty + ? null + : speeds.reduce((a, b) => a + b) / speeds.length; + _maxSpeedMps = speeds.isEmpty ? null : speeds.reduce((a, b) => a > b ? a : b); + } + @override Widget build(BuildContext context) { final units = context.watch(); - final vertices = rmath.buildVertices(route.points, route.hr, maxHr); + final route = widget.route; + final vertices = _vertices; final avgPace = units.pace(route.distanceMeters, route.movingSec); // Best pace = the fastest FULL split (partial trailing split excluded). final unitMeters = units.distanceUnitMeters; @@ -390,18 +542,8 @@ class RouteCard extends StatelessWidget { } final bestPaceText = bestPace == null ? '—' : '${units.formatPace(bestPace)} ${units.paceUnit}'; - // Avg/max speed from the per-point recorded speeds — a Strava-style stat - // distinct from pace (more natural for cycling, and "max speed" a - // descent/sprint peak that avg-pace/best-split don't surface at all). - final speeds = [ - for (final p in route.points) - if (p.speed != null && p.speed! >= 0) p.speed!, - ]; - final avgSpeedMps = speeds.isEmpty - ? null - : speeds.reduce((a, b) => a + b) / speeds.length; - final maxSpeedMps = - speeds.isEmpty ? null : speeds.reduce((a, b) => a > b ? a : b); + final avgSpeedMps = _avgSpeedMps; + final maxSpeedMps = _maxSpeedMps; return ProCard( padding: const EdgeInsets.all(Sp.x4), child: Column( @@ -440,7 +582,7 @@ class RouteCard extends StatelessWidget { // Speed is only meaningful when the platform actually reported it // for this route (older recordings / some Android devices may // have none) — omit the row entirely rather than show a row of "—". - if (speeds.isNotEmpty) ...[ + if (avgSpeedMps != null) ...[ const SizedBox(height: Sp.x4), Row( children: [ diff --git a/test/design_system_test.dart b/test/design_system_test.dart index 25d309df..954431c9 100644 --- a/test/design_system_test.dart +++ b/test/design_system_test.dart @@ -8,6 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:provider/provider.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; import 'package:openstrap_edge/theme/theme.dart'; import 'package:openstrap_edge/theme/theme_controller.dart'; import 'package:openstrap_edge/theme/tokens.dart'; @@ -329,14 +330,43 @@ void main() { t.view.physicalSize = const Size(390, 844); t.view.devicePixelRatio = 1.0; addTearDown(t.view.reset); + + // The gallery now renders the real share card, which means real map + // tiles — and flutter_test's HTTP mock answers 400 for every request, + // so each tile throws through the image-resource service. + // + // Filtered rather than drained: this test exists to catch RenderFlex + // overflow across every section, so swallowing ALL exceptions would + // gut it. Only the image-resource library is dropped; everything else + // still fails the test. (Installed here in the body, not in setUp — + // the test binding replaces FlutterError.onError before the body runs.) + final previousOnError = FlutterError.onError; + FlutterError.onError = (details) { + if (details.library == 'image resource service') return; + if (details.exception.toString().contains('ClientException')) return; + previousOnError?.call(details); + }; + addTearDown(() => FlutterError.onError = previousOnError); + for (final (palette, choice) in [ (kLightPalette, AppThemeChoice.light), (kDarkPalette, AppThemeChoice.dark), ]) { AppColors.active = palette; await t.pumpWidget( - ChangeNotifierProvider.value( - value: ThemeController.seed(choice, Brightness.light), + MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: ThemeController.seed(choice, Brightness.light), + ), + // The gallery's share-card demo formats distance and pace + // through UnitsController, exactly as the finish screen does. + // In the app this always sits above the gallery; the test host + // has to mirror that. + ChangeNotifierProvider.value( + value: UnitsController.seed(UnitSystem.metric), + ), + ], child: MaterialApp( theme: buildOpenStrapTheme(palette), home: const DesignGalleryScreen(), diff --git a/test/live_session_layout_test.dart b/test/live_session_layout_test.dart new file mode 100644 index 00000000..011327ef --- /dev/null +++ b/test/live_session_layout_test.dart @@ -0,0 +1,181 @@ +// Layout regressions for the live activity screen (run / ride / walk). +// +// The screen used to be one flat Stack of absolutely-positioned layers with no +// layout relationship between them, and they collided on real devices: +// +// • the map's re-centre button was pinned `bottom: 96` while the control +// panel is far taller than that — so it rendered UNDERNEATH the panel; +// • the centred "Recording" pill ran under the 44 px map toggle; +// • the ring-mode core was a fixed 270 px in a Center, with the session +// clock absolutely positioned above it and the panel below, so all three +// collided on a shorter phone. +// +// The fix is structural: a bounded hero and a metric sheet as SIBLINGS in a +// Column. These tests assert that property directly at several real device +// sizes, so a future "just Positioned it" change fails loudly. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/state/app_state.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:openstrap_edge/theme/theme.dart'; +import 'package:openstrap_edge/theme/tokens.dart'; +import 'package:openstrap_edge/ui/activity/live_session_screen.dart'; +import 'package:path/path.dart' as p; +import 'package:provider/provider.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +/// Real handset sizes, smallest first — the small ones are where the old +/// fixed-size layers collided. +const _sizes = { + 'iPhone SE': Size(375, 667), + 'iPhone 15': Size(393, 852), + 'Pixel 7': Size(412, 915), +}; + +Widget _host(Widget child, AppState app) => MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: app), + ChangeNotifierProvider.value( + value: UnitsController.seed(UnitSystem.metric), + ), + ], + child: MaterialApp( + theme: buildOpenStrapTheme(kDarkPalette), + home: child, + ), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUpAll(() async { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + LocalDb.dbName = 'openstrap_live_session_layout_test.db'; + final dir = await databaseFactory.getDatabasesPath(); + await databaseFactory.deleteDatabase(p.join(dir, LocalDb.dbName)); + AppColors.active = kDarkPalette; + }); + + AppState liveApp({required int hr, String type = 'run'}) { + final app = AppState.forTesting(); + app.activeWorkout = LiveWorkoutState( + startTime: DateTime.now().subtract(const Duration(minutes: 24)), + targetKcal: 300, + workoutId: 'live-1', + type: type, + )..currentHr = hr; + return app; + } + + for (final entry in _sizes.entries) { + testWidgets('${entry.key}: hero and metric sheet never overlap', + (t) async { + t.view.physicalSize = entry.value; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final app = liveApp(hr: 148); + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + // Assert against the BOTTOM-MOST hero element, not the clock at the + // top — the clock clears the sheet even in the broken layout, so + // asserting on it would pass vacuously (it did; that is why this + // compares the zone label instead). + // + // The zone name is the last thing in the hero column. If the sheet ever + // goes back to floating over the hero, this is what it covers first. + final sheetTop = t.getRect(find.text('HOLD TO FINISH')).top; + final zoneLabel = t.getRect(find.textContaining('·').first); + expect( + zoneLabel.bottom, + lessThanOrEqualTo(sheetTop), + reason: 'hero content must not be painted underneath the sheet', + ); + // And the hero's own hard-anchored overlay (the top rail) must not be + // pushed off-screen by a hero that grew past its bounds. + expect(t.takeException(), isNull, + reason: 'no overflow at ${entry.key} (${entry.value})'); + }); + } + + testWidgets('the top rail keeps its contents inside the viewport', + (t) async { + t.view.physicalSize = _sizes['iPhone SE']!; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final app = liveApp(hr: 132); + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + // The view toggle moved OUT of the top rail and into the sheet, so with no + // location issue the rail is empty by design. What must hold is that + // anything it does show stays on screen rather than under the notch. + final chip = find.textContaining('Location off'); + if (chip.evaluate().isNotEmpty) { + final r = t.getRect(chip); + expect(r.top, greaterThanOrEqualTo(0)); + expect(r.right, lessThanOrEqualTo(_sizes['iPhone SE']!.width)); + } + expect(t.takeException(), isNull); + }); + + testWidgets('heart mode shows the clock and BPM ONCE, in the hero only', + (t) async { + t.view.physicalSize = _sizes['iPhone 15']!; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final app = liveApp(hr: 148); + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + // The hero already carries the session clock at 40 px and the BPM at ring + // scale with its zone name. The sheet used to repeat both, printing the + // same number twice on one screen. + expect(find.text('DURATION'), findsOneWidget); + expect(find.text('ELAPSED'), findsNothing, + reason: 'the sheet must not repeat the clock in heart mode'); + expect(find.text('BPM'), findsOneWidget); + expect(find.text('148'), findsOneWidget, + reason: 'heart rate belongs to the hero, not also the sheet'); + // The stats that are NOT duplicated still show. + expect(find.text('KCAL'), findsOneWidget); + expect(find.text('STRAIN'), findsOneWidget); + expect(find.text('STEPS'), findsOneWidget); + expect(t.takeException(), isNull); + }); + + testWidgets('the hero clock stays legible at an extreme duration', + (t) async { + t.view.physicalSize = _sizes['iPhone SE']!; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final app = liveApp(hr: 195); + app.activeWorkout = LiveWorkoutState( + startTime: DateTime.now().subtract(const Duration(hours: 12, minutes: 3)), + targetKcal: 300, + workoutId: 'ultra', + type: 'run', + )..currentHr = 195; + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + // A 12-hour clock is the widest the primary figure ever gets; it must + // scale down rather than overflow its row. + expect(t.takeException(), isNull); + }); +} diff --git a/test/zone_contrast_test.dart b/test/zone_contrast_test.dart new file mode 100644 index 00000000..22d81725 --- /dev/null +++ b/test/zone_contrast_test.dart @@ -0,0 +1,88 @@ +// Legibility guard for the HR-zone ramp on the live session screen. +// +// The live workout screen paints on AppColors.night/nightAlt regardless of the +// user's theme, so its zone colours cannot come from the ACTIVE palette. Two +// real bugs came out of that: +// +// 1. In light mode the ramp handed back hues tuned for a white background +// and painted them on near-black. +// 2. Even on the dark palette, Z0 mapped to `cool` — a SURFACE token, not an +// ink. As a foreground it measured 1.03:1 against nightAlt: invisible. +// Z0 is the RESTING zone, so it is what is on screen at the start of +// every workout and whenever heart rate is low or absent. +// +// These assert the fix numerically rather than by eye, so a palette edit can't +// silently reintroduce an unreadable zone. + +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/theme/tokens.dart'; + +/// WCAG 2.1 relative luminance. +double _luminance(Color c) { + double channel(double v) { + final s = v / 255.0; + return s <= 0.03928 ? s / 12.92 : math.pow((s + 0.055) / 1.055, 2.4) as double; + } + + return 0.2126 * channel((c.r * 255).roundToDouble()) + + 0.7152 * channel((c.g * 255).roundToDouble()) + + 0.0722 * channel((c.b * 255).roundToDouble()); +} + +/// WCAG 2.1 contrast ratio, 1.0 (identical) … 21.0 (black on white). +double _contrast(Color a, Color b) { + final la = _luminance(a); + final lb = _luminance(b); + final hi = math.max(la, lb); + final lo = math.min(la, lb); + return (hi + 0.05) / (lo + 0.05); +} + +void main() { + // 3:1 is the WCAG AA floor for large text and non-text UI components, which + // is what these are: big tabular figures and coloured indicators. + const minRatio = 3.0; + + group('zoneOnDark is legible on the session surface', () { + for (final surface in { + 'night': AppColors.night, + 'nightAlt': AppColors.nightAlt, + }.entries) { + for (var z = 0; z <= 5; z++) { + test('Z$z on ${surface.key} clears $minRatio:1', () { + final ratio = _contrast(AppColors.zoneOnDark(z), surface.value); + expect( + ratio, + greaterThanOrEqualTo(minRatio), + reason: 'Z$z renders at ${ratio.toStringAsFixed(2)}:1 on ' + '${surface.key} — unreadable. Zone colours on the live ' + 'session screen must be inks, not surface tokens.', + ); + }); + } + } + + test('Z0 specifically — the regression that shipped', () { + // `cool` is what Z0 used to resolve to. Pin the old value as a failing + // reference so the intent of the fix stays legible. + final broken = _contrast(kDarkPalette.cool, AppColors.nightAlt); + final fixed = _contrast(AppColors.zoneOnDark(0), AppColors.nightAlt); + expect(broken, lessThan(1.5), reason: 'the old Z0 was invisible'); + expect(fixed, greaterThan(6.0), reason: 'the new Z0 is clearly legible'); + }); + }); + + test('the ramp is independent of the ACTIVE palette', () { + // The live screen is always dark; flipping the app theme must not change + // a single zone colour on it. + AppColors.active = kLightPalette; + final inLight = [for (var z = 0; z <= 5; z++) AppColors.zoneOnDark(z)]; + AppColors.active = kDarkPalette; + final inDark = [for (var z = 0; z <= 5; z++) AppColors.zoneOnDark(z)]; + addTearDown(() => AppColors.active = kLightPalette); + expect(inLight, equals(inDark)); + }); +} From 062884af4142802fff805c151cca9d983151dcf5 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 27 Jul 2026 21:38:40 +0530 Subject: [PATCH 5/8] feat(share): compose a share image instead of screenshotting the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharing rasterised the whole finish card — header, route thumbnail, strain gauge, peak/avg/kcal/steps, time-in-zones, the HR-recovery curve and any PR badges — into one tall PNG and handed it straight to the OS sheet. A screenshot of a dashboard, and you never saw it before it hit the composer. The card is now composed for the job: map full-bleed owning the frame, one headline figure, three supporting stats, nothing else. Post (4:5) and Story (9:16). The scrim is bottom-weighted at 52% height on purpose — a full-height gradient greys the whole map out, which is what makes these look muddy. Indoor workouts get the same composition with a zone-washed backdrop rather than a second design. Preview before send: the card is on screen at real proportions with a format switcher. Better UX, and it removes a real failure mode — capturing a map that was never on screen races tile loading and yields half-blank images. Share is the primary action now; "Full breakdown" is a quiet link. Also dropped the canned "My OpenStrap workout" caption, which is exactly the filler that makes a share feel automated. Reachable from the workout DETAIL screen too — it was finish-screen only, so leaving that screen made a workout unshareable forever. Both go through buildWorkoutShareData so the same run cannot produce two different cards; there is a test asserting exactly that. A live workout is not shareable (no final numbers yet). Includes a real release-only bug I shipped and had to fix: the preview called RenderRepaintBoundary.debugNeedsPaint, which is bool get debugNeedsPaint { late bool result; assert(() { result = _needsPaint; return true; }()); return result; } Asserts are stripped in release/profile, so reading it throws LateInitializationError and sharing worked in debug while failing on every real build. Nothing catches this: the analyzer is happy and the whole test suite runs in debug. test/no_debug_only_apis_test.dart now greps lib/ for every getter in the Flutter SDK with that shape — the denylist was derived, not guessed, and the regeneration command is in the file. New OsIcon.share maps to FluentIcons.share_24_regular from a pack already imported; goes through the OsIcon seam like every other icon. --- lib/ui/activity/workout_share_card.dart | 602 ++++++++++++++++++++++++ lib/ui/kit/os_icons.dart | 3 + lib/ui/workouts/workouts_screen.dart | 91 +++- test/no_debug_only_apis_test.dart | 111 +++++ test/workout_share_card_test.dart | 280 +++++++++++ test/workout_sleep_redesign_test.dart | 8 +- 6 files changed, 1090 insertions(+), 5 deletions(-) create mode 100644 lib/ui/activity/workout_share_card.dart create mode 100644 test/no_debug_only_apis_test.dart create mode 100644 test/workout_share_card_test.dart diff --git a/lib/ui/activity/workout_share_card.dart b/lib/ui/activity/workout_share_card.dart new file mode 100644 index 00000000..7cafe294 --- /dev/null +++ b/lib/ui/activity/workout_share_card.dart @@ -0,0 +1,602 @@ +// The workout share card + its preview screen. +// +// WHY THIS EXISTS AS ITS OWN COMPOSITION +// +// Sharing used to capture the finish screen's card wholesale: the header, the +// route, the strain gauge, the peak/avg/kcal/steps row, the time-in-zones bar, +// the heart-rate-recovery curve and any PR badges — everything, in one tall +// PNG. That is a screenshot of a dashboard, not something anyone wants on a +// feed. It also meant the single most share-worthy thing in the whole workout, +// the route, arrived as a thumbnail sandwiched between charts. +// +// A share image has exactly one job: be worth looking at in someone else's +// feed at thumbnail size. So this is composed for that job rather than reused +// from the screen — +// +// • the map is FULL BLEED and owns the frame; everything else sits on a +// scrim over it, +// • one headline number (distance), +// • three supporting stats, no more, +// • no gauges, no curves, no badges, no zone bars — none of it survives +// being scaled to a feed thumbnail anyway, +// • aspect ratios that match where these actually get posted. +// +// Workouts with no route (indoor, treadmill, permission denied) get the same +// composition with a zone-washed panel in place of the map, so the layout and +// the code path stay identical rather than forking into a second design. + +import 'dart:io'; +import 'dart:typed_data' show ByteData; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../gps/route_math.dart' as rmath; +import '../../gps/route_models.dart'; +import '../../state/units_controller.dart'; +import '../../theme/theme.dart'; +import '../../theme/tokens.dart'; +import '../kit/kit.dart' show AppIcon, OsIcon; +import '../kit/route_map.dart'; + +/// Where the image is going. Strava-style: a feed post and a story, because +/// those are the two shapes people actually post into and a 1:1 crop of a +/// portrait card loses the route. +enum ShareFormat { + /// 4:5 — the tallest a feed post can be without being cropped. + feed('Post', 4 / 5), + + /// 9:16 — full-bleed story. + story('Story', 9 / 16); + + const ShareFormat(this.label, this.aspect); + final String label; + final double aspect; +} + +/// Everything the card renders. Values arrive pre-formatted so the card stays +/// a pure, testable composition with no unit/locale logic of its own. +class WorkoutShareData { + /// "Morning Run", "Evening Ride" — the human title. + final String title; + + /// Quiet date line under the title. + final String subtitle; + + /// The route. Empty ⇒ the no-map composition. + final List vertices; + + /// Headline figure, split so the unit can be set smaller ("8.42" + "KM"). + final String heroValue; + final String heroUnit; + + /// Up to three supporting stats — (value, label). + final List<(String, String)> stats; + + /// Tints the no-map fallback and the accent rule. Defaults to the brand. + final Color accent; + + const WorkoutShareData({ + required this.title, + required this.subtitle, + required this.vertices, + required this.heroValue, + required this.heroUnit, + required this.stats, + required this.accent, + }); + + bool get hasRoute => vertices.length >= 2; +} + +/// The card itself. Fixed logical width; height follows [format]. +/// +/// Rendered on screen in the preview (never offscreen) so the map tiles are +/// genuinely loaded by the time the user taps Share — capturing a hidden +/// FlutterMap is a race against tile loading that produces half-blank images. +class WorkoutShareCard extends StatelessWidget { + final WorkoutShareData data; + final ShareFormat format; + + /// Logical width the card lays out at. The capture scales from this. + static const double kWidth = 360; + + const WorkoutShareCard({ + super.key, + required this.data, + required this.format, + }); + + @override + Widget build(BuildContext context) { + final height = kWidth / format.aspect; + return SizedBox( + width: kWidth, + height: height, + child: ClipRRect( + borderRadius: BorderRadius.circular(R.card), + child: Stack( + fit: StackFit.expand, + children: [ + // ── The map owns the frame ────────────────────────────────── + if (data.hasRoute) + RouteMapView( + vertices: data.vertices, + borderRadius: BorderRadius.zero, + ) + else + _NoRouteBackdrop(accent: data.accent), + + // A scrim only where type sits, so the top of the route stays + // clean. Two stops, weighted low — a full-height gradient greys + // the whole map out, which is what makes these cards look muddy. + Positioned( + left: 0, + right: 0, + bottom: 0, + child: IgnorePointer( + child: Container( + height: height * 0.52, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.night.withValues(alpha: 0.0), + AppColors.night.withValues(alpha: 0.72), + AppColors.night.withValues(alpha: 0.94), + ], + stops: const [0.0, 0.55, 1.0], + ), + ), + ), + ), + ), + + // ── Content ──────────────────────────────────────────────── + Positioned( + left: Sp.x5, + right: Sp.x5, + bottom: Sp.x5, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + data.title.toUpperCase(), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 10, + letterSpacing: 2.5, + ), + ), + const SizedBox(height: Sp.x2), + // Headline: the number, with its unit set small beside it so + // the figure itself reads at thumbnail size. + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + data.heroValue, + maxLines: 1, + style: AppText.display.copyWith( + color: AppColors.onNight, + fontSize: 64, + height: 1.0, + fontWeight: FontWeight.w900, + fontFeatures: [ + const FontFeature.tabularFigures() + ], + ), + ), + ), + ), + if (data.heroUnit.isNotEmpty) ...[ + const SizedBox(width: Sp.x2), + Text( + data.heroUnit.toUpperCase(), + style: AppText.h2.copyWith( + color: AppColors.onNightSoft, + fontSize: 18, + fontWeight: FontWeight.w800, + letterSpacing: 1, + ), + ), + ], + ], + ), + const SizedBox(height: Sp.x4), + Container(height: 2, width: 34, color: data.accent), + const SizedBox(height: Sp.x4), + Row( + children: [ + for (final (value, label) in data.stats) + Expanded(child: _ShareStat(value: value, label: label)), + ], + ), + const SizedBox(height: Sp.x4), + Row( + children: [ + AppIcon(OsIcon.activity, size: 13, color: data.accent), + const SizedBox(width: Sp.x2), + Text( + 'OpenStrap', + style: AppText.caption.copyWith( + color: AppColors.onNightMuted, + fontWeight: FontWeight.w700, + letterSpacing: 0.5, + ), + ), + const Spacer(), + Text( + data.subtitle, + style: AppText.caption + .copyWith(color: AppColors.onNightMuted), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ShareStat extends StatelessWidget { + final String value; + final String label; + const _ShareStat({required this.value, required this.label}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.metric.copyWith( + color: AppColors.onNight, + fontSize: 19, + fontFeatures: [const FontFeature.tabularFigures()], + ), + ), + const SizedBox(height: 1), + Text( + label.toUpperCase(), + style: AppText.overline.copyWith( + color: AppColors.onNightMuted, + fontSize: 8, + letterSpacing: 1.6, + ), + ), + ], + ); +} + +/// Stand-in for the map on an indoor/no-GPS workout. Deliberately the same +/// composition, not a different card: a quiet zone-tinted field so the type +/// below still has something to sit on. +class _NoRouteBackdrop extends StatelessWidget { + final Color accent; + const _NoRouteBackdrop({required this.accent}); + + @override + Widget build(BuildContext context) => DecoratedBox( + decoration: BoxDecoration( + gradient: RadialGradient( + center: const Alignment(0, -0.35), + radius: 1.1, + colors: [ + Color.alphaBlend( + accent.withValues(alpha: 0.34), AppColors.night), + AppColors.night, + ], + ), + ), + ); +} + +/// Preview-then-share, the way every app that takes sharing seriously does it. +/// +/// Tapping Share used to silently rasterise the finish screen and hand the PNG +/// straight to the OS sheet — the athlete never saw what they were about to +/// post until it was already in the composer. Here the card is on screen at +/// real proportions, the format is switchable, and Share is the one obvious +/// action. +/// +/// It also removes a real failure mode: capturing a map that was never on +/// screen races tile loading and yields half-blank images. What you see here +/// is literally the thing that gets captured. +class WorkoutSharePreviewScreen extends StatefulWidget { + final WorkoutShareData data; + const WorkoutSharePreviewScreen({super.key, required this.data}); + + @override + State createState() => + _WorkoutSharePreviewScreenState(); +} + +class _WorkoutSharePreviewScreenState extends State { + final GlobalKey _captureKey = GlobalKey(); + ShareFormat _format = ShareFormat.feed; + bool _sharing = false; + + Future _share() async { + if (_sharing) return; + setState(() => _sharing = true); + try { + final boundary = _captureKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) throw StateError('Card not ready'); + + // Let the current frame finish so the boundary definitely has a layer to + // rasterise. The card has been on screen since this route opened, so this + // is belt-and-braces rather than the main defence. + // + // This deliberately does NOT use `boundary.debugNeedsPaint`. That getter + // is debug-only: + // + // bool get debugNeedsPaint { + // late bool result; + // assert(() { result = _needsPaint; return true; }()); + // return result; + // } + // + // In release and profile builds the assert is stripped, `result` is never + // assigned, and reading it throws LateInitializationError — so sharing + // worked in debug and failed on every real build. Nothing catches this: + // the analyzer is happy, and the whole test suite runs in debug mode. + // Treat any `debug*` member as unusable outside an assert. + await WidgetsBinding.instance.endOfFrame; + if (!mounted) return; + + // Target ~1080 px wide — the native width of a feed post; anything more + // is bytes nobody sees. + final pixelRatio = 1080 / WorkoutShareCard.kWidth; + final ui.Image image = await boundary.toImage(pixelRatio: pixelRatio); + final ByteData? bytes = + await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + if (bytes == null) throw StateError('Failed to encode image'); + + final dir = await getTemporaryDirectory(); + final file = File( + '${dir.path}/openstrap_${DateTime.now().millisecondsSinceEpoch}.png'); + await file.writeAsBytes(bytes.buffer.asUint8List()); + + if (!mounted) return; + final box = context.findRenderObject() as RenderBox?; + final origin = (box != null && box.hasSize) + ? (box.localToGlobal(Offset.zero) & box.size) + : null; + // No caption text: the image carries everything, and a canned + // "My OpenStrap workout" string is exactly the kind of filler that makes + // a share feel automated. + await Share.shareXFiles([XFile(file.path)], sharePositionOrigin: origin); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text("Couldn't share: $e"))); + } finally { + if (mounted) setState(() => _sharing = false); + } + } + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData.dark().copyWith(scaffoldBackgroundColor: AppColors.night), + child: Scaffold( + backgroundColor: AppColors.night, + body: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(Sp.x4, Sp.x3, Sp.x4, 0), + child: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(context).maybePop(), + icon: Icon(Icons.close_rounded, + color: AppColors.onNightSoft), + tooltip: 'Close', + ), + const Spacer(), + Text('Share workout', + style: AppText.label + .copyWith(color: AppColors.onNight)), + const Spacer(), + const SizedBox(width: 48), // balances the close button + ], + ), + ), + Expanded( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(vertical: Sp.x4), + child: RepaintBoundary( + key: _captureKey, + child: WorkoutShareCard( + data: widget.data, + format: _format, + ), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(Sp.x5, 0, Sp.x5, Sp.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _FormatToggle( + value: _format, + onChanged: (f) => setState(() => _format = f), + ), + const SizedBox(height: Sp.x4), + SizedBox( + width: double.infinity, + height: 54, + child: FilledButton.icon( + onPressed: _sharing ? null : _share, + icon: _sharing + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Icon(Icons.ios_share_rounded, size: 19), + label: Text(_sharing ? 'Preparing…' : 'Share'), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} + +class _FormatToggle extends StatelessWidget { + final ShareFormat value; + final ValueChanged onChanged; + const _FormatToggle({required this.value, required this.onChanged}); + + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(3), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.06), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all(color: Colors.white12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (final f in ShareFormat.values) + GestureDetector( + onTap: () => onChanged(f), + behavior: HitTestBehavior.opaque, + child: AnimatedContainer( + duration: Motion.fast, + curve: Motion.curve, + padding: const EdgeInsets.symmetric( + horizontal: Sp.x5, vertical: 8), + decoration: BoxDecoration( + color: f == value + ? Colors.white.withValues(alpha: 0.14) + : Colors.transparent, + borderRadius: BorderRadius.circular(R.pill), + ), + child: Text( + f.label, + style: AppText.caption.copyWith( + color: f == value + ? AppColors.onNight + : AppColors.onNightSoft, + fontWeight: FontWeight.w800, + ), + ), + ), + ), + ], + ), + ); +} + +/// THE one place a share composition is built. +/// +/// Both entry points — the finish screen right after a workout, and the detail +/// screen for any past workout — go through here, so the card can never say +/// one thing in one place and something else in the other. Everything the card +/// renders is decided here; the card itself just lays out what it's handed. +/// +/// The rule it encodes: **distance leads when there is a route**, because the +/// map is what the image is showing and the headline should name it. With no +/// route the clock leads instead, and the supporting stats swap to the ones +/// that still mean something indoors. +WorkoutShareData buildWorkoutShareData({ + required UnitsController units, + required String type, + required Duration duration, + required DateTime when, + required int maxHr, + required double strain, + required int calories, + WorkoutRoute? route, + int? avgHr, +}) { + final hasRoute = route != null && route.hasPath; + final title = type.isEmpty + ? 'Workout' + : type[0].toUpperCase() + type.substring(1); + + String heroValue; + String heroUnit; + List<(String, String)> stats; + if (hasRoute) { + final parts = units.distance(route.distanceMeters).split(' '); + heroValue = parts.first; + heroUnit = parts.length > 1 ? parts.sublist(1).join(' ') : ''; + stats = [ + (_shareDuration(duration), 'Time'), + // Moving pace, like everywhere else — see the note in _GpsControlPanel. + (units.pace(route.distanceMeters, route.movingSec), 'Pace'), + (strain.toStringAsFixed(1), 'Strain'), + ]; + } else { + heroValue = _shareDuration(duration); + heroUnit = ''; + stats = [ + (strain.toStringAsFixed(1), 'Strain'), + ('$calories', 'Kcal'), + (avgHr != null && avgHr > 0 ? '$avgHr' : '—', 'Avg bpm'), + ]; + } + + return WorkoutShareData( + title: title, + subtitle: _shareDate(when), + vertices: hasRoute + ? rmath.buildVertices(route.points, route.hr, maxHr) + : const [], + heroValue: heroValue, + heroUnit: heroUnit, + stats: stats, + accent: AppColors.coral, + ); +} + +String _shareDuration(Duration d) { + final h = d.inHours; + final m = d.inMinutes % 60; + final s = d.inSeconds % 60; + if (h > 0) return '${h}h ${m.toString().padLeft(2, '0')}m'; + return '${m}m ${s.toString().padLeft(2, '0')}s'; +} + +const _shareMonths = [ + 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', // + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', +]; + +String _shareDate(DateTime t) => + '${t.day} ${_shareMonths[t.month - 1]} ${t.year}'; diff --git a/lib/ui/kit/os_icons.dart b/lib/ui/kit/os_icons.dart index 13e3b716..5ec13dfb 100644 --- a/lib/ui/kit/os_icons.dart +++ b/lib/ui/kit/os_icons.dart @@ -115,6 +115,7 @@ enum OsIcon { battery, bluetooth, settings, + share, privacy, sync, info, @@ -229,6 +230,8 @@ const Map _glyphs = { OsIcon.battery: Iconsax.battery_full, OsIcon.bluetooth: Iconsax.bluetooth, OsIcon.settings: FluentIcons.settings_24_regular, + // Utility chrome → Fluent, per the pack policy in this file's header. + OsIcon.share: FluentIcons.share_24_regular, OsIcon.privacy: PhosphorIconsDuotone.shield, OsIcon.sync: PhosphorIconsDuotone.arrowsClockwise, OsIcon.info: FluentIcons.info_24_regular, diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index f3d264f7..bc40ee98 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -17,6 +17,7 @@ import '../../models/payloads.dart'; import '../../data/day_label.dart'; import '../../data/db.dart'; import '../activity/live_session_screen.dart'; +import '../activity/workout_share_card.dart'; import '../../theme/theme_switcher.dart'; import '../design/design.dart'; import '../kit/route_map.dart'; @@ -904,10 +905,40 @@ class WorkoutFeedCard extends StatelessWidget { } /// Post-workout breakdown (also the tap target from the list). -class WorkoutDetailScreen extends StatelessWidget { +class WorkoutDetailScreen extends StatefulWidget { final String id; const WorkoutDetailScreen({super.key, required this.id}); + @override + State createState() => _WorkoutDetailScreenState(); +} + +class _WorkoutDetailScreenState extends State { + /// Published by the body once the workout (and its route) have loaded. + /// + /// The share action lives in the scaffold's action row but the data it needs + /// is loaded by the body below it, so the body hands it up here. Held as a + /// notifier rather than lifted into setState so a load doesn't rebuild the + /// whole screen just to enable one button. + final ValueNotifier _shareData = ValueNotifier(null); + + String get id => widget.id; + + @override + void dispose() { + _shareData.dispose(); + super.dispose(); + } + + Future _share() async { + final data = _shareData.value; + if (data == null) return; + await Navigator.of(context).push( + themedRoute((_) => WorkoutSharePreviewScreen(data: data), + name: 'WorkoutSharePreviewScreen'), + ); + } + Future _delete(BuildContext context) async { final ok = await showDialog( context: context, @@ -954,16 +985,32 @@ class WorkoutDetailScreen extends StatelessWidget { title: 'Workout', largeTitle: false, actions: [ + // Sharing a past workout is the same action as sharing one you just + // finished, and produces the same card — it was only reachable from + // the finish screen, so the moment you left that screen the workout + // became unshareable. Appears once there is something to share. + ValueListenableBuilder( + valueListenable: _shareData, + builder: (context, data, _) => data == null + ? const SizedBox.shrink() + : Padding( + padding: const EdgeInsets.only(right: Sp.x2), + child: RoundIconButton(OsIcon.share, onTap: _share), + ), + ), RoundIconButton(OsIcon.trash, onTap: () => _delete(context)), ], - body: _WorkoutDetailBody(id: id), + body: _WorkoutDetailBody(id: id, shareData: _shareData), ); } } class _WorkoutDetailBody extends StatefulWidget { final String id; - const _WorkoutDetailBody({required this.id}); + + /// Filled in once loaded, so the scaffold above can offer a share action. + final ValueNotifier shareData; + const _WorkoutDetailBody({required this.id, required this.shareData}); @override State<_WorkoutDetailBody> createState() => _WorkoutDetailBodyState(); } @@ -994,6 +1041,7 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { _route = route; _loading = false; }); + _publishShareData(); } } catch (_) { if (mounted) { @@ -1002,6 +1050,43 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { } } + /// Hand the scaffold a ready-to-share composition. + /// + /// Built through [buildWorkoutShareData] — the same function the finish + /// screen uses — so sharing a run from here and from the finish screen + /// produce identical cards rather than two subtly different ones. + void _publishShareData() { + final d = _d; + if (d == null || d.isEmpty) { + widget.shareData.value = null; + return; + } + // A still-running workout has no final numbers yet; sharing one would post + // a half-finished card. + if (d['status'] == 'live') { + widget.shareData.value = null; + return; + } + final startTs = d['start_ts'] as int?; + final endTs = d['end_ts'] as int?; + final durationSec = (startTs != null && endTs != null && endTs > startTs) + ? endTs - startTs + : ((d['duration_min'] as num?)?.toDouble() ?? 0) * 60; + widget.shareData.value = buildWorkoutShareData( + units: context.read(), + type: (d['type'] as String?) ?? '', + duration: Duration(seconds: durationSec.round()), + when: startTs != null && startTs > 0 + ? DateTime.fromMillisecondsSinceEpoch(startTs * 1000).toLocal() + : DateTime.now(), + maxHr: context.read().maxHr, + strain: (d['strain'] as num?)?.toDouble() ?? 0, + calories: (d['calories'] as num?)?.toInt() ?? 0, + route: _route, + avgHr: (d['avg_hr'] as num?)?.toInt(), + ); + } + Future _correctType() async { final d = _d; if (d == null) return; diff --git a/test/no_debug_only_apis_test.dart b/test/no_debug_only_apis_test.dart new file mode 100644 index 00000000..676c9e40 --- /dev/null +++ b/test/no_debug_only_apis_test.dart @@ -0,0 +1,111 @@ +// Source hygiene: no assert-stripped Flutter APIs in production code. +// +// THE BUG THIS EXISTS FOR +// +// The workout share flow called `RenderRepaintBoundary.debugNeedsPaint` to +// decide whether to wait a frame before rasterising. That getter is shaped like +// this in the Flutter SDK (rendering/object.dart): +// +// bool get debugNeedsPaint { +// late bool result; +// assert(() { result = _needsPaint; return true; }()); +// return result; +// } +// +// Asserts are stripped in release and profile builds, so `result` is never +// assigned and simply READING the getter throws: +// +// LateInitializationError: Local 'result' has not been initialized. +// +// Sharing therefore worked perfectly in debug and failed on every real build. +// +// Nothing else catches this. `flutter analyze` is happy — it is a legal getter +// call. The entire test suite runs in DEBUG mode, where the assert executes and +// the getter behaves, so no widget or unit test can reproduce it. Only a +// release build on a device does, which is the slowest possible feedback loop. +// +// So this test greps instead. The denylist below was not guessed: it is every +// getter in the Flutter SDK matching the `late result; assert(...)` shape. +// Regenerate it with: +// +// grep -rzoP '\w[\w<>, ?]*\s+get\s+\w+\s*\{\s*late\s+[\w<>, ?]+\s+result;\s*assert\(' \ +// $FLUTTER_ROOT/packages/flutter/lib/src +// +// If a member here is genuinely needed, guard it inside an `assert(() {...})` +// block — never on a code path that runs in release. + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +/// Getters whose value only exists when asserts are enabled. +const _assertStrippedMembers = [ + 'debugNeedsPaint', + 'debugNeedsLayout', + 'debugNeedsCompositedLayerUpdate', +]; + +/// Strip `//` line comments and `/* */` blocks so a member named in an +/// explanatory comment (this file's own history, for one) isn't a false hit. +String _stripComments(String source) { + final noBlocks = source.replaceAll(RegExp(r'/\*.*?\*/', dotAll: true), ''); + return noBlocks + .split('\n') + .map((l) { + final i = l.indexOf('//'); + return i == -1 ? l : l.substring(0, i); + }) + .join('\n'); +} + +void main() { + test('no assert-stripped Flutter APIs are called in lib/', () { + final lib = Directory('lib'); + expect(lib.existsSync(), isTrue, reason: 'run from the package root'); + + final offences = []; + for (final entity in lib.listSync(recursive: true)) { + if (entity is! File || !entity.path.endsWith('.dart')) continue; + final code = _stripComments(entity.readAsStringSync()); + final lines = code.split('\n'); + for (var i = 0; i < lines.length; i++) { + for (final member in _assertStrippedMembers) { + // `.member` — a call on an instance. A declaration of the same name + // (we don't have one) wouldn't match the leading dot. + if (lines[i].contains('.$member')) { + offences.add('${entity.path}:${i + 1} → $member'); + } + } + } + } + + expect( + offences, + isEmpty, + reason: 'These read fine in debug and throw LateInitializationError in ' + 'release/profile, because their value is only assigned inside an ' + 'assert. Guard them inside `assert(() { ... }())` or drop them:\n' + '${offences.join('\n')}', + ); + }); + + test('the denylist itself is non-empty and plausible', () { + // A regenerated-but-emptied denylist would make the test above vacuous. + expect(_assertStrippedMembers, isNotEmpty); + expect(_assertStrippedMembers, contains('debugNeedsPaint')); + }); + + test('the comment stripper does not hide a real call', () { + const sample = ''' + // boundary.debugNeedsPaint is mentioned here in prose + final x = boundary.debugNeedsPaint; + '''; + final stripped = _stripComments(sample); + expect(stripped.contains('.debugNeedsPaint'), isTrue, + reason: 'the real call on line 2 must survive stripping'); + expect('\n'.allMatches(stripped).length, greaterThan(1)); + // And a comment-only mention must NOT trip it. + const commentOnly = '// see boundary.debugNeedsPaint for why'; + expect(_stripComments(commentOnly).contains('.debugNeedsPaint'), isFalse); + }); +} diff --git a/test/workout_share_card_test.dart b/test/workout_share_card_test.dart new file mode 100644 index 00000000..cd9366f1 --- /dev/null +++ b/test/workout_share_card_test.dart @@ -0,0 +1,280 @@ +// The share card is defined as much by what it LEAVES OUT as by what it shows. +// +// Sharing used to rasterise the whole finish card — header, route thumbnail, +// strain gauge, peak/avg/kcal/steps, time-in-zones, the HR-recovery curve and +// any PR badges — into one tall PNG. These tests pin the composition that +// replaced it: map-led, one headline figure, three supporting stats, and none +// of the dashboard furniture that doesn't survive a feed thumbnail. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:openstrap_edge/gps/route_models.dart'; +import 'package:openstrap_edge/state/units_controller.dart'; +import 'package:openstrap_edge/theme/theme.dart'; +import 'package:openstrap_edge/theme/tokens.dart'; +import 'package:openstrap_edge/ui/activity/workout_share_card.dart'; +import 'package:openstrap_edge/ui/design/fake_route_fixture.dart'; +import 'package:openstrap_edge/ui/kit/route_map.dart'; +import 'package:provider/provider.dart'; + +List _route() => [ + for (var i = 0; i < 40; i++) + RouteVertex( + LatLng(51.5074 + i * 0.0004, -0.1278 + i * 0.0003), + (i ~/ 8).clamp(0, 5), + ), + ]; + +WorkoutShareData _data({List? vertices}) => WorkoutShareData( + title: 'Morning Run', + subtitle: '27 Jul 2026', + vertices: vertices ?? _route(), + heroValue: '8.42', + heroUnit: 'km', + stats: const [ + ('42:30', 'Time'), + ('5:02 /km', 'Pace'), + ('14.2', 'Strain'), + ], + accent: AppColors.coral, + ); + +Widget _host(Widget child) => MultiProvider( + providers: [ + ChangeNotifierProvider.value( + value: UnitsController.seed(UnitSystem.metric), + ), + ], + child: MaterialApp( + theme: buildOpenStrapTheme(kDarkPalette), + home: Scaffold(body: Center(child: child)), + ), + ); + +/// Drop map-tile fetch errors, keep everything else. +/// +/// flutter_test's HTTP mock answers 400 for every request, so each tile throws +/// through the image-resource service — and flutter_test fails a test on ANY +/// unhandled exception, so a card that renders perfectly still goes red. +/// +/// Filtered rather than drained, so RenderFlex overflow still fails these +/// tests. MUST be installed inside the test body: the test binding replaces +/// FlutterError.onError after setUp runs, so a filter installed there is +/// silently discarded (which is what sent me down a blind alley of +/// hand-stubbing HttpClient before noticing). +void _ignoreTileFetchErrors() { + final previous = FlutterError.onError; + FlutterError.onError = (details) { + if (details.library == 'image resource service') return; + if (details.exception.toString().contains('ClientException')) return; + previous?.call(details); + }; + addTearDown(() => FlutterError.onError = previous); +} + +void main() { + setUp(() => AppColors.active = kDarkPalette); + tearDown(() => AppColors.active = kLightPalette); + + group('WorkoutShareCard', () { + testWidgets('leads with the map and the headline figure', (t) async { + _ignoreTileFetchErrors(); + t.view.physicalSize = const Size(500, 900); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + await t.pumpWidget(_host( + WorkoutShareCard(data: _data(), format: ShareFormat.feed), + )); + await t.pump(const Duration(milliseconds: 400)); + + expect(find.byType(RouteMapView), findsOneWidget, + reason: 'the route is the reason anyone shares this'); + expect(find.text('8.42'), findsOneWidget); + expect(find.text('KM'), findsOneWidget); + expect(find.text('MORNING RUN'), findsOneWidget); + expect(t.takeException(), isNull); + }); + + testWidgets('carries exactly three supporting stats, no more', (t) async { + _ignoreTileFetchErrors(); + t.view.physicalSize = const Size(500, 900); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + await t.pumpWidget(_host( + WorkoutShareCard(data: _data(), format: ShareFormat.feed), + )); + await t.pump(const Duration(milliseconds: 400)); + + for (final label in ['TIME', 'PACE', 'STRAIN']) { + expect(find.text(label), findsOneWidget); + } + // The dashboard furniture that used to end up in the shared PNG. + for (final absent in ['PEAK BPM', 'TIME IN ZONES', 'KCAL', 'STEPS']) { + expect(find.text(absent), findsNothing, + reason: '"$absent" belongs on the screen, not in a feed post'); + } + expect(t.takeException(), isNull); + }); + + testWidgets('both formats lay out without overflowing', (t) async { + _ignoreTileFetchErrors(); + t.view.physicalSize = const Size(500, 1400); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + for (final f in ShareFormat.values) { + await t.pumpWidget(_host(WorkoutShareCard(data: _data(), format: f))); + await t.pump(const Duration(milliseconds: 400)); + final size = t.getSize(find.byType(WorkoutShareCard)); + expect(size.width, WorkoutShareCard.kWidth); + expect( + size.height, + closeTo(WorkoutShareCard.kWidth / f.aspect, 0.5), + reason: '${f.label} must honour its aspect ratio', + ); + expect(t.takeException(), isNull); + } + }); + + testWidgets('an indoor workout keeps the same composition, no map', + (t) async { + t.view.physicalSize = const Size(500, 900); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + final indoor = WorkoutShareData( + title: 'Strength', + subtitle: '27 Jul 2026', + vertices: const [], + heroValue: '48:10', + heroUnit: '', + stats: const [('14.2', 'Strain'), ('512', 'Kcal'), ('141', 'Avg bpm')], + accent: AppColors.coral, + ); + + await t.pumpWidget(_host( + WorkoutShareCard(data: indoor, format: ShareFormat.feed), + )); + await t.pump(const Duration(milliseconds: 400)); + + expect(find.byType(RouteMapView), findsNothing); + // Same layout, different backdrop — not a second design. + expect(find.text('48:10'), findsOneWidget); + expect(find.text('STRAIN'), findsOneWidget); + expect(find.text('AVG BPM'), findsOneWidget); + expect(t.takeException(), isNull); + }); + + testWidgets('a one-point route is treated as no route', (t) async { + t.view.physicalSize = const Size(500, 900); + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + // A single fix cannot draw a line; RouteMapView would render an empty + // box, so the card must fall back rather than show a blank frame. + final oneFix = _data(vertices: [ + RouteVertex(const LatLng(51.5074, -0.1278), 2), + ]); + expect(oneFix.hasRoute, isFalse); + + await t.pumpWidget(_host( + WorkoutShareCard(data: oneFix, format: ShareFormat.feed), + )); + await t.pump(const Duration(milliseconds: 400)); + expect(find.byType(RouteMapView), findsNothing); + expect(t.takeException(), isNull); + }); + }); + + group('buildWorkoutShareData — one composition, two entry points', () { + final units = UnitsController.seed(UnitSystem.metric); + final route = fakeRunRoute(); + final when = DateTime(2026, 7, 27, 8, 14); + + WorkoutShareData build({WorkoutRoute? r}) => buildWorkoutShareData( + units: units, + type: 'run', + duration: const Duration(minutes: 20, seconds: 6), + when: when, + maxHr: 190, + strain: 11.6, + calories: 284, + route: r, + avgHr: 148, + ); + + test('the finish screen and the detail screen produce the SAME card', () { + // Both call this factory with the same workout. If they ever diverge, + // sharing the same run from two places gives two different images. + final fromFinish = build(r: route); + final fromDetail = build(r: route); + expect(fromDetail.title, fromFinish.title); + expect(fromDetail.subtitle, fromFinish.subtitle); + expect(fromDetail.heroValue, fromFinish.heroValue); + expect(fromDetail.heroUnit, fromFinish.heroUnit); + expect(fromDetail.stats, fromFinish.stats); + expect(fromDetail.vertices.length, fromFinish.vertices.length); + }); + + test('a route leads with DISTANCE — the map is what the image shows', () { + final d = build(r: route); + expect(d.hasRoute, isTrue); + expect(d.heroUnit, 'km'); + expect(double.tryParse(d.heroValue), isNotNull); + expect([for (final (_, l) in d.stats) l], ['Time', 'Pace', 'Strain']); + }); + + test('no route leads with the CLOCK and swaps to indoor stats', () { + final d = build(); + expect(d.hasRoute, isFalse); + expect(d.heroValue, '20m 06s'); + expect(d.heroUnit, isEmpty); + expect([for (final (_, l) in d.stats) l], ['Strain', 'Kcal', 'Avg bpm']); + expect(d.stats.last.$1, '148'); + }); + + test('an absent average heart rate is "—", never a fabricated 0', () { + final d = buildWorkoutShareData( + units: units, + type: 'other', + duration: const Duration(minutes: 30), + when: when, + maxHr: 190, + strain: 8.0, + calories: 200, + avgHr: null, + ); + expect(d.stats.last, ('—', 'Avg bpm')); + }); + + test('imperial units carry through to the headline', () { + final d = buildWorkoutShareData( + units: UnitsController.seed(UnitSystem.imperial), + type: 'run', + duration: const Duration(minutes: 20), + when: when, + maxHr: 190, + strain: 11.6, + calories: 284, + route: route, + ); + expect(d.heroUnit, 'mi'); + }); + + test('an empty type still gets a human title', () { + final d = buildWorkoutShareData( + units: units, + type: '', + duration: const Duration(minutes: 5), + when: when, + maxHr: 190, + strain: 1.0, + calories: 20, + ); + expect(d.title, 'Workout'); + }); + }); +} diff --git a/test/workout_sleep_redesign_test.dart b/test/workout_sleep_redesign_test.dart index bfbd3d60..7d2bb6bf 100644 --- a/test/workout_sleep_redesign_test.dart +++ b/test/workout_sleep_redesign_test.dart @@ -365,7 +365,9 @@ void main() { expect(find.text('PEAK BPM'), findsOneWidget); expect(find.text('TIME IN ZONES'), findsOneWidget); expect(find.text('Full breakdown'), findsOneWidget); - expect(find.text('Share'), findsOneWidget); + // Share is the PRIMARY action now (filled, full-width), and it + // opens a composed preview rather than rasterising this screen. + expect(find.text('Share workout'), findsOneWidget); expect(t.takeException(), isNull); await t.pump(const Duration(milliseconds: 1600)); // settle shimmers } @@ -419,7 +421,9 @@ void main() { // TestWidgetsFlutterBinding, a sandboxing limitation, not a product // bug. Verify the actual share output manually on a real // device/simulator via the Design Gallery's "Workout preview" section. - expect(find.text('Share'), findsOneWidget); + // Share is the PRIMARY action now (filled, full-width), and it + // opens a composed preview rather than rasterising this screen. + expect(find.text('Share workout'), findsOneWidget); }); }); } From ea5bae6bd9f2ce2404a218fdb3f6515a512aeec6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 27 Jul 2026 22:10:36 +0530 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?a=20real=20interpolation=20bug=20and=20two=20vacuous=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit found 12 things on #162. Ten were valid; two were wrong and are left alone with the reasoning recorded below. CRITICAL, and mine: the almost-there nudge shipped as '\$gapBpm bpm to \${...} — push' — escaped, so the athlete saw the raw template instead of "4 bpm to Z4 — push". A shell/python escape leaked into Dart source. No test covered it because the nudge only appears within 5 bpm of the next zone; there is one now. Two of my tests asserted nothing, which is the same failure I caught in my own layout test earlier and did not generalise from: - the derive-gate test never enqueued a job, so `runs == 0` passed with the gate deleted. It now queues real work, asserts it stays parked, and asserts it drains on release. Verified by deleting the gate. - the ScreenWake tests mocked two MethodChannels that were never reached: Platform.isAndroid and isIOS are BOTH false on the host VM, so the dispatch short-circuited. Added a test seam. Correctness: - _drain() cleared the gate, then awaited takeNextComputeJob(); a workout starting inside that window still got a derive pass, and the job was already marked running. Re-checks after acquisition and hands the job back via a new LocalDb.requeueComputeJob (which also undoes the attempt increment — being deferred is not a failure). - ScreenWake latched _on before the platform answered. Android returns false when no activity is attached, so a failed enable left Dart believing the screen was held and suppressed every later retry. - _disposed only guarded the paths someone remembered. notifyListeners() is now overridden to no-op when disposed, which covers the scheduler's onChanged callback and in-flight _afterDrain() continuations too. - ScreenWake was armed inside _maybeStartRouteTracking, so indoor workouts, location-denied runs and resumed non-route sessions never held the screen. Moved to startWorkout() plus orphan rehydration. - RouteMapView's memo key was the vertex list alone, but the cached polylines bake in the ACTIVE palette (via _colorFor) and `interactive` (stroke width). A theme switch left a finished route in the old palette's colours — and the design gallery toggles the theme with this widget on screen. Key now includes both, and is re-checked in build() because the palette is a global static that need not trigger didUpdateWidget. Privacy: "Routes never leave your device" was too absolute now that Share renders a route into an image the user can send anywhere. Split into what the App does (never sends them) and the one exception (you, deliberately, having seen the image first). Both documents. Also: shared PNGs used a timestamped filename and accumulated in temp until the OS reclaimed them — one reused name now. And "Couldn't share: $e" put an internal exception string in front of the athlete; logged instead, with a fixed sentence shown. REJECTED, with reasons: - "hasRoute does not promote route, so route.distanceMeters is still nullable": Dart 3.11 DOES promote through a final boolean local. Verified with a standalone probe; the analyzer is clean. - "int.clamp() returns num, so this won't type-check against int?": int.clamp(int, int) is statically int in Dart. Also verified with a probe that analyzes clean. Unrelated flake found while verifying: notification_day_guard_test seeded empty prefs for the cases that expect a present to SUCCEED, inheriting the default 22:00-07:00 quiet window. Every one of those failed for nine hours a night locally and passed on CI only because CI runs at a different hour. Pinned quiet hours off, mirroring the _quietAllDay helper already there. 959 tests, analyzer clean. --- PRIVACY.md | 13 ++++-- docs/privacy.html | 15 ++++--- lib/compute/derive_scheduler.dart | 12 ++++++ lib/data/db.dart | 16 ++++++++ lib/gps/screen_wake.dart | 44 ++++++++++++++++---- lib/state/app_state.dart | 23 ++++++++++- lib/ui/activity/live_session_screen.dart | 2 +- lib/ui/activity/workout_share_card.dart | 18 +++++--- lib/ui/kit/route_map.dart | 22 +++++++++- test/live_session_layout_test.dart | 24 +++++++++++ test/notification_day_guard_test.dart | 22 +++++++--- test/workout_reliability_test.dart | 52 +++++++++++++++++++++--- 12 files changed, 226 insertions(+), 37 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index ea2e3406..081d0f16 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -60,10 +60,15 @@ so to be specific about it: indicator is shown for the whole time the App is reading location in the background. On Android the workout runs as a foreground service with a visible, persistent notification. -- **Routes never leave your device.** A route is written to a local database - table on your phone and nowhere else. It is not uploaded, not included in - anonymous diagnostics, and not sent to your AI Coach provider — the coach is - technically prevented from reading route data, not merely asked not to. +- **The App never sends your routes anywhere.** A route is written to a local + database table on your phone and nowhere else. We do not upload it, it is not + included in anonymous diagnostics, and it is not sent to your AI Coach + provider — the coach is technically prevented from reading route data, not + merely asked not to. +- **The one exception is you.** If you tap Share on a workout, the image you + are shown includes a picture of your route, and whatever you send it to + receives it. That is your choice, you see the image before it is sent, and it + goes wherever you send it — not to us. - **You can delete it.** Deleting a workout deletes its route with it, and uninstalling the App removes all of it immediately. diff --git a/docs/privacy.html b/docs/privacy.html index c6bb702c..c3940f6b 100644 --- a/docs/privacy.html +++ b/docs/privacy.html @@ -84,11 +84,16 @@

Location and workout routes

blue location indicator is shown for the whole time the App is reading location in the background. On Android the workout runs as a foreground service with a visible, persistent notification. -
  • Routes never leave your device. A route is written to - a local database table on your phone and nowhere else. It is not - uploaded, not included in anonymous diagnostics, and not sent to your AI - Coach provider — the coach is technically prevented from reading route - data, not merely asked not to.
  • +
  • The App never sends your routes anywhere. A route is + written to a local database table on your phone and nowhere else. We do + not upload it, it is not included in anonymous diagnostics, and it is + not sent to your AI Coach provider — the coach is technically prevented + from reading route data, not merely asked not to.
  • +
  • The one exception is you. If you tap Share on a + workout, the image you are shown includes a picture of your route, and + whatever you send it to receives it. That is your choice, you see the + image before it is sent, and it goes wherever you send it — not to + us.
  • You can delete it. Deleting a workout deletes its route with it, and uninstalling the App removes all of it immediately.
  • diff --git a/lib/compute/derive_scheduler.dart b/lib/compute/derive_scheduler.dart index b4e1001c..d7f15375 100644 --- a/lib/compute/derive_scheduler.dart +++ b/lib/compute/derive_scheduler.dart @@ -171,6 +171,18 @@ class DeriveScheduler { return; } final id = job['id']?.toString(); + // RE-CHECK THE GATES AFTER ACQUISITION. The checks above happened before a + // DB round-trip, and a workout can start (or an offload/background flip can + // land) inside it — at which point running the pass is exactly what the + // gate exists to prevent. The job is already marked `running` by + // takeNextComputeJob, so hand it back rather than leaving it claimed. + if (_offloadActive || _background || _workoutActive) { + if (id != null && id.isNotEmpty) { + await LocalDb.requeueComputeJob(id); + } + await _refreshSnapshot(); + return; + } final kind = _parseKind(job['type']?.toString()); _running = true; await _refreshSnapshot(); diff --git a/lib/data/db.dart b/lib/data/db.dart index e00b63c1..cb528a42 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -3951,6 +3951,22 @@ class LocalDb { }); } + /// Put a claimed job back on the queue without counting it as an attempt. + /// + /// Used when a gate closes DURING acquisition: `_drain()` clears the gate, + /// awaits [takeNextComputeJob], and by the time that returns a workout may + /// have started. The job is already marked `running`, so it has to be handed + /// back explicitly or it sits claimed until the next [recoverComputeJobs]. + /// The attempt increment is undone too — being deferred is not a failure. + static Future requeueComputeJob(String id) async { + final db = await instance; + await db.rawUpdate( + 'UPDATE compute_jobs SET state = ?, ' + 'attempts = MAX(attempts - 1, 0), updated_at = ? WHERE id = ?', + ['queued', DateTime.now().millisecondsSinceEpoch, id], + ); + } + static Future completeComputeJob(String id) async { final db = await instance; await db.delete('compute_jobs', where: 'id = ?', whereArgs: [id]); diff --git a/lib/gps/screen_wake.dart b/lib/gps/screen_wake.dart index 17d3c4f5..82c0f4ba 100644 --- a/lib/gps/screen_wake.dart +++ b/lib/gps/screen_wake.dart @@ -27,10 +27,29 @@ class ScreenWake { static const _android = MethodChannel('openstrap/edge_tracking'); static const _ios = MethodChannel('openstrap/ios_config'); - /// Tracks what we last asked for, so repeated `enable()` calls from a 1 Hz - /// tick don't hit the platform channel every second. + /// What the PLATFORM last confirmed, not what we last asked for. + /// + /// Only updated after a successful call. Android returns false when no + /// activity is attached; latching the requested value before the result came + /// back meant a failed enable left Dart believing the screen was held and + /// short-circuited every later retry. static bool _on = false; + /// Test seam for the platform switch below. + /// + /// `Platform.isAndroid` and `Platform.isIOS` are BOTH false on the host VM + /// that widget tests run on, so without this the dispatch short-circuits and + /// no test can reach either MethodChannel — the mocks looked wired up and + /// asserted nothing. Set to 'android' or 'ios' in a test; null = real + /// platform. + @visibleForTesting + static String? platformOverride; + + static bool get _isAndroid => + platformOverride == null ? Platform.isAndroid : platformOverride == 'android'; + static bool get _isIOS => + platformOverride == null ? Platform.isIOS : platformOverride == 'ios'; + @visibleForTesting static bool get isHeld => _on; @@ -43,19 +62,26 @@ class ScreenWake { static Future _set(bool on) async { if (on == _on) return; - _on = on; try { - if (Platform.isAndroid) { - await _android.invokeMethod('keepAwake', {'on': on}); - } else if (Platform.isIOS) { - await _ios.invokeMethod('keepAwake', {'on': on}); + if (_isAndroid) { + final ok = await _android.invokeMethod('keepAwake', {'on': on}); + // Android answers false when there is no attached activity. Leave the + // flag alone so the next call retries rather than assuming success. + if (ok != true) return; + } else if (_isIOS) { + await _ios.invokeMethod('keepAwake', {'on': on}); } + _on = on; } catch (e) { - // Never surface: losing the wake flag degrades to "screen sleeps". + // Never surface: losing the wake flag degrades to "screen sleeps". The + // flag stays unchanged, so a later attempt can still succeed. debugPrint('[screen-wake] ${on ? 'enable' : 'release'} failed: $e'); } } @visibleForTesting - static void resetForTest() => _on = false; + static void resetForTest() { + _on = false; + platformOverride = null; + } } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 7472e518..e83462e2 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -749,6 +749,21 @@ class AppState extends ChangeNotifier { if (route != null) _handleTapRoute(route); } + /// Central disposal guard. + /// + /// Setting `_disposed` and checking it at each await point only covers the + /// paths someone remembered to guard. Several notifications reach here from + /// places that never see that flag — the derive scheduler's `onChanged` + /// callback, in-flight `_afterDrain()` continuations, BLE engine callbacks — + /// and notifying a disposed ChangeNotifier throws in release. Overriding the + /// single funnel every one of them goes through makes the guard total instead + /// of a list of remembered sites. + @override + void notifyListeners() { + if (_disposed) return; + super.notifyListeners(); + } + @override void dispose() { _disposed = true; @@ -3209,6 +3224,11 @@ class AppState extends ChangeNotifier { // competes with GPS, the live map and the BLE drain (see // DeriveScheduler.setWorkoutActive). _deriveScheduler.setWorkoutActive(true); + // Hold the display for EVERY live session, not just route-eligible ones. + // Arming this from _maybeStartRouteTracking meant an indoor workout, a + // location-denied run, and a resumed non-route session all watched the + // screen sleep mid-set. Released unconditionally on both teardown paths. + ScreenWake.enable(); activeWorkout = LiveWorkoutState( startTime: start, targetKcal: targetKcal, @@ -3304,8 +3324,6 @@ class AppState extends ChangeNotifier { // Android: retype the already-running FGS to connectedDevice|location so // the OS keeps delivering fixes while a route session is live. EdgeTracking.start(location: true); - // Hold the display for the session (released on every teardown path below). - ScreenWake.enable(); notifyListeners(); _log('Route tracking started for $type.'); } @@ -3415,6 +3433,7 @@ class AppState extends ChangeNotifier { // kept and the gap shows honestly as a segment break. unawaited(_maybeStartRouteTracking(id, activeWorkout!.type)); _deriveScheduler.setWorkoutActive(true); + ScreenWake.enable(); } else { await LocalDb.putSession({...row, 'status': 'done'}); _log('[workout] finalized a stale live-session row from a previous run (id=${row['id']}).'); diff --git a/lib/ui/activity/live_session_screen.dart b/lib/ui/activity/live_session_screen.dart index b62b9969..0aea85b7 100644 --- a/lib/ui/activity/live_session_screen.dart +++ b/lib/ui/activity/live_session_screen.dart @@ -413,7 +413,7 @@ class _LiveSessionScreenState extends State redStreak: _redStreak, line: _line, almostText: almost - ? '\$gapBpm bpm to \${_zones[zone + 1].label} — push' + ? '$gapBpm bpm to ${_zones[zone + 1].label} — push' : null, almostColor: zone < 5 ? _zones[zone + 1].color : z.color, diff --git a/lib/ui/activity/workout_share_card.dart b/lib/ui/activity/workout_share_card.dart index 7cafe294..99807480 100644 --- a/lib/ui/activity/workout_share_card.dart +++ b/lib/ui/activity/workout_share_card.dart @@ -374,8 +374,11 @@ class _WorkoutSharePreviewScreenState extends State { if (bytes == null) throw StateError('Failed to encode image'); final dir = await getTemporaryDirectory(); - final file = File( - '${dir.path}/openstrap_${DateTime.now().millisecondsSinceEpoch}.png'); + // ONE reused filename, not a timestamped file per share. Each capture is + // a ~1080 px PNG; a unique name per tap left every one of them sitting in + // the temp directory until the OS felt like reclaiming it. The share + // sheet has finished reading the file before the next share overwrites. + final file = File('${dir.path}/openstrap_share.png'); await file.writeAsBytes(bytes.buffer.asUint8List()); if (!mounted) return; @@ -387,10 +390,15 @@ class _WorkoutSharePreviewScreenState extends State { // "My OpenStrap workout" string is exactly the kind of filler that makes // a share feel automated. await Share.shareXFiles([XFile(file.path)], sharePositionOrigin: origin); - } catch (e) { + } catch (e, st) { + // Log the detail; show the athlete a fixed sentence. "Couldn't share: + // PlatformException(...)" puts an internal error string in front of + // someone who just finished a workout and can do nothing with it. + debugPrint('[share] failed: $e\n$st'); if (!mounted) return; - ScaffoldMessenger.of(context) - .showSnackBar(SnackBar(content: Text("Couldn't share: $e"))); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("Couldn't prepare the image — try again")), + ); } finally { if (mounted) setState(() => _sharing = false); } diff --git a/lib/ui/kit/route_map.dart b/lib/ui/kit/route_map.dart index 6ef914cb..ce8d0831 100644 --- a/lib/ui/kit/route_map.dart +++ b/lib/ui/kit/route_map.dart @@ -166,6 +166,8 @@ class RouteMapView extends StatefulWidget { /// Now the work is redone only when the path actually changes. class _RouteMapViewState extends State { List? _builtFrom; + Object? _builtPalette; + bool? _builtInteractive; List _points = const []; List _glow = const []; List _crisp = const []; @@ -185,9 +187,22 @@ class _RouteMapViewState extends State { /// Identity check, not a deep compare: RouteTracker publishes a NEW /// unmodifiable list per accepted fix, so identity changes exactly when the /// path really grew. A deep compare would cost as much as the rebuild. + /// + /// The key includes the palette and `interactive` because the cached + /// polylines bake BOTH in: `_colorFor` resolves AppColors.zone() from the + /// active palette, and the stroke width comes from `interactive`. Keying on + /// the vertex list alone meant a theme switch left a finished route drawn in + /// the old palette's colours until the route object itself changed — and the + /// design gallery toggles the theme with exactly this widget on screen. void _rebuildIfNeeded() { - if (identical(_builtFrom, widget.vertices)) return; + if (identical(_builtFrom, widget.vertices) && + identical(_builtPalette, AppColors.active) && + _builtInteractive == widget.interactive) { + return; + } _builtFrom = widget.vertices; + _builtPalette = AppColors.active; + _builtInteractive = widget.interactive; _points = [for (final v in widget.vertices) v.pos]; _glow = _polylines(glow: true); _crisp = _polylines(); @@ -252,6 +267,11 @@ class _RouteMapViewState extends State { @override Widget build(BuildContext context) { + // Also checked here, not only in didUpdateWidget: the palette is a global + // static, so a theme switch that leaves this widget instance untouched + // would otherwise never invalidate the cache. Three identity comparisons + // when nothing changed. + _rebuildIfNeeded(); // Drop any non-finite GPS coordinate (a bad fix can carry NaN/Inf lat/lng). // Left in, it makes LatLngBounds + camera-fit NaN and crashes the tile layer // at build (NaN.toInt in flutter_map's _clampToNativeZoom) — a real FATAL. diff --git a/test/live_session_layout_test.dart b/test/live_session_layout_test.dart index 011327ef..76697d48 100644 --- a/test/live_session_layout_test.dart +++ b/test/live_session_layout_test.dart @@ -178,4 +178,28 @@ void main() { // scale down rather than overflow its row. expect(t.takeException(), isNull); }); + + testWidgets('the almost-there nudge interpolates its values', (t) async { + t.view.physicalSize = _sizes['iPhone 15']!; + t.view.devicePixelRatio = 1.0; + addTearDown(t.view.reset); + + // The nudge only appears within 5 bpm of the next zone, which is why no + // test ever hit it — and an escaped `\$` in the template shipped, rendering + // the literal text `$gapBpm bpm to ${_zones[zone + 1].label} — push` to the + // athlete. Default maxHr is 190 (age 30), Z4 starts at 0.8 => 152 bpm, so + // 148 sits 4 bpm short of it. + final app = liveApp(hr: 148); + addTearDown(app.dispose); + + await t.pumpWidget(_host(const LiveSessionScreen(), app)); + await t.pump(const Duration(milliseconds: 300)); + + expect(find.textContaining('bpm to'), findsOneWidget); + expect(find.textContaining(r'$gapBpm'), findsNothing, + reason: 'the template must be interpolated, not printed'); + expect(find.textContaining(r'${'), findsNothing, + reason: 'no raw interpolation syntax may reach the screen'); + expect(find.text('4 bpm to Z4 — push'), findsOneWidget); + }); } diff --git a/test/notification_day_guard_test.dart b/test/notification_day_guard_test.dart index 4babbf43..e607c218 100644 --- a/test/notification_day_guard_test.dart +++ b/test/notification_day_guard_test.dart @@ -63,6 +63,15 @@ Map _quietAllDay() => { 'notif_quiet_end': 1440, }; +/// The mirror of [_quietAllDay], for the cases that expect a present to SUCCEED. +/// +/// These used to pass `{}` and inherit the DEFAULT quiet window of 22:00–07:00. +/// Since nothing here stubs the clock, every "returns true on a real present" +/// assertion failed for nine hours a night on a developer machine and passed on +/// CI purely because CI happened to run at a different hour. Pin the window off +/// so the outcome depends on the code under test, not on what time it is. +Map _quietNever() => {'notif_quiet_enabled': false}; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -84,14 +93,14 @@ void main() { test('returns false when the OS present is refused (permission denied)', () async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues(_quietNever()); final sink = _Sink(grant: false); NotificationCenter.instance.presentSink = sink.call; expect(await NotificationCenter.instance.emit(_recoveryReady()), isFalse); }); test('returns true on a real present', () async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues(_quietNever()); final sink = _Sink(); NotificationCenter.instance.presentSink = sink.call; expect(await NotificationCenter.instance.emit(_recoveryReady()), isTrue); @@ -132,7 +141,7 @@ void main() { test('a permission-denied no-op does NOT burn the day guard either', () async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues(_quietNever()); final sink = _Sink(grant: false); NotificationCenter.instance.presentSink = sink.call; @@ -155,7 +164,7 @@ void main() { test('a real present consumes the guard, and the same day never re-fires', () async { - SharedPreferences.setMockInitialValues({}); + SharedPreferences.setMockInitialValues(_quietNever()); final sink = _Sink(); NotificationCenter.instance.presentSink = sink.call; @@ -173,7 +182,10 @@ void main() { }); test('a NEW day is a fresh guard', () async { - SharedPreferences.setMockInitialValues({kGuardKey: kDay}); + SharedPreferences.setMockInitialValues({ + ..._quietNever(), + kGuardKey: kDay, + }); final sink = _Sink(); NotificationCenter.instance.presentSink = sink.call; diff --git a/test/workout_reliability_test.dart b/test/workout_reliability_test.dart index 70031068..c325b1e3 100644 --- a/test/workout_reliability_test.dart +++ b/test/workout_reliability_test.dart @@ -79,12 +79,21 @@ void main() { }); test( - 'a live workout never runs a derive pass, even once the settle elapses', + 'a queued job stays parked for the session, then runs on release', () async { + // This MUST enqueue real work. An earlier version asserted runs == 0 + // without queueing anything, so it passed even with the gate deleted — + // CodeRabbit caught it on the PR, and it was right. s.setWorkoutActive(true); - // Long enough that an unheld scheduler would have drained twice over. - await Future.delayed(const Duration(milliseconds: 60)); - expect(runs, 0, reason: 'derivation must stay parked for the session'); + s.markStoredData(); // enqueues a durable derive_light job + await Future.delayed(const Duration(milliseconds: 80)); + expect(runs, 0, + reason: 'a queued job must not run while a workout is live'); + + s.setWorkoutActive(false); + await Future.delayed(const Duration(milliseconds: 120)); + expect(runs, 1, + reason: 'and it must drain once the session ends, not be dropped'); }, ); }); @@ -95,6 +104,10 @@ void main() { setUp(() { calls.clear(); ScreenWake.resetForTest(); + // Platform.isAndroid/isIOS are BOTH false on the host VM, so without this + // the dispatch short-circuits and these mocks are never reached — the + // call-count and failure assertions below asserted nothing at all. + ScreenWake.platformOverride = 'android'; for (final ch in const [ MethodChannel('openstrap/edge_tracking'), MethodChannel('openstrap/ios_config'), @@ -107,12 +120,39 @@ void main() { } }); - test('enable then release round-trips the flag', () async { + test('enable then release round-trips the flag and hits the channel', + () async { expect(ScreenWake.isHeld, isFalse); await ScreenWake.enable(); expect(ScreenWake.isHeld, isTrue); + expect(calls.single.method, 'keepAwake'); + expect((calls.single.arguments as Map)['on'], isTrue); await ScreenWake.release(); expect(ScreenWake.isHeld, isFalse); + expect((calls.last.arguments as Map)['on'], isFalse); + }); + + test('a platform that refuses does NOT latch, so a retry can succeed', + () async { + // Android answers false when no activity is attached. Latching the + // requested value there left Dart believing the screen was held and + // suppressed every later attempt. + var refuse = true; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('openstrap/edge_tracking'), + (c) async { + calls.add(c); + return !refuse; + }, + ); + await ScreenWake.enable(); + expect(ScreenWake.isHeld, isFalse, reason: 'refusal must not latch'); + + refuse = false; + await ScreenWake.enable(); + expect(ScreenWake.isHeld, isTrue, reason: 'the retry must go through'); + expect(calls.length, 2); }); test( @@ -147,6 +187,8 @@ void main() { // Losing the wake flag degrades to "the screen sleeps" — it must never // propagate and interrupt a session. await expectLater(ScreenWake.enable(), completes); + expect(ScreenWake.isHeld, isFalse, + reason: 'a throwing channel must not latch either'); }); }); From 1e8cac26f4a5531299233be332fa87ac293d68d9 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 27 Jul 2026 22:21:38 +0530 Subject: [PATCH 7/8] test: poll for the derive drain instead of sleeping a fixed 120 ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught a flake I introduced one commit ago: "a queued job stays parked for the session, then runs on release" failed with Expected: <1>, Actual: <0>. The drain does several DB round-trips and a fixed 120 ms sleep is simply not enough headroom on a loaded runner — it passed on this machine every time and failed on GitHub's. Polls for the positive direction with a 5 s ceiling, returning as soon as the condition holds, so the generous timeout costs nothing locally. The negative direction keeps a fixed wait, because you cannot poll for "this never happens"; 150 ms is comfortably past the 10 ms settle, and a longer wait there only makes that assertion stronger. Re-verified the test still bites by deleting the gate. Worth noting the first attempt at that check was itself wrong: sed only removed the _arm/_drain entry condition and the test still passed, because the post-acquisition re-check added for the CodeRabbit race is a genuine SECOND gate. Removing both does fail the test. --- test/workout_reliability_test.dart | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/test/workout_reliability_test.dart b/test/workout_reliability_test.dart index c325b1e3..e08462a5 100644 --- a/test/workout_reliability_test.dart +++ b/test/workout_reliability_test.dart @@ -16,6 +16,22 @@ import 'package:openstrap_edge/state/units_controller.dart'; import 'package:path/path.dart' as p; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +/// Wait until [condition] holds, or give up after [timeout]. +/// +/// Returns as soon as the condition is met, so the generous timeout costs +/// nothing on a fast machine — it only buys headroom on a loaded CI runner. +/// Deliberately does NOT assert; the caller asserts afterwards so the failure +/// message names the real expectation rather than "timed out". +Future _until( + bool Function() condition, { + Duration timeout = const Duration(seconds: 5), +}) async { + final deadline = DateTime.now().add(timeout); + while (!condition() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 10)); + } +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -86,12 +102,18 @@ void main() { // CodeRabbit caught it on the PR, and it was right. s.setWorkoutActive(true); s.markStoredData(); // enqueues a durable derive_light job - await Future.delayed(const Duration(milliseconds: 80)); + + // A fixed wait is correct HERE and only here: you cannot poll for + // "this never happens". Comfortably past the 10 ms settle. + await Future.delayed(const Duration(milliseconds: 150)); expect(runs, 0, reason: 'a queued job must not run while a workout is live'); s.setWorkoutActive(false); - await Future.delayed(const Duration(milliseconds: 120)); + // But the positive direction MUST poll. The drain does several DB + // round-trips, and a fixed 120 ms sleep here passed locally and failed + // on a slower CI runner — a flake I introduced in the previous commit. + await _until(() => runs == 1); expect(runs, 1, reason: 'and it must drain once the session ends, not be dropped'); }, From 78cd72a9ce9a7343e83963f4472817a844a69dfe Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Mon, 27 Jul 2026 22:33:12 +0530 Subject: [PATCH 8/8] fix(screen-wake): serialize transitions so a release can't be swallowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second CodeRabbit pass, both findings valid. The race: _on is only updated AFTER the platform await, so a release() arriving while an enable() is still in flight read the stale `false`, decided it had nothing to do, and returned — then the in-flight enable latched _on = true and the display stayed held for the rest of the app's life. Both call sites in AppState are fire-and-forget, so starting a workout and immediately stopping it was enough to hit it. Transitions now run through a chain, so each sees the state the previous one left. Covered by a test that fails on the unserialized version (isHeld true instead of false) and asserts both transitions reach the platform in order. Also adds the requeueComputeJob coverage that was missing: the gate test closed the gate BEFORE enqueueing, so no job was ever claimed and the requeue path never ran. That test deliberately exercises the PRIMITIVE rather than simulating the interleaving. Hitting the real window means racing a DB round-trip, which is a coin flip dressed up as a test — this same file already shipped one of those and it failed on CI. What is pinned instead is the guarantee the drain path depends on: a claimed job is not handed out twice, comes back claimable after a requeue, and a deferral does not burn an attempt. Verified by removing the attempts decrement and watching it fail. One wart found while writing it, left alone: takeNextComputeJob returns the row as it was BEFORE its own update, so the `attempts` it reports is the pre-increment value. Documented in the test rather than changed — nothing depends on that field today and it is not this PR's business. 962 tests, analyzer clean. --- lib/gps/screen_wake.dart | 22 ++++++- test/workout_reliability_test.dart | 92 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/lib/gps/screen_wake.dart b/lib/gps/screen_wake.dart index 82c0f4ba..a7b36b1e 100644 --- a/lib/gps/screen_wake.dart +++ b/lib/gps/screen_wake.dart @@ -53,6 +53,17 @@ class ScreenWake { @visibleForTesting static bool get isHeld => _on; + /// Serializes transitions so each one sees the state the previous one left. + /// + /// Without this, `_on` is only updated AFTER the platform await, so a + /// `release()` arriving while an `enable()` is still in flight reads the + /// stale `false`, decides it has nothing to do, and returns — then the + /// in-flight enable latches `_on = true` and the display stays held for the + /// rest of the app's life. Both call sites are fire-and-forget from + /// AppState, so a short workout (start then immediately stop) is enough to + /// hit it. + static Future _chain = Future.value(); + /// Keep the display awake. Safe to call repeatedly. static Future enable() => _set(true); @@ -60,7 +71,15 @@ class ScreenWake { /// the error/abort paths, or the screen stays awake until the app is killed. static Future release() => _set(false); - static Future _set(bool on) async { + static Future _set(bool on) { + final next = _chain.then((_) => _apply(on)); + // Keep the chain alive even if a link fails; _apply already swallows, this + // is belt-and-braces so one bad transition can't wedge every later one. + _chain = next.catchError((_) {}); + return next; + } + + static Future _apply(bool on) async { if (on == _on) return; try { if (_isAndroid) { @@ -83,5 +102,6 @@ class ScreenWake { static void resetForTest() { _on = false; platformOverride = null; + _chain = Future.value(); } } diff --git a/test/workout_reliability_test.dart b/test/workout_reliability_test.dart index e08462a5..c3d07eb6 100644 --- a/test/workout_reliability_test.dart +++ b/test/workout_reliability_test.dart @@ -120,6 +120,68 @@ void main() { ); }); + group('requeueComputeJob (the post-claim gate race)', () { + // `_drain()` clears the gate, then awaits takeNextComputeJob(). A workout + // starting inside that window leaves a job already marked `running` that + // must be handed back rather than run — otherwise it sits claimed until + // the next recoverComputeJobs(). + // + // Deliberately tests the PRIMITIVE rather than simulating the interleaving. + // Hitting that window means racing a real DB round-trip, which is a coin + // flip dressed up as a test — the kind that passes locally and fails on a + // loaded runner (this file already had one of those). What is worth + // pinning is the guarantee the drain path depends on: a claimed job comes + // back claimable, and being deferred does not burn an attempt. + setUp(() async { + // Leave no jobs behind from an earlier group. + for (var i = 0; i < 8; i++) { + final j = await LocalDb.takeNextComputeJob(); + if (j == null) break; + await LocalDb.completeComputeJob(j['id'].toString()); + } + }); + + test('a claimed job returns to the queue and stays runnable', () async { + await LocalDb.enqueueDeriveJob(type: 'derive_light', reason: 'test'); + + final claimed = await LocalDb.takeNextComputeJob(); + expect(claimed, isNotNull, reason: 'the job should be claimable'); + expect(claimed!['state'], 'running'); + final id = claimed['id'].toString(); + // NOTE: takeNextComputeJob returns the row as it was BEFORE its own + // update, so `attempts` here is the pre-increment value. That makes the + // comparison below the meaningful one: if the requeue failed to undo the + // increment, the second claim would report a higher number than the + // first. + final attemptsAtFirstClaim = (claimed['attempts'] as num).toInt(); + + // While claimed, nothing else can take it. + expect(await LocalDb.takeNextComputeJob(), isNull, + reason: 'a running job must not be handed out twice'); + + await LocalDb.requeueComputeJob(id); + + final again = await LocalDb.takeNextComputeJob(); + expect(again, isNotNull, + reason: 'a requeued job must be claimable again, not stranded'); + expect(again!['id'].toString(), id); + expect( + (again['attempts'] as num).toInt(), + attemptsAtFirstClaim, + reason: 'the requeue undid the increment, so the second claim starts ' + 'from the same count as the first — a deferral is not a retry', + ); + + await LocalDb.completeComputeJob(id); + expect(await LocalDb.takeNextComputeJob(), isNull); + }); + + test('requeueing an unknown id is harmless', () async { + await LocalDb.requeueComputeJob('no-such-job'); + expect(await LocalDb.takeNextComputeJob(), isNull); + }); + }); + group('ScreenWake', () { final calls = []; @@ -192,6 +254,36 @@ void main() { }, ); + test('a release fired while an enable is in flight still wins', () async { + // `_on` only updates AFTER the platform await, so a release arriving + // mid-enable used to read the stale `false`, decide it had nothing to do, + // and return — then the in-flight enable latched true and the display + // stayed held for the rest of the app's life. Both call sites in + // AppState are fire-and-forget, so starting a workout and immediately + // stopping it was enough to hit this. + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('openstrap/edge_tracking'), + (c) async { + calls.add(c); + // A real channel hop is not instantaneous; this is the window the + // race lived in. + await Future.delayed(const Duration(milliseconds: 20)); + return true; + }, + ); + + await Future.wait([ScreenWake.enable(), ScreenWake.release()]); + + expect(ScreenWake.isHeld, isFalse, + reason: 'the release must win — the screen cannot stay held'); + expect( + [for (final c in calls) (c.arguments as Map)['on']], + [true, false], + reason: 'both transitions must reach the platform, in order', + ); + }); + test('a release with nothing held is a no-op', () async { await ScreenWake.release(); expect(calls, isEmpty);