feat(train): weights in pounds too, the person's unit or their region's (ADR 0030, D64)

Every weight is still kept in kilograms. It is shown and entered in the
person's unit, chosen in Your body (Weight in: kg | lb, the profile's
new weightUnit), else the region's: pounds in the US, Liberia and
Myanmar. The entry takes 44 to 880 lb, the same 20 to 400 kg. A weight
left untouched in Your body is saved exactly as it was, not rounded
through pounds. The other figures stay metric.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-04 00:15:55 +01:00
commit 396e010949
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
15 changed files with 451 additions and 32 deletions

File diff suppressed because one or more lines are too long

View file

@ -751,5 +751,11 @@
"loopDetection": "Training selbst erkennen", "loopDetection": "Training selbst erkennen",
"loopDetectionHint": "Das Band bemerkt ein Training, das du nicht aufgezeichnet hast, und es kommt als Training herein.", "loopDetectionHint": "Das Band bemerkt ein Training, das du nicht aufgezeichnet hast, und es kommt als Training herein.",
"loopDetectionMinimum": "Behält Trainings ab", "loopDetectionMinimum": "Behält Trainings ab",
"loopDetectionMinutes": "{minutes} Min." "loopDetectionMinutes": "{minutes} Min.",
"bodyWeightPounds": "{lb} lb",
"weightInvalidPounds": "Ein Gewicht zwischen 44 und 880 lb",
"profileWeightUnitPounds": "lb",
"profileWeightIn": "Gewicht in",
"profileWeightKilograms": "Kilogramm",
"profileWeightPounds": "Pfund"
} }

View file

@ -263,13 +263,21 @@
}, },
"workoutMapUnavailable": "The map can't be loaded now.", "workoutMapUnavailable": "The map can't be loaded now.",
"workoutMapAbout": "About the map", "workoutMapAbout": "About the map",
"@workoutMapAbout": {"description": "Title of the explanation behind the question mark on a workout's page (D63): what the map's source sees."}, "@workoutMapAbout": {
"description": "Title of the explanation behind the question mark on a workout's page (D63): what the map's source sees."
},
"workoutShowMap": "Show the map", "workoutShowMap": "Show the map",
"@workoutShowMap": {"description": "Tooltip of the chevron that lowers a workout's sheet, the page pulled up, so that the map shows again (D63)."}, "@workoutShowMap": {
"description": "Tooltip of the chevron that lowers a workout's sheet, the page pulled up, so that the map shows again (D63)."
},
"mapCredits": "Map credits", "mapCredits": "Map credits",
"@mapCredits": {"description": "Tooltip of the button over a map that shows whose map and data it is."}, "@mapCredits": {
"description": "Tooltip of the button over a map that shows whose map and data it is."
},
"routeHelpTitle": "Planning a route", "routeHelpTitle": "Planning a route",
"@routeHelpTitle": {"description": "Title of the explanation behind the question mark over the route planner's map (D63)."}, "@routeHelpTitle": {
"description": "Title of the explanation behind the question mark over the route planner's map (D63)."
},
"workoutNotFound": "This workout is gone.", "workoutNotFound": "This workout is gone.",
"recordHeartRate": "Heart rate", "recordHeartRate": "Heart rate",
"recordNoStrap": "No strap", "recordNoStrap": "No strap",
@ -2122,5 +2130,21 @@
"type": "int" "type": "int"
} }
} }
} },
"bodyWeightPounds": "{lb} lb",
"@bodyWeightPounds": {
"placeholders": {
"lb": {
"type": "String"
}
}
},
"weightInvalidPounds": "A weight between 44 and 880 lb",
"profileWeightUnitPounds": "lb",
"profileWeightIn": "Weight in",
"@profileWeightIn": {
"description": "Above the choice between kilograms and pounds in Your body."
},
"profileWeightKilograms": "Kilograms",
"profileWeightPounds": "Pounds"
} }

View file

@ -4611,6 +4611,42 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'{minutes} min'** /// **'{minutes} min'**
String loopDetectionMinutes(int minutes); String loopDetectionMinutes(int minutes);
/// No description provided for @bodyWeightPounds.
///
/// In en, this message translates to:
/// **'{lb} lb'**
String bodyWeightPounds(String lb);
/// No description provided for @weightInvalidPounds.
///
/// In en, this message translates to:
/// **'A weight between 44 and 880 lb'**
String get weightInvalidPounds;
/// No description provided for @profileWeightUnitPounds.
///
/// In en, this message translates to:
/// **'lb'**
String get profileWeightUnitPounds;
/// Above the choice between kilograms and pounds in Your body.
///
/// In en, this message translates to:
/// **'Weight in'**
String get profileWeightIn;
/// No description provided for @profileWeightKilograms.
///
/// In en, this message translates to:
/// **'Kilograms'**
String get profileWeightKilograms;
/// No description provided for @profileWeightPounds.
///
/// In en, this message translates to:
/// **'Pounds'**
String get profileWeightPounds;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate

View file

@ -2978,4 +2978,24 @@ class AppLocalizationsDe extends AppLocalizations {
String loopDetectionMinutes(int minutes) { String loopDetectionMinutes(int minutes) {
return '$minutes Min.'; return '$minutes Min.';
} }
@override
String bodyWeightPounds(String lb) {
return '$lb lb';
}
@override
String get weightInvalidPounds => 'Ein Gewicht zwischen 44 und 880 lb';
@override
String get profileWeightUnitPounds => 'lb';
@override
String get profileWeightIn => 'Gewicht in';
@override
String get profileWeightKilograms => 'Kilogramm';
@override
String get profileWeightPounds => 'Pfund';
} }

View file

@ -2965,6 +2965,26 @@ class AppLocalizationsEn extends AppLocalizations {
String loopDetectionMinutes(int minutes) { String loopDetectionMinutes(int minutes) {
return '$minutes min'; return '$minutes min';
} }
@override
String bodyWeightPounds(String lb) {
return '$lb lb';
}
@override
String get weightInvalidPounds => 'A weight between 44 and 880 lb';
@override
String get profileWeightUnitPounds => 'lb';
@override
String get profileWeightIn => 'Weight in';
@override
String get profileWeightKilograms => 'Kilograms';
@override
String get profileWeightPounds => 'Pounds';
} }
/// The translations for English, as used in the United Kingdom (`en_GB`). /// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -26,6 +26,7 @@ ByteData encodeProfile(ProfileContent content) => ByteData.sublistView(
'trainingBackground': ?content.trainingBackground?.name, 'trainingBackground': ?content.trainingBackground?.name,
'typicalDay': ?content.typicalDay?.name, 'typicalDay': ?content.typicalDay?.name,
'sleepGoalMinutes': ?content.sleepGoal?.inMinutes, 'sleepGoalMinutes': ?content.sleepGoal?.inMinutes,
'weightUnit': ?content.weightUnit?.name,
}), }),
), ),
), ),
@ -59,6 +60,7 @@ ProfileContent? decodeProfile(ByteData payload) {
.asNameMap()[json['trainingBackground']], .asNameMap()[json['trainingBackground']],
typicalDay: TypicalDay.values.asNameMap()[json['typicalDay']], typicalDay: TypicalDay.values.asNameMap()[json['typicalDay']],
sleepGoal: sleep == null ? null : Duration(minutes: sleep), sleepGoal: sleep == null ? null : Duration(minutes: sleep),
weightUnit: WeightUnit.values.asNameMap()[json['weightUnit']],
); );
} on Object catch (error) { } on Object catch (error) {
log('A profile not read: ${error.runtimeType}', name: 'Profile'); log('A profile not read: ${error.runtimeType}', name: 'Profile');

View file

@ -24,6 +24,29 @@ enum TypicalDay() {
mostlyMoving, mostlyMoving,
} }
/// What a weight is shown and typed in (decided 4 Nov 2026); kept in
/// kilograms whatever it is.
enum WeightUnit() {
kilograms,
pounds;
/// A pound in kilograms, as defined.
static const poundInKilograms = 0.45359237;
/// Where the person chose none: pounds where the app's region weighs in
/// them (the United States, Liberia, Myanmar), kilograms elsewhere.
static WeightUnit ofRegion(String? countryCode) =>
const {'US', 'LR', 'MM'}.contains(countryCode) ? pounds : kilograms;
/// [kilograms] in this unit.
double fromKilograms(double kilograms) =>
this == pounds ? kilograms / poundInKilograms : kilograms;
/// [value], in this unit, in kilograms.
double toKilograms(double value) =>
this == pounds ? value * poundInKilograms : value;
}
/// The figures of the person's body (D33), each null where they gave none. /// The figures of the person's body (D33), each null where they gave none.
final class const ProfileContent({ final class const ProfileContent({
final Sex? sex, final Sex? sex,
@ -40,7 +63,16 @@ final class const ProfileContent({
/// How long they mean to sleep a night, 5 to 11 hours. /// How long they mean to sleep a night, 5 to 11 hours.
final Duration? sleepGoal, final Duration? sleepGoal,
/// What their weight is shown in; their region's ([WeightUnit.ofRegion])
/// where they chose none.
final WeightUnit? weightUnit,
}) with Equatable { }) with Equatable {
/// What their weight is shown in, in the region [countryCode] where they
/// chose none.
WeightUnit weightUnitIn(String? countryCode) =>
weightUnit ?? WeightUnit.ofRegion(countryCode);
/// The same figures with the weight [kilograms], as a weight measured /// The same figures with the weight [kilograms], as a weight measured
/// last sets it (D64). /// last sets it (D64).
ProfileContent withWeight(double kilograms) => ProfileContent( ProfileContent withWeight(double kilograms) => ProfileContent(
@ -54,6 +86,7 @@ final class const ProfileContent({
trainingBackground: trainingBackground, trainingBackground: trainingBackground,
typicalDay: typicalDay, typicalDay: typicalDay,
sleepGoal: sleepGoal, sleepGoal: sleepGoal,
weightUnit: weightUnit,
); );
/// Their age on [day], in whole years; null without a birth date. /// Their age on [day], in whole years; null without a birth date.
@ -80,6 +113,7 @@ final class const ProfileContent({
trainingBackground, trainingBackground,
typicalDay, typicalDay,
sleepGoal, sleepGoal,
weightUnit,
]; ];
} }

View file

@ -5,8 +5,8 @@ import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/app/train_dependencies.dart'; import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart'; import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart'; import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
import 'package:halkyon_train_flutter/src/feature/weight/widget/weight_card.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -58,7 +58,12 @@ class _BodyCardState() extends State<BodyCard> {
final locale = Localizations.localeOf(context).toString(); final locale = Localizations.localeOf(context).toString();
final figures = [ final figures = [
if (profile.weightKg case final kg?) if (profile.weightKg case final kg?)
l10n.bodyWeight(NumberFormat('0.#', locale).format(kg)), weightText(
l10n,
locale,
kg,
weightUnitOf(context, profile.weightUnit),
),
if (profile.restingHeartRate case final bpm?) l10n.bodyResting(bpm), if (profile.restingHeartRate case final bpm?) l10n.bodyResting(bpm),
if (profile.maxHeartRate case final bpm?) l10n.bodyMax(bpm), if (profile.maxHeartRate case final bpm?) l10n.bodyMax(bpm),
]; ];

View file

@ -6,6 +6,7 @@ import 'package:halkyon_train_flutter/src/app/train_dependencies.dart';
import 'package:halkyon_train_flutter/src/feature/profile/bloc/profile_cubit.dart'; import 'package:halkyon_train_flutter/src/feature/profile/bloc/profile_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart'; import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart'; import 'package:halkyon_train_flutter/src/feature/recording/widget/recording_texts.dart';
import 'package:halkyon_train_flutter/src/feature/weight/widget/weight_card.dart';
import 'package:halkyon_ui/halkyon_ui.dart'; import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
@ -95,11 +96,61 @@ class _ProfileFormState() extends State<ProfileForm> {
late TypicalDay? _day = widget.profile.typicalDay; late TypicalDay? _day = widget.profile.typicalDay;
late Duration? _sleep = widget.profile.sleepGoal; late Duration? _sleep = widget.profile.sleepGoal;
late final _height = _text(widget.profile.heightCm); late final _height = _text(widget.profile.heightCm);
/// What the weight is typed in; the region's until [didChangeDependencies]
/// where the person chose none.
late WeightUnit _unit = widget.profile.weightUnit ?? WeightUnit.kilograms;
late final bool _unitChosen = widget.profile.weightUnit != null;
late final _weight = _text(widget.profile.weightKg); late final _weight = _text(widget.profile.weightKg);
/// The weight in kilograms as it was before it was shown in [_unit],
/// rounded, and what it was shown as: kept while the field is untouched,
/// so that a weight passing through pounds comes back as it was.
late double? _exactKilograms = widget.profile.weightKg;
late String _shownWeight = _weight.text;
/// The weight in the field, in kilograms.
double? get _weightKilograms => _weight.text == _shownWeight
? _exactKilograms
: switch (_number(_weight.text)) {
final typed? => _unit.toKilograms(typed),
null => null,
};
late final _resting = _text(widget.profile.restingHeartRate); late final _resting = _text(widget.profile.restingHeartRate);
late final _max = _text(widget.profile.maxHeartRate); late final _max = _text(widget.profile.maxHeartRate);
late final _vo2 = _text(widget.profile.vo2Max); late final _vo2 = _text(widget.profile.vo2Max);
var _regionApplied = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (!_unitChosen && !_regionApplied) {
_regionApplied = true;
_setUnit(weightUnitOf(context, null), chosen: false);
}
}
/// Shows the weight typed so far in [unit] from now on.
void _setUnit(WeightUnit unit, {bool chosen = true}) {
final kilograms = _weightKilograms;
_unit = unit;
if (chosen) {
_chosenUnit = unit;
}
if (kilograms != null) {
_weight.text = _figure(unit.fromKilograms(kilograms));
_exactKilograms = kilograms;
_shownWeight = _weight.text;
}
}
/// The unit the person picked here; null until they do.
late WeightUnit? _chosenUnit = widget.profile.weightUnit;
static String _figure(double value) =>
value.toStringAsFixed(1).replaceFirst(RegExp(r'\.0$'), '');
bool _shows(ProfileField field) => widget.only?.contains(field) ?? true; bool _shows(ProfileField field) => widget.only?.contains(field) ?? true;
bool get _needed => widget.only != null; bool get _needed => widget.only != null;
@ -166,15 +217,48 @@ class _ProfileFormState() extends State<ProfileForm> {
unit: l10n.profileHeightUnit, unit: l10n.profileHeightUnit,
range: (100, 250), range: (100, 250),
), ),
if (_shows(ProfileField.weight)) if (_shows(ProfileField.weight)) ...[
_Figure( _Figure(
controller: _weight, controller: _weight,
needed: _needed, needed: _needed,
label: l10n.profileWeight, label: l10n.profileWeight,
unit: l10n.profileWeightUnit, unit: switch (_unit) {
range: (20, 300), WeightUnit.kilograms => l10n.profileWeightUnit,
WeightUnit.pounds => l10n.profileWeightUnitPounds,
},
range: switch (_unit) {
WeightUnit.kilograms => (20, 300),
WeightUnit.pounds => (44, 660),
},
decimal: true, decimal: true,
), ),
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Expanded(child: Text(l10n.profileWeightIn)),
SegmentedButton<WeightUnit>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: WeightUnit.kilograms,
label: Text(l10n.profileWeightUnit),
tooltip: l10n.profileWeightKilograms,
),
ButtonSegment(
value: WeightUnit.pounds,
label: Text(l10n.profileWeightUnitPounds),
tooltip: l10n.profileWeightPounds,
),
],
selected: {_unit},
onSelectionChanged: (units) =>
setState(() => _setUnit(units.single)),
),
],
),
),
],
if (_shows(ProfileField.restingHeartRate)) if (_shows(ProfileField.restingHeartRate))
_Figure( _Figure(
controller: _resting, controller: _resting,
@ -280,13 +364,17 @@ class _ProfileFormState() extends State<ProfileForm> {
sex: _sex, sex: _sex,
birthDate: _birthDate, birthDate: _birthDate,
heightCm: whole(_height), heightCm: whole(_height),
weightKg: _number(_weight.text), weightKg: _weightKilograms,
restingHeartRate: whole(_resting), restingHeartRate: whole(_resting),
maxHeartRate: whole(_max), maxHeartRate: whole(_max),
vo2Max: whole(_vo2), vo2Max: whole(_vo2),
trainingBackground: _background, trainingBackground: _background,
typicalDay: _day, typicalDay: _day,
sleepGoal: _sleep, sleepGoal: _sleep,
// Kept as it was where the form doesn't show the weight.
weightUnit: _shows(ProfileField.weight)
? _chosenUnit
: widget.profile.weightUnit,
), ),
); );
} }

View file

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart'; import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart'; import 'package:halkyon_train_flutter/src/feature/weight/data/weight_repository.dart';
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart'; import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
@ -13,12 +14,16 @@ final class const WeightsState({
/// Those that open on this device. /// Those that open on this device.
final List<StoredWeight> weights = const [], final List<StoredWeight> weights = const [],
/// What the person chose to see weights in, from their profile; null
/// where they chose none (their region's then) or it doesn't open here.
final WeightUnit? unit,
}) with Equatable { }) with Equatable {
/// The weight measured last; null without one. /// The weight measured last; null without one.
WeightContent? get latest => weights.lastOrNull?.content; WeightContent? get latest => weights.lastOrNull?.content;
@override @override
List<Object?> get props => [loaded, weights]; List<Object?> get props => [loaded, weights, unit];
} }
/// Reads the person's weights, keeps one they enter and deletes one; a /// Reads the person's weights, keeps one they enter and deletes one; a
@ -31,9 +36,11 @@ class WeightsCubit(
this : super(const WeightsState()); this : super(const WeightsState());
StreamSubscription<void>? _changes; StreamSubscription<void>? _changes;
StreamSubscription<void>? _profileChanges;
Future<void> open() async { Future<void> open() async {
_changes = _weights.changes.listen((_) => unawaited(_read())); _changes = _weights.changes.listen((_) => unawaited(_read()));
_profileChanges = _profile.changes.listen((_) => unawaited(_read()));
await _read(); await _read();
} }
@ -54,6 +61,7 @@ class WeightsCubit(
Future<void> _read() async { Future<void> _read() async {
final all = await _weights.all(); final all = await _weights.all();
final profile = await _profile.read();
if (!isClosed) { if (!isClosed) {
emit( emit(
WeightsState( WeightsState(
@ -62,6 +70,7 @@ class WeightsCubit(
for (final weight in all) for (final weight in all)
if (weight.content != null) weight, if (weight.content != null) weight,
], ],
unit: profile?.weightUnit,
), ),
); );
} }
@ -70,6 +79,7 @@ class WeightsCubit(
@override @override
Future<void> close() async { Future<void> close() async {
await _changes?.cancel(); await _changes?.cancel();
await _profileChanges?.cancel();
await super.close(); await super.close();
} }
} }

View file

@ -1,6 +1,7 @@
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_train_flutter/l10n/app_localizations.dart'; import 'package:halkyon_train_flutter/l10n/app_localizations.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import 'package:halkyon_train_flutter/src/feature/weight/bloc/weights_cubit.dart'; import 'package:halkyon_train_flutter/src/feature/weight/bloc/weights_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart'; import 'package:halkyon_train_flutter/src/feature/weight/model/weight.dart';
import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart'; import 'package:halkyon_train_flutter/src/feature/workout/widget/workout_chart.dart';
@ -9,9 +10,28 @@ import 'package:intl/intl.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart'; import 'package:material_ui/material_ui.dart';
/// [kilograms] with one decimal where it has one, in [locale]'s way. /// [kilograms] in [unit], with one decimal where it has one, in [locale]'s
String kilogramsText(AppLocalizations l10n, String locale, double kilograms) => /// way.
l10n.bodyWeight(NumberFormat('0.#', locale).format(kilograms)); String weightText(
AppLocalizations l10n,
String locale,
double kilograms,
WeightUnit unit,
) {
final figure = NumberFormat(
'0.#',
locale,
).format(unit.fromKilograms(kilograms));
return switch (unit) {
WeightUnit.kilograms => l10n.bodyWeight(figure),
WeightUnit.pounds => l10n.bodyWeightPounds(figure),
};
}
/// What weights are shown in for [context]: the person's choice ([chosen]),
/// else their region's.
WeightUnit weightUnitOf(BuildContext context, WeightUnit? chosen) =>
chosen ?? WeightUnit.ofRegion(Localizations.localeOf(context).countryCode);
/// The person's weight on Profile's Progress (ADR 0030, D64): the latest /// The person's weight on Profile's Progress (ADR 0030, D64): the latest
/// and when, its line over time (held, each weight and its day), a weight /// and when, its line over time (held, each weight and its day), a weight
@ -23,6 +43,7 @@ class const WeightCard({super.key}) extends StatelessWidget {
final locale = Localizations.localeOf(context).toString(); final locale = Localizations.localeOf(context).toString();
final theme = Theme.of(context); final theme = Theme.of(context);
final state = context.watch<WeightsCubit>().state; final state = context.watch<WeightsCubit>().state;
final unit = weightUnitOf(context, state.unit);
final latest = state.latest; final latest = state.latest;
final day = DateFormat.yMMMd(locale); final day = DateFormat.yMMMd(locale);
return SectionCard( return SectionCard(
@ -60,7 +81,7 @@ class const WeightCard({super.key}) extends StatelessWidget {
) )
else ...[ else ...[
Text( Text(
kilogramsText(l10n, locale, latest.kilograms), weightText(l10n, locale, latest.kilograms, unit),
style: theme.textTheme.headlineSmall?.copyWith( style: theme.textTheme.headlineSmall?.copyWith(
fontFeatures: const [FontFeature.tabularFigures()], fontFeatures: const [FontFeature.tabularFigures()],
), ),
@ -79,10 +100,12 @@ class const WeightCard({super.key}) extends StatelessWidget {
for (final weight in state.weights) for (final weight in state.weights)
( (
x: weight.content!.at.millisecondsSinceEpoch / 1000, x: weight.content!.at.millisecondsSinceEpoch / 1000,
value: weight.content!.kilograms, value: unit.fromKilograms(weight.content!.kilograms),
), ),
], ],
label: (kilograms) => kilogramsText(l10n, locale, kilograms), // The line's values are already in [unit].
label: (value) =>
weightText(l10n, locale, unit.toKilograms(value), unit),
xLabel: (seconds) => day.format( xLabel: (seconds) => day.format(
DateTime.fromMillisecondsSinceEpoch( DateTime.fromMillisecondsSinceEpoch(
(seconds * 1000).round(), (seconds * 1000).round(),
@ -115,7 +138,9 @@ class const WeightsScreen({super.key}) extends StatelessWidget {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString(); final locale = Localizations.localeOf(context).toString();
final when = DateFormat.yMMMd(locale).add_Hm(); final when = DateFormat.yMMMd(locale).add_Hm();
final weights = context.watch<WeightsCubit>().state.weights.reversed; final state = context.watch<WeightsCubit>().state;
final unit = weightUnitOf(context, state.unit);
final weights = state.weights.reversed;
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(l10n.weightTitle), title: Text(l10n.weightTitle),
@ -132,7 +157,7 @@ class const WeightsScreen({super.key}) extends StatelessWidget {
for (final weight in weights) for (final weight in weights)
ListTile( ListTile(
title: Text( title: Text(
kilogramsText(l10n, locale, weight.content!.kilograms), weightText(l10n, locale, weight.content!.kilograms, unit),
), ),
subtitle: Text( subtitle: Text(
[ [
@ -157,16 +182,18 @@ class const WeightsScreen({super.key}) extends StatelessWidget {
/// keeps it. /// keeps it.
Future<void> showWeightDialog(BuildContext context) async { Future<void> showWeightDialog(BuildContext context) async {
final cubit = context.read<WeightsCubit>(); final cubit = context.read<WeightsCubit>();
final unit = weightUnitOf(context, cubit.state.unit);
final entered = await showDialog<(double, DateTime)>( final entered = await showDialog<(double, DateTime)>(
context: context, context: context,
builder: (context) => const _WeightDialog(), builder: (context) => _WeightDialog(unit: unit),
); );
if (entered case (final kilograms, final at)) { if (entered case (final kilograms, final at)) {
await cubit.add(kilograms, at); await cubit.add(kilograms, at);
} }
} }
class const _WeightDialog() extends StatefulWidget { class const _WeightDialog({required final WeightUnit unit})
extends StatefulWidget {
@override @override
State<_WeightDialog> createState() => _WeightDialogState(); State<_WeightDialog> createState() => _WeightDialogState();
} }
@ -182,12 +209,15 @@ class _WeightDialogState() extends State<_WeightDialog> {
super.dispose(); super.dispose();
} }
/// The weight typed, a decimal comma taken as a point; null for one not /// The weight typed in the dialog's unit, in kilograms, a decimal comma
/// a person's. /// taken as a point; null for one not a person's (20 to 400 kg).
double? get _kilograms => switch (double.tryParse( double? get _kilograms => switch (double.tryParse(
_field.text.trim().replaceAll(',', '.'), _field.text.trim().replaceAll(',', '.'),
)) { )) {
final kg? when kg >= 20 && kg <= 400 => kg, final typed? => switch (widget.unit.toKilograms(typed)) {
final kg when kg >= 19.95 && kg <= 400 => kg,
_ => null,
},
_ => null, _ => null,
}; };
@ -235,8 +265,16 @@ class _WeightDialogState() extends State<_WeightDialog> {
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration( decoration: InputDecoration(
labelText: l10n.weightField, labelText: l10n.weightField,
suffixText: 'kg', suffixText: switch (widget.unit) {
errorText: _invalid ? l10n.weightInvalid : null, WeightUnit.kilograms => l10n.profileWeightUnit,
WeightUnit.pounds => l10n.profileWeightUnitPounds,
},
errorText: _invalid
? switch (widget.unit) {
WeightUnit.kilograms => l10n.weightInvalid,
WeightUnit.pounds => l10n.weightInvalidPounds,
}
: null,
), ),
onSubmitted: (_) => _save(), onSubmitted: (_) => _save(),
), ),

View file

@ -107,6 +107,13 @@ void main() {
/// Starts Train on a new install ([usedBefore] false), where nobody is /// Starts Train on a new install ([usedBefore] false), where nobody is
/// signed in, or on one that chose its instance before, signed in unless /// signed in, or on one that chose its instance before, signed in unless
/// [signedOut]. /// [signedOut].
/// Puts the test in a region weighing in kilograms (D64): the tests' own,
/// en_US, weighs in pounds.
void inKilograms(WidgetTester tester) {
tester.platformDispatcher.localesTestValue = const [Locale('en', 'GB')];
addTearDown(tester.platformDispatcher.clearLocalesTestValue);
}
Future<void> start( Future<void> start(
WidgetTester tester, { WidgetTester tester, {
bool usedBefore = false, bool usedBefore = false,
@ -1114,9 +1121,64 @@ void main() {
await tester.pumpAndSettle(); await tester.pumpAndSettle();
} }
testWidgets("Your body shows the weight in the region's unit until one is "
'picked, and one left alone stays as it was (D64)', (tester) async {
await tester.runAsync(
() => profile.save(const ProfileContent(weightKg: 64.5)),
);
await openBody(tester);
final weight = find.widgetWithText(TextFormField, 'Weight');
final units = find.byType(SegmentedButton<WeightUnit>);
final form = find
.descendant(
of: find.byType(ProfileForm),
matching: find.byType(Scrollable),
)
.first;
Future<void> save() async {
await tester.scrollUntilVisible(
find.text('Save'),
300,
scrollable: form,
);
await tester.tap(find.text('Save'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
}
// en_US weighs in pounds; 64.5 kg is 142.2 lb, rounded.
expect(
find.descendant(of: weight, matching: find.text('142.2')),
findsOneWidget,
);
await save();
expect(
await tester.runAsync(profile.read),
const ProfileContent(weightKg: 64.5),
reason: 'neither rounded through pounds nor a unit picked',
);
await tester.tap(find.text('Your body'));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(of: units, matching: find.text('kg')),
);
await tester.pump();
expect(
find.descendant(of: weight, matching: find.text('64.5')),
findsOneWidget,
);
await save();
expect(
await tester.runAsync(profile.read),
const ProfileContent(weightKg: 64.5, weightUnit: WeightUnit.kilograms),
);
});
testWidgets('each figure is optional; those given are kept', ( testWidgets('each figure is optional; those given are kept', (
tester, tester,
) async { ) async {
inKilograms(tester);
await openBody(tester); await openBody(tester);
await tester.enterText( await tester.enterText(
@ -1194,6 +1256,7 @@ void main() {
testWidgets("Profile's card shows the figures given, opens Your body, and " testWidgets("Profile's card shows the figures given, opens Your body, and "
'follows a change (D60)', (tester) async { 'follows a change (D60)', (tester) async {
inKilograms(tester);
await tester.runAsync( await tester.runAsync(
() => profile.save( () => profile.save(
const ProfileContent( const ProfileContent(
@ -1234,6 +1297,7 @@ void main() {
testWidgets('a weight entered on Progress shows on its card and in Your ' testWidgets('a weight entered on Progress shows on its card and in Your '
'body, and goes again from All (D64)', (tester) async { 'body, and goes again from All (D64)', (tester) async {
inKilograms(tester);
await start(tester, usedBefore: true, profile: profile); await start(tester, usedBefore: true, profile: profile);
await openProgress(tester); await openProgress(tester);
await tester.runAsync(pumpEventQueue); await tester.runAsync(pumpEventQueue);
@ -1283,6 +1347,43 @@ void main() {
expect(find.text('71.4 kg'), findsNothing); expect(find.text('71.4 kg'), findsNothing);
}); });
testWidgets('a person who weighs in pounds sees and enters pounds, and '
'kilograms are kept (D64)', (tester) async {
await tester.runAsync(
() => profile.save(
const ProfileContent(weightKg: 70, weightUnit: WeightUnit.pounds),
),
);
await start(tester, usedBefore: true, profile: profile);
await openProgress(tester);
await tester.runAsync(pumpEventQueue);
await tester.pump();
final card = find.byType(WeightCard);
await tester.tap(
find.descendant(of: card, matching: find.byTooltip('Add weight')),
);
await tester.pumpAndSettle();
await tester.enterText(find.byType(TextField), '30');
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('A weight between 44 and 880 lb'), findsOneWidget);
await tester.enterText(find.byType(TextField), '165');
await tester.tap(find.text('Save'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(
find.descendant(of: card, matching: find.text('165 lb')),
findsOneWidget,
);
final saved = await tester.runAsync(profile.read);
expect(saved?.weightKg, closeTo(74.84, 0.01));
expect(saved?.weightUnit, WeightUnit.pounds);
});
testWidgets("without figures, Profile's card says what they're for", ( testWidgets("without figures, Profile's card says what they're for", (
tester, tester,
) async { ) async {
@ -1447,6 +1548,7 @@ void main() {
testWidgets('what the profile lacks is asked alone and needed; then ' testWidgets('what the profile lacks is asked alone and needed; then '
'kept, and the band set up', (tester) async { 'kept, and the band set up', (tester) async {
inKilograms(tester);
await tester.runAsync( await tester.runAsync(
() => profile.save( () => profile.save(
ProfileContent( ProfileContent(

View file

@ -29,6 +29,7 @@ void main() {
trainingBackground: TrainingBackground.frequent, trainingBackground: TrainingBackground.frequent,
typicalDay: TypicalDay.mostlyStanding, typicalDay: TypicalDay.mostlyStanding,
sleepGoal: const Duration(hours: 7, minutes: 30), sleepGoal: const Duration(hours: 7, minutes: 30),
weightUnit: WeightUnit.pounds,
); );
group('its payload', () { group('its payload', () {
@ -66,6 +67,32 @@ void main() {
}); });
}); });
group('the weight unit', () {
test("is the person's choice, else their region's: pounds in the US, "
'Liberia and Myanmar', () {
expect(WeightUnit.ofRegion('US'), WeightUnit.pounds);
expect(WeightUnit.ofRegion('MM'), WeightUnit.pounds);
expect(WeightUnit.ofRegion('GB'), WeightUnit.kilograms);
expect(WeightUnit.ofRegion(null), WeightUnit.kilograms);
expect(
const ProfileContent(weightUnit: WeightUnit.kilograms)
.weightUnitIn('US'),
WeightUnit.kilograms,
);
expect(const ProfileContent().weightUnitIn('US'), WeightUnit.pounds);
});
test('converts both ways, kilograms kept as they are', () {
expect(WeightUnit.pounds.fromKilograms(0.45359237), closeTo(1, 1e-12));
expect(WeightUnit.pounds.toKilograms(165), closeTo(74.8428, 1e-4));
expect(WeightUnit.kilograms.toKilograms(70), 70);
});
test('goes with a new weight', () {
expect(full.withWeight(60).weightUnit, WeightUnit.pounds);
});
});
test('the age counts whole years, the birthday itself included', () { test('the age counts whole years, the birthday itself included', () {
final born = ProfileContent(birthDate: DateTime.utc(1990, 4, 12)); final born = ProfileContent(birthDate: DateTime.utc(1990, 4, 12));

View file

@ -1663,9 +1663,16 @@ Strava. What was found, 27 Oct:
band's and the zones' figures work from. Health Connect's weight records are read band's and the zones' figures work from. Health Connect's weight records are read
in when switched on in its screen (its own permission, asked as the others), each in when switched on in its screen (its own permission, asked as the others), each
record once (its id kept, also after deletion), not Train's own, and not one record once (its id kept, also after deletion), not Train's own, and not one
within 10 minutes and 0.1 kg of a weight entered here. **In kilograms alone**: within 10 minutes and 0.1 kg of a weight entered here. **In kilograms or pounds**
Train is metric throughout and has no units setting yet; pounds come with one, (the developer's call, 4 Nov 2026, weight alone): every weight is kept in
for every figure at once. kilograms, and shown and entered in the person's unit, *Your body*'s *Weight in*
(kg | lb, the profile's `weightUnit`), else their region's (pounds in the US,
Liberia and Myanmar, `WeightUnit.ofRegion`, from the app's locale); a pound is
0.45359237 kg, the entry's range the same 20 to 400 kg (44 to 880 lb). A weight
left untouched in *Your body* is saved as it was, not rounded through pounds.
The other figures stay metric; a units setting for all of them would come on
its own. A profile saved on Train 0.2.0, which doesn't know the field, loses
the unit, and the region's applies again until it is picked anew.
## Consequences ## Consequences