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:
parent
2acc9fc6b6
commit
de6fe5c15a
17 changed files with 20185 additions and 9 deletions
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in a new issue