Skip to content
19 changes: 18 additions & 1 deletion lib/compute/manual_session.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 extends num>(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;
Expand Down
13 changes: 13 additions & 0 deletions lib/compute/profile.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
50 changes: 50 additions & 0 deletions lib/health/health_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
72 changes: 57 additions & 15 deletions lib/state/app_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> _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<String> _routeTypes = {'run', 'cycle', 'walk', 'hike'};

DateTime _lastLaPush = DateTime.fromMillisecondsSinceEpoch(0);

Expand Down Expand Up @@ -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<void> _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;
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ->
Expand All @@ -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,
);
Expand All @@ -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`
Expand Down
32 changes: 22 additions & 10 deletions lib/ui/activity/live_session_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,13 @@ class _LiveSessionScreenState extends State<LiveSessionScreen>
// 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)) {
Expand Down Expand Up @@ -308,7 +313,7 @@ class _LiveSessionScreenState extends State<LiveSessionScreen>
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,
);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -922,7 +931,7 @@ class _WorkoutFinishScreenState extends State<WorkoutFinishScreen>
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<Map>().toList() ??
const <Map>[];
Expand Down Expand Up @@ -1073,7 +1082,7 @@ class _WorkoutFinishScreenState extends State<WorkoutFinishScreen>
/// 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(
Expand All @@ -1088,7 +1097,7 @@ class _WorkoutFinishScreenState extends State<WorkoutFinishScreen>
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'),
],
Expand Down Expand Up @@ -1397,7 +1406,7 @@ class _WorkoutFinishScreenState extends State<WorkoutFinishScreen>
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(),
);
Expand Down Expand Up @@ -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',
),
),
Expand All @@ -2524,7 +2536,7 @@ class _SessionSheet extends StatelessWidget {
Expanded(
child: _SheetStat(
isRoute
? '${workout.calories.round()}'
? (workout.caloriesOrNull?.toString() ?? '—')
: (steps?.toString() ?? '—'),
isRoute ? 'KCAL' : 'STEPS',
),
Expand Down
8 changes: 6 additions & 2 deletions lib/ui/activity/workout_share_card.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}) {
Expand Down Expand Up @@ -583,7 +587,7 @@ WorkoutShareData buildWorkoutShareData({
heroUnit = '';
stats = [
(strainText, 'Strain'),
('$calories', 'Kcal'),
(calories?.toString() ?? '—', 'Kcal'),
(avgHr != null && avgHr > 0 ? '$avgHr' : '—', 'Avg bpm'),
];
}
Expand Down
Loading
Loading