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
+8
-2
@@ -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(),
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
@@ -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: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, {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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