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:
1 parent
1289fadb98
commit
786dd52e30
16 files changed
+1272
-32
No files matched your search
@@ -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']);
|
||||
});
|
||||
}
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user