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