Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion lib/health/health_export.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1199,6 +1199,7 @@ class HealthExporter {
start: DateTime.fromMillisecondsSinceEpoch(st * 1000),
end: DateTime.fromMillisecondsSinceEpoch(en * 1000),
totalEnergyBurned: (r['calories'] as num?)?.round(),
title: healthWorkoutTitleForType(r['type']?.toString()),
);
} catch (e) {
debugPrint('[health] write workout @$st: $e');
Expand Down Expand Up @@ -1289,8 +1290,48 @@ class HealthExporter {
}
}

/// The app's workout-type key -> platform health activity type.
/// The Health Connect record title for a stored `sessions.type`.
///
/// Health Connect titles the record with the ACTIVITY TYPE NAME when the write
/// carries no title of its own — `HealthPlugin.kt` does
/// `call.argument("title") ?: type` — so every session landing on `OTHER`
/// appeared in the user's health app named "OTHER". That is most of the
/// catalogue, and NOT because the store lacks the types: Health Connect
/// accepts `TABLE_TENNIS`, `CRICKET`, `VOLLEYBALL` and more that
/// [healthActivityForType] below simply does not map yet. `OTHER` is this
/// app's fallback, not a platform limit, and widening the map is its own
/// audit — the title is what stops the gap from being user-visible meanwhile.
///
/// iOS ignores the field: `HKWorkout` has no title, and the activity type IS
/// the label there. One unconditional argument rather than a platform branch,
/// because a value the other store discards is not a platform difference.
///
/// FUTURE WRITES ONLY. `exportAll` skips dates at or before
/// `health_export_through`, so sessions already finalized on Android keep the
/// label they were written with. Relabelling them would need a bounded replay
/// of the finalized prefix; a wrong name on old rows is not worth that.
///
/// ponytail: this de-slugs the type key instead of reading the catalogue's
/// display name, so the three acronym-cased entries come back title-cased —
/// "Crossfit", "Hiit", "Diy". The upgrade is one import, and it is not worth
/// taking: `lib/health` reaching into `lib/ui2` to spell three words is the
/// wrong dependency, and every one of them already beats "OTHER".
@visibleForTesting
String? healthWorkoutTitleForType(String? type) {
// Underscores BEFORE the trim, or a type of `_` survives as a one-space
// title — non-null, so it suppresses the platform default and writes a
// blank name where "OTHER" at least said something.
final t = (type ?? '')
.replaceAll('_', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
if (t.isEmpty) return null;
return t
.split(' ')
.map((w) => w[0].toUpperCase() + w.substring(1))
.join(' ');
}

/// Parameterised by [ios] rather than reading `Platform` directly so a unit
/// test can exercise BOTH platform branches on a host VM (where `Platform.isIOS`
/// and `Platform.isAndroid` are both false) — see
Expand Down Expand Up @@ -1396,6 +1437,16 @@ HealthWorkoutActivityType healthActivityForType(
: HealthWorkoutActivityType.OTHER;
case 'golf':
return HealthWorkoutActivityType.GOLF;
case 'bowling':
// Android has no bowling: Health Connect's exercise types stop at the
// sports it knows, and `BOWLING` is absent from the plugin's Android
// set, so the call throws `HealthException` before the channel and the
// session never lands. iOS maps it to a real `HKWorkoutActivityType
// .bowling`. Same #184 shape as strength and swim, caught before the
// bug rather than after it.
return ios
? HealthWorkoutActivityType.BOWLING
: HealthWorkoutActivityType.OTHER;
default:
return HealthWorkoutActivityType.OTHER;
}
Expand Down
8 changes: 8 additions & 0 deletions lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -9123,6 +9123,14 @@
"@activitySetupCaloriesNeedWeight": {
"description": "Shown instead of a calorie estimate when the user has no weight on file"
},
"activitySetupNoMetEstimate": "No estimate up front: no published MET applies to a session that names no activity. Calories come from your heart rate instead — when your age, weight and sex are set, and your resting and maximum rates are measured rather than assumed.",
"@activitySetupNoMetEstimate": {
"description": "Shown instead of a calorie estimate when the activity has no published MET (e.g. General workout)"
},
"activitySummaryCalorieNoMet": "Estimated from your heart rate and your weight. No MET is in this figure: the session named no activity for one to apply to.",
"@activitySummaryCalorieNoMet": {
"description": "Calorie basis line on the summary screen for a session whose activity has no published MET (e.g. General workout)"
},
"activitySetupCalorieEstimate": "About {est} kcal per {minutes} min, from {met} MET and your weight.",
"@activitySetupCalorieEstimate": {
"description": "Calorie estimate line on the activity setup screen, e.g. 'About 250 kcal per 30 min, from 8.0 MET and your weight.'",
Expand Down
41 changes: 34 additions & 7 deletions lib/ui2/activity/catalogue.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// The activity vocabulary — ~70 things a person actually does, in eight
// groups, each carrying a published MET value.
// groups, each carrying a published MET value except the one row that names
// no activity at all.
//
// Why MET and not a made-up "intensity": a calorie figure has to come from
// somewhere, and "somewhere" is Ainsworth et al., Compendium of Physical
Expand Down Expand Up @@ -46,7 +47,17 @@ class Activity {

/// Metabolic equivalent of task — the honest basis for a calorie estimate.
/// Compendium of Physical Activities, Ainsworth et al.
final double met;
///
/// NULL for a row the compendium cannot price, which is exactly one:
/// 'General workout' means the user did not say what they did, and the
/// compendium prices named activities. Every number that could go here
/// would be a stand-in — which is the 'Custom activity' mistake below,
/// whose MET of 4.0 was invented. So [kcal] returns null, the picker and
/// the setup screen show no estimate, and the session still gets a REAL
/// calorie figure afterwards: the post-session estimator in
/// `compute/manual_session.dart` works from heart rate and never reads a
/// MET. The guess is what is missing, not the calories.
final double? met;

/// Whether a route is worth recording. GPS activities get the map.
final bool gps;
Expand All @@ -72,10 +83,12 @@ class Activity {

/// kcal = MET × 3.5 × kg / 200 × minutes.
///
/// Null when body weight is unknown. There is no default body weight: the
/// number would be indistinguishable from a real one on screen.
int? kcal(double? kg, int minutes) =>
kg == null || kg <= 0 ? null : (met * 3.5 * kg / 200 * minutes).round();
/// Null when body weight is unknown, and null when the activity carries no
/// MET. There is no default for either: both numbers would be
/// indistinguishable from a real one on screen.
int? kcal(double? kg, int minutes) => kg == null || kg <= 0 || met == null
? null
: (met! * 3.5 * kg / 200 * minutes).round();

/// The stored `sessions.type` for this activity.
String get typeKey => name.toLowerCase().replaceAll(' ', '_');
Expand Down Expand Up @@ -141,6 +154,11 @@ const activityLibrary = <ActGroup>[
Activity('Baseball', LucideIcons.target, C.red, Track.duration, 5.0),
Activity('Rugby', LucideIcons.volleyball, C.green, Track.duration, 8.3),
Activity('Golf', LucideIcons.flag, C.green, Track.duration, 4.8, gps: true),
// Compendium 15092, "bowling, indoor, bowling alley". This row IS the
// alley — that assumption is the choice being made here, not a fact about
// what users do — because the alternative is two near-identical rows. The
// bare 15090 "bowling" is 3.0, and neither number is the measured one.
Activity('Bowling', LucideIcons.circleDot, C.indigo, Track.duration, 3.8),
Activity('Boxing', LucideIcons.hand, C.red, Track.interval, 12.8),
Activity('Martial arts', LucideIcons.hand, C.red, Track.duration, 10.3),
Activity('Wrestling', LucideIcons.users, C.orange, Track.duration, 6.0),
Expand Down Expand Up @@ -197,6 +215,14 @@ const activityLibrary = <ActGroup>[
Activity('Stairs', LucideIcons.trendingUp, C.orange, Track.duration, 8.0),
]),
ActGroup('Other', LucideIcons.ellipsis, [
// The catch-all: nothing in the catalogue matched, or hunting for the
// match was not worth it. NO MET, and that is the whole design of the row
// — see [Activity.met]. 02060 "health club exercise, general" was the
// near miss, and it prices a gym session, which is not what this row
// means. Tracked by duration because the clock and the heart-rate trace
// are everything the app knows about a workout nobody named.
Activity('General workout', LucideIcons.activity, C.purple,
Track.duration, null),
Activity('Dancing', LucideIcons.music, C.pink, Track.duration, 7.8),
// A normal entry with a real MET and a privacy default, exactly as Apple
// Health carries it. Coyness here would be its own kind of judgement.
Expand All @@ -218,7 +244,8 @@ const activityLibrary = <ActGroup>[
/// What the app has to say about a calorie figure, in one place because it was
/// said in four and drifted: one site kept quoting a "±15%" error bar that no
/// estimator computes, long after the others dropped it.
const kCalorieWhy = 'MET value × your weight, refined by heart rate.';
const kCalorieWhy = 'MET value × your weight, refined by heart rate — or '
'heart rate alone, for an activity that carries no MET.';

/// TS-03 — what every zone chart in the app has to admit about its own edges.
///
Expand Down
15 changes: 12 additions & 3 deletions lib/ui2/activity/picker.dart
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,16 @@ class ActivityRow extends StatelessWidget {
final p = P.of(c);
final l = AppLocalizations.of(c);
final kcal = a.kcal(weightKg, 30);
final met = a.met;
final metStr = met?.toStringAsFixed(1);
// The catch-all row has neither: no weight means no kcal, and no named
// activity means no MET to fall back to. It gets no trailing number
// rather than a placeholder standing in for one.
final trailing = kcal != null
? '$kcal kcal / 30 min'
: metStr == null
? null
: '$metStr MET';
return Pressable(
onTap: onTap,
child: Padding(
Expand Down Expand Up @@ -289,12 +299,11 @@ class ActivityRow extends StatelessWidget {
// estimate drops off the row rather than overflowing it — the name
// and the tap are what the row is for, and the same number is on
// the setup screen the tap opens.
if (!bigText(c)) ...[
if (!bigText(c) && trailing != null) ...[
const SizedBox(width: S.x2),
Text(
kcal == null
? (l?.activityPickerMetValue(a.met.toStringAsFixed(1)) ??
'${a.met.toStringAsFixed(1)} MET')
? (l?.activityPickerMetValue(metStr!) ?? '$metStr MET')
: (l?.activityPickerKcalPer30(kcal) ??
'$kcal kcal / 30 min'),
style: F.over.copyWith(color: p.ink3)),
Expand Down
27 changes: 19 additions & 8 deletions lib/ui2/activity/setup.dart
Original file line number Diff line number Diff line change
Expand Up @@ -174,14 +174,25 @@ class _ActivitySetupState extends State<ActivitySetup> {
child: Row(children: [
Expanded(
child: Text(
est == null
? (l?.activitySetupCaloriesNeedWeight ??
'Calories need your weight.')
: (l?.activitySetupCalorieEstimate(est,
_estimateMin, a.met.toStringAsFixed(1)) ??
'About $est kcal per $_estimateMin min, '
'from ${a.met.toStringAsFixed(1)} MET '
'and your weight.'),
a.met == null
? (l?.activitySetupNoMetEstimate ??
'No estimate up front: no published MET '
'applies to a session that names no '
'activity. Calories come from your '
'heart rate instead — when your age, '
'weight and sex are set, and your '
'resting and maximum rates are '
'measured rather than assumed.')
: est == null
? (l?.activitySetupCaloriesNeedWeight ??
'Calories need your weight.')
: (l?.activitySetupCalorieEstimate(
est,
_estimateMin,
a.met!.toStringAsFixed(1)) ??
'About $est kcal per $_estimateMin min, '
'from ${a.met!.toStringAsFixed(1)} '
'MET and your weight.'),
style: F.cap.copyWith(color: p.ink3, height: 1.5)),
),
]),
Expand Down
11 changes: 10 additions & 1 deletion lib/ui2/activity/summary.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1089,7 +1089,7 @@ class _ActivitySummaryState extends State<ActivitySummary> {
return l?.activitySummaryCaloriesNeedWeight ??
'Calories need your weight.';
}
final met = a.met.toStringAsFixed(1);
final met = a.met?.toStringAsFixed(1);
if (r.calories == null) {
return r.strain == null
? l?.activitySummaryNoCalorieNoStrain ??
Expand All @@ -1102,6 +1102,15 @@ class _ActivitySummaryState extends State<ActivitySummary> {
'one of them is not set. Strain above is the effort that '
'was measured, on its own 0–21 scale.';
}
// No MET is the catch-all activity, whose figure is therefore entirely
// the heart-rate estimate — saying "from MET" over it would name a basis
// this session does not have.
if (met == null) {
return l?.activitySummaryCalorieNoMet ??
'Estimated from your heart rate and your weight. No MET is in '
'this figure: the session named no activity for one to apply '
'to.';
}
return r.avgHr == null
? l?.activitySummaryCalorieNoHr(met) ??
'Estimated from $met MET and your weight. No heart rate reached '
Expand Down
30 changes: 20 additions & 10 deletions lib/ui2/profile/gallery.dart
Original file line number Diff line number Diff line change
Expand Up @@ -1988,7 +1988,9 @@ const _fixtureWeightKg = 72.0;
/// Every number is derived from the activity's MET, track and name, so a
/// 1.3-MET meditation and a 23-MET sprint cannot end up sharing a heart-rate
/// curve — which is what a single shared fixture would have done, and is
/// exactly the class of bug the gallery exists to catch.
/// exactly the class of bug the gallery exists to catch. The one activity
/// with NO MET falls back to a preview-only 5.0 for the shape, and keeps its
/// real null calories — see below.
///
/// This is a PREVIEW, not a measurement, and nothing outside the gallery may
/// read it. What it is faithful about is SHAPE: which fields an archetype
Expand All @@ -1997,7 +1999,12 @@ const _fixtureWeightKg = 72.0;
ActivityResult _placeholder(Activity a) {
final arch = archOf(a);
final mins = 18 + (_n(a.name, 1) * 57).round();
final avg = (58 + a.met * 7).clamp(50, 172).round();
// The catch-all activity has no MET (it names no activity), and a fixture
// still has to draw a heart rate and a zone spread. 5.0 is invented HERE,
// in the preview, where inventing is the whole job — `calories` below stays
// `a.kcal(...)` and so previews the absent figure the real screen shows.
final met = a.met ?? 5.0;
final avg = (58 + met * 7).clamp(50, 172).round();
final peak = (avg + 9 + _n(a.name, 3) * 24).clamp(avg + 4, 198).round();

// One slot per minute, and a dropout in roughly a quarter of them, because
Expand All @@ -2013,7 +2020,7 @@ ActivityResult _placeholder(Activity a) {

// Mass moves up the zones with the MET. Meditation sits in Z1; a sprint
// session spends its minutes at the top.
final hard = (a.met / 23).clamp(0.0, 1.0);
final hard = (met / 23).clamp(0.0, 1.0);
final w = [1.6 - hard, 1.4 - hard * .5, .9 + hard, .4 + hard * 1.4, .1 + hard];
final sum = w.reduce((x, y) => x + y);
final zones = [for (final x in w) (mins * x / sum)];
Expand All @@ -2026,7 +2033,7 @@ ActivityResult _placeholder(Activity a) {
// Speed from the MET, which is the only thing the catalogue knows about how
// fast this activity moves. Good enough for a picture, and wrong enough
// that nobody could mistake it for a recording.
final km = double.parse((a.met * 1.02 * mins / 60).toStringAsFixed(2));
final km = double.parse((met * 1.02 * mins / 60).toStringAsFixed(2));
final paceSec = (mins * 60 / km).round();
final onRoute = arch == Arch.route || arch == Arch.journey;
// Laps are TAPPED, never measured — so the lap times are the invention here
Expand All @@ -2046,7 +2053,7 @@ ActivityResult _placeholder(Activity a) {
maxHr: peak,
calories: a.kcal(_fixtureWeightKg, mins),
strain:
double.parse((a.met * mins / 60 * 1.15).clamp(0, 21).toStringAsFixed(1)),
double.parse((met * mins / 60 * 1.15).clamp(0, 21).toStringAsFixed(1)),
hr: hr,
zoneMinutes: zones,
// Only a GPS activity gets a line. An indoor row or a treadmill leaves
Expand Down Expand Up @@ -2217,8 +2224,10 @@ class _FlowScreen extends StatelessWidget {
Padding(
padding: const EdgeInsets.symmetric(horizontal: S.x4),
child: NavBar(a.name,
sub: '${archLabel(r.arch).toUpperCase()} · '
'${a.met.toStringAsFixed(1)} MET'),
sub: a.met == null
? archLabel(r.arch).toUpperCase()
: '${archLabel(r.arch).toUpperCase()} · '
'${a.met!.toStringAsFixed(1)} MET'),
),
Expanded(
child: ListView(
Expand All @@ -2242,9 +2251,10 @@ class _FlowScreen extends StatelessWidget {
const StatusCard(
'These numbers are invented',
'Derived from this activity\'s MET and name so the '
'screens have something to draw. The SHAPE is real: '
'the fields this archetype fills, and the ones it '
'leaves empty.',
'screens have something to draw — or from a '
'preview-only stand-in, where the activity has no '
'MET. The SHAPE is real: the fields this archetype '
'fills, and the ones it leaves empty.',
icon: LucideIcons.flaskConical,
)
else
Expand Down
Loading
Loading