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']);
});
}