feat(train): strength plans and sessions (ADR 0035)
Strength plans of exercises from a catalogue or the person's own, in blocks of one exercise or several done in turn (supersets, circuits), each with its sets, reps or seconds, weights and rests; each a sealed `strength_plan` row. Plans are marked with what the place chosen lacks, and a session offers swaps moving the same way that the place allows. A session runs on every device, set by set with the rest counted down, each weight made up from the place's plates, bars and fixed weights with the plate changes between sets, kept in strength_session.json after each change so one cut off is carried on, and saved as a workout of the new activity Strength with its sets, listed on its page; strength training in Health Connect and FIT, no GPX. Migration 20261102113104496-strength-plans is purely additive: it creates the table strength_plan (id uuid v7, spaceId, keyId, the sealed payload and its nonce), its index on spaceId, and a foreign key to offline_sync_spaces with ON DELETE CASCADE, so deleting a person's sync spaces deletes their plans; then the modules' version rows. The gen_random_uuid_v7 function is created or replaced as in every migration. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
064b911aa2
commit
508994be3a
1 changed files with 27612 additions and 25 deletions
|
|
@ -48,6 +48,10 @@ import 'package:halkyon_train_flutter/src/feature/route/data/route_files.dart';
|
|||
import 'package:halkyon_train_flutter/src/feature/route/data/route_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/data/routing_gateway.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/route/model/route.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_plan_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/data/strength_session_store.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/exercise_catalogue.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/strength/model/strength_plan.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/data/workout_repository.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/model/workout.dart';
|
||||
import 'package:halkyon_train_flutter/src/feature/workout/widget/progress_cards.dart';
|
||||
|
|
@ -73,6 +77,7 @@ import '../support/memory_place_gateway.dart';
|
|||
import '../support/memory_plan_gateway.dart';
|
||||
import '../support/memory_profile_gateway.dart';
|
||||
import '../support/memory_route_gateway.dart';
|
||||
import '../support/memory_strength_plan_gateway.dart';
|
||||
import '../support/memory_workout_gateway.dart';
|
||||
|
||||
void main() {
|
||||
|
|
@ -109,6 +114,8 @@ void main() {
|
|||
PlanRepository? plans,
|
||||
RouteRepository? routes,
|
||||
PlaceRepository? places,
|
||||
StrengthPlanRepository? strengthPlans,
|
||||
StrengthSessionStore? strengthSession,
|
||||
RoutingGateway? routing,
|
||||
PlaceSearchGateway? placeSearch,
|
||||
OfflineMaps? offlineMaps,
|
||||
|
|
@ -239,6 +246,17 @@ void main() {
|
|||
),
|
||||
),
|
||||
chosenPlace: ChosenPlaceStore(),
|
||||
strengthPlans:
|
||||
strengthPlans ??
|
||||
StrengthPlanRepository(
|
||||
MemoryStrengthPlanGateway(),
|
||||
TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
),
|
||||
),
|
||||
strengthSession: strengthSession ?? StrengthSessionStore(),
|
||||
routing: routing ?? FakeRoutingGateway(),
|
||||
placeSearch:
|
||||
placeSearch ?? FakePlaceSearchGateway(isOffered: false),
|
||||
|
|
@ -723,6 +741,157 @@ void main() {
|
|||
});
|
||||
});
|
||||
|
||||
group('strength plans (ADR 0035)', () {
|
||||
late TrainKeys keys;
|
||||
late StrengthPlanRepository plans;
|
||||
late PlaceRepository places;
|
||||
late WorkoutRepository workouts;
|
||||
|
||||
setUp(() async {
|
||||
keys = TrainKeys(
|
||||
MemoryContentKeyGateway(),
|
||||
FakeContentCipher(),
|
||||
() async => FakeAccountCrypto().keysNumbered(1),
|
||||
);
|
||||
await keys.refresh();
|
||||
plans = StrengthPlanRepository(MemoryStrengthPlanGateway(), keys);
|
||||
places = PlaceRepository(MemoryPlaceGateway(), keys);
|
||||
workouts = WorkoutRepository(MemoryWorkoutGateway(), keys);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
await plans.dispose();
|
||||
keys.dispose();
|
||||
});
|
||||
|
||||
Future<void> openPlans(WidgetTester tester) async {
|
||||
await start(
|
||||
tester,
|
||||
usedBefore: true,
|
||||
strengthPlans: plans,
|
||||
places: places,
|
||||
workouts: workouts,
|
||||
);
|
||||
// Without a Record tab, the plans are beside the days in Profile.
|
||||
await goTab(tester, 'Profile');
|
||||
final tab = find.widgetWithText(Tab, 'Plans');
|
||||
await tester.ensureVisible(tab);
|
||||
await tester.tap(tab);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('a plan made of a catalogued exercise is saved with its '
|
||||
'usual sets and listed', (tester) async {
|
||||
await openPlans(tester);
|
||||
await tester.tap(find.text('New strength plan'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final save = find.widgetWithText(TextButton, 'Save');
|
||||
expect(tester.widget<TextButton>(save).onPressed, isNull);
|
||||
await tester.enterText(find.widgetWithText(TextField, 'Name'), 'Day B');
|
||||
await tester.tap(find.text('Add exercise'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).last, 'push');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Push-up'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<TextButton>(save).onPressed, isNotNull);
|
||||
|
||||
await tester.tap(save);
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Day B'), findsOneWidget);
|
||||
expect(find.text('1 exercise · 3 sets'), findsOneWidget);
|
||||
final [kept] = (await tester.runAsync(plans.all))!;
|
||||
expect(kept.content!.exercises.single.id, 'pushUp');
|
||||
});
|
||||
|
||||
testWidgets('a plan says what the place trained at lacks for it', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.runAsync(() async {
|
||||
await places.save(PlaceContent.home('Home'));
|
||||
await plans.save(
|
||||
StrengthPlanContent(
|
||||
name: 'Day A',
|
||||
blocks: [
|
||||
StrengthBlock(
|
||||
exercises: [
|
||||
PlannedExercise(
|
||||
exercise: catalogued('backSquat')!,
|
||||
sets: const [StrengthSet(reps: 5, grams: 60000)],
|
||||
),
|
||||
PlannedExercise(
|
||||
exercise: catalogued('pullUp')!,
|
||||
sets: const [StrengthSet(reps: 8)],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
await openPlans(tester);
|
||||
|
||||
// Home has a pull-up bar, but no rack.
|
||||
expect(find.textContaining('Missing here: a rack'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a session runs set by set with its rest, and is saved as '
|
||||
'a workout listing its sets', (tester) async {
|
||||
await tester.runAsync(
|
||||
() => plans.save(
|
||||
StrengthPlanContent(
|
||||
name: 'Push',
|
||||
blocks: [
|
||||
StrengthBlock(
|
||||
exercises: [
|
||||
PlannedExercise(
|
||||
exercise: catalogued('pushUp')!,
|
||||
sets: const [
|
||||
StrengthSet(reps: 10),
|
||||
StrengthSet(reps: 8),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
await openPlans(tester);
|
||||
await tester.tap(find.byTooltip('Start'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Set 1 of 2'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pump();
|
||||
expect(find.text('Skip rest'), findsOneWidget);
|
||||
await tester.tap(find.text('Skip rest'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Set 2 of 2'), findsOneWidget);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Done'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pump();
|
||||
expect(find.text('Every set is through.'), findsOneWidget);
|
||||
await tester.tap(find.text('Finish and save'));
|
||||
await tester.runAsync(pumpEventQueue);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('2 sets'), findsOneWidget);
|
||||
expect(find.text('Push-up'), findsOneWidget);
|
||||
expect(find.text('10 reps'), findsOneWidget);
|
||||
expect(find.text('8 reps'), findsOneWidget);
|
||||
final [saved] = (await tester.runAsync(workouts.all))!;
|
||||
expect(saved.content!.activity, Activity.strength);
|
||||
expect(saved.content!.strength!.sets, hasLength(2));
|
||||
});
|
||||
});
|
||||
|
||||
group('your body (ADR 0030, D33)', () {
|
||||
late TrainKeys keys;
|
||||
late ProfileRepository profile;
|
||||
|
|
@ -1712,6 +1881,11 @@ void main() {
|
|||
|
||||
// Back on the ride's sheet, its switch as it was left.
|
||||
await closeSheet(tester);
|
||||
await Scrollable.ensureVisible(
|
||||
tester.element(startFor('Cycling')),
|
||||
alignment: 0.5,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(startFor('Cycling'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
|
|
|
|||
Loading…
Reference in a new issue