From b1f452e7e8a8bb03f90f798075d064187afbf08c Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 13:54:28 +0530 Subject: [PATCH 1/7] more workout types, a filter on the list, and calories that stop guessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker had nine types, so boxing, rowing, hiking, skiing, racquet sports and the rest all had to be logged as "Other" and reached Apple Health as an unlabelled workout. There are twenty-one now, each with its own glyph and its own health activity — a type only earns a tile if a pack actually draws it, and a test now fails if a tile is added without a matching health mapping, because that failure is otherwise invisible until someone opens Apple Health and finds a wall of generic entries. That made the start sheet taller than the sheet itself. It is scroll- controlled now; the old guard against content falling off the bottom edge became a guard that every type can still be scrolled to. The list only ever filtered by timeframe. It filters by type, minimum duration and minimum strain too, and sorts by longest or hardest as well as by date. A narrowed list recomputes its own summary rather than showing whole-range totals above it, and a live session is never hidden by a floor it has not had time to clear yet. Calories were the last place the app still made something up. The live tick substituted a 30-year-old 70 kg male for whatever the profile was missing, so an untouched profile still produced a confident total — for somebody else's body, and reading high for anyone lighter than the stand-in. The re-score has always refused to guess; both now share one predicate, and a session with no anchors shows a dash instead of a number. --- lib/compute/manual_session.dart | 6 +- lib/compute/profile.dart | 13 ++ lib/health/health_export.dart | 39 ++++ lib/state/app_state.dart | 45 +++-- lib/ui/activity/live_session_screen.dart | 32 ++-- lib/ui/activity/workout_share_card.dart | 8 +- lib/ui/kit/os_icons.dart | 46 +++++ lib/ui/workouts/workout_filter.dart | 166 +++++++++++++++++ lib/ui/workouts/workout_filter_sheet.dart | 169 +++++++++++++++++ lib/ui/workouts/workout_types.dart | 68 +++++-- lib/ui/workouts/workouts_screen.dart | 208 ++++++++++++++++----- test/workout_calorie_anchors_test.dart | 108 +++++++++++ test/workout_filter_test.dart | 209 ++++++++++++++++++++++ test/workout_health_mapping_test.dart | 64 ++++++- test/workouts_header_actions_test.dart | 87 ++++----- 15 files changed, 1143 insertions(+), 125 deletions(-) create mode 100644 lib/ui/workouts/workout_filter.dart create mode 100644 lib/ui/workouts/workout_filter_sheet.dart create mode 100644 test/workout_calorie_anchors_test.dart create mode 100644 test/workout_filter_test.dart diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart index a1f7182b..c087d96c 100644 --- a/lib/compute/manual_session.dart +++ b/lib/compute/manual_session.dart @@ -310,7 +310,11 @@ ManualSessionStats computeManualSessionStats({ strainFromPerMinuteHr(perMin, profile: profile, restingHr: restingHr); double? calories; - if (hrMax != null && age != null && weightKg != null && sex != null) { + if (profile.hasCalorieAnchors && + hrMax != null && + age != null && + weightKg != null && + sex != null) { // Real anchors only — `usedDefaultAnchors` stays false, so we are never // persisting a kcal figure built on a fabricated 220/60. final bout = ana.Calories.estimateBoutCalories( diff --git a/lib/compute/profile.dart b/lib/compute/profile.dart index d33ee993..14e2df60 100644 --- a/lib/compute/profile.dart +++ b/lib/compute/profile.dart @@ -49,4 +49,17 @@ class Profile { bool get isComplete => ageYears != null && weightKg != null && heightCm != null && sex != null; + + /// The anchors Keytel (2005) needs to turn heart rate into kcal: age, body + /// mass and sex. Height is not one of them, so it is deliberately absent + /// here — gating calories on [isComplete] would refuse to score a profile + /// that has everything the formula actually reads. + /// + /// The one definition of "can we cost this session in calories", shared by + /// the live tick and the substrate re-score. They used to disagree: the + /// re-score refused to guess while the live tick silently substituted a + /// 30-year-old 70 kg male, so an unfinished profile produced a confident + /// kcal number that was simply somebody else's. + bool get hasCalorieAnchors => + ageYears != null && weightKg != null && sex != null; } diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 4b229455..412ac222 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -1118,6 +1118,45 @@ HealthWorkoutActivityType healthActivityForType( return HealthWorkoutActivityType.YOGA; case 'hiit': return HealthWorkoutActivityType.HIGH_INTENSITY_INTERVAL_TRAINING; + case 'boxing': + return HealthWorkoutActivityType.BOXING; + case 'rowing': + case 'row': + return HealthWorkoutActivityType.ROWING; + case 'hike': + case 'hiking': + return HealthWorkoutActivityType.HIKING; + case 'climb': + case 'climbing': + // Bare `CLIMBING` is iOS-only; `ROCK_CLIMBING` is the one spelling both + // stores accept, which is the #184 lesson applied ahead of the bug. + return HealthWorkoutActivityType.ROCK_CLIMBING; + case 'ski': + case 'skiing': + // `SKIING` is Android-only; `DOWNHILL_SKIING` exists on both. + return HealthWorkoutActivityType.DOWNHILL_SKIING; + case 'snowboard': + case 'snowboarding': + return HealthWorkoutActivityType.SNOWBOARDING; + case 'stairs': + case 'stair': + // `STAIRS` is iOS-only; `STAIR_CLIMBING` exists on both. + return HealthWorkoutActivityType.STAIR_CLIMBING; + case 'pilates': + return HealthWorkoutActivityType.PILATES; + case 'tennis': + case 'racquet': + case 'squash': + case 'padel': + case 'badminton': + return HealthWorkoutActivityType.TENNIS; + case 'basketball': + return HealthWorkoutActivityType.BASKETBALL; + case 'soccer': + case 'football': + return HealthWorkoutActivityType.SOCCER; + case 'golf': + return HealthWorkoutActivityType.GOLF; default: return HealthWorkoutActivityType.OTHER; } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 2866d216..35140cd7 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -4222,7 +4222,10 @@ class AppState extends ChangeNotifier { ScreenWake.release(); _deriveScheduler.setWorkoutActive(false); final w = activeWorkout!; - final finalKcal = w.calories.round(); + // Nullable for the same reason `steps` below is: an unanchored profile + // means this session was never costed, and a 0 in the column reads as + // "burned nothing" rather than "not measured". + final finalKcal = w.caloriesOrNull; // Nullable: an unmeasured workout must leave the column unset rather than // bank a zero that reads as "you took no steps". final wSteps = workoutStepsMeasured; @@ -4237,7 +4240,7 @@ class AppState extends ChangeNotifier { 'end_ts': endTs, 'type': w.type, 'status': 'done', - 'calories': w.calories, + 'calories': finalKcal, 'strain': w.strain, 'max_hr': w.maxHrSeen > 0 ? w.maxHrSeen : null, 'duration_min': w.elapsed.inMinutes, @@ -4260,7 +4263,11 @@ class AppState extends ChangeNotifier { _workoutRawBase = null; _workoutSawSamples = false; notifyListeners(); - _log('Live session ended. Burned $finalKcal kcal.'); + _log( + finalKcal == null + ? 'Live session ended. No calorie anchors in the profile.' + : 'Live session ended. Burned $finalKcal kcal.', + ); LiveActivity.end(); // A workout often rides the live feed; if the connection blipped during it, the // band may hold that window in flash. Pull it now over the live connection so the @@ -4396,13 +4403,17 @@ class AppState extends ChangeNotifier { // zone_min at stop — this is what feeds the Time-in-Zones bar). if (w.currentHr > 0) w.zoneSeconds[_zoneFor(w.currentHr)] += 1; - if (w.currentHr > 0) { - // Calorie burn formula (estimate per second). Personalized from the LOCAL - // profile, with representative fallbacks (30y, 70kg, male) when unset. - final u = user ?? const {}; - final age = (u['age'] as num?)?.toDouble() ?? 30.0; - final weight = (u['weight_kg'] as num?)?.toDouble() ?? 70.0; - final female = u['sex'] == 'f'; + if (w.currentHr > 0 && w.profile.hasCalorieAnchors) { + // Keytel (2005) per second, from the profile this session is being + // performed under. No fallbacks: this used to substitute 30y / 70 kg / + // male for whatever the profile was missing, which is how an untouched + // profile still produced a confident calorie total — and why the number + // read high for anyone lighter than the stand-in. The re-score path has + // always refused to guess (`hasCalorieAnchors`); now so does this one, + // and an unanchored session reports no calories at all. + final age = w.profile.ageYears!.toDouble(); + final weight = w.profile.weightKg!; + final female = w.profile.sex == 'f' || w.profile.sex == 'female'; double kcalMin; if (female) { @@ -4435,9 +4446,9 @@ class AppState extends ChangeNotifier { hr: w.currentHr, zone: _zoneFor(w.currentHr), // The Live Activity widget has no absent state; the in-app gauge - // shows "—" when strain is null, this pushes 0. + // shows "—" when strain or calories are null, this pushes 0. strain: w.strain ?? 0, - calories: w.calories.round(), + calories: w.caloriesOrNull ?? 0, maxHr: _maxHr, rhr: _restingHr, ); @@ -4453,8 +4464,18 @@ class LiveWorkoutState { final String? workoutId; // local session id (for the breakdown on finish) final String type; // exercise type label Duration elapsed = Duration.zero; + + /// Accrued kcal. Only ever added to when [Profile.hasCalorieAnchors] holds, + /// so a zero here means "never scored", not "burned nothing" — read + /// [caloriesOrNull] rather than this field anywhere a user can see it. double calories = 0.0; + /// Accrued kcal, or null when the profile lacks the anchors Keytel needs. + /// Absent beats fabricated: an unanchored session shows "—" instead of a + /// number computed for a stand-in body. + int? get caloriesOrNull => + profile.hasCalorieAnchors ? calories.round() : null; + /// Headline 0–21 strain, or null when the profile lacks an anchor the /// Banister formula needs. Recomputed on every HR sample by [accrueHr] — it /// is NOT accrued incrementally any more. The old `strain += %HRR * 0.01` diff --git a/lib/ui/activity/live_session_screen.dart b/lib/ui/activity/live_session_screen.dart index 6765c3db..6412ee92 100644 --- a/lib/ui/activity/live_session_screen.dart +++ b/lib/ui/activity/live_session_screen.dart @@ -238,8 +238,13 @@ class _LiveSessionScreenState extends State // Milestones — time / calories / new max HR. final mins = w.elapsed.inMinutes; if (mins > 0 && mins % 5 == 0) _milestone('t$mins', '$mins MINUTES', "Locked in. Keep going.", AppColors.good); - final kcalStep = (w.calories ~/ 100) * 100; - if (kcalStep >= 100) _milestone('k$kcalStep', '$kcalStep KCAL', "Burning clean.", AppColors.coral); + // No kcal milestones for an unanchored profile — celebrating a number + // we refuse to display would be the fabrication back by another door. + final kcal = w.caloriesOrNull; + if (kcal != null) { + final kcalStep = (kcal ~/ 100) * 100; + if (kcalStep >= 100) _milestone('k$kcalStep', '$kcalStep KCAL', "Burning clean.", AppColors.coral); + } // Announce on a new SMOOTHED peak (not raw instantaneous hr), so a transient // spike can't fire a spurious "NEW MAX"; the dedup key gates one per value. if (w.elapsed.inSeconds > 90 && w.maxHrSeen > 0 && w.maxHrSeen >= (_maxHr * 0.8)) { @@ -308,7 +313,7 @@ class _LiveSessionScreenState extends State type: w?.type ?? widget.type, duration: w?.elapsed ?? Duration.zero, peakHr: w?.maxHrSeen ?? 0, - calories: w?.calories ?? 0, + calories: w?.caloriesOrNull, strain: w?.strain, steps: app.workoutStepsMeasured, ); @@ -701,7 +706,11 @@ class WorkoutFinishSnapshot { final String type; final Duration duration; final int peakHr; - final double calories; + + /// Null when the profile lacks the anchors Keytel needs — same contract as + /// [strain] and [steps] below, so the finish card omits the stat instead of + /// printing a kcal figure computed for a stand-in body. + final int? calories; /// Null when the profile lacked an anchor the Banister score needs. Kept /// nullable all the way to the finish card: a `?? 0` here would print a @@ -922,7 +931,7 @@ class _WorkoutFinishScreenState extends State final strain = (d?['strain'] as num?)?.toDouble() ?? s.strain; final peak = (d?['max_hr'] as num?)?.toInt() ?? s.peakHr; final avg = (d?['avg_hr'] as num?)?.toInt(); - final kcal = (d?['calories'] as num?)?.toInt() ?? s.calories.round(); + final kcal = (d?['calories'] as num?)?.toInt() ?? s.calories; final steps = (d?['steps'] as num?)?.toInt() ?? s.steps; final bands = (d?['zone_bands'] as List?)?.whereType().toList() ?? const []; @@ -1073,7 +1082,7 @@ class _WorkoutFinishScreenState extends State /// 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) { + Widget _heroStats(int peak, int? avg, int? kcal, int? steps) { Widget stat(String v, String label) => Expanded(child: _FinishStat(v, label)); return AnimatedBuilder( @@ -1088,7 +1097,7 @@ class _WorkoutFinishScreenState extends State children: [ stat(peak > 0 ? '${(peak * p).round()}' : '—', 'PEAK BPM'), stat(avg != null ? '${(avg * p).round()}' : '—', 'AVG BPM'), - stat('${(kcal * p).round()}', 'KCAL'), + stat(kcal != null ? '${(kcal * p).round()}' : '—', 'KCAL'), if (steps != null && steps > 0) stat('${(steps * p).round()}', 'STEPS'), ], @@ -1397,7 +1406,7 @@ class _WorkoutFinishScreenState extends State when: DateTime.now(), maxHr: _maxHr, strain: (d?['strain'] as num?)?.toDouble() ?? s.strain, - calories: (d?['calories'] as num?)?.toInt() ?? s.calories.round(), + calories: (d?['calories'] as num?)?.toInt() ?? s.calories, route: _route, avgHr: (d?['avg_hr'] as num?)?.toInt(), ); @@ -2507,7 +2516,10 @@ class _SessionSheet extends StatelessWidget { children: [ Expanded( child: _SheetStat( - isRoute ? _fmtClock(elapsed) : '${workout.calories.round()}', + isRoute + ? _fmtClock(elapsed) + // A dash, never a 0 — same contract as strain below. + : (workout.caloriesOrNull?.toString() ?? '—'), isRoute ? 'TIME' : 'KCAL', ), ), @@ -2524,7 +2536,7 @@ class _SessionSheet extends StatelessWidget { Expanded( child: _SheetStat( isRoute - ? '${workout.calories.round()}' + ? (workout.caloriesOrNull?.toString() ?? '—') : (steps?.toString() ?? '—'), isRoute ? 'KCAL' : 'STEPS', ), diff --git a/lib/ui/activity/workout_share_card.dart b/lib/ui/activity/workout_share_card.dart index 0bf37bc9..b82732fc 100644 --- a/lib/ui/activity/workout_share_card.dart +++ b/lib/ui/activity/workout_share_card.dart @@ -555,7 +555,11 @@ WorkoutShareData buildWorkoutShareData({ /// confident "0.0 Strain" for a workout we simply could not score, which is /// the same fabrication issue #206 reported on the detail gauge. required double? strain, - required int calories, + + /// Null when the profile lacks the anchors Keytel needs — a shared card is + /// the most public place a fabricated calorie figure could end up, so it + /// dashes out exactly like [strain]. + required int? calories, WorkoutRoute? route, int? avgHr, }) { @@ -583,7 +587,7 @@ WorkoutShareData buildWorkoutShareData({ heroUnit = ''; stats = [ (strainText, 'Strain'), - ('$calories', 'Kcal'), + (calories?.toString() ?? '—', 'Kcal'), (avgHr != null && avgHr > 0 ? '$avgHr' : '—', 'Avg bpm'), ]; } diff --git a/lib/ui/kit/os_icons.dart b/lib/ui/kit/os_icons.dart index 5ec13dfb..cda6ce49 100644 --- a/lib/ui/kit/os_icons.dart +++ b/lib/ui/kit/os_icons.dart @@ -100,6 +100,22 @@ enum OsIcon { walk, swim, hiit, + // Sport glyphs for the workout-type vocabulary beyond the original nine. + // Every one of these is a real, distinct glyph — a type only earns a place + // in `kWorkoutTypes` if a pack draws it, because a picker full of + // near-identical stand-in icons is worse than a shorter picker. + boxing, + rowing, + hike, + climb, + ski, + snowboard, + stairs, + pilates, + tennis, + basketball, + soccer, + golf, workoutOther, hydration, /// Period/flow marker on the cycle screen. NEW member — the cycle screen @@ -219,6 +235,24 @@ const Map _glyphs = { // No pack has a literal "HIIT" glyph — a lightning bolt approximates // explosive interval training. OsIcon.hiit: PhosphorIconsDuotone.lightning, + OsIcon.boxing: PhosphorIconsDuotone.boxingGlove, + // An indoor rower has no glyph in any pack; a rowing boat is the nearest + // honest read of the movement. + OsIcon.rowing: PhosphorIconsDuotone.boat, + OsIcon.hike: PhosphorIconsDuotone.personSimpleHike, + OsIcon.climb: PhosphorIconsDuotone.mountains, + OsIcon.ski: PhosphorIconsDuotone.personSimpleSki, + OsIcon.snowboard: PhosphorIconsDuotone.personSimpleSnowboard, + OsIcon.stairs: PhosphorIconsDuotone.stairs, + // `personSimpleTaiChi` is already spoken for by yoga — arms-spread reads as + // the mat/mobility register without colliding with it. + OsIcon.pilates: PhosphorIconsDuotone.personArmsSpread, + // One racquet glyph stands for the whole racquet family (tennis, squash, + // padel, badminton) rather than four tiles that all look the same. + OsIcon.tennis: PhosphorIconsDuotone.racquet, + OsIcon.basketball: PhosphorIconsDuotone.basketball, + OsIcon.soccer: PhosphorIconsDuotone.soccerBall, + OsIcon.golf: PhosphorIconsDuotone.golf, OsIcon.workoutOther: FluentIcons.sport_24_regular, OsIcon.hydration: PhosphorIconsDuotone.drop, // Literal blood-drop — the second concept hugeicons was picked for (see @@ -283,6 +317,18 @@ Color _defaultTint(OsIcon icon) { case OsIcon.yoga: case OsIcon.hiit: case OsIcon.cardio: + case OsIcon.boxing: + case OsIcon.rowing: + case OsIcon.hike: + case OsIcon.climb: + case OsIcon.ski: + case OsIcon.snowboard: + case OsIcon.stairs: + case OsIcon.pilates: + case OsIcon.tennis: + case OsIcon.basketball: + case OsIcon.soccer: + case OsIcon.golf: case OsIcon.workoutOther: case OsIcon.strength: case OsIcon.streak: diff --git a/lib/ui/workouts/workout_filter.dart b/lib/ui/workouts/workout_filter.dart new file mode 100644 index 00000000..f8cc9830 --- /dev/null +++ b/lib/ui/workouts/workout_filter.dart @@ -0,0 +1,166 @@ +// Pure filtering/sorting policy for the workout list. Lives apart from +// workouts_screen.dart so the rules are unit-testable without pumping a +// widget — the screen wires, this decides. +// +// Everything here operates on the session maps the repo already returns +// (`start_ts`, `status`, `type`, `duration_min`, `strain`, `calories`, +// `zone_min`); nothing re-reads the database. + +import 'workout_types.dart'; + +/// Ordering for the filtered list. +enum WorkoutSort { + newest, + oldest, + longest, + hardest; + + String get label => switch (this) { + WorkoutSort.newest => 'Newest', + WorkoutSort.oldest => 'Oldest', + WorkoutSort.longest => 'Longest', + WorkoutSort.hardest => 'Hardest', + }; + + /// Whether the order still runs along the calendar. The feed groups by week, + /// which only means anything for a chronological order — the other two sorts + /// render as one flat list instead. + bool get isChronological => + this == WorkoutSort.newest || this == WorkoutSort.oldest; +} + +/// Canonical type key for filtering. Anything outside the [kWorkoutTypes] +/// vocabulary — an auto-detector string, a type from an older release, an +/// imported session — collapses to `other`, so the "Other" chip actually +/// catches those instead of leaving them unreachable by any filter. +String canonicalWorkoutType(String? raw) { + final t = (raw ?? '').toLowerCase(); + for (final e in kWorkoutTypes) { + if (e.$1 == t) return t; + } + return 'other'; +} + +/// A filter over the workout feed. All fields are floors, not ranges — the +/// range picker above the list already bounds the window in time. +class WorkoutFilter { + const WorkoutFilter({ + this.types = const {}, + this.minMinutes = 0, + this.minStrain = 0, + this.sort = WorkoutSort.newest, + }); + + /// Empty means every type. Keys are [kWorkoutTypes] keys. + final Set types; + final int minMinutes; + final double minStrain; + final WorkoutSort sort; + + /// Whether anything is actually narrowing the list. Sort alone doesn't + /// count — reordering hides nothing, so the summary stays truthful. + bool get isNarrowing => types.isNotEmpty || minMinutes > 0 || minStrain > 0; + + bool get isDefault => !isNarrowing && sort == WorkoutSort.newest; + + WorkoutFilter copyWith({ + Set? types, + int? minMinutes, + double? minStrain, + WorkoutSort? sort, + }) => WorkoutFilter( + types: types ?? this.types, + minMinutes: minMinutes ?? this.minMinutes, + minStrain: minStrain ?? this.minStrain, + sort: sort ?? this.sort, + ); + + /// One-line description of what's active, for the chip under the header. + /// Empty when nothing is narrowing. + String get description { + final parts = []; + if (types.isNotEmpty) { + final names = types.map(workoutTypeLabel).toList()..sort(); + parts.add(names.length <= 2 ? names.join(', ') : '${names.length} types'); + } + if (minMinutes > 0) parts.add('${minMinutes}m+'); + if (minStrain > 0) parts.add('strain ${minStrain.toStringAsFixed(0)}+'); + return parts.join(' · '); + } + + bool _matches(Map w) { + // A session that is happening right now always shows. It has no final + // duration or strain yet, so every numeric floor would hide the one thing + // the user is most likely looking for. + if (w['status'] == 'live') return true; + if (types.isNotEmpty && + !types.contains(canonicalWorkoutType(w['type'] as String?))) { + return false; + } + if (minMinutes > 0 && ((w['duration_min'] as num?) ?? 0) < minMinutes) { + return false; + } + if (minStrain > 0 && ((w['strain'] as num?) ?? 0) < minStrain) { + return false; + } + return true; + } + + List> apply(List> workouts) { + final out = workouts.where(_matches).toList(); + int startOf(Map w) => (w['start_ts'] as int?) ?? 0; + num durOf(Map w) => (w['duration_min'] as num?) ?? 0; + num strainOf(Map w) => (w['strain'] as num?) ?? 0; + switch (sort) { + case WorkoutSort.newest: + out.sort((a, b) => startOf(b).compareTo(startOf(a))); + case WorkoutSort.oldest: + out.sort((a, b) => startOf(a).compareTo(startOf(b))); + // Ties fall back to newest-first so the order is stable rather than + // dependent on however the query happened to return equal rows. + case WorkoutSort.longest: + out.sort((a, b) { + final c = durOf(b).compareTo(durOf(a)); + return c != 0 ? c : startOf(b).compareTo(startOf(a)); + }); + case WorkoutSort.hardest: + out.sort((a, b) { + final c = strainOf(b).compareTo(strainOf(a)); + return c != 0 ? c : startOf(b).compareTo(startOf(a)); + }); + } + return out; + } +} + +/// Re-aggregate the training summary over a filtered list, mirroring the +/// repo's own aggregation (`getWorkouts`) so a filtered feed never shows +/// whole-range totals above a narrowed list. +/// +/// Live sessions are excluded exactly as the repo excludes them — they have no +/// final numbers to add up. +Map summarizeWorkouts(List> workouts) { + var count = 0, totalMin = 0, totalCal = 0; + final zoneSum = []; + for (final w in workouts) { + if (w['status'] == 'live') continue; + count++; + totalMin += ((w['duration_min'] as num?) ?? 0).toInt(); + totalCal += ((w['calories'] as num?) ?? 0).toInt(); + final zm = (w['zone_min'] as List?) ?? const []; + for (var i = 0; i < zm.length; i++) { + final v = (zm[i] as num?) ?? 0; + if (i < zoneSum.length) { + zoneSum[i] += v; + } else { + zoneSum.add(v); + } + } + } + return { + 'count': count, + 'total_min': totalMin, + 'total_calories': totalCal, + 'zone_min': zoneSum, + }; +} diff --git a/lib/ui/workouts/workout_filter_sheet.dart b/lib/ui/workouts/workout_filter_sheet.dart new file mode 100644 index 00000000..bad36c5d --- /dev/null +++ b/lib/ui/workouts/workout_filter_sheet.dart @@ -0,0 +1,169 @@ +// The filter/sort bottom sheet for the workout list. Edits a [WorkoutFilter] +// locally and returns it on apply — the screen owns persistence, this owns +// nothing but the draft. + +import 'package:flutter/material.dart'; + +import '../design/design.dart'; +import 'workout_filter.dart'; +import 'workout_types.dart'; + +/// Minimum-duration steps, in minutes. 0 = no floor. +const _durationSteps = [0, 15, 30, 45, 60, 90]; + +/// Minimum-strain steps on the 0–21 scale. 0 = no floor. +const _strainSteps = [0, 5, 10, 14, 17]; + +/// Opens the filter sheet. Returns the edited filter, or null if dismissed. +/// +/// [workouts] is the unfiltered list for the current range, used only to show +/// a live match count on the apply button — nothing is mutated. +Future showWorkoutFilterSheet( + BuildContext context, { + required WorkoutFilter current, + required List> workouts, +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => _FilterSheet(current: current, workouts: workouts), + ); +} + +class _FilterSheet extends StatefulWidget { + const _FilterSheet({required this.current, required this.workouts}); + final WorkoutFilter current; + final List> workouts; + + @override + State<_FilterSheet> createState() => _FilterSheetState(); +} + +class _FilterSheetState extends State<_FilterSheet> { + late WorkoutFilter _draft = widget.current; + + void _toggleType(String key) { + final next = Set.from(_draft.types); + next.contains(key) ? next.remove(key) : next.add(key); + setState(() => _draft = _draft.copyWith(types: next)); + } + + @override + Widget build(BuildContext context) { + final matches = _draft.apply(widget.workouts).length; + return SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.8, + ), + child: Padding( + padding: const EdgeInsets.all(Sp.x5), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded(child: Text('Filter workouts', style: AppText.h2)), + if (!_draft.isDefault) + TextButton( + onPressed: () => + setState(() => _draft = const WorkoutFilter()), + child: const Text('Clear'), + ), + ], + ), + const SizedBox(height: Sp.x3), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _label('Type'), + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final e in kWorkoutTypes) + ToggleChip( + e.$2, + selected: _draft.types.contains(e.$1), + onTap: () => _toggleType(e.$1), + ), + ], + ), + const SizedBox(height: Sp.x4), + _label('Minimum duration'), + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final m in _durationSteps) + ToggleChip( + m == 0 ? 'Any' : '${m}m', + selected: _draft.minMinutes == m, + onTap: () => setState( + () => _draft = _draft.copyWith(minMinutes: m), + ), + ), + ], + ), + const SizedBox(height: Sp.x4), + _label('Minimum strain'), + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final s in _strainSteps) + ToggleChip( + s == 0 ? 'Any' : s.toStringAsFixed(0), + selected: _draft.minStrain == s, + onTap: () => setState( + () => _draft = _draft.copyWith(minStrain: s), + ), + ), + ], + ), + const SizedBox(height: Sp.x4), + _label('Sort'), + Wrap( + spacing: Sp.x2, + runSpacing: Sp.x2, + children: [ + for (final s in WorkoutSort.values) + ToggleChip( + s.label, + selected: _draft.sort == s, + onTap: () => setState( + () => _draft = _draft.copyWith(sort: s), + ), + ), + ], + ), + ], + ), + ), + ), + const SizedBox(height: Sp.x4), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => Navigator.pop(context, _draft), + child: Text( + matches == 1 ? 'Show 1 workout' : 'Show $matches workouts', + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _label(String text) => Padding( + padding: const EdgeInsets.only(bottom: Sp.x2), + child: Text(text, style: AppText.label.copyWith(color: AppColors.inkSoft)), + ); +} diff --git a/lib/ui/workouts/workout_types.dart b/lib/ui/workouts/workout_types.dart index 28b2e740..19917652 100644 --- a/lib/ui/workouts/workout_types.dart +++ b/lib/ui/workouts/workout_types.dart @@ -27,6 +27,18 @@ const kWorkoutTypes = <(String, String, OsIcon, OsIcon?)>[ ('cardio', 'Cardio', OsIcon.cardio, OsIcon.cardio), ('yoga', 'Yoga', OsIcon.yoga, OsIcon.yoga), ('hiit', 'HIIT', OsIcon.hiit, OsIcon.hiit), + ('boxing', 'Boxing', OsIcon.boxing, OsIcon.boxing), + ('rowing', 'Rowing', OsIcon.rowing, OsIcon.rowing), + ('hike', 'Hike', OsIcon.hike, OsIcon.hike), + ('climb', 'Climb', OsIcon.climb, OsIcon.climb), + ('ski', 'Ski', OsIcon.ski, OsIcon.ski), + ('snowboard', 'Snowboard', OsIcon.snowboard, OsIcon.snowboard), + ('stairs', 'Stairs', OsIcon.stairs, OsIcon.stairs), + ('pilates', 'Pilates', OsIcon.pilates, OsIcon.pilates), + ('tennis', 'Racquet', OsIcon.tennis, OsIcon.tennis), + ('basketball', 'Basketball', OsIcon.basketball, OsIcon.basketball), + ('soccer', 'Soccer', OsIcon.soccer, OsIcon.soccer), + ('golf', 'Golf', OsIcon.golf, OsIcon.golf), ('other', 'Other', OsIcon.workoutOther, OsIcon.workoutOther), ]; @@ -54,6 +66,12 @@ OsIcon? workoutTypeOsIcon(String? type) { String workoutTypeLabel(String? type) { if (type == null || type.isEmpty) return 'Workout'; if (type.toLowerCase().contains('autodetected')) return 'Workout'; + // The table's own label wins over capitalising the key, so the list row and + // the picker tile always read the same. Capitalising blind is how 'hiit' + // rendered as "Hiit" everywhere except the picker. + for (final e in kWorkoutTypes) { + if (e.$1 == type) return e.$2; + } return type[0].toUpperCase() + type.substring(1); } @@ -94,20 +112,21 @@ Widget workoutTypeGrid(BuildContext context) => Wrap( ], ); -/// Bottom-sheet type picker (no workout start) — used to confirm/correct an -/// auto-detected workout's type and to set the type on a manually logged one. -/// Returns the chosen type, or null if dismissed. +/// The body every type-picking bottom sheet shares: a title over a scrollable +/// grid, capped at 3/4 of the screen. /// -/// Lives here rather than in workouts_screen.dart so the manual-entry form can -/// reach it without the two screen files importing each other. -Future pickWorkoutType( - BuildContext context, { - String title = 'Set workout type', -}) { - return showModalBottomSheet( - context: context, - builder: (_) => SafeArea( - top: false, +/// The grid MUST stay scrollable and the sheet MUST be opened with +/// `isScrollControlled: true`. A plain `showModalBottomSheet` caps itself at +/// 9/16 of the screen (~475 pt on a 390x844 device); the type list outgrew +/// that the moment it went past nine tiles, and the overflow is invisible in +/// release — the last rows are simply clipped off and untappable, with no +/// overflow stripes to give it away. +Widget workoutTypeSheet(BuildContext context, String title) { + final maxHeight = MediaQuery.sizeOf(context).height * 0.75; + return SafeArea( + top: false, + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), child: Padding( padding: const EdgeInsets.all(Sp.x5), child: Column( @@ -116,7 +135,11 @@ Future pickWorkoutType( children: [ Text(title, style: AppText.h2), const SizedBox(height: Sp.x4), - Builder(builder: workoutTypeGrid), + Flexible( + child: SingleChildScrollView( + child: Builder(builder: workoutTypeGrid), + ), + ), const SizedBox(height: Sp.x4), ], ), @@ -124,3 +147,20 @@ Future pickWorkoutType( ), ); } + +/// Bottom-sheet type picker (no workout start) — used to confirm/correct an +/// auto-detected workout's type and to set the type on a manually logged one. +/// Returns the chosen type, or null if dismissed. +/// +/// Lives here rather than in workouts_screen.dart so the manual-entry form can +/// reach it without the two screen files importing each other. +Future pickWorkoutType( + BuildContext context, { + String title = 'Set workout type', +}) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (ctx) => workoutTypeSheet(ctx, title), + ); +} diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 5cbce1c2..9f9d539a 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -25,6 +25,8 @@ import '../kit/route_map.dart'; import '../screens/detail_cards.dart' show hm; import '../../gps/route_models.dart'; import 'manual_workout_screen.dart'; +import 'workout_filter.dart'; +import 'workout_filter_sheet.dart'; import 'workout_types.dart'; const _ranges = ['Today', 'Week', 'Month', '3M']; @@ -80,33 +82,17 @@ String _whenLabel(int? startTs) { /// Bottom-sheet exercise picker → starts a workout → opens the live screen. /// -/// NOTE — do not grow this sheet. `showModalBottomSheet` defaults to -/// `isScrollControlled: false`, which caps it at 9/16 of the screen (~475 pt on -/// a 390x844 device). The nine type tiles already wrap to three rows (~294 pt) -/// and, with the title and gutters, land close to that ceiling. A fourth child -/// was added here once and was clipped clean off the bottom — invisible and -/// untappable in release, where there are no overflow stripes to give it away. -/// Logging a PAST workout is a header action ([_AddButton]) for exactly that -/// reason. +/// NOTE — the sheet body lives in [workoutTypeSheet] and is scroll-controlled +/// for a reason documented there: a default (unscrolled) sheet caps at 9/16 of +/// the screen and silently clips whatever doesn't fit, with no overflow +/// stripes in release to give it away. Anything added here must stay inside +/// that scrollable. Logging a PAST workout remains a header action +/// ([_AddButton]) rather than a tile, so the grid stays one vocabulary. Future startWorkoutFlow(BuildContext context) async { final type = await showModalBottomSheet( context: context, - builder: (_) => SafeArea( - top: false, - child: Padding( - padding: const EdgeInsets.all(Sp.x5), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Start a workout', style: AppText.h2), - const SizedBox(height: Sp.x4), - Builder(builder: workoutTypeGrid), - const SizedBox(height: Sp.x4), - ], - ), - ), - ), + isScrollControlled: true, + builder: (ctx) => workoutTypeSheet(ctx, 'Start a workout'), ); if (type == null || !context.mounted) return; final app = context.read(); @@ -140,6 +126,10 @@ class _WorkoutsScreenState extends State { Prefs.workoutsRange, 0, ).clamp(0, _ranges.length - 1); + // Deliberately NOT persisted, unlike the timeframe. A filter that survives a + // relaunch reads as "my workouts are gone" — the timeframe is visible in the + // segmented control, a type/duration floor is not. + WorkoutFilter _filter = const WorkoutFilter(); Map? _data; List> _suggestions = const []; RecordsData? _records; // for inline PR badges in the feed @@ -199,6 +189,15 @@ class _WorkoutsScreenState extends State { if (await showManualWorkoutScreen(context) && mounted) await _load(); } + Future _openFilter(List> inRange) async { + final next = await showWorkoutFilterSheet( + context, + current: _filter, + workouts: inRange, + ); + if (next != null && mounted) setState(() => _filter = next); + } + // LOCAL calendar day — "today's workouts" must match the local day model // (a UTC comparison shifted early-morning sessions into yesterday's bucket). bool _isToday(int startTs) => @@ -208,12 +207,17 @@ class _WorkoutsScreenState extends State { @override Widget build(BuildContext context) { final all = (_data?['workouts'] as List?) ?? const []; - final list = _range == 0 - ? all - .where((w) => _isToday((w as Map)['start_ts'] as int? ?? 0)) - .toList() - : all; - final summary = (_data?['summary'] as Map?)?.cast(); + final inRange = (_range == 0 + ? all.where((w) => _isToday((w as Map)['start_ts'] as int? ?? 0)) + : all) + .cast>() + .toList(); + final list = _filter.apply(inRange); + // A narrowed list gets a summary recomputed over exactly what's visible — + // showing whole-range totals above a filtered feed is just a wrong number. + final summary = _filter.isNarrowing + ? summarizeWorkouts(list) + : (_data?['summary'] as Map?)?.cast(); return AppScaffold( title: 'Workouts', @@ -225,15 +229,26 @@ class _WorkoutsScreenState extends State { _StartButton( onTap: () => startWorkoutFlow(context).then((_) => _load())), ], - header: SegmentedControl( - options: _ranges, - index: _range, - expanded: true, - onChanged: (i) { - setState(() => _range = i); - Prefs.setInt(Prefs.workoutsRange, i); - _load(); - }, + header: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SegmentedControl( + options: _ranges, + index: _range, + expanded: true, + onChanged: (i) { + setState(() => _range = i); + Prefs.setInt(Prefs.workoutsRange, i); + _load(); + }, + ), + const SizedBox(height: Sp.x2), + _FilterBar( + filter: _filter, + onTap: () => _openFilter(inRange), + onClear: () => setState(() => _filter = const WorkoutFilter()), + ), + ], ), body: RefreshIndicator( onRefresh: _load, @@ -274,7 +289,17 @@ class _WorkoutsScreenState extends State { ).dsEnter(), const SizedBox(height: Sp.x4), ], - if (list.isEmpty) + if (list.isEmpty && _filter.isNarrowing) + StateCard( + icon: OsIcon.run, + title: 'Nothing matches', + message: 'No workout in this timeframe fits the filter. ' + 'Widen it, or clear it to see everything again.', + actionLabel: 'Clear filter', + onAction: () => + setState(() => _filter = const WorkoutFilter()), + ) + else if (list.isEmpty) StateCard( icon: OsIcon.run, title: 'No workouts', @@ -284,7 +309,7 @@ class _WorkoutsScreenState extends State { onAction: () => startWorkoutFlow(context).then((_) => _load()), ) else - ..._feed(list.cast>()), + ..._feed(list), ], ], ), @@ -306,7 +331,13 @@ class _WorkoutsScreenState extends State { .add(w); } - if (_range == 0) { + if (!_filter.sort.isChronological) { + // Week buckets only mean something in calendar order. Under "longest" or + // "hardest" the list is flat, headed by what it's ordered by. + for (final w in list) { + add('${_filter.sort.label} first', w); + } + } else if (_range == 0) { for (final w in list) { add('Today', w); } @@ -418,7 +449,9 @@ class _WorkoutsScreenState extends State { type: (w['type'] as String?) ?? 'other', duration: Duration(minutes: (w['duration_min'] as num?)?.toInt() ?? 0), peakHr: (w['max_hr'] as num?)?.toInt() ?? 0, - calories: ((w['calories'] as num?) ?? 0).toDouble(), + // Nullable for the same reason `steps` below is — a session logged + // against an incomplete profile was never costed, not costed at zero. + calories: (w['calories'] as num?)?.toInt(), strain: (w['strain'] as num?)?.toDouble(), // Nullable: an unmeasured workout is not a zero-step one. steps: (w['steps'] as num?)?.toInt(), @@ -511,6 +544,93 @@ class _AddButton extends StatelessWidget { } } +/// The filter/sort row under the timeframe control. Idle it's one quiet pill; +/// once something is narrowing the list it states what, and grows a clear +/// affordance so the filter can never be silently stuck on. +class _FilterBar extends StatelessWidget { + const _FilterBar({ + required this.filter, + required this.onTap, + required this.onClear, + }); + + final WorkoutFilter filter; + final VoidCallback onTap; + final VoidCallback onClear; + + @override + Widget build(BuildContext context) { + final active = filter.isNarrowing; + final tint = active ? AppColors.accent : AppColors.inkSoft; + final label = active + ? filter.description + : (filter.sort == WorkoutSort.newest + ? 'Filter' + : 'Sorted: ${filter.sort.label.toLowerCase()}'); + return Row( + children: [ + Semantics( + button: true, + label: active ? 'Filter workouts, active: $label' : 'Filter workouts', + child: Pressable( + pressedScale: 0.96, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Sp.x3, + vertical: Sp.x2, + ), + decoration: BoxDecoration( + color: active + ? AppColors.tonalFill(AppColors.accent) + : Elevation.surfaceAt(1), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all( + color: active + ? AppColors.accent.withValues(alpha: 0.55) + : AppColors.divider, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.tune_rounded, size: 16, color: tint), + const SizedBox(width: Sp.x1), + Text( + label, + style: AppText.label + .copyWith(color: tint, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ), + ), + if (active) ...[ + const SizedBox(width: Sp.x2), + Semantics( + button: true, + label: 'Clear workout filter', + child: Pressable( + pressedScale: 0.9, + onTap: onClear, + child: Padding( + padding: const EdgeInsets.all(Sp.x2), + child: Icon( + Icons.close_rounded, + size: 16, + color: AppColors.inkSoft, + ), + ), + ), + ), + ], + const Spacer(), + ], + ); + } +} + /// Solid ember Start pill — top-right of the Workouts header. Restrained: /// brand accent + standard elevation, no gradient/glow. class _StartButton extends StatelessWidget { @@ -1216,7 +1336,7 @@ class _WorkoutDetailBodyState extends State<_WorkoutDetailBody> { : DateTime.now(), maxHr: context.read().maxHr, strain: (d['strain'] as num?)?.toDouble(), - calories: (d['calories'] as num?)?.toInt() ?? 0, + calories: (d['calories'] as num?)?.toInt(), route: _route, avgHr: (d['avg_hr'] as num?)?.toInt(), ); diff --git a/test/workout_calorie_anchors_test.dart b/test/workout_calorie_anchors_test.dart new file mode 100644 index 00000000..6683389c --- /dev/null +++ b/test/workout_calorie_anchors_test.dart @@ -0,0 +1,108 @@ +// Calories are only ever reported for a profile that carries the anchors +// Keytel (2005) actually reads. +// +// The live 1 Hz tick used to substitute 30 years / 70 kg / male for whatever +// the profile was missing, while the substrate re-score refused to guess. So +// an untouched profile produced a confident kcal total for a stand-in body — +// and since 70 kg is heavier than a lot of people, it read high, which is +// exactly the "calories are always overstated" complaint. Both paths now share +// one predicate. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/compute/manual_session.dart'; +import 'package:openstrap_edge/compute/profile.dart'; +import 'package:openstrap_edge/state/app_state.dart'; + +const _anchored = Profile(ageYears: 34, weightKg: 72, sex: 'm'); + +LiveWorkoutState _session(Profile p) => LiveWorkoutState( + startTime: DateTime(2026, 1, 1, 7), + targetKcal: 300, + profile: p, +); + +void main() { + group('Profile.hasCalorieAnchors', () { + test('needs age, mass and sex', () { + expect(_anchored.hasCalorieAnchors, isTrue); + expect(const Profile().hasCalorieAnchors, isFalse); + expect( + const Profile(weightKg: 72, sex: 'm').hasCalorieAnchors, + isFalse, + reason: 'age is a Keytel term', + ); + expect( + const Profile(ageYears: 34, sex: 'm').hasCalorieAnchors, + isFalse, + reason: 'body mass is a Keytel term', + ); + expect( + const Profile(ageYears: 34, weightKg: 72).hasCalorieAnchors, + isFalse, + reason: 'the formula has a different constant per sex', + ); + }); + + test('does not require height, unlike isComplete', () { + // Gating calories on `isComplete` would refuse to score a profile that + // has everything the formula reads. + expect(_anchored.isComplete, isFalse); + expect(_anchored.hasCalorieAnchors, isTrue); + }); + }); + + group('LiveWorkoutState.caloriesOrNull', () { + test('is null without anchors, however much was accrued', () { + final s = _session(const Profile()); + s.calories = 431.7; + expect( + s.caloriesOrNull, + isNull, + reason: 'a stale accumulator must not resurrect a fabricated total', + ); + }); + + test('rounds the accrued figure when anchored', () { + final s = _session(_anchored); + s.calories = 431.7; + expect(s.caloriesOrNull, 432); + }); + + test('an anchored session that burned nothing reports zero, not absent', () { + // Absent and zero are different claims and both are reachable — this is + // the one that means "we measured, and it was nothing". + expect(_session(_anchored).caloriesOrNull, 0); + }); + }); + + test('the substrate re-score abstains on the same predicate', () { + final start = DateTime(2026, 1, 1, 7).millisecondsSinceEpoch ~/ 1000; + final ts = [for (var i = 0; i < 600; i++) start + i]; + final hr = [for (var i = 0; i < 600; i++) 140]; + + final unanchored = computeManualSessionStats( + hrTs: ts, + hrBpm: hr, + zoneMaxHr: 185, + profile: const Profile(weightKg: 72, sex: 'm'), + restingHr: 55, + ); + expect(unanchored.calories, isNull); + expect( + unanchored.avgHr, + 140, + reason: 'only the calorie figure abstains — the rest of the session is ' + 'still perfectly scoreable without a body mass', + ); + + final anchored = computeManualSessionStats( + hrTs: ts, + hrBpm: hr, + zoneMaxHr: 185, + profile: _anchored, + restingHr: 55, + ); + expect(anchored.calories, isNotNull); + expect(anchored.calories, greaterThan(0)); + }); +} diff --git a/test/workout_filter_test.dart b/test/workout_filter_test.dart new file mode 100644 index 00000000..91387c2a --- /dev/null +++ b/test/workout_filter_test.dart @@ -0,0 +1,209 @@ +// The workout list's filter/sort rules. Pure policy, so these are plain unit +// tests — no widget pumping, no database. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui/workouts/workout_filter.dart'; + +Map w({ + required int startTs, + String type = 'run', + int durationMin = 30, + double strain = 8, + int calories = 200, + String status = 'done', + List zoneMin = const [1, 2, 3, 4, 5], +}) => { + 'start_ts': startTs, + 'type': type, + 'duration_min': durationMin, + 'strain': strain, + 'calories': calories, + 'status': status, + 'zone_min': zoneMin, +}; + +void main() { + group('canonicalWorkoutType', () { + test('keeps a known key', () { + expect(canonicalWorkoutType('boxing'), 'boxing'); + }); + + test('collapses anything outside the vocabulary to other', () { + // The detector's own strings, rows from an older release, and imports + // all land here — if they mapped to nothing, no filter chip could ever + // reach them and they would vanish the moment a type filter went on. + for (final t in ['autodetected_workout', 'kitesurfing', '', null]) { + expect(canonicalWorkoutType(t), 'other'); + } + }); + }); + + group('filtering', () { + final list = [ + w(startTs: 300, type: 'run', durationMin: 60, strain: 12), + w(startTs: 200, type: 'strength', durationMin: 20, strain: 5), + w(startTs: 100, type: 'autodetected_workout', durationMin: 45, strain: 9), + ]; + + test('no filter keeps everything', () { + expect(const WorkoutFilter().apply(list).length, 3); + expect(const WorkoutFilter().isNarrowing, isFalse); + }); + + test('type filter keeps only that type', () { + final out = const WorkoutFilter(types: {'run'}).apply(list); + expect(out.map((e) => e['type']), ['run']); + }); + + test('the other chip catches unrecognised types', () { + final out = const WorkoutFilter(types: {'other'}).apply(list); + expect(out.single['start_ts'], 100); + }); + + test('duration floor is inclusive', () { + expect(const WorkoutFilter(minMinutes: 45).apply(list).length, 2); + expect(const WorkoutFilter(minMinutes: 60).apply(list).length, 1); + }); + + test('strain floor is inclusive', () { + expect(const WorkoutFilter(minStrain: 9).apply(list).length, 2); + }); + + test('floors compose', () { + final out = const WorkoutFilter(minMinutes: 45, minStrain: 10) + .apply(list); + expect(out.single['start_ts'], 300); + }); + + test('a live session survives every floor', () { + // It has no final duration or strain yet, so any numeric floor would + // hide the one session the user is most likely looking at. + final live = w( + startTs: 400, + type: 'cycle', + durationMin: 0, + strain: 0, + status: 'live', + ); + final out = const WorkoutFilter(minMinutes: 60, minStrain: 15) + .apply([...list, live]); + expect(out.map((e) => e['status']), contains('live')); + }); + + test('sort alone is not narrowing', () { + // Reordering hides nothing, so the summary above the list stays the + // repo's own whole-range aggregate rather than being recomputed. + const f = WorkoutFilter(sort: WorkoutSort.longest); + expect(f.isNarrowing, isFalse); + expect(f.isDefault, isFalse); + }); + }); + + group('sorting', () { + final list = [ + w(startTs: 100, durationMin: 60, strain: 4), + w(startTs: 300, durationMin: 20, strain: 15), + w(startTs: 200, durationMin: 60, strain: 9), + ]; + + test('newest first is the default', () { + expect( + const WorkoutFilter().apply(list).map((e) => e['start_ts']), + [300, 200, 100], + ); + }); + + test('oldest first reverses it', () { + expect( + const WorkoutFilter(sort: WorkoutSort.oldest) + .apply(list) + .map((e) => e['start_ts']), + [100, 200, 300], + ); + }); + + test('longest breaks ties by newest, not by query order', () { + expect( + const WorkoutFilter(sort: WorkoutSort.longest) + .apply(list) + .map((e) => e['start_ts']), + [200, 100, 300], + ); + }); + + test('hardest orders by strain', () { + expect( + const WorkoutFilter(sort: WorkoutSort.hardest) + .apply(list) + .map((e) => e['start_ts']), + [300, 200, 100], + ); + }); + + test('only the chronological sorts keep week grouping', () { + expect(WorkoutSort.newest.isChronological, isTrue); + expect(WorkoutSort.oldest.isChronological, isTrue); + expect(WorkoutSort.longest.isChronological, isFalse); + expect(WorkoutSort.hardest.isChronological, isFalse); + }); + + test('does not mutate the source list', () { + final source = [...list]; + const WorkoutFilter(sort: WorkoutSort.hardest).apply(source); + expect(source.map((e) => e['start_ts']), [100, 300, 200]); + }); + }); + + group('summarizeWorkouts', () { + test('totals match the visible list', () { + final out = summarizeWorkouts([ + w(startTs: 100, durationMin: 30, calories: 200, zoneMin: [1, 2]), + w(startTs: 200, durationMin: 45, calories: 300, zoneMin: [3, 4, 5]), + ]); + expect(out['count'], 2); + expect(out['total_min'], 75); + expect(out['total_calories'], 500); + expect(out['zone_min'], [4, 6, 5]); + }); + + test('excludes live sessions exactly as the repo does', () { + final out = summarizeWorkouts([ + w(startTs: 100, durationMin: 30), + w(startTs: 200, durationMin: 99, status: 'live'), + ]); + expect(out['count'], 1); + expect(out['total_min'], 30); + }); + + test('a missing calorie figure counts as absent, not as a total', () { + // Sessions logged against an incomplete profile carry a null kcal — the + // sum must skip them rather than treat them as a real zero. + final out = summarizeWorkouts([ + {'duration_min': 30, 'calories': null, 'status': 'done'}, + w(startTs: 100, durationMin: 30, calories: 250), + ]); + expect(out['total_calories'], 250); + }); + }); + + group('description', () { + test('is empty when nothing narrows', () { + expect(const WorkoutFilter().description, ''); + }); + + test('names one or two types, counts more', () { + expect(const WorkoutFilter(types: {'run'}).description, 'Run'); + expect( + const WorkoutFilter(types: {'run', 'boxing', 'golf'}).description, + '3 types', + ); + }); + + test('joins the active floors', () { + expect( + const WorkoutFilter(minMinutes: 30, minStrain: 10).description, + '30m+ · strain 10+', + ); + }); + }); +} diff --git a/test/workout_health_mapping_test.dart b/test/workout_health_mapping_test.dart index 0a2cf668..4d9d409a 100644 --- a/test/workout_health_mapping_test.dart +++ b/test/workout_health_mapping_test.dart @@ -30,6 +30,18 @@ const _iosSupported = { HealthWorkoutActivityType.TRADITIONAL_STRENGTH_TRAINING, HealthWorkoutActivityType.YOGA, HealthWorkoutActivityType.HIGH_INTENSITY_INTERVAL_TRAINING, + HealthWorkoutActivityType.BOXING, + HealthWorkoutActivityType.ROWING, + HealthWorkoutActivityType.HIKING, + HealthWorkoutActivityType.ROCK_CLIMBING, + HealthWorkoutActivityType.DOWNHILL_SKIING, + HealthWorkoutActivityType.SNOWBOARDING, + HealthWorkoutActivityType.STAIR_CLIMBING, + HealthWorkoutActivityType.PILATES, + HealthWorkoutActivityType.TENNIS, + HealthWorkoutActivityType.BASKETBALL, + HealthWorkoutActivityType.SOCCER, + HealthWorkoutActivityType.GOLF, HealthWorkoutActivityType.OTHER, }; @@ -42,6 +54,18 @@ const _androidSupported = { HealthWorkoutActivityType.STRENGTH_TRAINING, HealthWorkoutActivityType.YOGA, HealthWorkoutActivityType.HIGH_INTENSITY_INTERVAL_TRAINING, + HealthWorkoutActivityType.BOXING, + HealthWorkoutActivityType.ROWING, + HealthWorkoutActivityType.HIKING, + HealthWorkoutActivityType.ROCK_CLIMBING, + HealthWorkoutActivityType.DOWNHILL_SKIING, + HealthWorkoutActivityType.SNOWBOARDING, + HealthWorkoutActivityType.STAIR_CLIMBING, + HealthWorkoutActivityType.PILATES, + HealthWorkoutActivityType.TENNIS, + HealthWorkoutActivityType.BASKETBALL, + HealthWorkoutActivityType.SOCCER, + HealthWorkoutActivityType.GOLF, HealthWorkoutActivityType.OTHER, }; @@ -56,6 +80,17 @@ const _extraTypeStrings = [ 'swimming', 'weights', 'lifting', + 'row', + 'hiking', + 'climbing', + 'skiing', + 'snowboarding', + 'stair', + 'racquet', + 'squash', + 'padel', + 'badminton', + 'football', 'autodetected', 'autodetected_workout', 'workout', @@ -90,6 +125,31 @@ void main() { } }); + // The picker table and the health switch are two hand-maintained lists. + // Adding a tile to `kWorkoutTypes` without adding a case to + // `healthActivityForType` is silent — the workout still exports, just as an + // unlabelled "Other", so it is invisible until someone opens Apple Health + // and finds a wall of generic entries. + test('every picker type has its own health activity, not a silent OTHER', () { + // `cardio` and `other` are genuinely unspecific: neither store has a + // better home for them than OTHER, and that is a decision, not an + // oversight. + const deliberatelyOther = {'cardio', 'other'}; + for (final e in kWorkoutTypes) { + if (deliberatelyOther.contains(e.$1)) continue; + for (final ios in [true, false]) { + expect( + healthActivityForType(e.$1, ios: ios), + isNot(HealthWorkoutActivityType.OTHER), + reason: + '"${e.$1}" is offered in the workout picker but falls through to ' + 'OTHER on ${ios ? 'iOS' : 'Android'} — add a case to ' + 'healthActivityForType', + ); + } + } + }); + test('strength maps to the platform-correct strength spelling', () { expect( healthActivityForType('strength', ios: true), @@ -121,7 +181,9 @@ void main() { }); test('an unknown type degrades to OTHER rather than an unwritable value', () { - for (final unknown in ['surfing', 'padel', 'autodetected', null]) { + // 'padel' used to stand in for "unknown" here and is now a racquet alias — + // pick strings the switch genuinely has no case for. + for (final unknown in ['surfing', 'kitesurfing', 'autodetected', null]) { expect( healthActivityForType(unknown, ios: true), HealthWorkoutActivityType.OTHER, diff --git a/test/workouts_header_actions_test.dart b/test/workouts_header_actions_test.dart index 233732bc..194c1ed6 100644 --- a/test/workouts_header_actions_test.dart +++ b/test/workouts_header_actions_test.dart @@ -4,13 +4,20 @@ // WHY THIS FILE EXISTS. "Log a past workout" first shipped as a fourth child // of the start bottom sheet, where it was invisible and untappable: the sheet // defaults to `isScrollControlled: false`, capping it at 9/16 of the screen -// (~475 pt at 390x844), and the nine type tiles already wrap to three rows. +// (~475 pt at 390x844), and nine type tiles already wrapped to three rows. // The row fell off the bottom edge. In release there are no overflow stripes, // so nothing announced it — it just silently did not work. // -// Two lessons, both pinned here: +// The type vocabulary has since outgrown that cap outright, so the sheet is +// scroll-controlled and the grid scrolls inside it. That changes what has to +// be guarded, not why: the failure mode is still "content past the edge is +// silently unhittable", and the defence is now that the grid is genuinely +// inside a Scrollable and every tile can be reached. +// +// Three lessons, all pinned here: // 1. Two pills plus the title must fit the header row at real phone widths. // 2. A layout overflow must FAIL a test rather than ship as dead pixels. +// 3. Every workout type must be reachable, however long the list grows. import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -68,38 +75,24 @@ void main() { } testWidgets( - 'the start sheet content fits the default 9/16 bottom-sheet cap — ' - 'the regression that made "Log a past workout" untappable', + 'every workout type in the start sheet is reachable — the regression ' + 'that made "Log a past workout" untappable', (t) async { t.view.physicalSize = _iphone14; t.view.devicePixelRatio = 1.0; addTearDown(t.view.reset); AppColors.active = kLightPalette; - // The sheet body exactly as startWorkoutFlow builds it. + // The sheet body exactly as startWorkoutFlow builds it, under the same + // ceiling showModalBottomSheet imposes with isScrollControlled: true. await t.pumpWidget(_harness( Scaffold( body: Align( alignment: Alignment.bottomCenter, - child: ConstrainedBox( - // What showModalBottomSheet imposes with isScrollControlled:false. - constraints: BoxConstraints(maxHeight: _iphone14.height * 9 / 16), - child: SafeArea( - key: const Key('sheet-body'), - top: false, - child: Padding( - padding: const EdgeInsets.all(Sp.x5), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Start a workout', style: AppText.h2), - const SizedBox(height: Sp.x4), - Builder(builder: workoutTypeGrid), - const SizedBox(height: Sp.x4), - ], - ), - ), + child: KeyedSubtree( + key: const Key('sheet-body'), + child: Builder( + builder: (ctx) => workoutTypeSheet(ctx, 'Start a workout'), ), ), ), @@ -109,30 +102,42 @@ void main() { await t.pump(); expect(t.takeException(), isNull, - reason: 'the start sheet must not overflow its 9/16 cap — anything ' - 'past the edge is silently unhittable in release'); + reason: 'the start sheet must not overflow — anything past the edge ' + 'is silently unhittable in release'); + + // The sheet stays inside its own ceiling rather than growing to fit. + final used = t.getSize(find.byKey(const Key('sheet-body'))).height; + expect(used, lessThanOrEqualTo(_iphone14.height * 0.75 + 0.5), + reason: 'sheet grew past the height it constrains itself to'); + + // THE load-bearing assertion: the grid is inside a Scrollable. Without + // it the overflowing tiles are drawn nowhere and cannot be tapped, which + // is the original bug with more tiles. + expect( + find.ancestor( + of: find.text(kWorkoutTypes.first.$2), + matching: find.byType(Scrollable), + ), + findsWidgets, + reason: 'the type grid must scroll — the list is taller than the sheet', + ); - // Every type tile is inside the visible sheet, not clipped past it. - final ceiling = _iphone14.height * 9 / 16; + // Every tile exists, has real size, and can be brought fully on screen. + final scrollable = find + .descendant( + of: find.byKey(const Key('sheet-body')), + matching: find.byType(Scrollable), + ) + .first; for (final e in kWorkoutTypes) { final tile = find.text(e.$2); expect(tile, findsOneWidget, reason: '${e.$2} tile missing'); + await t.scrollUntilVisible(tile, 120, scrollable: scrollable); final r = t.getRect(tile); - expect(r.bottom, lessThanOrEqualTo(_iphone14.height), - reason: '${e.$2} is clipped off the bottom of the screen'); expect(r.height, greaterThan(0), reason: '${e.$2} collapsed to zero'); + expect(r.bottom, lessThanOrEqualTo(_iphone14.height), + reason: '${e.$2} could not be scrolled onto the screen'); } - - // And the sheet genuinely is close to its ceiling — this is the fact - // that makes adding a fourth child unsafe. If this ever goes slack - // (fewer types, a tighter grid), the note in startWorkoutFlow can be - // revisited; until then it stands. - // By key: `find.byType(SafeArea).last` depended on how many SafeAreas - // the harness happened to nest, which is not what this asserts. - final used = t.getSize(find.byKey(const Key('sheet-body'))).height; - expect(used, greaterThan(ceiling * 0.6), - reason: 'sheet is nowhere near its cap — re-check the guidance in ' - 'startWorkoutFlow before trusting it'); }, ); } From 14acd22b7751d0e6396207b04e9ccec5b1039ad6 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 13:59:08 +0530 Subject: [PATCH 2/7] say skip rather than default when a session was never costed --- lib/ui/workouts/workout_filter.dart | 7 ++++++- test/workout_filter_test.dart | 16 ++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/lib/ui/workouts/workout_filter.dart b/lib/ui/workouts/workout_filter.dart index f8cc9830..50d0209f 100644 --- a/lib/ui/workouts/workout_filter.dart +++ b/lib/ui/workouts/workout_filter.dart @@ -146,7 +146,12 @@ Map summarizeWorkouts(List> workouts) { if (w['status'] == 'live') continue; count++; totalMin += ((w['duration_min'] as num?) ?? 0).toInt(); - totalCal += ((w['calories'] as num?) ?? 0).toInt(); + // An absent calorie figure is skipped, not defaulted. Arithmetically that + // is the same as adding zero, but writing it as `?? 0` invites someone to + // later swap the zero for a "reasonable" estimate and turn the total into + // a fabrication — the null is the whole point. + final cal = (w['calories'] as num?)?.toInt(); + if (cal != null) totalCal += cal; final zm = (w['zone_min'] as List?) ?? const []; for (var i = 0; i < zm.length; i++) { final v = (zm[i] as num?) ?? 0; diff --git a/test/workout_filter_test.dart b/test/workout_filter_test.dart index 91387c2a..ffe68ddc 100644 --- a/test/workout_filter_test.dart +++ b/test/workout_filter_test.dart @@ -175,14 +175,22 @@ void main() { expect(out['total_min'], 30); }); - test('a missing calorie figure counts as absent, not as a total', () { - // Sessions logged against an incomplete profile carry a null kcal — the - // sum must skip them rather than treat them as a real zero. + test('a missing calorie figure is skipped, never defaulted', () { + // Sessions logged against an incomplete profile carry a null kcal. The + // total is the sum of what was actually measured — the uncosted session + // still counts as a session and still contributes its minutes. final out = summarizeWorkouts([ - {'duration_min': 30, 'calories': null, 'status': 'done'}, + { + 'duration_min': 30, + 'calories': null, + 'status': 'done', + 'zone_min': const [], + }, w(startTs: 100, durationMin: 30, calories: 250), ]); expect(out['total_calories'], 250); + expect(out['count'], 2, reason: 'uncosted is still a workout'); + expect(out['total_min'], 60, reason: 'its minutes are real'); }); }); From ab7a385fa7ad786aa21b223fc67876871824d335 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 14:07:47 +0530 Subject: [PATCH 3/7] the type filter applies to a live session too, and type lookups survive any case --- lib/ui/workouts/workout_filter.dart | 11 +++--- lib/ui/workouts/workout_types.dart | 13 ++++--- test/workout_filter_test.dart | 8 +++++ test/workout_types_label_test.dart | 55 +++++++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 test/workout_types_label_test.dart diff --git a/lib/ui/workouts/workout_filter.dart b/lib/ui/workouts/workout_filter.dart index 50d0209f..bff4c942 100644 --- a/lib/ui/workouts/workout_filter.dart +++ b/lib/ui/workouts/workout_filter.dart @@ -89,14 +89,17 @@ class WorkoutFilter { } bool _matches(Map w) { - // A session that is happening right now always shows. It has no final - // duration or strain yet, so every numeric floor would hide the one thing - // the user is most likely looking for. - if (w['status'] == 'live') return true; + // Type is known the moment a session starts, so it applies to a live one + // like any other — filtering to runs should not surface the ride that is + // in progress. if (types.isNotEmpty && !types.contains(canonicalWorkoutType(w['type'] as String?))) { return false; } + // The numeric floors are different: a session happening right now has no + // final duration or strain to clear them with, so holding it to them would + // hide the one thing the user is most likely looking at. + if (w['status'] == 'live') return true; if (minMinutes > 0 && ((w['duration_min'] as num?) ?? 0) < minMinutes) { return false; } diff --git a/lib/ui/workouts/workout_types.dart b/lib/ui/workouts/workout_types.dart index 19917652..60006530 100644 --- a/lib/ui/workouts/workout_types.dart +++ b/lib/ui/workouts/workout_types.dart @@ -49,7 +49,7 @@ OsIcon workoutTypeIcon(String? type) { if (raw.contains('autodetected')) return OsIcon.strength; if (raw.contains('workout')) return OsIcon.strength; for (final e in kWorkoutTypes) { - if (e.$1 == type) return e.$3; + if (e.$1 == raw) return e.$3; } return OsIcon.strength; } @@ -57,20 +57,25 @@ OsIcon workoutTypeIcon(String? type) { /// Illustrated art for a workout type — null only for autodetected/unknown /// types, which stay on the glyph fallback ([workoutTypeIcon]). OsIcon? workoutTypeOsIcon(String? type) { + final key = (type ?? '').toLowerCase(); for (final e in kWorkoutTypes) { - if (e.$1 == type) return e.$4; + if (e.$1 == key) return e.$4; } return null; } String workoutTypeLabel(String? type) { if (type == null || type.isEmpty) return 'Workout'; - if (type.toLowerCase().contains('autodetected')) return 'Workout'; + // The stored `type` column is free-form text, so rows written by older + // releases, by an import, or by hand can arrive in any case. Every lookup in + // this file normalizes first for that reason. + final key = type.toLowerCase(); + if (key.contains('autodetected')) return 'Workout'; // The table's own label wins over capitalising the key, so the list row and // the picker tile always read the same. Capitalising blind is how 'hiit' // rendered as "Hiit" everywhere except the picker. for (final e in kWorkoutTypes) { - if (e.$1 == type) return e.$2; + if (e.$1 == key) return e.$2; } return type[0].toUpperCase() + type.substring(1); } diff --git a/test/workout_filter_test.dart b/test/workout_filter_test.dart index ffe68ddc..36495cee 100644 --- a/test/workout_filter_test.dart +++ b/test/workout_filter_test.dart @@ -75,6 +75,14 @@ void main() { expect(out.single['start_ts'], 300); }); + test('a live session is still subject to the type filter', () { + // Type is known the moment a session starts, so filtering to runs must + // not surface the ride that happens to be in progress. + final live = w(startTs: 400, type: 'cycle', status: 'live'); + final out = const WorkoutFilter(types: {'run'}).apply([...list, live]); + expect(out.map((e) => e['type']), ['run']); + }); + test('a live session survives every floor', () { // It has no final duration or strain yet, so any numeric floor would // hide the one session the user is most likely looking at. diff --git a/test/workout_types_label_test.dart b/test/workout_types_label_test.dart new file mode 100644 index 00000000..d8d798a9 --- /dev/null +++ b/test/workout_types_label_test.dart @@ -0,0 +1,55 @@ +// The workout-type vocabulary is looked up by a free-form `type` string that +// comes straight out of the database, so every lookup has to survive whatever +// case an older release, an import, or a hand-edited row happened to write. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ui/kit/os_icons.dart'; +import 'package:openstrap_edge/ui/workouts/workout_types.dart'; + +void main() { + group('workoutTypeLabel', () { + test('uses the table label rather than capitalising the key', () { + // Capitalising blind is how 'hiit' read as "Hiit" everywhere except the + // picker, and it cannot produce a label that differs from its key at all. + expect(workoutTypeLabel('hiit'), 'HIIT'); + expect(workoutTypeLabel('tennis'), 'Racquet'); + }); + + test('is case-insensitive', () { + for (final variant in ['TENNIS', 'Tennis', 'tEnNiS']) { + expect(workoutTypeLabel(variant), 'Racquet'); + } + expect(workoutTypeLabel('STRENGTH'), 'Strength'); + }); + + test('falls back to capitalising an unknown type', () { + expect(workoutTypeLabel('kitesurfing'), 'Kitesurfing'); + }); + + test('collapses the detector vocabulary and the empty case', () { + expect(workoutTypeLabel('autodetected_workout'), 'Workout'); + expect(workoutTypeLabel('AUTODETECTED'), 'Workout'); + expect(workoutTypeLabel(''), 'Workout'); + expect(workoutTypeLabel(null), 'Workout'); + }); + }); + + group('icon lookups', () { + test('are case-insensitive too', () { + expect(workoutTypeIcon('BOXING'), OsIcon.boxing); + expect(workoutTypeOsIcon('Boxing'), OsIcon.boxing); + }); + + test('an unknown type still renders something', () { + expect(workoutTypeIcon('kitesurfing'), OsIcon.strength); + expect(workoutTypeOsIcon('kitesurfing'), isNull); + }); + }); + + test('every type key is already lowercase, so lookups can normalize once', + () { + for (final e in kWorkoutTypes) { + expect(e.$1, e.$1.toLowerCase(), reason: '${e.$1} breaks the lookups'); + } + }); +} From 7d44eb8c21446ccc1724dd26f39c6baf89ffaf4d Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 14:18:41 +0530 Subject: [PATCH 4/7] an unscored session is not a zero-strain one --- lib/ui/workouts/workout_filter.dart | 26 ++++++++++++++++---- test/workout_filter_test.dart | 38 +++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/lib/ui/workouts/workout_filter.dart b/lib/ui/workouts/workout_filter.dart index bff4c942..01173156 100644 --- a/lib/ui/workouts/workout_filter.dart +++ b/lib/ui/workouts/workout_filter.dart @@ -103,8 +103,14 @@ class WorkoutFilter { if (minMinutes > 0 && ((w['duration_min'] as num?) ?? 0) < minMinutes) { return false; } - if (minStrain > 0 && ((w['strain'] as num?) ?? 0) < minStrain) { - return false; + // An unscored session (null strain — the profile lacked an anchor, or the + // window has no HR) cannot be shown to clear a strain floor, so a floor + // excludes it. That is the conservative reading and it is deliberate: the + // alternative is listing sessions under "strain 10+" that may be nothing + // of the sort. + if (minStrain > 0) { + final s = (w['strain'] as num?)?.toDouble(); + if (s == null || s < minStrain) return false; } return true; } @@ -113,7 +119,7 @@ class WorkoutFilter { final out = workouts.where(_matches).toList(); int startOf(Map w) => (w['start_ts'] as int?) ?? 0; num durOf(Map w) => (w['duration_min'] as num?) ?? 0; - num strainOf(Map w) => (w['strain'] as num?) ?? 0; + num? strainOf(Map w) => (w['strain'] as num?); switch (sort) { case WorkoutSort.newest: out.sort((a, b) => startOf(b).compareTo(startOf(a))); @@ -126,10 +132,20 @@ class WorkoutFilter { final c = durOf(b).compareTo(durOf(a)); return c != 0 ? c : startOf(b).compareTo(startOf(a)); }); + // An unscored session sinks below every scored one rather than ranking + // as a genuine zero — "hardest last" should not be a list of sessions we + // could not score. case WorkoutSort.hardest: out.sort((a, b) { - final c = strainOf(b).compareTo(strainOf(a)); - return c != 0 ? c : startOf(b).compareTo(startOf(a)); + final sa = strainOf(a); + final sb = strainOf(b); + if (sa == null || sb == null) { + if (sa != sb) return sa == null ? 1 : -1; + } else { + final c = sb.compareTo(sa); + if (c != 0) return c; + } + return startOf(b).compareTo(startOf(a)); }); } return out; diff --git a/test/workout_filter_test.dart b/test/workout_filter_test.dart index 36495cee..ee218aea 100644 --- a/test/workout_filter_test.dart +++ b/test/workout_filter_test.dart @@ -69,6 +69,30 @@ void main() { expect(const WorkoutFilter(minStrain: 9).apply(list).length, 2); }); + test('a strain floor excludes a session that was never scored', () { + // Null strain means the profile lacked an anchor, or the window had no + // HR. It cannot be shown to clear the bar, so listing it under + // "strain 10+" would be a claim we cannot make. + final unscored = { + 'start_ts': 500, + 'type': 'run', + 'duration_min': 60, + 'strain': null, + 'status': 'done', + }; + // All three scored sessions clear 5; the unscored one is the only + // thing the floor removes. + expect( + const WorkoutFilter(minStrain: 5).apply([...list, unscored]).length, + 3, + ); + expect( + const WorkoutFilter().apply([...list, unscored]).length, + 4, + reason: 'with no floor it is an ordinary workout', + ); + }); + test('floors compose', () { final out = const WorkoutFilter(minMinutes: 45, minStrain: 10) .apply(list); @@ -139,6 +163,20 @@ void main() { ); }); + test('hardest sinks unscored sessions below real zeros', () { + final unscored = { + 'start_ts': 400, + 'duration_min': 30, + 'strain': null, + 'status': 'done', + }; + final zero = w(startTs: 350, strain: 0); + final out = const WorkoutFilter(sort: WorkoutSort.hardest) + .apply([unscored, zero, ...list]); + expect(out.last['start_ts'], 400, reason: 'unscored ranks below a real 0'); + expect(out[out.length - 2]['start_ts'], 350); + }); + test('hardest orders by strain', () { expect( const WorkoutFilter(sort: WorkoutSort.hardest) From b4bced400db8160b880680e820d6d1d9ad0a0c02 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 14:25:18 +0530 Subject: [PATCH 5/7] soccer would have taken a whole day's health export down with it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health Connect's Kotlin write map has SOCCER commented out, but the plugin's Dart-side guard still lists it — so the call reaches the channel and comes back false instead of throwing. This exporter reads a false as a real write failure and counts it toward the day's give-up budget, so one soccer workout would have silently paused that day's entire export, resting HR and sleep included. It maps to OTHER on Android now, and lands unlabelled rather than taking the day with it. The mapping test could not have caught this: its Android set was transcribed from the plugin's Dart list, which is only an advisory pre-check and does not agree with the Kotlin map that actually decides. It pins against the Kotlin map now, and the header says which source is authoritative per platform. A hike records a route, like the walk it sits next to in the picker. Ski and snowboard deliberately do not — pace down a lift-served descent would read as a performance number while measuring gravity. Climb was drawing mountains, directly beside a person hiking. Of those two tiles the mountains were the one that read as hiking. --- lib/health/health_export.dart | 13 +++++++- lib/state/app_state.dart | 11 ++++-- lib/ui/kit/os_icons.dart | 15 ++++++--- test/workout_health_mapping_test.dart | 48 ++++++++++++++++++++++----- 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/lib/health/health_export.dart b/lib/health/health_export.dart index 412ac222..492f9045 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -1154,7 +1154,18 @@ HealthWorkoutActivityType healthActivityForType( return HealthWorkoutActivityType.BASKETBALL; case 'soccer': case 'football': - return HealthWorkoutActivityType.SOCCER; + // `SOCCER` passes the plugin's Dart-side guard but is COMMENTED OUT of + // Health Connect's Kotlin write map (HealthPlugin.kt, "TODO: add + // soccer"), so the call reaches the channel and comes back + // `success(false)` rather than throwing. This file treats a false as a + // genuine write failure and counts it toward the day's give-up budget — + // so one soccer workout would silently pause that day's ENTIRE export, + // resting HR and sleep included. Worse than #184, which at least failed + // only itself. OTHER is accepted on Android, so the workout lands + // unlabelled instead of taking the day down with it. + return ios + ? HealthWorkoutActivityType.SOCCER + : HealthWorkoutActivityType.OTHER; case 'golf': return HealthWorkoutActivityType.GOLF; default: diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 35140cd7..06c0a14a 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -3873,7 +3873,12 @@ class AppState extends ChangeNotifier { // no session is live or the type isn't route-eligible / permission denied. RouteTracker? _routeTracker; RouteTracker? get routeTracker => _routeTracker; - static const Set _routeTypes = {'run', 'cycle', 'walk'}; + // A hike is a walk that goes somewhere, so it records a route like one. + // Ski and snowboard are deliberately NOT here despite being outdoors: the + // route screen's hero numbers are distance and pace, and pace down a + // lift-served descent is not the same claim as pace on a walk — it would + // read as a performance figure while measuring gravity. + static const Set _routeTypes = {'run', 'cycle', 'walk', 'hike'}; DateTime _lastLaPush = DateTime.fromMillisecondsSinceEpoch(0); @@ -4031,7 +4036,9 @@ class AppState extends ChangeNotifier { /// is granted. Denial is surfaced (routeLocationIssue) — the workout still /// runs without a map, but the user is told why and how to fix it. Future _maybeStartRouteTracking(String id, String type) async { - if (!_routeTypes.contains(type)) return; + // Lowercased for the same reason every other type lookup is: the stored + // `type` column is free-form text and older rows carry mixed case. + if (!_routeTypes.contains(type.toLowerCase())) return; if (_routeTracker != null) return; routeLocationIssue = null; var perm = GpsPermissionStatus.error; diff --git a/lib/ui/kit/os_icons.dart b/lib/ui/kit/os_icons.dart index cda6ce49..8d94940b 100644 --- a/lib/ui/kit/os_icons.dart +++ b/lib/ui/kit/os_icons.dart @@ -101,9 +101,12 @@ enum OsIcon { swim, hiit, // Sport glyphs for the workout-type vocabulary beyond the original nine. - // Every one of these is a real, distinct glyph — a type only earns a place - // in `kWorkoutTypes` if a pack draws it, because a picker full of - // near-identical stand-in icons is worse than a shorter picker. + // A type earns a place in `kWorkoutTypes` only when a pack draws something + // recognisable for it, because a picker full of near-identical stand-ins is + // worse than a shorter picker. That bar is "recognisable", not "literal": + // two of these are acknowledged approximations (see `rowing` and `pilates` + // in the map below), which is exactly why elliptical and dance were left + // out — nothing in any pack reads as either. boxing, rowing, hike, @@ -240,7 +243,11 @@ const Map _glyphs = { // honest read of the movement. OsIcon.rowing: PhosphorIconsDuotone.boat, OsIcon.hike: PhosphorIconsDuotone.personSimpleHike, - OsIcon.climb: PhosphorIconsDuotone.mountains, + // A ladder, not `mountains`. Mountains is terrain rather than a person, and + // it sits next to `hike: personSimpleHike` in the picker — between "a person + // hiking" and "some mountains", the mountains are the tile that reads as + // hiking. A ladder reads as vertical ascent and separates the pair. + OsIcon.climb: PhosphorIconsDuotone.ladderSimple, OsIcon.ski: PhosphorIconsDuotone.personSimpleSki, OsIcon.snowboard: PhosphorIconsDuotone.personSimpleSnowboard, OsIcon.stairs: PhosphorIconsDuotone.stairs, diff --git a/test/workout_health_mapping_test.dart b/test/workout_health_mapping_test.dart index 4d9d409a..2831d863 100644 --- a/test/workout_health_mapping_test.dart +++ b/test/workout_health_mapping_test.dart @@ -8,13 +8,22 @@ // reached Apple Health. The same latent bug existed for `swim`, which mapped to // bare `SWIMMING` — an iOS-only value — and so was dropped on Android. // -// The supported sets below are transcribed from `health: 11.1.1` -// (`lib/src/health_plugin.dart`, `_isOnIOS` / `_isOnAndroid`), restricted to the -// values `healthActivityForType` can actually emit. They are a PIN, not a -// mirror: on a `health` upgrade, re-check those two functions and update these -// sets deliberately. If a value silently leaves a platform's set upstream, this -// test is what catches it before another workout family goes missing for a -// release. +// The supported sets below are a PIN, not a mirror: on a `health` upgrade, +// re-check the sources named below and update them deliberately. If a value +// silently leaves a platform's set upstream, this test is what catches it +// before another workout family goes missing for a release. +// +// SOURCE OF TRUTH, and it differs per platform: +// iOS — `ios/Classes/SwiftHealthPlugin.swift`, `workoutActivityTypeMap`. +// Android — `android/.../HealthPlugin.kt`, `workoutTypeMap`. NOT the Dart +// `_isOnAndroid` list, which is only an advisory pre-check and +// does NOT agree with the Kotlin map. `SOCCER` is the live example: +// the Dart list contains it, the Kotlin map has it commented out, +// and a write of it returns `success(false)` instead of throwing. +// This file's exporter reads a false as a real write failure and +// counts it toward the day's give-up budget, so trusting the Dart +// list here would have shipped a workout type that silently paused +// a whole day of health export. import 'package:flutter_test/flutter_test.dart'; import 'package:health/health.dart'; @@ -64,7 +73,6 @@ const _androidSupported = { HealthWorkoutActivityType.PILATES, HealthWorkoutActivityType.TENNIS, HealthWorkoutActivityType.BASKETBALL, - HealthWorkoutActivityType.SOCCER, HealthWorkoutActivityType.GOLF, HealthWorkoutActivityType.OTHER, }; @@ -135,9 +143,14 @@ void main() { // better home for them than OTHER, and that is a decision, not an // oversight. const deliberatelyOther = {'cardio', 'other'}; + // Android-only exemption: Health Connect has no writable soccer type at + // this plugin version (see the header), so OTHER there is the correct + // answer rather than a missing case. + const androidOtherOk = {'soccer'}; for (final e in kWorkoutTypes) { if (deliberatelyOther.contains(e.$1)) continue; for (final ios in [true, false]) { + if (!ios && androidOtherOk.contains(e.$1)) continue; expect( healthActivityForType(e.$1, ios: ios), isNot(HealthWorkoutActivityType.OTHER), @@ -169,6 +182,25 @@ void main() { } }); + test('soccer does not reach a Health Connect type it would reject', () { + // The Kotlin write map has SOCCER commented out. Sending it anyway comes + // back false rather than throwing, and this exporter reads a false as a + // real write failure — one soccer workout would take the whole day's + // export down with it. + expect( + healthActivityForType('soccer', ios: true), + HealthWorkoutActivityType.SOCCER, + ); + expect( + healthActivityForType('soccer', ios: false), + HealthWorkoutActivityType.OTHER, + ); + expect( + healthActivityForType('football', ios: false), + HealthWorkoutActivityType.OTHER, + ); + }); + test('swim maps to the platform-correct swim spelling', () { expect( healthActivityForType('swim', ios: true), From c0db836ab499409aa6ef3ecf5e78ca8b08ccd769 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 14:35:40 +0530 Subject: [PATCH 6/7] absent, zero and never-measured are three different answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail screen — the most-viewed place a session appears — still printed "0 kcal" for a session that was never costed, while the share card exported from that same screen already read "—". A live session with a complete profile but no heart rate at all (link dropped, strap off) accrued nothing and reported a confident 0. That is not a measurement. Calories now track whether the estimate ever ran, so absent and a genuine zero stay distinguishable, and calories agree with strain, which already reported that case as absent. The filter pill could overflow its row. "Basketball, Snowboard · 90m+ · strain 17+" is a reachable description and the header is about 350 pt wide; unconstrained that clips silently in release. It ellipsizes now. A filtered summary was dropping the auto-type accuracy note, which describes the whole range and has nothing to do with the filter. Imported sessions carry the export's own spelling — running, weightlifting, functional fitness — so filtering by Run hid every imported run while the feed cheerfully showed cards titled "Running". There is one alias table now, used by the filter and by every label and icon lookup, so a row cannot read as one type and filter as another. --- lib/compute/manual_session.dart | 13 ++++ lib/state/app_state.dart | 32 +++++--- lib/ui/workouts/workout_filter.dart | 20 +++-- lib/ui/workouts/workout_types.dart | 84 +++++++++++++++++++- lib/ui/workouts/workouts_screen.dart | 101 +++++++++++++++---------- test/workout_calorie_anchors_test.dart | 35 +++++---- test/workout_filter_test.dart | 19 +++++ test/workout_types_label_test.dart | 45 +++++++++++ 8 files changed, 271 insertions(+), 78 deletions(-) diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart index c087d96c..0c4907dd 100644 --- a/lib/compute/manual_session.dart +++ b/lib/compute/manual_session.dart @@ -513,6 +513,19 @@ ReconciledSessionScore reconcileSessionScore({ // same minutes, so the larger is the better estimate and the smaller is just // a less complete view. T? better(T? live, T? sub) { + // `sub ?? live`, NOT `sub`. A null from a complete substrate is "I have + // nothing to say", not "the answer is nothing": a max HR of null means no + // worn samples survived, while the live tally actually watched the session + // happen, and a null strain means the profile no longer carries the anchor + // the score needs — neither is grounds for destroying a real measurement + // taken when it did. + // + // The cost of that is real and accepted: a calorie figure fabricated by an + // older build (30 y / 70 kg / male, before the live tick learned to + // abstain) is never cleared by a re-score. Making the null authoritative + // would heal those, and would also wipe legitimately scored sessions + // whenever the substrate happens not to be able to score them, which is + // the worse trade. if (substrateIsComplete) return sub ?? live; if (live == null) return sub; if (sub == null) return live; diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 06c0a14a..75f17d0f 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -4439,7 +4439,7 @@ class AppState extends ChangeNotifier { 4.184; } // Add per-second slice (kcal/min / 60). Clamp to 0 in case of low HR. - w.calories += (kcalMin.clamp(0.0, 30.0) / 60.0); + w.accrueCalories(kcalMin.clamp(0.0, 30.0) / 60.0); // Strain is NOT accrued here. `accrueHr` (called above) recomputes it // from the session's per-minute HR through the one shared Banister -> @@ -4472,16 +4472,30 @@ class LiveWorkoutState { final String type; // exercise type label Duration elapsed = Duration.zero; - /// Accrued kcal. Only ever added to when [Profile.hasCalorieAnchors] holds, - /// so a zero here means "never scored", not "burned nothing" — read - /// [caloriesOrNull] rather than this field anywhere a user can see it. + /// Accrued kcal. Zero here is ambiguous on its own — read [caloriesOrNull] + /// anywhere a user can see it. double calories = 0.0; - /// Accrued kcal, or null when the profile lacks the anchors Keytel needs. - /// Absent beats fabricated: an unanchored session shows "—" instead of a - /// number computed for a stand-in body. - int? get caloriesOrNull => - profile.hasCalorieAnchors ? calories.round() : null; + /// Whether the calorie estimate has run even once this session. + /// + /// Separate from [Profile.hasCalorieAnchors] because "can we score this" and + /// "did we score this" are different questions and both have a zero-shaped + /// answer. A complete profile whose band never delivered a heart rate — the + /// link dropped, the strap was off — accrues nothing, and reporting that as + /// 0 kcal claims a measurement that was never taken. Strain already reports + /// that case as absent; this makes calories agree. + bool _caloriesScored = false; + + /// Accrued kcal, or null when this session was never costed at all — either + /// the profile lacks the anchors Keytel needs, or no heart rate ever + /// arrived. Absent beats fabricated, and absent also beats a confident zero. + int? get caloriesOrNull => _caloriesScored ? calories.round() : null; + + /// Record a per-second slice. The only writer of [calories]. + void accrueCalories(double kcal) { + _caloriesScored = true; + calories += kcal; + } /// Headline 0–21 strain, or null when the profile lacks an anchor the /// Banister formula needs. Recomputed on every HR sample by [accrueHr] — it diff --git a/lib/ui/workouts/workout_filter.dart b/lib/ui/workouts/workout_filter.dart index 01173156..a3c4fa6e 100644 --- a/lib/ui/workouts/workout_filter.dart +++ b/lib/ui/workouts/workout_filter.dart @@ -29,17 +29,15 @@ enum WorkoutSort { this == WorkoutSort.newest || this == WorkoutSort.oldest; } -/// Canonical type key for filtering. Anything outside the [kWorkoutTypes] -/// vocabulary — an auto-detector string, a type from an older release, an -/// imported session — collapses to `other`, so the "Other" chip actually -/// catches those instead of leaving them unreachable by any filter. -String canonicalWorkoutType(String? raw) { - final t = (raw ?? '').toLowerCase(); - for (final e in kWorkoutTypes) { - if (e.$1 == t) return t; - } - return 'other'; -} +/// Canonical type key for filtering. Alternate spellings resolve to their real +/// family via [resolveWorkoutTypeKey] — an imported session stored as +/// `running` must be found by the Run chip, not hidden under Other while the +/// feed shows it titled "Run". +/// +/// Anything genuinely outside the vocabulary — an auto-detector string, a type +/// from an older release — still collapses to `other`, so the Other chip +/// catches those rather than leaving them unreachable by any filter. +String canonicalWorkoutType(String? raw) => resolveWorkoutTypeKey(raw) ?? 'other'; /// A filter over the workout feed. All fields are floors, not ranges — the /// range picker above the list already bounds the window in time. diff --git a/lib/ui/workouts/workout_types.dart b/lib/ui/workouts/workout_types.dart index 60006530..a98c3e91 100644 --- a/lib/ui/workouts/workout_types.dart +++ b/lib/ui/workouts/workout_types.dart @@ -42,14 +42,90 @@ const kWorkoutTypes = <(String, String, OsIcon, OsIcon?)>[ ('other', 'Other', OsIcon.workoutOther, OsIcon.workoutOther), ]; +/// Built-ins by key, so a lookup does not walk the list. +final Map kWorkoutTypesByKey = { + for (final e in kWorkoutTypes) e.$1: e, +}; + +/// Alternate spellings that mean an existing [kWorkoutTypes] key. +/// +/// These are not hypothetical. The WHOOP importer stores the raw slug of the +/// export's "Activity name" column (`lib/import/whoop_import.dart`), so an +/// imported library is full of `running`, `weightlifting`, `functional +/// fitness` — rows the app would otherwise label by blind capitalisation and +/// treat as unrecognised. Filtering by Run would hide every imported run while +/// the feed happily showed cards titled "Running". +/// +/// Keys here must be lowercase, and must never shadow a key in +/// [kWorkoutTypes]. +const kWorkoutTypeAliases = { + 'running': 'run', + 'jog': 'run', + 'jogging': 'run', + 'treadmill': 'run', + 'cycling': 'cycle', + 'bike': 'cycle', + 'biking': 'cycle', + 'ride': 'cycle', + 'spinning': 'cycle', + 'walking': 'walk', + 'rucking': 'walk', + 'swimming': 'swim', + 'weights': 'strength', + 'weightlifting': 'strength', + 'lifting': 'strength', + 'strength training': 'strength', + 'functional fitness': 'strength', + 'crossfit': 'hiit', + 'interval': 'hiit', + 'hiking': 'hike', + 'climbing': 'climb', + 'bouldering': 'climb', + 'skiing': 'ski', + 'snowboarding': 'snowboard', + 'rowing machine': 'rowing', + 'row': 'rowing', + 'erg': 'rowing', + 'stair climber': 'stairs', + 'stairmaster': 'stairs', + 'stair': 'stairs', + 'racquet': 'tennis', + 'racquetball': 'tennis', + 'squash': 'tennis', + 'padel': 'tennis', + 'badminton': 'tennis', + 'table tennis': 'tennis', + 'pickleball': 'tennis', + 'football': 'soccer', + 'boxing training': 'boxing', + 'kickboxing': 'boxing', + 'martial arts': 'boxing', + 'meditation': 'yoga', + 'stretching': 'pilates', + 'mobility': 'pilates', +}; + +/// The [kWorkoutTypes] key a stored type string means, or null when it belongs +/// to no known family. One seam for every lookup here and for the filter — a +/// row must not render as "Running" in the feed and as "Other" to the filter. +String? resolveWorkoutTypeKey(String? type) { + final raw = (type ?? '').toLowerCase().trim(); + if (raw.isEmpty) return null; + for (final e in kWorkoutTypes) { + if (e.$1 == raw) return e.$1; + } + return kWorkoutTypeAliases[raw]; +} + /// Glyph fallback for a workout type — always returns something renderable, /// even for autodetected/unrecognized types. OsIcon workoutTypeIcon(String? type) { final raw = (type ?? '').toLowerCase(); if (raw.contains('autodetected')) return OsIcon.strength; if (raw.contains('workout')) return OsIcon.strength; + final key = resolveWorkoutTypeKey(type); for (final e in kWorkoutTypes) { - if (e.$1 == raw) return e.$3; + if (e.$1 == key) return e.$3; } return OsIcon.strength; } @@ -57,7 +133,7 @@ OsIcon workoutTypeIcon(String? type) { /// Illustrated art for a workout type — null only for autodetected/unknown /// types, which stay on the glyph fallback ([workoutTypeIcon]). OsIcon? workoutTypeOsIcon(String? type) { - final key = (type ?? '').toLowerCase(); + final key = resolveWorkoutTypeKey(type); for (final e in kWorkoutTypes) { if (e.$1 == key) return e.$4; } @@ -69,11 +145,11 @@ String workoutTypeLabel(String? type) { // The stored `type` column is free-form text, so rows written by older // releases, by an import, or by hand can arrive in any case. Every lookup in // this file normalizes first for that reason. - final key = type.toLowerCase(); - if (key.contains('autodetected')) return 'Workout'; + if (type.toLowerCase().contains('autodetected')) return 'Workout'; // The table's own label wins over capitalising the key, so the list row and // the picker tile always read the same. Capitalising blind is how 'hiit' // rendered as "Hiit" everywhere except the picker. + final key = resolveWorkoutTypeKey(type); for (final e in kWorkoutTypes) { if (e.$1 == key) return e.$2; } diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 9f9d539a..07722cee 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -215,9 +215,20 @@ class _WorkoutsScreenState extends State { final list = _filter.apply(inRange); // A narrowed list gets a summary recomputed over exactly what's visible — // showing whole-range totals above a filtered feed is just a wrong number. + final repoSummary = (_data?['summary'] as Map?)?.cast(); + // A narrowed list gets a summary recomputed over exactly what's visible — + // showing whole-range totals above a filtered feed is just a wrong number. + // `classifier` rides along untouched: it describes how well auto-typing did + // over the whole range, which a filter does not change, and recomputing + // only the totals would otherwise make the accuracy note vanish whenever + // any filter is on. final summary = _filter.isNarrowing - ? summarizeWorkouts(list) - : (_data?['summary'] as Map?)?.cast(); + ? { + ...summarizeWorkouts(list), + if (repoSummary?['classifier'] != null) + 'classifier': repoSummary!['classifier'], + } + : repoSummary; return AppScaffold( title: 'Workouts', @@ -569,45 +580,59 @@ class _FilterBar extends StatelessWidget { : 'Sorted: ${filter.sort.label.toLowerCase()}'); return Row( children: [ - Semantics( - button: true, - label: active ? 'Filter workouts, active: $label' : 'Filter workouts', - child: Pressable( - pressedScale: 0.96, - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Sp.x3, - vertical: Sp.x2, - ), - decoration: BoxDecoration( - color: active - ? AppColors.tonalFill(AppColors.accent) - : Elevation.surfaceAt(1), - borderRadius: BorderRadius.circular(R.pill), - border: Border.all( - color: active - ? AppColors.accent.withValues(alpha: 0.55) - : AppColors.divider, + // Flexible + ellipsis, and no Spacer. The description grows with every + // chip and floor the user picks ("Basketball, Snowboard · 90m+ · + // strain 17+" is a real reachable string) while the header row is only + // ~350 pt wide on a 390 pt phone. Unconstrained this is a RenderFlex + // overflow at default text scale, and release builds clip it silently. + Flexible( + child: Semantics( + button: true, + label: active + ? 'Filter workouts, active: $label' + : 'Filter workouts', + child: Pressable( + pressedScale: 0.96, + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: Sp.x3, + vertical: Sp.x2, ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.tune_rounded, size: 16, color: tint), - const SizedBox(width: Sp.x1), - Text( - label, - style: AppText.label - .copyWith(color: tint, fontWeight: FontWeight.w700), + decoration: BoxDecoration( + color: active + ? AppColors.tonalFill(AppColors.accent) + : Elevation.surfaceAt(1), + borderRadius: BorderRadius.circular(R.pill), + border: Border.all( + color: active + ? AppColors.accent.withValues(alpha: 0.55) + : AppColors.divider, ), - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.tune_rounded, size: 16, color: tint), + const SizedBox(width: Sp.x1), + Flexible( + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppText.label.copyWith( + color: tint, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), ), ), ), ), - if (active) ...[ - const SizedBox(width: Sp.x2), + if (active) Semantics( button: true, label: 'Clear workout filter', @@ -624,8 +649,6 @@ class _FilterBar extends StatelessWidget { ), ), ), - ], - const Spacer(), ], ); } @@ -1665,7 +1688,9 @@ class WorkoutDetailContent extends StatelessWidget { children: [ _toneStat(tone, noData ? '—' : '${d['avg_hr'] ?? '—'}', 'avg bpm'), _toneStat(tone, noData ? '—' : '${d['max_hr'] ?? '—'}', 'max bpm'), - _toneStat(tone, '${d['calories'] ?? 0}', 'kcal'), + // A dash, not a 0 — this is the most-viewed place a session + // that was never costed could claim to have burned nothing. + _toneStat(tone, '${d['calories'] ?? '—'}', 'kcal'), if (distanceLabel != null) _toneStat(tone, distanceLabel!, 'distance') // Steps are recorded only for manual workouts ridden by the live diff --git a/test/workout_calorie_anchors_test.dart b/test/workout_calorie_anchors_test.dart index 6683389c..2d84e0d0 100644 --- a/test/workout_calorie_anchors_test.dart +++ b/test/workout_calorie_anchors_test.dart @@ -52,26 +52,29 @@ void main() { }); group('LiveWorkoutState.caloriesOrNull', () { - test('is null without anchors, however much was accrued', () { - final s = _session(const Profile()); - s.calories = 431.7; - expect( - s.caloriesOrNull, - isNull, - reason: 'a stale accumulator must not resurrect a fabricated total', - ); - }); - - test('rounds the accrued figure when anchored', () { + test('rounds the accrued figure once something was actually costed', () { final s = _session(_anchored); - s.calories = 431.7; + s.accrueCalories(431.7); expect(s.caloriesOrNull, 432); }); - test('an anchored session that burned nothing reports zero, not absent', () { - // Absent and zero are different claims and both are reachable — this is - // the one that means "we measured, and it was nothing". - expect(_session(_anchored).caloriesOrNull, 0); + test('is null until the estimate has run even once', () { + // "Can we score this" and "did we score this" are different questions + // and both have a zero-shaped answer. A complete profile whose band + // never delivered a heart rate — link dropped, strap off — accrues + // nothing, and reporting that as 0 kcal claims a measurement nobody + // took. Strain already reports that case as absent. + expect(_session(_anchored).caloriesOrNull, isNull); + expect(_session(const Profile()).caloriesOrNull, isNull); + }); + + test('a costed session that came to nothing reports zero, not absent', () { + // Reachable: the Keytel term goes negative at a low enough heart rate + // and is clamped at zero. That IS a measurement, and it is not the same + // claim as never having measured. + final s = _session(_anchored); + s.accrueCalories(0); + expect(s.caloriesOrNull, 0); }); }); diff --git a/test/workout_filter_test.dart b/test/workout_filter_test.dart index ee218aea..a97b4ad0 100644 --- a/test/workout_filter_test.dart +++ b/test/workout_filter_test.dart @@ -36,6 +36,12 @@ void main() { expect(canonicalWorkoutType(t), 'other'); } }); + + test('resolves an alias rather than collapsing it', () { + expect(canonicalWorkoutType('running'), 'run'); + expect(canonicalWorkoutType('weightlifting'), 'strength'); + expect(canonicalWorkoutType('HIKING'), 'hike'); + }); }); group('filtering', () { @@ -55,6 +61,19 @@ void main() { expect(out.map((e) => e['type']), ['run']); }); + test('an alias is found by its real family chip', () { + // Imported sessions carry the export's own spelling. Filtering by Run + // must find the row the feed titles "Run", not leave it under Other. + final imported = w(startTs: 600, type: 'running'); + final out = const WorkoutFilter(types: {'run'}).apply([imported]); + expect(out, hasLength(1)); + expect( + const WorkoutFilter(types: {'other'}).apply([imported]), + isEmpty, + reason: 'it is a run, so Other must not claim it as well', + ); + }); + test('the other chip catches unrecognised types', () { final out = const WorkoutFilter(types: {'other'}).apply(list); expect(out.single['start_ts'], 100); diff --git a/test/workout_types_label_test.dart b/test/workout_types_label_test.dart index d8d798a9..aabf8078 100644 --- a/test/workout_types_label_test.dart +++ b/test/workout_types_label_test.dart @@ -22,6 +22,16 @@ void main() { expect(workoutTypeLabel('STRENGTH'), 'Strength'); }); + test('resolves the spellings an import can carry', () { + // The WHOOP importer stores the raw slug of the export's activity name, + // so a real library is full of these. + expect(workoutTypeLabel('running'), 'Run'); + expect(workoutTypeLabel('weightlifting'), 'Strength'); + expect(workoutTypeLabel('functional fitness'), 'Strength'); + expect(workoutTypeLabel('hiking'), 'Hike'); + expect(workoutTypeLabel('squash'), 'Racquet'); + }); + test('falls back to capitalising an unknown type', () { expect(workoutTypeLabel('kitesurfing'), 'Kitesurfing'); }); @@ -52,4 +62,39 @@ void main() { expect(e.$1, e.$1.toLowerCase(), reason: '${e.$1} breaks the lookups'); } }); + + group('aliases', () { + test('resolve to a real key, and real keys resolve to themselves', () { + for (final e in kWorkoutTypes) { + expect(resolveWorkoutTypeKey(e.$1), e.$1); + } + for (final entry in kWorkoutTypeAliases.entries) { + expect( + kWorkoutTypesByKey.containsKey(entry.value), + isTrue, + reason: '"${entry.key}" points at "${entry.value}", which is not a ' + 'real type — the alias is unreachable', + ); + } + }); + + test('never shadow a real key', () { + // An alias that collides with a key would be dead code at best and a + // silent re-routing of a real type at worst. + for (final key in kWorkoutTypeAliases.keys) { + expect( + kWorkoutTypesByKey.containsKey(key), + isFalse, + reason: '"$key" is both a type and an alias', + ); + expect(key, key.toLowerCase()); + } + }); + + test('an unknown string resolves to nothing', () { + expect(resolveWorkoutTypeKey('kitesurfing'), isNull); + expect(resolveWorkoutTypeKey(''), isNull); + expect(resolveWorkoutTypeKey(null), isNull); + }); + }); } From a8eb2596e7d469a8e03b679b16e06f5f1a7f2bb4 Mon Sep 17 00:00:00 2001 From: abdulsaheel Date: Sun, 9 Aug 2026 14:45:57 +0530 Subject: [PATCH 7/7] a whole range of uncosted sessions is not zero calories --- lib/ui/workouts/workout_filter.dart | 15 +++++++++------ lib/ui/workouts/workout_types.dart | 23 ++++++----------------- lib/ui/workouts/workouts_screen.dart | 6 ++++-- test/workout_filter_test.dart | 16 ++++++++++++++++ 4 files changed, 35 insertions(+), 25 deletions(-) diff --git a/lib/ui/workouts/workout_filter.dart b/lib/ui/workouts/workout_filter.dart index a3c4fa6e..03d439ab 100644 --- a/lib/ui/workouts/workout_filter.dart +++ b/lib/ui/workouts/workout_filter.dart @@ -157,18 +157,21 @@ class WorkoutFilter { /// Live sessions are excluded exactly as the repo excludes them — they have no /// final numbers to add up. Map summarizeWorkouts(List> workouts) { - var count = 0, totalMin = 0, totalCal = 0; + var count = 0, totalMin = 0; + // Null until something actually contributes. A range where every session + // was uncosted must report nothing, not a confident 0 kcal — the same + // fabrication this whole change removes, one level up. + int? totalCal; final zoneSum = []; for (final w in workouts) { if (w['status'] == 'live') continue; count++; totalMin += ((w['duration_min'] as num?) ?? 0).toInt(); - // An absent calorie figure is skipped, not defaulted. Arithmetically that - // is the same as adding zero, but writing it as `?? 0` invites someone to - // later swap the zero for a "reasonable" estimate and turn the total into - // a fabrication — the null is the whole point. + // An absent calorie figure is skipped, not defaulted — and if EVERY + // session is absent the total stays null rather than collapsing to a + // confident zero. final cal = (w['calories'] as num?)?.toInt(); - if (cal != null) totalCal += cal; + if (cal != null) totalCal = (totalCal ?? 0) + cal; final zm = (w['zone_min'] as List?) ?? const []; for (var i = 0; i < zm.length; i++) { final v = (zm[i] as num?) ?? 0; diff --git a/lib/ui/workouts/workout_types.dart b/lib/ui/workouts/workout_types.dart index a98c3e91..3eb78e35 100644 --- a/lib/ui/workouts/workout_types.dart +++ b/lib/ui/workouts/workout_types.dart @@ -111,9 +111,7 @@ const kWorkoutTypeAliases = { String? resolveWorkoutTypeKey(String? type) { final raw = (type ?? '').toLowerCase().trim(); if (raw.isEmpty) return null; - for (final e in kWorkoutTypes) { - if (e.$1 == raw) return e.$1; - } + if (kWorkoutTypesByKey.containsKey(raw)) return raw; return kWorkoutTypeAliases[raw]; } @@ -123,21 +121,14 @@ OsIcon workoutTypeIcon(String? type) { final raw = (type ?? '').toLowerCase(); if (raw.contains('autodetected')) return OsIcon.strength; if (raw.contains('workout')) return OsIcon.strength; - final key = resolveWorkoutTypeKey(type); - for (final e in kWorkoutTypes) { - if (e.$1 == key) return e.$3; - } - return OsIcon.strength; + final e = kWorkoutTypesByKey[resolveWorkoutTypeKey(type)]; + return e?.$3 ?? OsIcon.strength; } /// Illustrated art for a workout type — null only for autodetected/unknown /// types, which stay on the glyph fallback ([workoutTypeIcon]). OsIcon? workoutTypeOsIcon(String? type) { - final key = resolveWorkoutTypeKey(type); - for (final e in kWorkoutTypes) { - if (e.$1 == key) return e.$4; - } - return null; + return kWorkoutTypesByKey[resolveWorkoutTypeKey(type)]?.$4; } String workoutTypeLabel(String? type) { @@ -149,10 +140,8 @@ String workoutTypeLabel(String? type) { // The table's own label wins over capitalising the key, so the list row and // the picker tile always read the same. Capitalising blind is how 'hiit' // rendered as "Hiit" everywhere except the picker. - final key = resolveWorkoutTypeKey(type); - for (final e in kWorkoutTypes) { - if (e.$1 == key) return e.$2; - } + final e = kWorkoutTypesByKey[resolveWorkoutTypeKey(type)]; + if (e != null) return e.$2; return type[0].toUpperCase() + type.substring(1); } diff --git a/lib/ui/workouts/workouts_screen.dart b/lib/ui/workouts/workouts_screen.dart index 07722cee..f0711619 100644 --- a/lib/ui/workouts/workouts_screen.dart +++ b/lib/ui/workouts/workouts_screen.dart @@ -893,7 +893,9 @@ class TrainingSummaryCard extends StatelessWidget { Widget build(BuildContext context) { final count = (summary['count'] as num?)?.toInt() ?? 0; final totalMin = summary['total_min'] as num?; - final kcal = (summary['total_calories'] as num?)?.toInt() ?? 0; + // Nullable: a range in which nothing could be costed reports no figure + // rather than 0 kcal. + final kcal = (summary['total_calories'] as num?)?.toInt(); final zoneMin = ((summary['zone_min'] as List?) ?? const []) .map((e) => (e as num).toDouble()) .toList(); @@ -952,7 +954,7 @@ class TrainingSummaryCard extends StatelessWidget { Row( children: [ _miniStat(context, '$count', 'workouts'), - _miniStat(context, '$kcal', 'kcal'), + _miniStat(context, kcal?.toString() ?? '—', 'kcal'), _miniStat(context, avgBpm == null ? '—' : '$avgBpm', 'avg bpm'), _miniStat( context, diff --git a/test/workout_filter_test.dart b/test/workout_filter_test.dart index a97b4ad0..456fe2af 100644 --- a/test/workout_filter_test.dart +++ b/test/workout_filter_test.dart @@ -257,6 +257,22 @@ void main() { expect(out['count'], 2, reason: 'uncosted is still a workout'); expect(out['total_min'], 60, reason: 'its minutes are real'); }); + + test('a range where nothing could be costed reports no total at all', () { + // Not 0. A whole timeframe of uncosted sessions showing "0 kcal" is the + // same fabrication as one session showing it. + final out = summarizeWorkouts([ + {'duration_min': 30, 'calories': null, 'status': 'done'}, + {'duration_min': 45, 'calories': null, 'status': 'done'}, + ]); + expect(out['total_calories'], isNull); + expect(out['count'], 2); + expect(out['total_min'], 75); + }); + + test('an empty list has no total either', () { + expect(summarizeWorkouts(const [])['total_calories'], isNull); + }); }); group('description', () {