feat(train): the body's figures in a sealed, synced profile (ADR 0030, D33, D34)

A person's profile, one row per person (its ID derived from theirs),
sealed like a workout: sex, birth date, height, weight, resting and
maximum heart rate, VO2max, training background, typical day and sleep
goal, each optional. "Your body" in the account sheet edits it; the
Loop's setup is to reuse its form. The migration only adds the synced
table `profile`, deleted with the person's sync space.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-28 18:02:04 +01:00
commit de6fe5c15a
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
17 changed files with 20185 additions and 9 deletions

View file

@ -153,5 +153,40 @@
"workoutFromList": "von {app}",
"workoutFrom": "Von {app}, über Health Connect",
"workoutBringTrack": "Strecke holen",
"workoutNoTrack": "Health Connect hat keine Strecke dazu gegeben."
"workoutNoTrack": "Health Connect hat keine Strecke dazu gegeben.",
"profileTitle": "Dein Körper",
"profileHint": "Woraus dein Armband und deine Herzfrequenzzonen rechnen",
"profileIntro": "Jede Angabe ist freiwillig und mit deinem Schlüssel versiegelt, wie deine Trainings; deine Instanz kann sie nicht lesen.",
"profileLocked": "Dein Profil ist mit einem Schlüssel versiegelt, den dieses Gerät nicht hat.",
"profileSex": "Geschlecht",
"profileSexHint": "Wie Polars Formeln es nehmen: das, dem die Werte deines Körpers folgen",
"profileSexFemale": "Weiblich",
"profileSexMale": "Männlich",
"profileNotGiven": "Keine Angabe",
"profileBirthDate": "Geburtsdatum",
"profileHeight": "Größe",
"profileHeightUnit": "cm",
"profileWeight": "Gewicht",
"profileWeightUnit": "kg",
"profileRestingHeartRate": "Ruhepuls",
"profileMaxHeartRate": "Höchste Herzfrequenz",
"profileBpmUnit": "S/min",
"profileVo2Max": "VO2max",
"profileVo2MaxUnit": "ml/kg/min",
"profileTrainingBackground": "Trainingshintergrund",
"trainingOccasional": "Gelegentlich",
"trainingRegular": "Regelmäßig",
"trainingFrequent": "Häufig",
"trainingHeavy": "Intensiv",
"trainingSemiPro": "Halbprofi",
"trainingPro": "Profi",
"profileTypicalDay": "Typischer Tag",
"typicalDaySitting": "Meist sitzend",
"typicalDayStanding": "Meist stehend",
"typicalDayMoving": "Meist in Bewegung",
"profileSleepGoal": "Schlafziel",
"profileSave": "Speichern",
"profileSaved": "Gespeichert",
"profileInvalid": "Prüfe die markierten Angaben."
}

View file

@ -203,5 +203,41 @@
"@workoutFrom": {"description": "On an imported workout's page.", "placeholders": {"app": {"type": "String", "example": "Gadgetbridge"}}},
"workoutBringTrack": "Bring in the track",
"@workoutBringTrack": {"description": "On an imported workout's page: asks Health Connect for its route."},
"workoutNoTrack": "Health Connect gave no track for it."
"workoutNoTrack": "Health Connect gave no track for it.",
"profileTitle": "Your body",
"profileHint": "What your band and your heart-rate zones work from",
"@profileHint": {"description": "Under Your body in the account sheet."},
"profileIntro": "Each figure is optional and sealed with your key, as your workouts are; your instance can't read them.",
"profileLocked": "Your profile is sealed with a key this device doesn't hold.",
"profileSex": "Sex",
"profileSexHint": "As Polar's formulas take it: the one your body's figures follow",
"profileSexFemale": "Female",
"profileSexMale": "Male",
"profileNotGiven": "Not given",
"profileBirthDate": "Birth date",
"profileHeight": "Height",
"profileHeightUnit": "cm",
"profileWeight": "Weight",
"profileWeightUnit": "kg",
"profileRestingHeartRate": "Resting heart rate",
"profileMaxHeartRate": "Highest heart rate",
"profileBpmUnit": "bpm",
"profileVo2Max": "VO2max",
"profileVo2MaxUnit": "ml/kg/min",
"profileTrainingBackground": "Training background",
"trainingOccasional": "Occasional",
"trainingRegular": "Regular",
"trainingFrequent": "Frequent",
"trainingHeavy": "Heavy",
"trainingSemiPro": "Semi-pro",
"trainingPro": "Pro",
"profileTypicalDay": "Typical day",
"typicalDaySitting": "Mostly sitting",
"typicalDayStanding": "Mostly standing",
"typicalDayMoving": "Mostly moving",
"profileSleepGoal": "Sleep goal",
"profileSave": "Save",
"profileSaved": "Saved",
"profileInvalid": "Check the figures marked."
}

View file

@ -957,6 +957,210 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'Health Connect gave no track for it.'**
String get workoutNoTrack;
/// No description provided for @profileTitle.
///
/// In en, this message translates to:
/// **'Your body'**
String get profileTitle;
/// Under Your body in the account sheet.
///
/// In en, this message translates to:
/// **'What your band and your heart-rate zones work from'**
String get profileHint;
/// No description provided for @profileIntro.
///
/// In en, this message translates to:
/// **'Each figure is optional and sealed with your key, as your workouts are; your instance can\'t read them.'**
String get profileIntro;
/// No description provided for @profileLocked.
///
/// In en, this message translates to:
/// **'Your profile is sealed with a key this device doesn\'t hold.'**
String get profileLocked;
/// No description provided for @profileSex.
///
/// In en, this message translates to:
/// **'Sex'**
String get profileSex;
/// No description provided for @profileSexHint.
///
/// In en, this message translates to:
/// **'As Polar\'s formulas take it: the one your body\'s figures follow'**
String get profileSexHint;
/// No description provided for @profileSexFemale.
///
/// In en, this message translates to:
/// **'Female'**
String get profileSexFemale;
/// No description provided for @profileSexMale.
///
/// In en, this message translates to:
/// **'Male'**
String get profileSexMale;
/// No description provided for @profileNotGiven.
///
/// In en, this message translates to:
/// **'Not given'**
String get profileNotGiven;
/// No description provided for @profileBirthDate.
///
/// In en, this message translates to:
/// **'Birth date'**
String get profileBirthDate;
/// No description provided for @profileHeight.
///
/// In en, this message translates to:
/// **'Height'**
String get profileHeight;
/// No description provided for @profileHeightUnit.
///
/// In en, this message translates to:
/// **'cm'**
String get profileHeightUnit;
/// No description provided for @profileWeight.
///
/// In en, this message translates to:
/// **'Weight'**
String get profileWeight;
/// No description provided for @profileWeightUnit.
///
/// In en, this message translates to:
/// **'kg'**
String get profileWeightUnit;
/// No description provided for @profileRestingHeartRate.
///
/// In en, this message translates to:
/// **'Resting heart rate'**
String get profileRestingHeartRate;
/// No description provided for @profileMaxHeartRate.
///
/// In en, this message translates to:
/// **'Highest heart rate'**
String get profileMaxHeartRate;
/// No description provided for @profileBpmUnit.
///
/// In en, this message translates to:
/// **'bpm'**
String get profileBpmUnit;
/// No description provided for @profileVo2Max.
///
/// In en, this message translates to:
/// **'VO2max'**
String get profileVo2Max;
/// No description provided for @profileVo2MaxUnit.
///
/// In en, this message translates to:
/// **'ml/kg/min'**
String get profileVo2MaxUnit;
/// No description provided for @profileTrainingBackground.
///
/// In en, this message translates to:
/// **'Training background'**
String get profileTrainingBackground;
/// No description provided for @trainingOccasional.
///
/// In en, this message translates to:
/// **'Occasional'**
String get trainingOccasional;
/// No description provided for @trainingRegular.
///
/// In en, this message translates to:
/// **'Regular'**
String get trainingRegular;
/// No description provided for @trainingFrequent.
///
/// In en, this message translates to:
/// **'Frequent'**
String get trainingFrequent;
/// No description provided for @trainingHeavy.
///
/// In en, this message translates to:
/// **'Heavy'**
String get trainingHeavy;
/// No description provided for @trainingSemiPro.
///
/// In en, this message translates to:
/// **'Semi-pro'**
String get trainingSemiPro;
/// No description provided for @trainingPro.
///
/// In en, this message translates to:
/// **'Pro'**
String get trainingPro;
/// No description provided for @profileTypicalDay.
///
/// In en, this message translates to:
/// **'Typical day'**
String get profileTypicalDay;
/// No description provided for @typicalDaySitting.
///
/// In en, this message translates to:
/// **'Mostly sitting'**
String get typicalDaySitting;
/// No description provided for @typicalDayStanding.
///
/// In en, this message translates to:
/// **'Mostly standing'**
String get typicalDayStanding;
/// No description provided for @typicalDayMoving.
///
/// In en, this message translates to:
/// **'Mostly moving'**
String get typicalDayMoving;
/// No description provided for @profileSleepGoal.
///
/// In en, this message translates to:
/// **'Sleep goal'**
String get profileSleepGoal;
/// No description provided for @profileSave.
///
/// In en, this message translates to:
/// **'Save'**
String get profileSave;
/// No description provided for @profileSaved.
///
/// In en, this message translates to:
/// **'Saved'**
String get profileSaved;
/// No description provided for @profileInvalid.
///
/// In en, this message translates to:
/// **'Check the figures marked.'**
String get profileInvalid;
}
class _AppLocalizationsDelegate

View file

@ -531,4 +531,110 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get workoutNoTrack => 'Health Connect hat keine Strecke dazu gegeben.';
@override
String get profileTitle => 'Dein Körper';
@override
String get profileHint =>
'Woraus dein Armband und deine Herzfrequenzzonen rechnen';
@override
String get profileIntro =>
'Jede Angabe ist freiwillig und mit deinem Schlüssel versiegelt, wie deine Trainings; deine Instanz kann sie nicht lesen.';
@override
String get profileLocked =>
'Dein Profil ist mit einem Schlüssel versiegelt, den dieses Gerät nicht hat.';
@override
String get profileSex => 'Geschlecht';
@override
String get profileSexHint =>
'Wie Polars Formeln es nehmen: das, dem die Werte deines Körpers folgen';
@override
String get profileSexFemale => 'Weiblich';
@override
String get profileSexMale => 'Männlich';
@override
String get profileNotGiven => 'Keine Angabe';
@override
String get profileBirthDate => 'Geburtsdatum';
@override
String get profileHeight => 'Größe';
@override
String get profileHeightUnit => 'cm';
@override
String get profileWeight => 'Gewicht';
@override
String get profileWeightUnit => 'kg';
@override
String get profileRestingHeartRate => 'Ruhepuls';
@override
String get profileMaxHeartRate => 'Höchste Herzfrequenz';
@override
String get profileBpmUnit => 'S/min';
@override
String get profileVo2Max => 'VO2max';
@override
String get profileVo2MaxUnit => 'ml/kg/min';
@override
String get profileTrainingBackground => 'Trainingshintergrund';
@override
String get trainingOccasional => 'Gelegentlich';
@override
String get trainingRegular => 'Regelmäßig';
@override
String get trainingFrequent => 'Häufig';
@override
String get trainingHeavy => 'Intensiv';
@override
String get trainingSemiPro => 'Halbprofi';
@override
String get trainingPro => 'Profi';
@override
String get profileTypicalDay => 'Typischer Tag';
@override
String get typicalDaySitting => 'Meist sitzend';
@override
String get typicalDayStanding => 'Meist stehend';
@override
String get typicalDayMoving => 'Meist in Bewegung';
@override
String get profileSleepGoal => 'Schlafziel';
@override
String get profileSave => 'Speichern';
@override
String get profileSaved => 'Gespeichert';
@override
String get profileInvalid => 'Prüfe die markierten Angaben.';
}

View file

@ -527,6 +527,112 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get workoutNoTrack => 'Health Connect gave no track for it.';
@override
String get profileTitle => 'Your body';
@override
String get profileHint =>
'What your band and your heart-rate zones work from';
@override
String get profileIntro =>
'Each figure is optional and sealed with your key, as your workouts are; your instance can\'t read them.';
@override
String get profileLocked =>
'Your profile is sealed with a key this device doesn\'t hold.';
@override
String get profileSex => 'Sex';
@override
String get profileSexHint =>
'As Polar\'s formulas take it: the one your body\'s figures follow';
@override
String get profileSexFemale => 'Female';
@override
String get profileSexMale => 'Male';
@override
String get profileNotGiven => 'Not given';
@override
String get profileBirthDate => 'Birth date';
@override
String get profileHeight => 'Height';
@override
String get profileHeightUnit => 'cm';
@override
String get profileWeight => 'Weight';
@override
String get profileWeightUnit => 'kg';
@override
String get profileRestingHeartRate => 'Resting heart rate';
@override
String get profileMaxHeartRate => 'Highest heart rate';
@override
String get profileBpmUnit => 'bpm';
@override
String get profileVo2Max => 'VO2max';
@override
String get profileVo2MaxUnit => 'ml/kg/min';
@override
String get profileTrainingBackground => 'Training background';
@override
String get trainingOccasional => 'Occasional';
@override
String get trainingRegular => 'Regular';
@override
String get trainingFrequent => 'Frequent';
@override
String get trainingHeavy => 'Heavy';
@override
String get trainingSemiPro => 'Semi-pro';
@override
String get trainingPro => 'Pro';
@override
String get profileTypicalDay => 'Typical day';
@override
String get typicalDaySitting => 'Mostly sitting';
@override
String get typicalDayStanding => 'Mostly standing';
@override
String get typicalDayMoving => 'Mostly moving';
@override
String get profileSleepGoal => 'Sleep goal';
@override
String get profileSave => 'Save';
@override
String get profileSaved => 'Saved';
@override
String get profileInvalid => 'Check the figures marked.';
}
/// The translations for English, as used in the United Kingdom (`en_GB`).

View file

@ -3,6 +3,7 @@ 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_frame.dart';
import 'package:halkyon_train_flutter/src/feature/health/widget/health_screen.dart';
import 'package:halkyon_train_flutter/src/feature/profile/widget/profile_screen.dart';
import 'package:halkyon_ui/halkyon_ui.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:material_ui/material_ui.dart';
@ -43,6 +44,14 @@ class const _AccountSheet() extends StatelessWidget {
children: [SimpleLoginSection(), AccountSecuritySection()],
),
),
// The figures of the person's body (ADR 0030, D33), here until
// Train has settings.
ListTile(
leading: const Icon(LucideIcons.personStanding),
title: Text(l10n.profileTitle),
subtitle: Text(l10n.profileHint),
onTap: () => openProfileScreen(context),
),
// Health Connect on this phone (ADR 0030, D31), here until Train
// has settings.
if (PersonScope.of<TrainPerson>(context).health != null)

View file

@ -13,6 +13,8 @@ import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dar
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_repository.dart';
import 'package:halkyon_train_flutter/src/feature/map/widget/map_tiles.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/android_recorder_platform.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
@ -47,6 +49,9 @@ class TrainPerson(
/// Their workouts, sealed and synced.
required final WorkoutRepository workouts,
/// The figures of their body (D33), sealed and synced.
required final ProfileRepository profile,
/// The instance's map source (D22).
required final MapSourceRepository maps,
@ -300,6 +305,11 @@ Future<TrainPerson> _openPersonData(
);
await keys.refresh();
final workouts = WorkoutRepository(SyncWorkoutGateway(db, sync), keys);
final profile = ProfileRepository(
SyncProfileGateway(db, sync),
keys,
person: person.authUserId,
);
// Writes what changed while the app was closed, as it opens (D31).
final health = Platform.isAndroid
? (HealthConnect(
@ -311,6 +321,7 @@ Future<TrainPerson> _openPersonData(
return TrainPerson(
person,
workouts: workouts,
profile: profile,
maps: MapSourceRepository(ServerpodMapSourceGateway(client)),
recording: Platform.isAndroid
? (
@ -324,6 +335,7 @@ Future<TrainPerson> _openPersonData(
onDispose: () async {
try {
await workouts.dispose();
await profile.dispose();
await sync.dispose();
await db.close();
} finally {

View file

@ -0,0 +1,48 @@
import 'package:equatable/equatable.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/model/profile.dart';
/// Where *Your body* stands.
enum ProfilePhase() {
loading,
loaded,
locked,
saved,
failed,
}
/// What *Your body* shows (ADR 0030, D33).
final class const ProfileState({
final ProfilePhase phase = ProfilePhase.loading,
final ProfileContent profile = const ProfileContent(),
}) with Equatable {
@override
List<Object?> get props => [phase, profile];
}
/// Reads the person's profile and keeps what they changed.
class ProfileCubit(final ProfileRepository _profile)
extends Cubit<ProfileState> {
this : super(const ProfileState());
Future<void> open() async {
final profile = await _profile.read();
if (!isClosed) {
emit(
profile == null
? const ProfileState(phase: ProfilePhase.locked)
: ProfileState(phase: ProfilePhase.loaded, profile: profile),
);
}
}
Future<void> save(ProfileContent profile) async {
try {
await _profile.save(profile);
emit(ProfileState(phase: ProfilePhase.saved, profile: profile));
} on Object {
emit(ProfileState(phase: ProfilePhase.failed, profile: state.profile));
}
}
}

View file

@ -0,0 +1,14 @@
import 'package:halkyon_app/halkyon_app.dart';
/// Where the person's profile is kept: their synced `profile` row. The
/// repository seals and opens; this only keeps.
abstract interface class ProfileGateway() {
/// The envelope of the row [id]; null where there is none.
Future<Envelope?> read(String id);
/// Keeps [envelope] as the row [id], made where there is none.
Future<void> write(String id, Envelope envelope);
/// Fires when a sync brought changes from the person's other devices.
Stream<void> get remoteChanges;
}

View file

@ -0,0 +1,67 @@
import 'dart:convert';
import 'dart:developer';
import 'dart:typed_data';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
/// The schema of a profile's payload this app writes and reads.
const profileSchemaVersion = 1;
/// [content] as its row's payload, before sealing: UTF-8 JSON, the birth
/// date as `YYYY-MM-DD`, the sleep goal in minutes, a figure not given left
/// out.
ByteData encodeProfile(ProfileContent content) => ByteData.sublistView(
Uint8List.fromList(
utf8.encode(
jsonEncode({
'schemaVersion': profileSchemaVersion,
'sex': ?content.sex?.name,
if (content.birthDate case final born?)
'birthDate': born.toIso8601String().substring(0, 10),
'heightCm': ?content.heightCm,
'weightKg': ?content.weightKg,
'restingHeartRate': ?content.restingHeartRate,
'maxHeartRate': ?content.maxHeartRate,
'vo2Max': ?content.vo2Max,
'trainingBackground': ?content.trainingBackground?.name,
'typicalDay': ?content.typicalDay?.name,
'sleepGoalMinutes': ?content.sleepGoal?.inMinutes,
}),
),
),
);
/// The profile [payload] holds, opened; null for one of a newer schema or
/// one it can't read. A value it doesn't know is no value.
ProfileContent? decodeProfile(ByteData payload) {
try {
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(payload)),
) as Map<String, Object?>;
final version = json['schemaVersion']! as int;
if (version > profileSchemaVersion) {
log('A profile of schema $version skipped', name: 'Profile');
return null;
}
final sleep = json['sleepGoalMinutes'] as int?;
return ProfileContent(
sex: Sex.values.asNameMap()[json['sex']],
birthDate: switch (json['birthDate']) {
final String date => DateTime.parse('${date}T00:00:00Z'),
_ => null,
},
heightCm: json['heightCm'] as int?,
weightKg: (json['weightKg'] as num?)?.toDouble(),
restingHeartRate: json['restingHeartRate'] as int?,
maxHeartRate: json['maxHeartRate'] as int?,
vo2Max: json['vo2Max'] as int?,
trainingBackground: TrainingBackground.values
.asNameMap()[json['trainingBackground']],
typicalDay: TypicalDay.values.asNameMap()[json['typicalDay']],
sleepGoal: sleep == null ? null : Duration(minutes: sleep),
);
} on Object catch (error) {
log('A profile not read: ${error.runtimeType}', name: 'Profile');
return null;
}
}

View file

@ -0,0 +1,72 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart'
show Namespace, Uuid;
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_payload.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
/// The person's profile (ADR 0030, D33), sealed with their key as their
/// workouts are (D7), in one row whose ID is made from theirs, so that
/// every device of theirs writes the same one.
class ProfileRepository(
final ProfileGateway _gateway,
final TrainKeys _keys, {
/// Whose profile it is: their auth user ID.
required final String person,
}) {
late final String _id = const Uuid().v5(
Namespace.url.value,
'https://halkyon.app/train/profile/$person',
);
late final _envelope = PayloadEnvelope(_keys.cipher);
StreamSubscription<void>? _remote;
late final StreamController<void> _changes = StreamController.broadcast(
onListen: () => _remote = _gateway.remoteChanges.listen(_changes.add),
onCancel: () => _remote?.cancel(),
);
/// Fires when the profile changed, here or on another device.
Stream<void> get changes => _changes.stream;
/// The profile; an empty one where none was written, null where one is
/// there that doesn't open on this device.
Future<ProfileContent?> read() async {
await _keys.refresh();
final envelope = await _gateway.read(_id);
if (envelope == null) {
return const ProfileContent();
}
if (envelope.keyId == null) {
// Train writes nothing unsealed: a readable row is the instance's.
return null;
}
return switch (_envelope.open(envelope, table: 'profile', id: _id)) {
Readable(:final payload) => decodeProfile(payload),
Locked() => null,
};
}
/// Keeps [content] as the profile; throws [KeysMissing] where this device
/// can't seal.
Future<void> save(ProfileContent content) async {
final keyId = await _keys.keyToWrite();
await _gateway.write(
_id,
_envelope.seal(
encodeProfile(content),
keyId: keyId,
table: 'profile',
id: _id,
),
);
_changes.add(null);
}
/// Closes what it holds open.
Future<void> dispose() => _changes.close();
}

View file

@ -0,0 +1,49 @@
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_gateway.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
/// [ProfileGateway] on the person's local database, which the sync engine
/// keeps in step with their other devices.
class SyncProfileGateway(
final OfflineSyncDatabaseSession _db,
final SyncService _sync,
) implements ProfileGateway {
@override
Future<Envelope?> read(String id) async => switch (await protocol.Profile.db
.findById(_db, protocol.UuidValue.withValidation(id))) {
final row? => (keyId: row.keyId, nonce: row.nonce, payload: row.payload),
null => null,
};
@override
Future<void> write(String id, Envelope envelope) async {
final uuid = protocol.UuidValue.withValidation(id);
if (await protocol.Profile.db.findById(_db, uuid) case final row?) {
// Only the named columns change (ADR 0001, rule 1).
await protocol.Profile.db.updateRow(
_db,
row.copyWith(
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
columns: (t) => [t.keyId, t.nonce, t.payload],
);
} else {
await protocol.Profile.db.insertRow(
_db,
protocol.Profile(
id: uuid,
keyId: envelope.keyId,
nonce: envelope.nonce,
payload: envelope.payload,
),
);
}
_sync.requestSync();
}
@override
Stream<void> get remoteChanges => _sync.merges;
}

View file

@ -0,0 +1,69 @@
import 'package:equatable/equatable.dart';
/// Sex as Polar's formulas take it (ADR 0030, D33): its guidance asks for
/// the biological one, for the most accurate figures.
enum Sex() {
female,
male,
}
/// How much the person trains, in Polar's six levels.
enum TrainingBackground() {
occasional,
regular,
frequent,
heavy,
semiPro,
pro,
}
/// What the person's day mostly is, in Polar's three.
enum TypicalDay() {
mostlySitting,
mostlyStanding,
mostlyMoving,
}
/// The figures of the person's body (D33), each null where they gave none.
final class const ProfileContent({
final Sex? sex,
/// Their birth date, its day alone (UTC midnight).
final DateTime? birthDate,
final int? heightCm,
final double? weightKg,
final int? restingHeartRate,
final int? maxHeartRate,
final int? vo2Max,
final TrainingBackground? trainingBackground,
final TypicalDay? typicalDay,
/// How long they mean to sleep a night, 5 to 11 hours.
final Duration? sleepGoal,
}) with Equatable {
/// Their age on [day], in whole years; null without a birth date.
int? ageOn(DateTime day) {
final born = birthDate;
if (born == null) {
return null;
}
final hadBirthday =
day.month > born.month ||
(day.month == born.month && day.day >= born.day);
return day.year - born.year - (hadBirthday ? 0 : 1);
}
@override
List<Object?> get props => [
sex,
birthDate,
heightCm,
weightKg,
restingHeartRate,
maxHeartRate,
vo2Max,
trainingBackground,
typicalDay,
sleepGoal,
];
}

View file

@ -0,0 +1,326 @@
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:halkyon_app/halkyon_app.dart';
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/feature/profile/bloc/profile_cubit.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_ui/halkyon_ui.dart';
import 'package:intl/intl.dart';
import 'package:material_ui/material_ui.dart';
/// Opens *Your body* over [context]'s.
Future<void> openProfileScreen(BuildContext context) {
final profile = PersonScope.of<TrainPerson>(context).profile;
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (context) => BlocProvider(
create: (context) => ProfileCubit(profile)..open(),
child: const ProfileScreen(),
),
),
);
}
/// The figures of the person's body (ADR 0030, D33), each optional: what the
/// Loop is set up with, and calories and heart-rate zones will work from.
class const ProfileScreen({super.key}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return BlocConsumer<ProfileCubit, ProfileState>(
listenWhen: (before, now) => before.phase != now.phase,
listener: (context, state) {
if (state.phase == ProfilePhase.saved) {
Navigator.of(context).pop();
}
},
builder: (context, state) => Scaffold(
appBar: AppBar(title: Text(l10n.profileTitle)),
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: readableContentMaxWidth,
),
child: switch (state.phase) {
ProfilePhase.loading => const AppLoadingIndicator(),
ProfilePhase.locked => Padding(
padding: const EdgeInsets.all(24),
child: Text(
l10n.profileLocked,
textAlign: TextAlign.center,
),
),
_ => ProfileForm(
profile: state.profile,
onSave: (profile) =>
context.read<ProfileCubit>().save(profile),
),
},
),
),
),
),
);
}
}
/// The profile's fields, [onSave] given what they hold once each is valid.
class const ProfileForm({
required final ProfileContent profile,
required final void Function(ProfileContent profile) onSave,
/// What Save says; *Save* where not given.
final String? saveLabel,
super.key,
}) extends StatefulWidget {
@override
State<ProfileForm> createState() => _ProfileFormState();
}
class _ProfileFormState() extends State<ProfileForm> {
final _form = GlobalKey<FormState>();
late Sex? _sex = widget.profile.sex;
late DateTime? _birthDate = widget.profile.birthDate;
late TrainingBackground? _background = widget.profile.trainingBackground;
late TypicalDay? _day = widget.profile.typicalDay;
late Duration? _sleep = widget.profile.sleepGoal;
late final _height = _text(widget.profile.heightCm);
late final _weight = _text(widget.profile.weightKg);
late final _resting = _text(widget.profile.restingHeartRate);
late final _max = _text(widget.profile.maxHeartRate);
late final _vo2 = _text(widget.profile.vo2Max);
static TextEditingController _text(num? value) => TextEditingController(
text: switch (value) {
null => '',
final double kg =>
kg.toStringAsFixed(1).replaceFirst(RegExp(r'\.0$'), ''),
_ => '$value',
},
);
@override
void dispose() {
for (final controller in [_height, _weight, _resting, _max, _vo2]) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final locale = Localizations.localeOf(context).toString();
final born = _birthDate;
return Form(
key: _form,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(l10n.profileIntro),
const SizedBox(height: 16),
_Choice<Sex>(
label: l10n.profileSex,
helper: l10n.profileSexHint,
value: _sex,
names: {
Sex.female: l10n.profileSexFemale,
Sex.male: l10n.profileSexMale,
},
onChanged: (sex) => setState(() => _sex = sex),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.profileBirthDate),
subtitle: Text(
born == null
? l10n.profileNotGiven
: DateFormat.yMMMd(locale).format(born),
),
onTap: _pickBirthDate,
),
_Figure(
controller: _height,
label: l10n.profileHeight,
unit: l10n.profileHeightUnit,
range: (100, 250),
),
_Figure(
controller: _weight,
label: l10n.profileWeight,
unit: l10n.profileWeightUnit,
range: (20, 300),
decimal: true,
),
_Figure(
controller: _resting,
label: l10n.profileRestingHeartRate,
unit: l10n.profileBpmUnit,
range: (20, 120),
),
_Figure(
controller: _max,
label: l10n.profileMaxHeartRate,
unit: l10n.profileBpmUnit,
range: (100, 240),
),
_Figure(
controller: _vo2,
label: l10n.profileVo2Max,
unit: l10n.profileVo2MaxUnit,
range: (10, 95),
),
_Choice<TrainingBackground>(
label: l10n.profileTrainingBackground,
value: _background,
names: {
TrainingBackground.occasional: l10n.trainingOccasional,
TrainingBackground.regular: l10n.trainingRegular,
TrainingBackground.frequent: l10n.trainingFrequent,
TrainingBackground.heavy: l10n.trainingHeavy,
TrainingBackground.semiPro: l10n.trainingSemiPro,
TrainingBackground.pro: l10n.trainingPro,
},
onChanged: (value) => setState(() => _background = value),
),
_Choice<TypicalDay>(
label: l10n.profileTypicalDay,
value: _day,
names: {
TypicalDay.mostlySitting: l10n.typicalDaySitting,
TypicalDay.mostlyStanding: l10n.typicalDayStanding,
TypicalDay.mostlyMoving: l10n.typicalDayMoving,
},
onChanged: (value) => setState(() => _day = value),
),
_Choice<Duration>(
label: l10n.profileSleepGoal,
value: _sleep,
names: {
for (var minutes = 300; minutes <= 660; minutes += 30)
Duration(minutes: minutes): clockTime(
Duration(minutes: minutes),
),
},
onChanged: (value) => setState(() => _sleep = value),
),
const SizedBox(height: 16),
FilledButton(
onPressed: _save,
child: Text(widget.saveLabel ?? l10n.profileSave),
),
],
),
);
}
Future<void> _pickBirthDate() async {
final now = DateTime.now();
final picked = await showDatePicker(
context: context,
initialDate: _birthDate?.toLocal() ?? DateTime(now.year - 30),
firstDate: DateTime(1900),
lastDate: now,
);
if (picked != null) {
setState(
() => _birthDate = DateTime.utc(picked.year, picked.month, picked.day),
);
}
}
void _save() {
if (!(_form.currentState?.validate() ?? false)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(AppLocalizations.of(context).profileInvalid)),
);
return;
}
int? whole(TextEditingController c) => int.tryParse(c.text.trim());
widget.onSave(
ProfileContent(
sex: _sex,
birthDate: _birthDate,
heightCm: whole(_height),
weightKg: _number(_weight.text),
restingHeartRate: whole(_resting),
maxHeartRate: whole(_max),
vo2Max: whole(_vo2),
trainingBackground: _background,
typicalDay: _day,
sleepGoal: _sleep,
),
);
}
}
/// [text] as a number, a decimal comma taken as a point; null for none.
double? _number(String text) =>
double.tryParse(text.trim().replaceAll(',', '.'));
/// A figure typed in, blank or within [range].
class const _Figure({
required final TextEditingController controller,
required final String label,
required final String unit,
required final (num, num) range,
final bool decimal = false,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
suffixText: unit,
helperText: '${range.$1}–${range.$2}',
),
keyboardType: TextInputType.numberWithOptions(decimal: decimal),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(decimal ? '[0-9.,]' : '[0-9]'),
),
],
validator: (text) {
if (text == null || text.trim().isEmpty) {
return null;
}
final value = _number(text);
return value == null || value < range.$1 || value > range.$2
? '${range.$1}–${range.$2}'
: null;
},
),
);
}
/// One of [names]' values, or none.
class const _Choice<T>({
required final String label,
final String? helper,
required final T? value,
required final Map<T, String> names,
required final void Function(T? value) onChanged,
}) extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: DropdownButtonFormField<T?>(
initialValue: value,
decoration: InputDecoration(labelText: label, helperText: helper),
items: [
DropdownMenuItem(child: Text(l10n.profileNotGiven)),
for (final MapEntry(:key, value: name) in names.entries)
DropdownMenuItem(value: key, child: Text(name)),
],
onChanged: onChanged,
),
);
}
}

View file

@ -13,6 +13,9 @@ import 'package:halkyon_train_flutter/src/feature/health/data/health_connect.dar
import 'package:halkyon_train_flutter/src/feature/health/data/health_platform.dart';
import 'package:halkyon_train_flutter/src/feature/health/data/health_store.dart';
import 'package:halkyon_train_flutter/src/feature/map/data/map_source_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/profile/widget/profile_screen.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/device_store.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorded_workout.dart';
import 'package:halkyon_train_flutter/src/feature/recording/data/recorder_platform.dart';
@ -25,6 +28,7 @@ import 'package:material_ui/material_ui.dart';
import '../support/fake_health_platform.dart';
import '../support/fake_recorder_platform.dart';
import '../support/memory_content_key_gateway.dart';
import '../support/memory_profile_gateway.dart';
import '../support/memory_workout_gateway.dart';
void main() {
@ -54,6 +58,7 @@ void main() {
WorkoutRepository? workouts,
bool mapReachable = true,
HealthConnect? health,
ProfileRepository? profile,
}) async {
tester.view.physicalSize = const Size(412, 900);
tester.view.devicePixelRatio = 1;
@ -122,6 +127,17 @@ void main() {
() async => null,
),
),
profile:
profile ??
ProfileRepository(
MemoryProfileGateway(),
TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => null,
),
person: person.authUserId,
),
recording: recording,
health: health,
maps: MapSourceRepository(
@ -310,6 +326,111 @@ void main() {
expect(find.text('Health Connect'), findsNothing);
});
group('your body (ADR 0030, D33)', () {
late TrainKeys keys;
late ProfileRepository profile;
setUp(() {
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
profile = ProfileRepository(
MemoryProfileGateway(),
keys,
person: 'alice',
);
});
tearDown(() => keys.dispose());
Future<void> openBody(WidgetTester tester) async {
await start(tester, usedBefore: true, profile: profile);
await openAccount(tester);
await tester.tap(find.text('Your body'));
await tester.pumpAndSettle();
}
testWidgets('each figure is optional; those given are kept', (
tester,
) async {
await openBody(tester);
await tester.enterText(
find.widgetWithText(TextFormField, 'Height'),
'172',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Weight'),
'63,5',
);
await tester.scrollUntilVisible(
find.text('Save'),
300,
// The form's own list, not a text field's.
scrollable: find
.descendant(
of: find.byType(ProfileForm),
matching: find.byType(Scrollable),
)
.first,
);
await tester.tap(find.text('Save'));
await tester.runAsync(pumpEventQueue);
await tester.pumpAndSettle();
expect(find.text('Your body'), findsOneWidget, reason: 'the sheet');
expect(
await tester.runAsync(profile.read),
const ProfileContent(heightCm: 172, weightKg: 63.5),
);
});
testWidgets('a figure out of range is marked, and nothing kept', (
tester,
) async {
await openBody(tester);
await tester.enterText(
find.widgetWithText(TextFormField, 'Height'),
'17',
);
await tester.scrollUntilVisible(
find.text('Save'),
300,
// The form's own list, not a text field's.
scrollable: find
.descendant(
of: find.byType(ProfileForm),
matching: find.byType(Scrollable),
)
.first,
);
await tester.tap(find.text('Save'));
await tester.pumpAndSettle();
expect(find.text('100–250'), findsWidgets);
expect(find.text('Check the figures marked.'), findsOneWidget);
expect(
await tester.runAsync(profile.read),
const ProfileContent(),
);
});
testWidgets('what was kept shows again', (tester) async {
await tester.runAsync(
() => profile.save(
const ProfileContent(restingHeartRate: 52, sex: Sex.female),
),
);
await openBody(tester);
expect(find.text('52'), findsOneWidget);
expect(find.text('Female'), findsOneWidget);
});
});
group('Health Connect (ADR 0030, D31)', () {
final at = DateTime.utc(2026, 10, 28, 7, 30);
late Directory folder;

View file

@ -0,0 +1,187 @@
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_payload.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_repository.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/sync_profile_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/profile/model/profile.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_profile_gateway.dart';
/// The person's profile (ADR 0030, D33): the figures of their body, sealed
/// and synced in one row of theirs.
void main() {
final full = ProfileContent(
sex: Sex.female,
birthDate: DateTime.utc(1990, 4, 12),
heightCm: 172,
weightKg: 63.5,
restingHeartRate: 52,
maxHeartRate: 186,
vo2Max: 48,
trainingBackground: TrainingBackground.frequent,
typicalDay: TypicalDay.mostlyStanding,
sleepGoal: const Duration(hours: 7, minutes: 30),
);
group('its payload', () {
test('comes back as it was written, a blank one too', () {
expect(decodeProfile(encodeProfile(full)), full);
expect(
decodeProfile(encodeProfile(const ProfileContent())),
const ProfileContent(),
);
});
test('keeps the birth date as a day and the sleep goal in minutes', () {
final json = jsonDecode(
utf8.decode(Uint8List.sublistView(encodeProfile(full))),
) as Map<String, Object?>;
expect(json['birthDate'], '1990-04-12');
expect(json['sleepGoalMinutes'], 450);
expect(json.containsKey('notGiven'), isFalse);
});
test('of a newer schema, it is skipped; a value it does not know is '
'none', () {
ByteData of(Map<String, Object?> json) => ByteData.sublistView(
Uint8List.fromList(utf8.encode(jsonEncode(json))),
);
expect(decodeProfile(of({'schemaVersion': 2})), isNull);
expect(
decodeProfile(
of({'schemaVersion': 1, 'sex': 'other', 'heightCm': 170}),
),
const ProfileContent(heightCm: 170),
);
});
});
test('the age counts whole years, the birthday itself included', () {
final born = ProfileContent(birthDate: DateTime.utc(1990, 4, 12));
expect(born.ageOn(DateTime.utc(2026, 4, 11)), 35);
expect(born.ageOn(DateTime.utc(2026, 4, 12)), 36);
expect(const ProfileContent().ageOn(DateTime.utc(2026)), isNull);
});
group('the repository', () {
late MemoryProfileGateway rows;
late MemoryContentKeyGateway keyRows;
final opened = <TrainKeys>[];
ProfileRepository device({String person = 'alice'}) {
final keys = TrainKeys(
keyRows,
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
opened.add(keys);
return ProfileRepository(rows, keys, person: person);
}
setUp(() {
rows = MemoryProfileGateway();
keyRows = MemoryContentKeyGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('a person without one has an empty one', () async {
expect(await device().read(), const ProfileContent());
});
test(
"is one row, the same on each of the person's devices, sealed",
() async {
final phone = device();
final laptop = device();
await phone.save(const ProfileContent(heightCm: 172));
await laptop.save(const ProfileContent(heightCm: 173));
expect(rows.rows, hasLength(1));
expect(rows.rows.values.single.keyId, isNotNull);
expect(await phone.read(), const ProfileContent(heightCm: 173));
},
);
test("another person's is another row", () async {
await device().save(const ProfileContent(heightCm: 172));
await device(person: 'bob').save(const ProfileContent(heightCm: 180));
expect(rows.rows, hasLength(2));
});
test("a readable row is never taken as the person's", () async {
final alice = device();
await alice.save(const ProfileContent(heightCm: 172));
final id = rows.rows.keys.single;
rows.rows[id] = (
keyId: null,
nonce: null,
payload: encodeProfile(const ProfileContent(heightCm: 150)),
);
expect(await alice.read(), isNull);
});
test('one moved from another row does not open', () async {
await device(person: 'bob').save(const ProfileContent(heightCm: 180));
final alice = device();
await alice.save(const ProfileContent(heightCm: 172));
final [bobs, alices] = rows.rows.keys.toList();
rows.rows[alices] = rows.rows[bobs]!;
expect(await alice.read(), isNull);
});
});
test('on the sync database, writing it twice keeps one row', () async {
final folder = await Directory.systemTemp.createTemp('profile');
addTearDown(() => folder.delete(recursive: true));
final person = protocol.UuidValue.withValidation(
'01a16000-0000-7000-8000-000000000001',
);
final db = await protocol.Client(
'http://127.0.0.1:9/',
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: person);
addTearDown(db.close);
final sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
addTearDown(sync.dispose);
final keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
addTearDown(keys.dispose);
final profile = ProfileRepository(
SyncProfileGateway(db, sync),
keys,
person: person.uuid,
);
await profile.save(const ProfileContent(heightCm: 172));
await profile.save(full);
expect(await protocol.Profile.db.count(db), 1);
expect(await profile.read(), full);
});
}

View file

@ -0,0 +1,23 @@
import 'dart:async';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/feature/profile/data/profile_gateway.dart';
/// [ProfileGateway] in memory, whose rows a test changes as an instance
/// could.
class MemoryProfileGateway() implements ProfileGateway {
final rows = <String, Envelope>{};
final _remote = StreamController<void>.broadcast();
/// As a sync bringing others' changes.
void bringRemoteChanges() => _remote.add(null);
@override
Future<Envelope?> read(String id) async => rows[id];
@override
Future<void> write(String id, Envelope envelope) async => rows[id] = envelope;
@override
Stream<void> get remoteChanges => _remote.stream;
}