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

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;