Stage 2: Library search, filters and Exercise detail with Flip Animation

- LibraryFilter: word search on names; muscle (primary), equipment and
  level choices that widen within a filter and narrow across filters
- ExerciseBrowser (reusable for the Plan picker) with search field,
  filter sheets showing live result counts, a clear button and an
  empty state
- Exercise detail: start/end Flip Animation at each Mode's pace (still
  with reduced motion), facts, main/secondary muscles, numbered steps

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-28 23:33:07 +01:00
1 parent 1289fadb98
commit 786dd52e30
16 files changed
+1272 -32

No files matched your search

+78
View File
@@ -0,0 +1,78 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/exercises/exercise.dart';
import 'package:zenxercise/src/exercises/library_filter.dart';
import 'package:zenxercise/src/modes/mode.dart';
Exercise _exercise(
String name, {
List<String> primary = const ['hamstrings'],
List<String> secondary = const [],
String? equipment = 'body only',
String level = 'beginner',
}) => Exercise(
id: name.replaceAll(' ', '_'),
name: name,
mode: Mode.workout,
category: 'strength',
level: level,
equipment: equipment,
primaryMuscles: primary,
secondaryMuscles: secondary,
instructions: const [],
imageCount: 0,
);
void main() {
final squat = _exercise('Barbell Full Squat', primary: ['quadriceps'], secondary: ['glutes'], equipment: 'barbell');
final curl = _exercise('Dumbbell Bicep Curl', primary: ['biceps'], equipment: 'dumbbell', level: 'intermediate');
final deadlift = _exercise('Romanian Deadlift', primary: ['hamstrings'], equipment: 'barbell', level: 'expert');
final mystery = _exercise('Mystery Move', primary: ['glutes'], equipment: null);
final all = [squat, curl, deadlift, mystery];
List<Exercise> apply(LibraryFilter f) => f.apply(all);
test('an empty filter keeps everything', () {
const filter = LibraryFilter();
expect(filter.isActive, isFalse);
expect(apply(filter), all);
});
test('search matches every word of the query anywhere in the name, ignoring case', () {
expect(apply(const LibraryFilter(query: 'squat')), [squat]);
expect(apply(const LibraryFilter(query: 'barbell SQU')), [squat]);
expect(apply(const LibraryFilter(query: ' dead romanian ')), [deadlift]);
expect(apply(const LibraryFilter(query: 'curl squat')), isEmpty);
});
test('choices within one filter widen, filters together narrow', () {
expect(apply(const LibraryFilter(muscles: {'biceps', 'hamstrings'})), [curl, deadlift]);
expect(apply(const LibraryFilter(muscles: {'biceps', 'hamstrings'}, equipment: {'barbell'})), [deadlift]);
expect(apply(const LibraryFilter(levels: {'expert', 'intermediate'})), [curl, deadlift]);
});
test('the muscle filter looks at the primary muscles only', () {
expect(apply(const LibraryFilter(muscles: {'glutes'})), [mystery]);
});
test('an Exercise without equipment drops out once equipment is filtered', () {
expect(apply(const LibraryFilter(equipment: {'barbell', 'dumbbell', 'body only'})), [squat, curl, deadlift]);
});
test('toggling a choice adds and removes it, and clearing keeps the query', () {
final filter = const LibraryFilter(query: 'curl').toggleMuscle('biceps').toggleLevel('beginner');
expect(filter.muscles, {'biceps'});
expect(filter.toggleMuscle('biceps').muscles, isEmpty);
expect(filter.activeChoices, 2);
expect(filter.clearChoices(), const LibraryFilter(query: 'curl'));
});
test('the options are the values present, levels in their natural order', () {
final options = FilterOptions.of(all);
expect(options.muscles, ['biceps', 'glutes', 'hamstrings', 'quadriceps']);
expect(options.equipment, ['barbell', 'dumbbell']);
expect(options.levels, ['beginner', 'intermediate', 'expert']);
});
}
+36 -9
View File
@@ -1,3 +1,4 @@
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -7,25 +8,51 @@ import 'package:zenxercise/src/exercises/exercise_library.dart';
import 'package:zenxercise/src/modes/mode.dart';
import 'package:zenxercise/src/settings/settings_controller.dart';
Exercise exercise(String id, Mode mode, {String? name, int imageCount = 0}) => Exercise(
Exercise exercise(
String id,
Mode mode, {
String? name,
List<String> primary = const ['hamstrings'],
List<String> secondary = const [],
String? equipment = 'body only',
String level = 'beginner',
List<String> instructions = const ['Breathe.'],
int imageCount = 0,
}) => Exercise(
id: id,
name: name ?? id.replaceAll('_', ' '),
mode: mode,
category: mode == Mode.stretching ? 'stretching' : 'strength',
level: 'beginner',
equipment: 'body only',
primaryMuscles: const ['hamstrings'],
secondaryMuscles: const [],
instructions: const ['Breathe.'],
level: level,
equipment: equipment,
primaryMuscles: primary,
secondaryMuscles: secondary,
instructions: instructions,
imageCount: imageCount,
);
/// Two stretches and a squat. The 90/90 Hamstring's images exist in the real assets.
final sampleLibrary = ExerciseLibrary([
exercise('Hamstring_Stretch', Mode.stretching),
exercise('Calf_Stretch', Mode.stretching),
exercise('Barbell_Squat', Mode.workout),
exercise(
'90_90_Hamstring',
Mode.stretching,
name: '90/90 Hamstring',
secondary: const ['calves'],
instructions: const ['Lie on your back.', 'Straighten one leg.'],
imageCount: 2,
),
exercise('Calf_Stretch', Mode.stretching, primary: const ['calves'], equipment: 'other', level: 'intermediate'),
exercise('Barbell_Squat', Mode.workout, primary: const ['quadriceps'], equipment: 'barbell'),
]);
/// A Pixel-sized screen instead of the default 800×600, so the floating
/// navigation bar does not cover what a test taps.
void usePhoneScreen(WidgetTester tester) {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2.75;
addTearDown(tester.view.reset);
}
/// The whole app over [library], with preferences starting from [prefs].
Future<SettingsController> pumpApp(
WidgetTester tester, {
+2 -2
View File
@@ -16,7 +16,7 @@ void main() {
expect(styleOf(tester).mode, Mode.stretching);
expect(find.text('2 stretches'), findsOneWidget);
expect(find.text('Hamstring Stretch'), findsOneWidget);
expect(find.text('90/90 Hamstring'), findsOneWidget);
await tester.tap(find.text('Workout'));
await tester.pumpAndSettle();
@@ -26,7 +26,7 @@ void main() {
expect(styleOf(tester).brightness, Brightness.dark);
expect(find.text('1 exercise'), findsOneWidget);
expect(find.text('Barbell Squat'), findsOneWidget);
expect(find.text('Hamstring Stretch'), findsNothing);
expect(find.text('90/90 Hamstring'), findsNothing);
});
testWidgets('opens in the last-used Mode', (tester) async {
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:zenxercise/src/exercises/views/exercise_detail_view.dart';
import 'package:zenxercise/src/exercises/views/flip_animation.dart';
import '../support/test_app.dart';
void main() {
Future<void> openLibrary(WidgetTester tester) async {
usePhoneScreen(tester);
await pumpApp(tester);
await tester.tap(find.byTooltip('Library'));
await tester.pumpAndSettle();
}
testWidgets('search narrows the list and the count follows', (tester) async {
await openLibrary(tester);
await tester.enterText(find.byType(TextField), 'calf');
await tester.pumpAndSettle();
expect(find.text('Calf Stretch'), findsOneWidget);
expect(find.text('90/90 Hamstring'), findsNothing);
expect(find.text('1 of 2 stretches'), findsOneWidget);
});
testWidgets('a muscle filter narrows the list and clearing brings everything back', (tester) async {
await openLibrary(tester);
await tester.tap(find.text('Muscle'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilterChip, 'Calves'));
await tester.pumpAndSettle();
await tester.tap(find.text('Show 1 result'));
await tester.pumpAndSettle();
expect(find.text('Calf Stretch'), findsOneWidget);
expect(find.text('90/90 Hamstring'), findsNothing);
await tester.tap(find.byTooltip('Clear filters'));
await tester.pumpAndSettle();
expect(find.text('90/90 Hamstring'), findsOneWidget);
});
testWidgets('no matches says so and offers to clear', (tester) async {
await openLibrary(tester);
await tester.enterText(find.byType(TextField), 'zzz');
await tester.pumpAndSettle();
expect(find.text('No matches'), findsOneWidget);
await tester.tap(find.text('Clear filters'));
await tester.pumpAndSettle();
expect(find.text('90/90 Hamstring'), findsOneWidget);
expect(find.text('zzz'), findsNothing);
});
testWidgets('an Exercise opens its detail page, whose images flip', (tester) async {
await openLibrary(tester);
await tester.tap(find.text('90/90 Hamstring'));
await tester.pumpAndSettle();
expect(find.byType(ExerciseDetailView), findsOneWidget);
int frame() => tester.state<FlipAnimationState>(find.byType(FlipAnimation)).frame;
expect(frame(), 0);
await tester.pump(const Duration(milliseconds: 1500));
expect(frame(), 1);
final page = find.descendant(of: find.byType(ExerciseDetailView), matching: find.byType(Scrollable)).first;
for (final text in ['Hamstrings', 'Calves', 'Lie on your back.']) {
await tester.scrollUntilVisible(find.text(text), 100, scrollable: page);
expect(find.text(text), findsOneWidget);
}
// Leave the page so its flip timer stops.
await tester.pageBack();
await tester.pumpAndSettle();
});
}