diff --git a/lib/compute/manual_session.dart b/lib/compute/manual_session.dart index a1f7182..0c4907d 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( @@ -509,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/compute/profile.dart b/lib/compute/profile.dart index d33ee99..14e2df6 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 4b22945..492f904 100644 --- a/lib/health/health_export.dart +++ b/lib/health/health_export.dart @@ -1118,6 +1118,56 @@ 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': + // `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: return HealthWorkoutActivityType.OTHER; } diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index 2866d21..75f17d0 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; @@ -4222,7 +4229,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 +4247,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 +4270,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 +4410,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) { @@ -4421,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 -> @@ -4435,9 +4453,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 +4471,32 @@ 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. Zero here is ambiguous on its own — read [caloriesOrNull] + /// anywhere a user can see it. double calories = 0.0; + /// 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 /// 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 6765c3d..6412ee9 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 0bf37bc..b82732f 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 5ec13df..8d94940 100644 --- a/lib/ui/kit/os_icons.dart +++ b/lib/ui/kit/os_icons.dart @@ -100,6 +100,25 @@ enum OsIcon { walk, swim, hiit, + // Sport glyphs for the workout-type vocabulary beyond the original nine. + // 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, + 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 +238,28 @@ 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, + // 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, + // `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 +324,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 0000000..03d439a --- /dev/null +++ b/lib/ui/workouts/workout_filter.dart @@ -0,0 +1,191 @@ +// 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. 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. +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) { + // 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; + } + // 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; + } + + 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?); + 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)); + }); + // 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 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; + } +} + +/// 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; + // 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 — 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 = (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; + 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 0000000..bad36c5 --- /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 28b2e74..3eb78e3 100644 --- a/lib/ui/workouts/workout_types.dart +++ b/lib/ui/workouts/workout_types.dart @@ -27,33 +27,121 @@ 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), ]; +/// 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; + if (kWorkoutTypesByKey.containsKey(raw)) return raw; + 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; - for (final e in kWorkoutTypes) { - if (e.$1 == type) 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) { - for (final e in kWorkoutTypes) { - if (e.$1 == type) return e.$4; - } - return null; + return kWorkoutTypesByKey[resolveWorkoutTypeKey(type)]?.$4; } String workoutTypeLabel(String? type) { if (type == null || type.isEmpty) 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. 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 e = kWorkoutTypesByKey[resolveWorkoutTypeKey(type)]; + if (e != null) return e.$2; return type[0].toUpperCase() + type.substring(1); } @@ -94,20 +182,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 +205,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 +217,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 5cbce1c..f071161 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,28 @@ 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 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), + if (repoSummary?['classifier'] != null) + 'classifier': repoSummary!['classifier'], + } + : repoSummary; return AppScaffold( title: 'Workouts', @@ -225,15 +240,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 +300,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 +320,7 @@ class _WorkoutsScreenState extends State { onAction: () => startWorkoutFlow(context).then((_) => _load()), ) else - ..._feed(list.cast>()), + ..._feed(list), ], ], ), @@ -306,7 +342,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 +460,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 +555,105 @@ 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: [ + // 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, + ), + 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) + 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, + ), + ), + ), + ), + ], + ); + } +} + /// Solid ember Start pill — top-right of the Workouts header. Restrained: /// brand accent + standard elevation, no gradient/glow. class _StartButton extends StatelessWidget { @@ -750,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(); @@ -809,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, @@ -1216,7 +1361,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(), ); @@ -1545,7 +1690,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 new file mode 100644 index 0000000..2d84e0d --- /dev/null +++ b/test/workout_calorie_anchors_test.dart @@ -0,0 +1,111 @@ +// 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('rounds the accrued figure once something was actually costed', () { + final s = _session(_anchored); + s.accrueCalories(431.7); + expect(s.caloriesOrNull, 432); + }); + + 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); + }); + }); + + 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 0000000..456fe2a --- /dev/null +++ b/test/workout_filter_test.dart @@ -0,0 +1,298 @@ +// 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'); + } + }); + + test('resolves an alias rather than collapsing it', () { + expect(canonicalWorkoutType('running'), 'run'); + expect(canonicalWorkoutType('weightlifting'), 'strength'); + expect(canonicalWorkoutType('HIKING'), 'hike'); + }); + }); + + 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('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); + }); + + 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('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); + 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. + 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 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) + .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 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', + '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'); + }); + + 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', () { + 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 0a2cf66..2831d86 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'; @@ -30,6 +39,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 +63,17 @@ 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.GOLF, HealthWorkoutActivityType.OTHER, }; @@ -56,6 +88,17 @@ const _extraTypeStrings = [ 'swimming', 'weights', 'lifting', + 'row', + 'hiking', + 'climbing', + 'skiing', + 'snowboarding', + 'stair', + 'racquet', + 'squash', + 'padel', + 'badminton', + 'football', 'autodetected', 'autodetected_workout', 'workout', @@ -90,6 +133,36 @@ 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'}; + // 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), + 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), @@ -109,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), @@ -121,7 +213,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/workout_types_label_test.dart b/test/workout_types_label_test.dart new file mode 100644 index 0000000..aabf807 --- /dev/null +++ b/test/workout_types_label_test.dart @@ -0,0 +1,100 @@ +// 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('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'); + }); + + 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'); + } + }); + + 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); + }); + }); +} diff --git a/test/workouts_header_actions_test.dart b/test/workouts_header_actions_test.dart index 233732b..194c1ed 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'); }, ); }