feat(train): equipment by place and a plate calculator (ADR 0034)

The places a person trains at (home, a gym), each a sealed, synced
`training_place` row with its equipment: bars and handles with their own
weight, plates by weight and count, dumbbells and kettlebells, the rest
by count, the person's own by name. A new place starts from what a home
or a gym usually has. The place last chosen is remembered on the device,
never its location.

The plate calculator loads each weight both sides alike, the heaviest
load not above it with the fewest plates, heavier inside, and over a
series of sets picks the loads making the fewest plate moves, listing
what comes off and goes on. Equipment is in Settings; strength plans
are to build on it.

The migration only creates the table, its space index and its cascade
to the sync engine's spaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-11-02 11:59:24 +01:00
commit 064b911aa2
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
5 changed files with 24759 additions and 7 deletions

View file

@ -0,0 +1,100 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/data/place_payload.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
ByteData _bytes(Object json) =>
ByteData.sublistView(Uint8List.fromList(utf8.encode(jsonEncode(json))));
/// A place's payload (ADR 0034, D5): what it writes reads back, and what
/// it can't read is left out rather than shown half.
void main() {
test("a home and a gym read back as written, a person's own item too", () {
for (final place in [
PlaceContent.home('Home'),
PlaceContent.gym('Gym'),
const PlaceContent(
name: 'Garage',
items: [
EquipmentItem(kind: EquipmentKind.own, name: 'Sandbag', count: 2),
],
),
]) {
expect(decodePlace(encodePlace(place)), place);
}
});
test('plates are kept in grams, the heaviest first', () {
final json = jsonDecode(
utf8.decode(
Uint8List.sublistView(
encodePlace(
const PlaceContent(
name: 'Home',
items: [
EquipmentItem(
kind: EquipmentKind.plates,
weights: {1250: 4, 20000: 2},
),
],
),
),
),
),
) as Map<String, Object?>;
expect((json['items']! as List<Object?>).single, {
'kind': 'plates',
'count': 1,
'weights': [
[20000, 2],
[1250, 4],
],
});
});
test('a newer schema is skipped whole', () {
expect(
decodePlace(
_bytes({'schemaVersion': 2, 'name': 'Home', 'items': <Object?>[]}),
),
isNull,
);
});
test('an unknown kind and a weight that is none are left out', () {
expect(
decodePlace(
_bytes({
'schemaVersion': 1,
'name': 'Home',
'items': [
{'kind': 'rowingMachine'},
{
'kind': 'plates',
'count': 0,
'weights': [
[5000, 2],
[-1, 2],
[2500, 0],
'x',
],
},
],
}),
),
const PlaceContent(
name: 'Home',
items: [
EquipmentItem(kind: EquipmentKind.plates, weights: {5000: 2}),
],
),
);
});
test('what is no place reads as none', () {
expect(decodePlace(_bytes([1, 2])), isNull);
expect(decodePlace(ByteData(0)), isNull);
});
}

View file

@ -0,0 +1,108 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/data/place_repository.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_place_gateway.dart';
/// The places the person trains at, sealed on the way to the instance and opened
/// on the way back (ADR 0030, D7; ADR 0034), with libsodium.
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late UnlockedAccountKeys bob;
late MemoryContentKeyGateway keyRows;
late MemoryPlaceGateway rows;
final opened = <TrainKeys>[];
final home = PlaceContent.home('Home');
Future<PlaceRepository> device(UnlockedAccountKeys? account) async {
final keys = TrainKeys(keyRows, SodiumContentCipher(), () async => account);
opened.add(keys);
await keys.refresh();
return PlaceRepository(rows, keys);
}
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
bob = (await crypto.newAccount('bob password')).keys;
});
setUp(() {
keyRows = MemoryContentKeyGateway();
rows = MemoryPlaceGateway();
});
tearDown(() {
for (final keys in opened) {
keys.dispose();
}
opened.clear();
});
test('a place made on one device reads on another of the account, '
'edited and deleted there', () async {
final laptop = await device(alice);
final id = await laptop.save(home);
final phone = await device(alice);
expect(await phone.all(), [StoredPlace(id, home)]);
final renamed = home.copyWith(name: 'Home, upstairs');
await phone.update(id, renamed);
expect(await laptop.all(), [StoredPlace(id, renamed)]);
await laptop.delete(id);
expect(await phone.all(), isEmpty);
});
test('nothing the instance gets is readable: no name, no JSON', () async {
await (await device(alice)).save(home);
final sealed = rows.rows.values.single;
expect(sealed.keyId, isNotNull);
expect(
utf8.decode(Uint8List.sublistView(sealed.payload), allowMalformed: true),
isNot(contains('Home')),
);
});
test("another person's place, a readable one, or one moved to another row "
'shows locked, never as theirs', () async {
final id = await (await device(alice)).save(home);
final sealed = rows.rows[id]!;
rows.rows
..['moved'] = sealed
..['readable'] = (
keyId: null,
nonce: null,
payload: ByteData.sublistView(
Uint8List.fromList(utf8.encode('{"schemaVersion":1}')),
),
);
final others = await device(bob);
expect(
(await others.all()).where((place) => place.content != null),
isEmpty,
);
final mine = await device(alice);
expect(
{for (final place in await mine.all()) place.id: place.content},
{id: home, 'moved': null, 'readable': null},
);
});
test('a device without the keys reads nothing and saves nothing', () async {
await (await device(alice)).save(home);
final stranger = await device(null);
expect((await stranger.all()).single.content, isNull);
await expectLater(stranger.save(home), throwsA(isA<KeysMissing>()));
});
}

View file

@ -0,0 +1,92 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/testing.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/bloc/places_cubit.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/data/chosen_place_store.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/data/place_repository.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
import '../../support/memory_content_key_gateway.dart';
import '../../support/memory_place_gateway.dart';
/// The places as Equipment lists them, and the one chosen (ADR 0034, D4).
void main() {
late MemoryPlaceGateway rows;
late TrainKeys keys;
late PlaceRepository places;
late ChosenPlaceStore chosen;
setUp(() async {
rows = MemoryPlaceGateway();
keys = TrainKeys(
MemoryContentKeyGateway(),
FakeContentCipher(),
() async => FakeAccountCrypto().keysNumbered(1),
);
await keys.refresh();
places = PlaceRepository(rows, keys);
chosen = ChosenPlaceStore();
});
tearDown(() async {
await places.dispose();
keys.dispose();
});
test('places by name, the first chosen until another is, which is '
'remembered for the next time', () async {
final gym = await places.save(PlaceContent.gym('Gym'));
final home = await places.save(PlaceContent.home('home'));
final cubit = PlacesCubit(places, chosen);
await cubit.open();
expect([for (final place in cubit.state.all) place.id], [gym, home]);
expect(cubit.state.chosen?.id, gym);
await cubit.choose(home);
expect(cubit.state.chosen?.id, home);
await cubit.close();
final again = PlacesCubit(places, chosen);
await again.open();
expect(again.state.chosen?.id, home);
await again.close();
});
test(
'a place added is chosen; one deleted gives way to the first left',
() async {
final cubit = PlacesCubit(places, chosen);
await cubit.open();
expect(cubit.state.loaded, isTrue);
expect(cubit.state.chosen, isNull);
final home = await cubit.add(PlaceContent.home('Home'));
final gym = await cubit.add(PlaceContent.gym('Gym'));
await pumpEventQueue();
expect(cubit.state.chosen?.id, gym);
await cubit.delete(gym);
await pumpEventQueue();
expect(cubit.state.chosen?.id, home);
await cubit.close();
},
);
test(
"a place this device can't open is listed last and never chosen",
() async {
rows.rows['locked'] = (keyId: 'other', nonce: null, payload: ByteData(0));
final home = await places.save(PlaceContent.home('Home'));
await chosen.write('locked');
final cubit = PlacesCubit(places, chosen);
await cubit.open();
expect([for (final place in cubit.state.all) place.id], [home, 'locked']);
expect(cubit.state.chosen?.id, home);
await cubit.close();
},
);
}

View file

@ -0,0 +1,113 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/model/plate_maths.dart';
void main() {
// A home's plates, in grams by weight: two of each but four 1.25 kg.
const home = {20000: 2, 10000: 2, 5000: 2, 2500: 2, 1250: 4};
group('a load (ADR 0034, D3)', () {
test('the same on both sides, heaviest inside, the plates a side may '
'take only as often as the place has them twice', () {
final load = loadFor(60000, bar: 20000, plates: home)!;
expect(load.side, [20000]);
expect(load.grams, 60000);
expect(load.short, 0);
expect(loadFor(77500, bar: 20000, plates: home)!.side, [
20000,
5000,
2500,
1250,
]);
});
test('never heavier than the target: the heaviest load below it, and '
'how far below', () {
final load = loadFor(61000, bar: 20000, plates: home)!;
expect(load.grams, 60000);
expect(load.short, 1000);
});
test('of loads that weigh the same, the one of fewest plates', () {
// 2 × 1.25 kg or one 2.5 kg a side make 25 kg alike.
expect(loadFor(25000, bar: 20000, plates: home)!.side, [2500]);
});
test('the bar alone where the target is below its first plates; '
'nothing below the bar', () {
expect(loadFor(21000, bar: 20000, plates: home)!.side, isEmpty);
expect(loadFor(15000, bar: 20000, plates: home), isNull);
});
test('a pair of dumbbells takes four of a plate, two handles of two '
'sides', () {
// Four 1.25 kg, two of everything else: only 1.25 kg go on a pair.
final pair = loadFor(10000, bar: 2000, plates: home, implements: 2)!;
expect(pair.side, [1250]);
expect(pair.grams, 4500);
});
test('everything the place has, where the target is beyond it', () {
final load = loadFor(500000, bar: 20000, plates: home)!;
expect(load.side, [20000, 10000, 5000, 2500, 1250, 1250]);
expect(load.grams, 20000 + 2 * 40000);
});
});
group('moves between loads', () {
test('the inner plates stay as long as the next load starts with them; '
'the rest come off from the outside, and the next go on', () {
final change = movesBetween([20000, 5000, 2500], [20000, 10000]);
expect(change.off, [2500, 5000]);
expect(change.on, [10000]);
expect(change.count, 3);
});
test('a series takes, of the loads weighing what each set asks, those '
'that make the fewest moves over the whole series', () {
const plates = {10000: 2, 5000: 4, 2500: 2};
// 40 kg is 10 kg a side, or 5 + 5; 50 kg only 10 + 5. From 10, 50 kg
// adds one plate a side; from 5 + 5 it would change all of them.
final series = loadSeries(
[30000, 40000, 50000],
bar: 20000,
plates: plates,
);
expect(
[for (final step in series) step.load?.side],
[
[5000],
[10000],
[10000, 5000],
],
);
expect(series[2].change!.off, isEmpty);
expect(series[2].change!.on, [5000]);
});
test('a series keeps an inner plate rather than the fewest plates, where '
'that saves moves', () {
const plates = {10000: 2, 5000: 4};
// 40 kg alone is 10 kg a side; after 30 kg (5 kg a side), 5 + 5
// takes one move where 10 takes two.
final series = loadSeries([30000, 40000], bar: 20000, plates: plates);
expect(series[1].load!.side, [5000, 5000]);
expect(series[1].change!.count, 1);
});
test('a target below the bar has no load in a series, and the next '
'starts from the bar', () {
final series = loadSeries([15000, 30000], bar: 20000, plates: home);
expect(series.first.load, isNull);
expect(series[1].change!.on, [5000]);
});
});
}

View file

@ -0,0 +1,89 @@
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_train_client/halkyon_train_client.dart' as protocol;
import 'package:halkyon_train_flutter/src/core/privacy/sync_content_key_gateway.dart';
import 'package:halkyon_train_flutter/src/core/privacy/train_keys.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/data/place_repository.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/data/sync_place_gateway.dart';
import 'package:halkyon_train_flutter/src/feature/equipment/model/equipment.dart';
import 'package:serverpod_offline_sync_client/offline_sync.dart';
// The training places on the device's own database, as the sync engine keeps them,
// with libsodium as its build hook compiles it.
final _person = protocol.UuidValue.withValidation(
'01a16000-0000-7000-8000-000000000001',
);
void main() {
final crypto = SodiumAccountCrypto();
late UnlockedAccountKeys alice;
late Directory folder;
late OfflineSyncDatabaseSession db;
late SyncService sync;
late TrainKeys keys;
late PlaceRepository places;
final home = PlaceContent.home('Home');
setUpAll(() async {
alice = (await crypto.newAccount('alice password')).keys;
});
setUp(() async {
folder = await Directory.systemTemp.createTemp('places');
db = await protocol.Client(
'http://127.0.0.1:9/',
).createSyncSession('${folder.path}/db.sqlite', persistentUserId: _person);
sync = SyncService(
({required onMerge}) async {},
debounce: const Duration(days: 1),
);
keys = TrainKeys(
SyncContentKeyGateway(db, sync),
SodiumContentCipher(),
() async => alice,
);
await keys.refresh();
places = PlaceRepository(SyncPlaceGateway(db, sync), keys);
});
tearDown(() async {
await places.dispose();
keys.dispose();
await sync.dispose();
await db.close();
await folder.delete(recursive: true);
});
test('a place is kept, edited and read back opened', () async {
final id = await places.save(home);
final renamed = home.copyWith(name: 'Home, upstairs');
await places.update(id, renamed);
expect(await places.all(), [StoredPlace(id, renamed)]);
});
test('deleting a place blanks its payload first', () async {
final id = await places.save(home);
final kept = await places.save(home);
await places.delete(id);
expect(await places.all(), [StoredPlace(kept, home)]);
// The sync engine keeps the deleted row as its tombstone: blank.
final rows = await db.db.unsafeQuery(
'SELECT length("payload"), "keyId", "nonce" FROM "training_place"',
);
final blank = [
for (final row in rows)
if (row[0] == 0) row,
];
expect(rows.length - blank.length, 1);
expect(blank, hasLength(1));
expect(blank.single.skip(1), [null, null]);
});
}