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

+8 -2
View File
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import 'design/app_theme.dart'; import 'design/app_theme.dart';
import 'design/zen_style.dart'; import 'design/zen_style.dart';
import 'exercises/views/exercise_detail_view.dart';
import 'home/home_view.dart'; import 'home/home_view.dart';
import 'localization/app_localizations.dart'; import 'localization/app_localizations.dart';
import 'settings/settings_controller.dart'; import 'settings/settings_controller.dart';
@@ -29,8 +30,13 @@ class ZenXerciseApp extends StatelessWidget {
], ],
supportedLocales: AppLocalizations.supportedLocales, supportedLocales: AppLocalizations.supportedLocales,
onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, onGenerateTitle: (context) => AppLocalizations.of(context).appTitle,
onGenerateRoute: (settings) => onGenerateRoute: (settings) => MaterialPageRoute<Object?>(
MaterialPageRoute<Object?>(settings: settings, builder: (context) => const HomeView()), settings: settings,
builder: (context) => switch (settings.name) {
ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String),
_ => const HomeView(),
},
),
); );
} }
} }
+3 -1
View File
@@ -47,7 +47,9 @@ class AppTheme {
titleLarge: heading(text.titleLarge, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.15), titleLarge: heading(text.titleLarge, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.15),
titleMedium: heading(text.titleMedium, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.15), titleMedium: heading(text.titleMedium, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.15),
titleSmall: heading(text.titleSmall, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.1), titleSmall: heading(text.titleSmall, workout ? FontWeight.w700 : FontWeight.w800, scale: 1.1),
bodyMedium: text.bodyMedium?.copyWith(color: s.ink, height: 1.4), // Barlow's default tracking reads loose at body sizes.
bodyLarge: text.bodyLarge?.copyWith(letterSpacing: workout ? 0.15 : null),
bodyMedium: text.bodyMedium?.copyWith(color: s.ink, height: 1.4, letterSpacing: workout ? 0.1 : null),
labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w700), labelLarge: text.labelLarge?.copyWith(fontWeight: FontWeight.w700),
), ),
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
+7
View File
@@ -25,6 +25,7 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
required this.controlRadius, required this.controlRadius,
required this.motion, required this.motion,
required this.curve, required this.curve,
required this.flipInterval,
required this.headingFont, required this.headingFont,
required this.bodyFont, required this.bodyFont,
}); });
@@ -61,6 +62,9 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
/// for Stretching, short and sharp for Workout. /// for Stretching, short and sharp for Workout.
final Duration motion; final Duration motion;
final Curve curve; final Curve curve;
/// How long each image of an Exercise's Flip Animation stays up.
final Duration flipInterval;
final String headingFont; final String headingFont;
final String bodyFont; final String bodyFont;
@@ -84,6 +88,7 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
controlRadius: 999, controlRadius: 999,
motion: Duration(milliseconds: 700), motion: Duration(milliseconds: 700),
curve: Curves.easeInOutSine, curve: Curves.easeInOutSine,
flipInterval: Duration(milliseconds: 1400),
headingFont: 'Nunito', headingFont: 'Nunito',
bodyFont: 'NunitoSans', bodyFont: 'NunitoSans',
); );
@@ -105,6 +110,7 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
controlRadius: 4, controlRadius: 4,
motion: Duration(milliseconds: 200), motion: Duration(milliseconds: 200),
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
flipInterval: Duration(milliseconds: 900),
headingFont: 'BarlowCondensed', headingFont: 'BarlowCondensed',
bodyFont: 'Barlow', bodyFont: 'Barlow',
); );
@@ -143,6 +149,7 @@ class ZenStyle extends ThemeExtension<ZenStyle> {
controlRadius: lerpDouble(controlRadius.clamp(0, 40), other.controlRadius.clamp(0, 40), t)!, controlRadius: lerpDouble(controlRadius.clamp(0, 40), other.controlRadius.clamp(0, 40), t)!,
motion: discrete.motion, motion: discrete.motion,
curve: discrete.curve, curve: discrete.curve,
flipInterval: discrete.flipInterval,
headingFont: discrete.headingFont, headingFont: discrete.headingFont,
bodyFont: discrete.bodyFont, bodyFont: discrete.bodyFont,
); );
+97
View File
@@ -0,0 +1,97 @@
import 'package:flutter/foundation.dart';
import 'exercise.dart';
/// What the user is looking for in the Exercise Library: a search query plus
/// muscle, equipment and level choices. Choices within one filter widen the
/// result, filters together narrow it.
@immutable
class LibraryFilter {
const LibraryFilter({this.query = '', this.muscles = const {}, this.equipment = const {}, this.levels = const {}});
final String query;
/// Matched against an Exercise's primary muscles only.
final Set<String> muscles;
final Set<String> equipment;
final Set<String> levels;
int get activeChoices => muscles.length + equipment.length + levels.length;
bool get isActive => query.trim().isNotEmpty || activeChoices > 0;
List<Exercise> apply(List<Exercise> exercises) {
final words = query.toLowerCase().split(' ').where((w) => w.isNotEmpty).toList();
return [
for (final e in exercises)
if (_matchesWords(e, words) &&
(muscles.isEmpty || e.primaryMuscles.any(muscles.contains)) &&
(equipment.isEmpty || equipment.contains(e.equipment)) &&
(levels.isEmpty || levels.contains(e.level)))
e,
];
}
static bool _matchesWords(Exercise e, List<String> words) {
final name = e.name.toLowerCase();
return words.every(name.contains);
}
LibraryFilter withQuery(String query) => _copy(query: query);
LibraryFilter toggleMuscle(String muscle) => _copy(muscles: _toggle(muscles, muscle));
LibraryFilter toggleEquipment(String item) => _copy(equipment: _toggle(equipment, item));
LibraryFilter toggleLevel(String level) => _copy(levels: _toggle(levels, level));
/// Drops the muscle, equipment and level choices but keeps the search.
LibraryFilter clearChoices() => LibraryFilter(query: query);
static Set<String> _toggle(Set<String> set, String value) =>
set.contains(value) ? ({...set}..remove(value)) : {...set, value};
LibraryFilter _copy({String? query, Set<String>? muscles, Set<String>? equipment, Set<String>? levels}) =>
LibraryFilter(
query: query ?? this.query,
muscles: muscles ?? this.muscles,
equipment: equipment ?? this.equipment,
levels: levels ?? this.levels,
);
@override
bool operator ==(Object other) =>
other is LibraryFilter &&
other.query == query &&
setEquals(other.muscles, muscles) &&
setEquals(other.equipment, equipment) &&
setEquals(other.levels, levels);
@override
int get hashCode => Object.hash(
query,
Object.hashAllUnordered(muscles),
Object.hashAllUnordered(equipment),
Object.hashAllUnordered(levels),
);
}
/// The choices each filter offers: the values present among a Mode's Exercises.
class FilterOptions {
const FilterOptions({required this.muscles, required this.equipment, required this.levels});
factory FilterOptions.of(List<Exercise> exercises) => FilterOptions(
muscles: ({for (final e in exercises) ...e.primaryMuscles}).toList()..sort(),
equipment: ({for (final e in exercises) ?e.equipment}).toList()..sort(),
levels: [
for (final level in _levelOrder)
if (exercises.any((e) => e.level == level)) level,
],
);
static const _levelOrder = ['beginner', 'intermediate', 'expert'];
final List<String> muscles;
final List<String> equipment;
final List<String> levels;
}
@@ -0,0 +1,271 @@
import 'package:flutter/material.dart';
import '../../design/page_header.dart';
import '../../design/zen_style.dart';
import '../../localization/app_localizations.dart';
import '../../modes/mode.dart';
import '../exercise.dart';
import '../library_filter.dart';
import 'filter_sheet.dart';
import 'library_view.dart';
/// Search, the three filters and the matching Exercises of one Mode. The
/// Library shows it; the Plan editor reuses it to pick Exercises.
class ExerciseBrowser extends StatefulWidget {
const ExerciseBrowser({
super.key,
required this.mode,
required this.exercises,
required this.title,
required this.onTap,
this.bottomPadding = 24,
});
final Mode mode;
final List<Exercise> exercises;
final String title;
final ValueChanged<Exercise> onTap;
/// Room below the list, e.g. for the floating navigation bar.
final double bottomPadding;
@override
State<ExerciseBrowser> createState() => _ExerciseBrowserState();
}
class _ExerciseBrowserState extends State<ExerciseBrowser> {
final _filter = ValueNotifier(const LibraryFilter());
final _search = TextEditingController();
late FilterOptions _options = FilterOptions.of(widget.exercises);
@override
void didUpdateWidget(ExerciseBrowser old) {
super.didUpdateWidget(old);
if (old.exercises != widget.exercises) _options = FilterOptions.of(widget.exercises);
}
@override
void dispose() {
_filter.dispose();
_search.dispose();
super.dispose();
}
void _clearAll() {
_search.clear();
_filter.value = const LibraryFilter();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return ValueListenableBuilder(
valueListenable: _filter,
builder: (context, filter, _) {
final shown = filter.apply(widget.exercises);
final total = widget.exercises.length;
return CustomScrollView(
key: PageStorageKey('browser-${widget.mode.name}'),
slivers: [
SliverToBoxAdapter(
child: PageHeader(
title: widget.title,
subtitle: filter.isActive
? l10n.librarySubtitleFiltered(widget.mode.name, shown.length, total)
: l10n.librarySubtitle(widget.mode.name, total),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: _SearchField(
controller: _search,
hint: l10n.searchHint(widget.mode.name),
onChanged: (q) => _filter.value = filter.withQuery(q),
),
),
),
SliverToBoxAdapter(
child: _FilterRow(
filter: filter,
onOpen: (kind) => showFilterSheet(
context,
kind: kind,
options: _options,
exercises: widget.exercises,
filter: _filter,
),
onClear: () => _filter.value = filter.clearChoices(),
),
),
if (shown.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.only(top: 32, bottom: widget.bottomPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
EmptyState(icon: Icons.search_off_rounded, title: l10n.noMatchesTitle, body: l10n.noMatchesBody),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 0),
child: OutlinedButton(onPressed: _clearAll, child: Text(l10n.clearFilters)),
),
],
),
),
)
else
SliverPadding(
padding: EdgeInsets.fromLTRB(16, 4, 16, widget.bottomPadding),
sliver: SliverList.builder(
itemCount: shown.length,
itemBuilder: (context, i) => Padding(
padding: const EdgeInsets.only(bottom: 10),
child: ExerciseTile(exercise: shown[i], onTap: () => widget.onTap(shown[i])),
),
),
),
],
);
},
);
}
}
class _SearchField extends StatelessWidget {
const _SearchField({required this.controller, required this.hint, required this.onChanged});
final TextEditingController controller;
final String hint;
final ValueChanged<String> onChanged;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final l10n = AppLocalizations.of(context);
return ListenableBuilder(
listenable: controller,
builder: (context, _) => TextField(
controller: controller,
onChanged: onChanged,
textInputAction: TextInputAction.search,
decoration: InputDecoration(
hintText: hint,
hintStyle: TextStyle(color: s.inkMuted),
prefixIcon: Icon(Icons.search_rounded, color: s.inkMuted),
suffixIcon: controller.text.isEmpty
? null
: IconButton(
tooltip: l10n.searchClear,
icon: Icon(Icons.close_rounded, color: s.inkMuted),
onPressed: () {
controller.clear();
onChanged('');
},
),
),
),
);
}
}
class _FilterRow extends StatelessWidget {
const _FilterRow({required this.filter, required this.onOpen, required this.onClear});
final LibraryFilter filter;
final ValueChanged<FilterKind> onOpen;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Row(
children: [
// Up front, so it never scrolls out of reach behind the filters.
if (filter.activeChoices > 0) ...[
Material(
color: s.surfaceSoft,
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 20)),
child: InkWell(
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 20)),
onTap: onClear,
child: Tooltip(
message: l10n.clearFilters,
child: SizedBox.square(dimension: 40, child: Icon(Icons.close_rounded, size: 20, color: s.ink)),
),
),
),
const SizedBox(width: 8),
],
_FilterButton(label: l10n.filterMuscle, count: filter.muscles.length, onTap: () => onOpen(FilterKind.muscle)),
const SizedBox(width: 8),
_FilterButton(
label: l10n.filterEquipment,
count: filter.equipment.length,
onTap: () => onOpen(FilterKind.equipment),
),
const SizedBox(width: 8),
_FilterButton(label: l10n.filterLevel, count: filter.levels.length, onTap: () => onOpen(FilterKind.level)),
],
),
);
}
}
/// A filter's button: quiet when unused, filled with the Mode's accent and a
/// count once something is chosen.
class _FilterButton extends StatelessWidget {
const _FilterButton({required this.label, required this.count, required this.onTap});
final String label;
final int count;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final active = count > 0;
final fg = active ? s.onAccent : s.ink;
final radius = BorderRadius.circular(s.controlRadius.clamp(0, 20));
return Material(
color: active ? s.accent : s.surface,
borderRadius: radius,
child: InkWell(
borderRadius: radius,
onTap: onTap,
child: Container(
height: 40,
padding: const EdgeInsets.fromLTRB(14, 0, 8, 0),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(color: fg, fontWeight: FontWeight.w700),
),
if (active) ...[
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: s.onAccent.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'$count',
style: TextStyle(color: fg, fontWeight: FontWeight.w800, fontSize: 12),
),
),
],
Icon(Icons.expand_more_rounded, size: 20, color: fg),
],
),
),
),
);
}
}
@@ -0,0 +1,194 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../design/zen_style.dart';
import '../../localization/app_localizations.dart';
import '../exercise.dart';
import '../exercise_labels.dart';
import '../exercise_library.dart';
import 'flip_animation.dart';
/// One Exercise: its Flip Animation, facts, muscles and numbered instructions.
class ExerciseDetailView extends StatelessWidget {
const ExerciseDetailView({super.key, required this.exerciseId});
static const routeName = '/exercise';
final String exerciseId;
@override
Widget build(BuildContext context) {
final exercise = context.read<ExerciseLibrary>().byId(exerciseId)!;
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(),
body: ListView(
padding: EdgeInsets.fromLTRB(16, 0, 16, 32 + MediaQuery.paddingOf(context).bottom),
children: [
FlipAnimation(exercise: exercise),
const SizedBox(height: 20),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text(exercise.name, style: text.headlineMedium),
),
const SizedBox(height: 16),
_Facts(exercise: exercise),
const SizedBox(height: 24),
_Section(
title: l10n.detailMainMuscles,
child: _MuscleChips(muscles: exercise.primaryMuscles, strong: true),
),
if (exercise.secondaryMuscles.isNotEmpty)
_Section(
title: l10n.detailAlsoWorks,
child: _MuscleChips(muscles: exercise.secondaryMuscles, strong: false),
),
_Section(
title: l10n.detailHowTo,
child: Column(
children: [
for (final (i, step) in exercise.instructions.indexed)
Padding(
padding: const EdgeInsets.only(bottom: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 28,
height: 28,
alignment: Alignment.center,
decoration: BoxDecoration(
color: s.surfaceSoft,
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 14)),
),
child: Text(
'${i + 1}',
style: TextStyle(fontFamily: s.headingFont, fontWeight: FontWeight.w800, color: s.accent),
),
),
const SizedBox(width: 14),
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(step, style: text.bodyLarge?.copyWith(height: 1.45)),
),
),
],
),
),
],
),
),
],
),
);
}
}
/// Level, equipment and (when known) compound or isolation, side by side.
class _Facts extends StatelessWidget {
const _Facts({required this.exercise});
final Exercise exercise;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final facts = [
(l10n.detailLevel, sentenceCase(exercise.level)),
(l10n.detailEquipment, exercise.equipment == null ? l10n.equipmentNone : sentenceCase(exercise.equipment!)),
if (exercise.mechanic != null) (l10n.detailType, sentenceCase(exercise.mechanic!)),
];
return Row(
children: [
for (final (i, (label, value)) in facts.indexed) ...[
if (i > 0) const SizedBox(width: 8),
Expanded(
child: _Fact(label: label, value: value),
),
],
],
);
}
}
class _Fact extends StatelessWidget {
const _Fact({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
return Container(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
decoration: BoxDecoration(color: s.surface, borderRadius: BorderRadius.circular(s.radius * 0.7)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: text.bodySmall?.copyWith(color: s.inkMuted)),
const SizedBox(height: 2),
Text(value, style: text.titleSmall, maxLines: 1, overflow: TextOverflow.ellipsis),
],
),
);
}
}
class _Section extends StatelessWidget {
const _Section({required this.title, required this.child});
final String title;
final Widget child;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: text.titleMedium),
const SizedBox(height: 10),
child,
],
),
);
}
}
/// Main muscles in the Mode's accent, the others quiet.
class _MuscleChips extends StatelessWidget {
const _MuscleChips({required this.muscles, required this.strong});
final List<String> muscles;
final bool strong;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final m in muscles)
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: strong ? s.accent.withValues(alpha: 0.16) : s.surfaceSoft,
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 16)),
),
child: Text(
sentenceCase(m),
style: TextStyle(color: strong ? s.accent : s.ink, fontWeight: FontWeight.w700),
),
),
],
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import '../../design/zen_style.dart';
import '../../localization/app_localizations.dart';
import '../exercise.dart';
import '../exercise_labels.dart';
import '../library_filter.dart';
/// Which of the three filters a sheet edits.
enum FilterKind { muscle, equipment, level }
/// A bottom sheet of choices for one filter. Changes apply as they are made;
/// the button shows how many Exercises match and closes the sheet.
Future<void> showFilterSheet(
BuildContext context, {
required FilterKind kind,
required FilterOptions options,
required List<Exercise> exercises,
required ValueNotifier<LibraryFilter> filter,
}) => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (context) => _FilterSheet(kind: kind, options: options, exercises: exercises, filter: filter),
);
class _FilterSheet extends StatelessWidget {
const _FilterSheet({required this.kind, required this.options, required this.exercises, required this.filter});
final FilterKind kind;
final FilterOptions options;
final List<Exercise> exercises;
final ValueNotifier<LibraryFilter> filter;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final s = ZenStyle.of(context);
final text = Theme.of(context).textTheme;
final (title, values) = switch (kind) {
FilterKind.muscle => (l10n.filterMuscle, options.muscles),
FilterKind.equipment => (l10n.filterEquipment, options.equipment),
FilterKind.level => (l10n.filterLevel, options.levels),
};
return ValueListenableBuilder(
valueListenable: filter,
builder: (context, current, _) {
final selected = switch (kind) {
FilterKind.muscle => current.muscles,
FilterKind.equipment => current.equipment,
FilterKind.level => current.levels,
};
void toggle(String value) => filter.value = switch (kind) {
FilterKind.muscle => current.toggleMuscle(value),
FilterKind.equipment => current.toggleEquipment(value),
FilterKind.level => current.toggleLevel(value),
};
final count = current.apply(exercises).length;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: text.titleLarge),
const SizedBox(height: 16),
Flexible(
child: SingleChildScrollView(
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final value in values)
FilterChip(
label: Text(sentenceCase(value)),
selected: selected.contains(value),
onSelected: (_) => toggle(value),
selectedColor: s.accent,
checkmarkColor: s.onAccent,
labelStyle: TextStyle(
color: selected.contains(value) ? s.onAccent : s.ink,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 48,
child: FilledButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.showResults(count)),
),
),
],
),
),
);
},
);
}
}
+127
View File
@@ -0,0 +1,127 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../../design/zen_style.dart';
import '../../localization/app_localizations.dart';
import '../exercise.dart';
/// An Exercise's start and end image, alternating at the Mode's pace. With
/// reduced motion, or a single image, it stays on the start image.
class FlipAnimation extends StatefulWidget {
const FlipAnimation({super.key, required this.exercise});
final Exercise exercise;
@override
State<FlipAnimation> createState() => FlipAnimationState();
}
class FlipAnimationState extends State<FlipAnimation> {
Timer? _timer;
Duration? _interval;
var _frame = 0;
/// The image showing: 0 for the start position, 1 for the end.
int get frame => _frame;
@override
void didChangeDependencies() {
super.didChangeDependencies();
final images = widget.exercise.images;
for (final path in images) {
precacheImage(AssetImage(path), context);
}
final interval = ZenStyle.of(context).flipInterval;
final still = images.length < 2 || MediaQuery.disableAnimationsOf(context);
if (still) {
_stop();
_frame = 0;
} else if (_timer == null || interval != _interval) {
_stop();
_interval = interval;
_timer = Timer.periodic(interval, (_) => setState(() => _frame = 1 - _frame));
}
}
void _stop() {
_timer?.cancel();
_timer = null;
}
@override
void dispose() {
_stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
final l10n = AppLocalizations.of(context);
final images = widget.exercise.images;
return Semantics(
image: true,
label: '${widget.exercise.name}, ${_frame == 0 ? l10n.flipStart : l10n.flipEnd}',
child: ClipRRect(
borderRadius: BorderRadius.circular(s.radius),
child: ColoredBox(
// The photos are shot on white; a white well keeps their edges invisible.
color: Colors.white,
child: AspectRatio(
aspectRatio: 3 / 2,
child: images.isEmpty
? Icon(Icons.image_not_supported_outlined, color: s.inkMuted, size: 40)
: Stack(
fit: StackFit.expand,
children: [
AnimatedSwitcher(
duration: s.motion,
switchInCurve: s.curve,
switchOutCurve: s.curve,
layoutBuilder: (current, previous) =>
Stack(fit: StackFit.expand, children: [...previous, ?current]),
child: Image.asset(
images[_frame],
key: ValueKey(_frame),
fit: BoxFit.contain,
gaplessPlayback: true,
),
),
if (images.length > 1) Positioned(right: 12, bottom: 12, child: _FrameDots(frame: _frame)),
],
),
),
),
),
);
}
}
/// Two small marks showing whether the start or end position is up.
class _FrameDots extends StatelessWidget {
const _FrameDots({required this.frame});
final int frame;
@override
Widget build(BuildContext context) {
final s = ZenStyle.of(context);
return Row(
children: [
for (var i = 0; i < 2; i++)
AnimatedContainer(
duration: s.motion,
curve: s.curve,
margin: const EdgeInsets.only(left: 6),
width: i == frame ? 18 : 8,
height: 8,
decoration: BoxDecoration(
color: i == frame ? s.accent : s.line,
borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 4)),
),
),
],
);
}
}
+11 -14
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../design/page_header.dart';
import '../../design/zen_nav_bar.dart'; import '../../design/zen_nav_bar.dart';
import '../../design/zen_style.dart'; import '../../design/zen_style.dart';
import '../../localization/app_localizations.dart'; import '../../localization/app_localizations.dart';
@@ -9,8 +8,10 @@ import '../../settings/settings_controller.dart';
import '../exercise.dart'; import '../exercise.dart';
import '../exercise_labels.dart'; import '../exercise_labels.dart';
import '../exercise_library.dart'; import '../exercise_library.dart';
import 'exercise_browser.dart';
import 'exercise_detail_view.dart';
/// The current Mode's Exercises, by name. /// The current Mode's Exercise Library; an Exercise opens its detail page.
class LibraryView extends StatelessWidget { class LibraryView extends StatelessWidget {
const LibraryView({super.key}); const LibraryView({super.key});
@@ -18,18 +19,14 @@ class LibraryView extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context); final l10n = AppLocalizations.of(context);
final mode = context.watch<SettingsController>().mode; final mode = context.watch<SettingsController>().mode;
final exercises = context.read<ExerciseLibrary>().forMode(mode); return ExerciseBrowser(
final bottomInset = MediaQuery.paddingOf(context).bottom; // Each Mode has its own choices, so switching Mode starts a fresh search.
return ListView.builder( key: ValueKey(mode),
key: PageStorageKey('library-${mode.name}'), mode: mode,
padding: EdgeInsets.only(bottom: ZenNavBar.height + 32 + bottomInset), exercises: context.read<ExerciseLibrary>().forMode(mode),
itemCount: exercises.length + 1, title: l10n.libraryTitle,
itemBuilder: (context, i) => i == 0 bottomPadding: ZenNavBar.height + 32 + MediaQuery.paddingOf(context).bottom,
? PageHeader(title: l10n.libraryTitle, subtitle: l10n.librarySubtitle(mode.name, exercises.length)) onTap: (e) => Navigator.of(context).pushNamed(ExerciseDetailView.routeName, arguments: e.id),
: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 10),
child: ExerciseTile(exercise: exercises[i - 1]),
),
); );
} }
} }
+56 -4
View File
@@ -12,18 +12,70 @@
"plansEmptyBody": "{mode, select, stretching{Put together a stretch routine from the library and it will wait for you here.} other{Put together a workout from the library and it will wait for you here.}}", "plansEmptyBody": "{mode, select, stretching{Put together a stretch routine from the library and it will wait for you here.} other{Put together a workout from the library and it will wait for you here.}}",
"@plansEmptyBody": { "@plansEmptyBody": {
"placeholders": { "placeholders": {
"mode": {"type": "String"} "mode": {
"type": "String"
}
} }
}, },
"libraryTitle": "Library", "libraryTitle": "Library",
"librarySubtitle": "{mode, select, stretching{{count, plural, =1{1 stretch} other{{count} stretches}}} other{{count, plural, =1{1 exercise} other{{count} exercises}}}}", "librarySubtitle": "{mode, select, stretching{{count, plural, =1{1 stretch} other{{count} stretches}}} other{{count, plural, =1{1 exercise} other{{count} exercises}}}}",
"@librarySubtitle": { "@librarySubtitle": {
"placeholders": { "placeholders": {
"mode": {"type": "String"}, "mode": {
"count": {"type": "int"} "type": "String"
},
"count": {
"type": "int"
}
} }
}, },
"historyTitle": "History", "historyTitle": "History",
"historyEmptyTitle": "Nothing logged yet", "historyEmptyTitle": "Nothing logged yet",
"historyEmptyBody": "Finished sessions show up here with their date and duration." "historyEmptyBody": "Finished sessions show up here with their date and duration.",
"librarySubtitleFiltered": "{mode, select, stretching{{shown} of {total} stretches} other{{shown} of {total} exercises}}",
"@librarySubtitleFiltered": {
"placeholders": {
"mode": {
"type": "String"
},
"shown": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"searchHint": "{mode, select, stretching{Search stretches} other{Search exercises}}",
"@searchHint": {
"placeholders": {
"mode": {
"type": "String"
}
}
},
"searchClear": "Clear search",
"filterMuscle": "Muscle",
"filterEquipment": "Equipment",
"filterLevel": "Level",
"clearFilters": "Clear filters",
"showResults": "{count, plural, =0{No results} =1{Show 1 result} other{Show {count} results}}",
"@showResults": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"noMatchesTitle": "No matches",
"noMatchesBody": "Try fewer words, or clear the filters to see everything again.",
"detailMainMuscles": "Main muscles",
"detailAlsoWorks": "Also works",
"detailHowTo": "How to do it",
"detailLevel": "Level",
"detailEquipment": "Equipment",
"detailType": "Type",
"equipmentNone": "None listed",
"flipStart": "Start position",
"flipEnd": "End position"
} }
+114
View File
@@ -183,6 +183,120 @@ abstract class AppLocalizations {
/// In en, this message translates to: /// In en, this message translates to:
/// **'Finished sessions show up here with their date and duration.'** /// **'Finished sessions show up here with their date and duration.'**
String get historyEmptyBody; String get historyEmptyBody;
/// No description provided for @librarySubtitleFiltered.
///
/// In en, this message translates to:
/// **'{mode, select, stretching{{shown} of {total} stretches} other{{shown} of {total} exercises}}'**
String librarySubtitleFiltered(String mode, int shown, int total);
/// No description provided for @searchHint.
///
/// In en, this message translates to:
/// **'{mode, select, stretching{Search stretches} other{Search exercises}}'**
String searchHint(String mode);
/// No description provided for @searchClear.
///
/// In en, this message translates to:
/// **'Clear search'**
String get searchClear;
/// No description provided for @filterMuscle.
///
/// In en, this message translates to:
/// **'Muscle'**
String get filterMuscle;
/// No description provided for @filterEquipment.
///
/// In en, this message translates to:
/// **'Equipment'**
String get filterEquipment;
/// No description provided for @filterLevel.
///
/// In en, this message translates to:
/// **'Level'**
String get filterLevel;
/// No description provided for @clearFilters.
///
/// In en, this message translates to:
/// **'Clear filters'**
String get clearFilters;
/// No description provided for @showResults.
///
/// In en, this message translates to:
/// **'{count, plural, =0{No results} =1{Show 1 result} other{Show {count} results}}'**
String showResults(int count);
/// No description provided for @noMatchesTitle.
///
/// In en, this message translates to:
/// **'No matches'**
String get noMatchesTitle;
/// No description provided for @noMatchesBody.
///
/// In en, this message translates to:
/// **'Try fewer words, or clear the filters to see everything again.'**
String get noMatchesBody;
/// No description provided for @detailMainMuscles.
///
/// In en, this message translates to:
/// **'Main muscles'**
String get detailMainMuscles;
/// No description provided for @detailAlsoWorks.
///
/// In en, this message translates to:
/// **'Also works'**
String get detailAlsoWorks;
/// No description provided for @detailHowTo.
///
/// In en, this message translates to:
/// **'How to do it'**
String get detailHowTo;
/// No description provided for @detailLevel.
///
/// In en, this message translates to:
/// **'Level'**
String get detailLevel;
/// No description provided for @detailEquipment.
///
/// In en, this message translates to:
/// **'Equipment'**
String get detailEquipment;
/// No description provided for @detailType.
///
/// In en, this message translates to:
/// **'Type'**
String get detailType;
/// No description provided for @equipmentNone.
///
/// In en, this message translates to:
/// **'None listed'**
String get equipmentNone;
/// No description provided for @flipStart.
///
/// In en, this message translates to:
/// **'Start position'**
String get flipStart;
/// No description provided for @flipEnd.
///
/// In en, this message translates to:
/// **'End position'**
String get flipEnd;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
@@ -79,4 +79,83 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get historyEmptyBody => String get historyEmptyBody =>
'Finished sessions show up here with their date and duration.'; 'Finished sessions show up here with their date and duration.';
@override
String librarySubtitleFiltered(String mode, int shown, int total) {
String _temp0 = intl.Intl.selectLogic(mode, {
'stretching': '$shown of $total stretches',
'other': '$shown of $total exercises',
});
return '$_temp0';
}
@override
String searchHint(String mode) {
String _temp0 = intl.Intl.selectLogic(mode, {
'stretching': 'Search stretches',
'other': 'Search exercises',
});
return '$_temp0';
}
@override
String get searchClear => 'Clear search';
@override
String get filterMuscle => 'Muscle';
@override
String get filterEquipment => 'Equipment';
@override
String get filterLevel => 'Level';
@override
String get clearFilters => 'Clear filters';
@override
String showResults(int count) {
String _temp0 = intl.Intl.pluralLogic(
count,
locale: localeName,
other: 'Show $count results',
one: 'Show 1 result',
zero: 'No results',
);
return '$_temp0';
}
@override
String get noMatchesTitle => 'No matches';
@override
String get noMatchesBody =>
'Try fewer words, or clear the filters to see everything again.';
@override
String get detailMainMuscles => 'Main muscles';
@override
String get detailAlsoWorks => 'Also works';
@override
String get detailHowTo => 'How to do it';
@override
String get detailLevel => 'Level';
@override
String get detailEquipment => 'Equipment';
@override
String get detailType => 'Type';
@override
String get equipmentNone => 'None listed';
@override
String get flipStart => 'Start position';
@override
String get flipEnd => 'End position';
} }
+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:flutter_test/flutter_test.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.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/modes/mode.dart';
import 'package:zenxercise/src/settings/settings_controller.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, id: id,
name: name ?? id.replaceAll('_', ' '), name: name ?? id.replaceAll('_', ' '),
mode: mode, mode: mode,
category: mode == Mode.stretching ? 'stretching' : 'strength', category: mode == Mode.stretching ? 'stretching' : 'strength',
level: 'beginner', level: level,
equipment: 'body only', equipment: equipment,
primaryMuscles: const ['hamstrings'], primaryMuscles: primary,
secondaryMuscles: const [], secondaryMuscles: secondary,
instructions: const ['Breathe.'], instructions: instructions,
imageCount: imageCount, imageCount: imageCount,
); );
/// Two stretches and a squat. The 90/90 Hamstring's images exist in the real assets.
final sampleLibrary = ExerciseLibrary([ final sampleLibrary = ExerciseLibrary([
exercise('Hamstring_Stretch', Mode.stretching), exercise(
exercise('Calf_Stretch', Mode.stretching), '90_90_Hamstring',
exercise('Barbell_Squat', Mode.workout), 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]. /// The whole app over [library], with preferences starting from [prefs].
Future<SettingsController> pumpApp( Future<SettingsController> pumpApp(
WidgetTester tester, { WidgetTester tester, {
+2 -2
View File
@@ -16,7 +16,7 @@ void main() {
expect(styleOf(tester).mode, Mode.stretching); expect(styleOf(tester).mode, Mode.stretching);
expect(find.text('2 stretches'), findsOneWidget); 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.tap(find.text('Workout'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -26,7 +26,7 @@ void main() {
expect(styleOf(tester).brightness, Brightness.dark); expect(styleOf(tester).brightness, Brightness.dark);
expect(find.text('1 exercise'), findsOneWidget); expect(find.text('1 exercise'), findsOneWidget);
expect(find.text('Barbell Squat'), 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 { 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();
});
}