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
3 changed files with 20185 additions and 9 deletions
|
|
@ -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