diff --git a/lib/src/app.dart b/lib/src/app.dart index a21846f..ce9b2a3 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'design/app_theme.dart'; import 'design/zen_style.dart'; +import 'exercises/views/exercise_detail_view.dart'; import 'home/home_view.dart'; import 'localization/app_localizations.dart'; import 'settings/settings_controller.dart'; @@ -29,8 +30,13 @@ class ZenXerciseApp extends StatelessWidget { ], supportedLocales: AppLocalizations.supportedLocales, onGenerateTitle: (context) => AppLocalizations.of(context).appTitle, - onGenerateRoute: (settings) => - MaterialPageRoute(settings: settings, builder: (context) => const HomeView()), + onGenerateRoute: (settings) => MaterialPageRoute( + settings: settings, + builder: (context) => switch (settings.name) { + ExerciseDetailView.routeName => ExerciseDetailView(exerciseId: settings.arguments! as String), + _ => const HomeView(), + }, + ), ); } } diff --git a/lib/src/design/app_theme.dart b/lib/src/design/app_theme.dart index 20df97c..82f1715 100644 --- a/lib/src/design/app_theme.dart +++ b/lib/src/design/app_theme.dart @@ -47,7 +47,9 @@ class AppTheme { titleLarge: heading(text.titleLarge, 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), - 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), ), appBarTheme: AppBarTheme( diff --git a/lib/src/design/zen_style.dart b/lib/src/design/zen_style.dart index 42c9dde..e7e787d 100644 --- a/lib/src/design/zen_style.dart +++ b/lib/src/design/zen_style.dart @@ -25,6 +25,7 @@ class ZenStyle extends ThemeExtension { required this.controlRadius, required this.motion, required this.curve, + required this.flipInterval, required this.headingFont, required this.bodyFont, }); @@ -61,6 +62,9 @@ class ZenStyle extends ThemeExtension { /// for Stretching, short and sharp for Workout. final Duration motion; final Curve curve; + + /// How long each image of an Exercise's Flip Animation stays up. + final Duration flipInterval; final String headingFont; final String bodyFont; @@ -84,6 +88,7 @@ class ZenStyle extends ThemeExtension { controlRadius: 999, motion: Duration(milliseconds: 700), curve: Curves.easeInOutSine, + flipInterval: Duration(milliseconds: 1400), headingFont: 'Nunito', bodyFont: 'NunitoSans', ); @@ -105,6 +110,7 @@ class ZenStyle extends ThemeExtension { controlRadius: 4, motion: Duration(milliseconds: 200), curve: Curves.easeOutCubic, + flipInterval: Duration(milliseconds: 900), headingFont: 'BarlowCondensed', bodyFont: 'Barlow', ); @@ -143,6 +149,7 @@ class ZenStyle extends ThemeExtension { controlRadius: lerpDouble(controlRadius.clamp(0, 40), other.controlRadius.clamp(0, 40), t)!, motion: discrete.motion, curve: discrete.curve, + flipInterval: discrete.flipInterval, headingFont: discrete.headingFont, bodyFont: discrete.bodyFont, ); diff --git a/lib/src/exercises/library_filter.dart b/lib/src/exercises/library_filter.dart new file mode 100644 index 0000000..3489e71 --- /dev/null +++ b/lib/src/exercises/library_filter.dart @@ -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 muscles; + final Set equipment; + final Set levels; + + int get activeChoices => muscles.length + equipment.length + levels.length; + + bool get isActive => query.trim().isNotEmpty || activeChoices > 0; + + List apply(List 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 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 _toggle(Set set, String value) => + set.contains(value) ? ({...set}..remove(value)) : {...set, value}; + + LibraryFilter _copy({String? query, Set? muscles, Set? equipment, Set? 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 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 muscles; + final List equipment; + final List levels; +} diff --git a/lib/src/exercises/views/exercise_browser.dart b/lib/src/exercises/views/exercise_browser.dart new file mode 100644 index 0000000..b013b81 --- /dev/null +++ b/lib/src/exercises/views/exercise_browser.dart @@ -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 exercises; + final String title; + final ValueChanged onTap; + + /// Room below the list, e.g. for the floating navigation bar. + final double bottomPadding; + + @override + State createState() => _ExerciseBrowserState(); +} + +class _ExerciseBrowserState extends State { + 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 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 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), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/exercises/views/exercise_detail_view.dart b/lib/src/exercises/views/exercise_detail_view.dart new file mode 100644 index 0000000..8fe1afb --- /dev/null +++ b/lib/src/exercises/views/exercise_detail_view.dart @@ -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().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 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), + ), + ), + ], + ); + } +} diff --git a/lib/src/exercises/views/filter_sheet.dart b/lib/src/exercises/views/filter_sheet.dart new file mode 100644 index 0000000..5bd3bc2 --- /dev/null +++ b/lib/src/exercises/views/filter_sheet.dart @@ -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 showFilterSheet( + BuildContext context, { + required FilterKind kind, + required FilterOptions options, + required List exercises, + required ValueNotifier filter, +}) => showModalBottomSheet( + 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 exercises; + final ValueNotifier 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)), + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/src/exercises/views/flip_animation.dart b/lib/src/exercises/views/flip_animation.dart new file mode 100644 index 0000000..fe61b07 --- /dev/null +++ b/lib/src/exercises/views/flip_animation.dart @@ -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 createState() => FlipAnimationState(); +} + +class FlipAnimationState extends State { + 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)), + ), + ), + ], + ); + } +} diff --git a/lib/src/exercises/views/library_view.dart b/lib/src/exercises/views/library_view.dart index dfbbb90..dae0ecf 100644 --- a/lib/src/exercises/views/library_view.dart +++ b/lib/src/exercises/views/library_view.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../../design/page_header.dart'; import '../../design/zen_nav_bar.dart'; import '../../design/zen_style.dart'; import '../../localization/app_localizations.dart'; @@ -9,8 +8,10 @@ import '../../settings/settings_controller.dart'; import '../exercise.dart'; import '../exercise_labels.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 { const LibraryView({super.key}); @@ -18,18 +19,14 @@ class LibraryView extends StatelessWidget { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final mode = context.watch().mode; - final exercises = context.read().forMode(mode); - final bottomInset = MediaQuery.paddingOf(context).bottom; - return ListView.builder( - key: PageStorageKey('library-${mode.name}'), - padding: EdgeInsets.only(bottom: ZenNavBar.height + 32 + bottomInset), - itemCount: exercises.length + 1, - itemBuilder: (context, i) => i == 0 - ? PageHeader(title: l10n.libraryTitle, subtitle: l10n.librarySubtitle(mode.name, exercises.length)) - : Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), - child: ExerciseTile(exercise: exercises[i - 1]), - ), + return ExerciseBrowser( + // Each Mode has its own choices, so switching Mode starts a fresh search. + key: ValueKey(mode), + mode: mode, + exercises: context.read().forMode(mode), + title: l10n.libraryTitle, + bottomPadding: ZenNavBar.height + 32 + MediaQuery.paddingOf(context).bottom, + onTap: (e) => Navigator.of(context).pushNamed(ExerciseDetailView.routeName, arguments: e.id), ); } } diff --git a/lib/src/localization/app_en.arb b/lib/src/localization/app_en.arb index 2551654..a99b41c 100644 --- a/lib/src/localization/app_en.arb +++ b/lib/src/localization/app_en.arb @@ -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": { "placeholders": { - "mode": {"type": "String"} + "mode": { + "type": "String" + } } }, "libraryTitle": "Library", "librarySubtitle": "{mode, select, stretching{{count, plural, =1{1 stretch} other{{count} stretches}}} other{{count, plural, =1{1 exercise} other{{count} exercises}}}}", "@librarySubtitle": { "placeholders": { - "mode": {"type": "String"}, - "count": {"type": "int"} + "mode": { + "type": "String" + }, + "count": { + "type": "int" + } } }, "historyTitle": "History", "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" } diff --git a/lib/src/localization/app_localizations.dart b/lib/src/localization/app_localizations.dart index 48358e9..a805298 100644 --- a/lib/src/localization/app_localizations.dart +++ b/lib/src/localization/app_localizations.dart @@ -183,6 +183,120 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Finished sessions show up here with their date and duration.'** 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 diff --git a/lib/src/localization/app_localizations_en.dart b/lib/src/localization/app_localizations_en.dart index 61b09aa..524b0fe 100644 --- a/lib/src/localization/app_localizations_en.dart +++ b/lib/src/localization/app_localizations_en.dart @@ -79,4 +79,83 @@ class AppLocalizationsEn extends AppLocalizations { @override String get historyEmptyBody => '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'; } diff --git a/test/exercises/library_filter_test.dart b/test/exercises/library_filter_test.dart new file mode 100644 index 0000000..fbed1c7 --- /dev/null +++ b/test/exercises/library_filter_test.dart @@ -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 primary = const ['hamstrings'], + List 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 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']); + }); +} diff --git a/test/support/test_app.dart b/test/support/test_app.dart index da1a5e1..e8fb8a8 100644 --- a/test/support/test_app.dart +++ b/test/support/test_app.dart @@ -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 primary = const ['hamstrings'], + List secondary = const [], + String? equipment = 'body only', + String level = 'beginner', + List 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 pumpApp( WidgetTester tester, { diff --git a/test/views/home_view_test.dart b/test/views/home_view_test.dart index 34f39f3..07c0bc3 100644 --- a/test/views/home_view_test.dart +++ b/test/views/home_view_test.dart @@ -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 { diff --git a/test/views/library_view_test.dart b/test/views/library_view_test.dart new file mode 100644 index 0000000..b20e72c --- /dev/null +++ b/test/views/library_view_test.dart @@ -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 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(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(); + }); +}