diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca14e9a..80fca89 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,14 @@ jobs: working-directory: ./workout-logger run: flutter test --coverage + - name: Upload visual regression failures + if: failure() + uses: actions/upload-artifact@v4 + with: + name: exercise-visual-failures + path: workout-logger/test/failures/**/*.png + if-no-files-found: ignore + - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index 43d2703..97110f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ User-facing changes. Follow [the changelog format](docs/changelog-format.md). ## [Unreleased] +### Features added +- Inspect offline movement illustrations for all 44 built-in exercises, with play/pause, restart, a repetition slider, and playback speed controls in exercise details and active workouts. +- View instructions for six built-in exercise variants and download their curated photo guides individually or all at once from Profile for offline viewing. + ## [2.1.7] ### Features added diff --git a/docs/features/exercise-guides.md b/docs/features/exercise-guides.md new file mode 100644 index 0000000..83b86fb --- /dev/null +++ b/docs/features/exercise-guides.md @@ -0,0 +1,15 @@ +# Exercise guides + +All 44 built-in exercises have bundled movement illustrations in **Exercise guide**, accessible from exercise details and the active workout. Each exact catalogue ID selects an authored joint diagram and equipment configuration. Illustrations work offline with no downloads or additional media dependencies. + +The active workout opens a full guide page with a Back control. The player uses curved body segments, outlined weight plates, and highlighted target regions. Optional photo references appear together in a separate panel; technique instructions expand below them. + +Playback starts paused. **Play / Pause**, **Restart movement**, the repetition slider, and **0.5× / 1.0× / 1.5× speed** let users inspect movement and return phases. Playback pauses when the app backgrounds, the guide leaves the viewport, or another route covers it. The system's reduced-motion setting disables continuous playback while preserving the slider. Plank shows a static hold with breathing cues instead of inventing a repetition. Custom and unknown exercise IDs show an unavailable state. + +These are simplified movement diagrams, not anatomical models or form assessments. The highlighted region indicates the exercise's first catalogue target muscle group. Joint paths illustrate a representative movement; they do not measure muscle stretch or force. + +Six exact built-in exercise variants have instructions and two curated reference photos each. Instructions are bundled with RepForge. Photos download only when a user taps **Download photo guide** on an exercise, or **Download all exercise guides** in Profile. The Profile action saves all 12 photos for offline viewing and can be retried after an interruption. Existing photos are skipped. + +The app stores these fixed photos in its private cache. Users cannot attach or import media. Custom and unsupported exercises have no photo guide. Cached photos are not included in workout backups; users can download them again after reinstalling or restoring a backup. + +Reference content: [Free Exercise DB](https://github.com/yuhonas/free-exercise-db) at commit `f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5`, Unlicense. Guides are matched to exact variants to avoid showing a different movement under a generic exercise name. diff --git a/workout-logger/THIRD_PARTY_NOTICES.md b/workout-logger/THIRD_PARTY_NOTICES.md index 8eb4a6b..5c461eb 100644 --- a/workout-logger/THIRD_PARTY_NOTICES.md +++ b/workout-logger/THIRD_PARTY_NOTICES.md @@ -24,3 +24,7 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Curated exercise instructions and downloadable reference photos are from Free Exercise DB +(https://github.com/yuhonas/free-exercise-db), pinned at commit +f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5 and published under the Unlicense. diff --git a/workout-logger/lib/data/exercise_animations.dart b/workout-logger/lib/data/exercise_animations.dart new file mode 100644 index 0000000..ab2479c --- /dev/null +++ b/workout-logger/lib/data/exercise_animations.dart @@ -0,0 +1,343 @@ +import '../models/models.dart'; + +enum ExerciseMovement { + benchPress, + fly, + pushUp, + dip, + pullDown, + pullUp, + row, + deadlift, + facePull, + shoulderPress, + lateralRaise, + frontRaise, + rearFly, + shrug, + squat, + legPress, + legExtension, + legCurl, + lunge, + hipThrust, + calfRaise, + curl, + pushDown, + skullCrusher, + tricepsExtension, + plank, + crunch, + legRaise, + twist, + cableCrunch, +} + +enum ExerciseEquipment { none, barbell, dumbbell, cable, machine } + +/// Authored movement diagrams for exact catalogue IDs. No name-based guesses +/// are made for custom or downloaded exercises. +class ExerciseAnimation { + const ExerciseAnimation( + this.movement, + this.firstCue, + this.secondCue, { + this.equipment = ExerciseEquipment.none, + this.incline = false, + this.seated = false, + this.hinge = false, + this.supported = false, + }); + + final ExerciseMovement movement; + final ExerciseEquipment equipment; + final String firstCue; + final String secondCue; + final bool incline; + final bool seated; + final bool hinge; + final bool supported; + bool get isHold => movement == ExerciseMovement.plank; + + static ExerciseAnimation? forExercise(Exercise exercise) => + exercise.isCustom ? null : exerciseAnimations[exercise.id]; +} + +const exerciseAnimations = { + 'bench_press': ExerciseAnimation( + ExerciseMovement.benchPress, + 'Press above your chest', + 'Lower toward your chest', + equipment: ExerciseEquipment.barbell, + ), + 'incline_bench_press': ExerciseAnimation( + ExerciseMovement.benchPress, + 'Press above your upper chest', + 'Lower with control', + equipment: ExerciseEquipment.barbell, + incline: true, + ), + 'dumbbell_bench_press': ExerciseAnimation( + ExerciseMovement.benchPress, + 'Press the dumbbells up', + 'Lower beside your chest', + equipment: ExerciseEquipment.dumbbell, + ), + 'incline_dumbbell_press': ExerciseAnimation( + ExerciseMovement.benchPress, + 'Press the dumbbells up', + 'Lower with control', + equipment: ExerciseEquipment.dumbbell, + incline: true, + ), + 'cable_fly': ExerciseAnimation( + ExerciseMovement.fly, + 'Bring your hands together', + 'Open your arms with control', + equipment: ExerciseEquipment.cable, + ), + 'pec_deck': ExerciseAnimation( + ExerciseMovement.fly, + 'Bring the pads together', + 'Return with control', + equipment: ExerciseEquipment.machine, + seated: true, + ), + 'push_ups': ExerciseAnimation( + ExerciseMovement.pushUp, + 'Lower your chest toward the floor', + 'Push the floor away', + ), + 'dips': ExerciseAnimation( + ExerciseMovement.dip, + 'Lower between the bars', + 'Press back up', + ), + 'lat_pulldown': ExerciseAnimation( + ExerciseMovement.pullDown, + 'Pull toward your upper chest', + 'Return the bar overhead', + equipment: ExerciseEquipment.cable, + seated: true, + ), + 'pull_ups': ExerciseAnimation( + ExerciseMovement.pullUp, + 'Pull your body toward the bar', + 'Lower with control', + ), + 'chin_ups': ExerciseAnimation( + ExerciseMovement.pullUp, + 'Pull up with palms facing you', + 'Lower with control', + ), + 'barbell_row': ExerciseAnimation( + ExerciseMovement.row, + 'Pull toward your lower ribs', + 'Extend your arms with control', + equipment: ExerciseEquipment.barbell, + hinge: true, + ), + 'dumbbell_row': ExerciseAnimation( + ExerciseMovement.row, + 'Pull the dumbbell toward your hip', + 'Lower with control', + equipment: ExerciseEquipment.dumbbell, + hinge: true, + supported: true, + ), + 'seated_cable_row': ExerciseAnimation( + ExerciseMovement.row, + 'Pull the handle toward your torso', + 'Reach forward with control', + equipment: ExerciseEquipment.cable, + seated: true, + ), + 't_bar_row': ExerciseAnimation( + ExerciseMovement.row, + 'Pull the handle toward your torso', + 'Lower with control', + equipment: ExerciseEquipment.barbell, + hinge: true, + supported: true, + ), + 'deadlift': ExerciseAnimation( + ExerciseMovement.deadlift, + 'Stand by extending hips and knees', + 'Lower the bar close to your legs', + equipment: ExerciseEquipment.barbell, + ), + 'face_pull': ExerciseAnimation( + ExerciseMovement.facePull, + 'Pull the rope toward your face', + 'Extend your arms with control', + equipment: ExerciseEquipment.cable, + ), + 'overhead_press': ExerciseAnimation( + ExerciseMovement.shoulderPress, + 'Press the bar overhead', + 'Lower toward your shoulders', + equipment: ExerciseEquipment.barbell, + ), + 'dumbbell_shoulder_press': ExerciseAnimation( + ExerciseMovement.shoulderPress, + 'Press the dumbbells overhead', + 'Lower toward your shoulders', + equipment: ExerciseEquipment.dumbbell, + seated: true, + ), + 'lateral_raise': ExerciseAnimation( + ExerciseMovement.lateralRaise, + 'Raise your arms out to the sides', + 'Lower with control', + equipment: ExerciseEquipment.dumbbell, + ), + 'front_raise': ExerciseAnimation( + ExerciseMovement.frontRaise, + 'Raise your arms in front', + 'Lower with control', + equipment: ExerciseEquipment.dumbbell, + ), + 'rear_delt_fly': ExerciseAnimation( + ExerciseMovement.rearFly, + 'Open your arms to the sides', + 'Return with control', + equipment: ExerciseEquipment.dumbbell, + hinge: true, + ), + 'shrugs': ExerciseAnimation( + ExerciseMovement.shrug, + 'Lift your shoulders upward', + 'Lower your shoulders', + equipment: ExerciseEquipment.dumbbell, + ), + 'squat': ExerciseAnimation( + ExerciseMovement.squat, + 'Sit down between your hips', + 'Push through your feet to stand', + equipment: ExerciseEquipment.barbell, + ), + 'leg_press': ExerciseAnimation( + ExerciseMovement.legPress, + 'Press the platform away', + 'Bend your knees with control', + equipment: ExerciseEquipment.machine, + seated: true, + ), + 'leg_extension': ExerciseAnimation( + ExerciseMovement.legExtension, + 'Straighten your knees', + 'Lower with control', + equipment: ExerciseEquipment.machine, + seated: true, + ), + 'leg_curl': ExerciseAnimation( + ExerciseMovement.legCurl, + 'Bend your knees', + 'Lower with control', + equipment: ExerciseEquipment.machine, + ), + 'romanian_deadlift': ExerciseAnimation( + ExerciseMovement.deadlift, + 'Bring your hips forward to stand', + 'Hinge back with soft knees', + equipment: ExerciseEquipment.barbell, + hinge: true, + ), + 'lunges': ExerciseAnimation( + ExerciseMovement.lunge, + 'Lower into a split stance', + 'Push through your front foot to rise', + equipment: ExerciseEquipment.dumbbell, + ), + 'hip_thrust': ExerciseAnimation( + ExerciseMovement.hipThrust, + 'Raise your hips', + 'Lower with control', + equipment: ExerciseEquipment.barbell, + supported: true, + ), + 'calf_raise': ExerciseAnimation( + ExerciseMovement.calfRaise, + 'Rise onto the balls of your feet', + 'Lower your heels', + equipment: ExerciseEquipment.machine, + ), + 'bicep_curl': ExerciseAnimation( + ExerciseMovement.curl, + 'Curl without moving your upper arms', + 'Lower with control', + equipment: ExerciseEquipment.barbell, + ), + 'hammer_curl': ExerciseAnimation( + ExerciseMovement.curl, + 'Curl with palms facing inward', + 'Lower with control', + equipment: ExerciseEquipment.dumbbell, + ), + 'preacher_curl': ExerciseAnimation( + ExerciseMovement.curl, + 'Curl with upper arms on the pad', + 'Extend with control', + equipment: ExerciseEquipment.dumbbell, + seated: true, + supported: true, + ), + 'concentration_curl': ExerciseAnimation( + ExerciseMovement.curl, + 'Curl with your elbow against your thigh', + 'Lower with control', + equipment: ExerciseEquipment.dumbbell, + seated: true, + hinge: true, + ), + 'tricep_pushdown': ExerciseAnimation( + ExerciseMovement.pushDown, + 'Extend your elbows downward', + 'Return with upper arms still', + equipment: ExerciseEquipment.cable, + ), + 'skull_crushers': ExerciseAnimation( + ExerciseMovement.skullCrusher, + 'Extend your elbows above your chest', + 'Bend your elbows with control', + equipment: ExerciseEquipment.barbell, + ), + 'overhead_tricep_extension': ExerciseAnimation( + ExerciseMovement.tricepsExtension, + 'Extend your elbows overhead', + 'Lower behind your head', + equipment: ExerciseEquipment.dumbbell, + ), + 'close_grip_bench': ExerciseAnimation( + ExerciseMovement.benchPress, + 'Press with a close grip', + 'Lower toward your chest', + equipment: ExerciseEquipment.barbell, + ), + 'plank': ExerciseAnimation( + ExerciseMovement.plank, + 'Hold a straight line from head to heels', + 'Breathe steadily and keep your hips level', + ), + 'crunches': ExerciseAnimation( + ExerciseMovement.crunch, + 'Lift your shoulders off the floor', + 'Lower with control', + ), + 'leg_raises': ExerciseAnimation( + ExerciseMovement.legRaise, + 'Raise your legs', + 'Lower with control', + ), + 'russian_twist': ExerciseAnimation( + ExerciseMovement.twist, + 'Rotate your torso to one side', + 'Rotate toward the other side', + ), + 'cable_crunch': ExerciseAnimation( + ExerciseMovement.cableCrunch, + 'Curl your ribs toward your hips', + 'Return with control', + equipment: ExerciseEquipment.cable, + ), +}; diff --git a/workout-logger/lib/data/exercise_demonstrations.dart b/workout-logger/lib/data/exercise_demonstrations.dart new file mode 100644 index 0000000..7431e72 --- /dev/null +++ b/workout-logger/lib/data/exercise_demonstrations.dart @@ -0,0 +1,100 @@ +// Curated exact exercise variants from Free Exercise DB, published under Unlicense. +// Upstream commit: f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5 +class ExerciseDemonstration { + final String title; + final List images; + final List instructions; + const ExerciseDemonstration({ + required this.title, + required this.images, + required this.instructions, + }); +} + +const exerciseDemonstrations = { + 'bench_press': ExerciseDemonstration( + title: "Barbell Bench Press - Medium Grip", + images: [ + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Barbell_Bench_Press_-_Medium_Grip/0.jpg", + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Barbell_Bench_Press_-_Medium_Grip/1.jpg", + ], + instructions: [ + "Lie back on a flat bench. Using a medium width grip (a grip that creates a 90-degree angle in the middle of the movement between the forearms and the upper arms), lift the bar from the rack and hold it straight over you with your arms locked. This will be your starting position.", + "From the starting position, breathe in and begin coming down slowly until the bar touches your middle chest.", + "After a brief pause, push the bar back to the starting position as you breathe out. Focus on pushing the bar using your chest muscles. Lock your arms and squeeze your chest in the contracted position at the top of the motion, hold for a second and then start coming down slowly again. Tip: Ideally, lowering the weight should take about twice as long as raising it.", + "Repeat the movement for the prescribed amount of repetitions.", + "When you are done, place the bar back in the rack.", + ], + ), + 'squat': ExerciseDemonstration( + title: "Barbell Full Squat", + images: [ + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Barbell_Full_Squat/0.jpg", + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Barbell_Full_Squat/1.jpg", + ], + instructions: [ + "This exercise is best performed inside a squat rack for safety purposes. To begin, first set the bar on a rack just above shoulder level. Once the correct height is chosen and the bar is loaded, step under the bar and place the back of your shoulders (slightly below the neck) across it.", + "Hold on to the bar using both arms at each side and lift it off the rack by first pushing with your legs and at the same time straightening your torso.", + "Step away from the rack and position your legs using a shoulder-width medium stance with the toes slightly pointed out. Keep your head up at all times and maintain a straight back. This will be your starting position.", + "Begin to slowly lower the bar by bending the knees and sitting back with your hips as you maintain a straight posture with the head up. Continue down until your hamstrings are on your calves. Inhale as you perform this portion of the movement.", + "Begin to raise the bar as you exhale by pushing the floor with the heel or middle of your foot as you straighten the legs and extend the hips to go back to the starting position.", + "Repeat for the recommended amount of repetitions.", + ], + ), + 'deadlift': ExerciseDemonstration( + title: "Barbell Deadlift", + images: [ + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Barbell_Deadlift/0.jpg", + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Barbell_Deadlift/1.jpg", + ], + instructions: [ + "Stand in front of a loaded barbell.", + "While keeping the back as straight as possible, bend your knees, bend forward and grasp the bar using a medium (shoulder width) overhand grip. This will be the starting position of the exercise. Tip: If it is difficult to hold on to the bar with this grip, alternate your grip or use wrist straps.", + "While holding the bar, start the lift by pushing with your legs while simultaneously getting your torso to the upright position as you breathe out. In the upright position, stick your chest out and contract the back by bringing the shoulder blades back. Think of how the soldiers in the military look when they are in standing in attention.", + "Go back to the starting position by bending at the knees while simultaneously leaning the torso forward at the waist while keeping the back straight. When the weights on the bar touch the floor you are back at the starting position and ready to perform another repetition.", + "Perform the amount of repetitions prescribed in the program.", + ], + ), + 'overhead_press': ExerciseDemonstration( + title: "Standing Military Press", + images: [ + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Standing_Military_Press/0.jpg", + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Standing_Military_Press/1.jpg", + ], + instructions: [ + "Start by placing a barbell that is about chest high on a squat rack. Once you have selected the weights, grab the barbell using a pronated (palms facing forward) grip. Make sure to grip the bar wider than shoulder width apart from each other.", + "Slightly bend the knees and place the barbell on your collar bone. Lift the barbell up keeping it lying on your chest. Take a step back and position your feet shoulder width apart from each other.", + "Once you pick up the barbell with the correct grip length, lift the bar up over your head by locking your arms. Hold at about shoulder level and slightly in front of your head. This is your starting position.", + "Lower the bar down to the collarbone slowly as you inhale.", + "Lift the bar back up to the starting position as you exhale.", + "Repeat for the recommended amount of repetitions.", + ], + ), + 'bicep_curl': ExerciseDemonstration( + title: "Barbell Curl", + images: [ + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Barbell_Curl/0.jpg", + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Barbell_Curl/1.jpg", + ], + instructions: [ + "Stand up with your torso upright while holding a barbell at a shoulder-width grip. The palm of your hands should be facing forward and the elbows should be close to the torso. This will be your starting position.", + "While holding the upper arms stationary, curl the weights forward while contracting the biceps as you breathe out. Tip: Only the forearms should move.", + "Continue the movement until your biceps are fully contracted and the bar is at shoulder level. Hold the contracted position for a second and squeeze the biceps hard.", + "Slowly begin to bring the bar back to starting position as your breathe in.", + "Repeat for the recommended amount of repetitions.", + ], + ), + 'push_ups': ExerciseDemonstration( + title: "Pushups", + images: [ + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Pushups/0.jpg", + "https://raw.githubusercontent.com/yuhonas/free-exercise-db/f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5/exercises/Pushups/1.jpg", + ], + instructions: [ + "Lie on the floor face down and place your hands about 36 inches apart while holding your torso up at arms length.", + "Next, lower yourself downward until your chest almost touches the floor as you inhale.", + "Now breathe out and press your upper body back up to the starting position while squeezing your chest.", + "After a brief pause at the top contracted position, you can begin to lower yourself downward again for as many repetitions as needed.", + ], + ), +}; diff --git a/workout-logger/lib/screens/exercise_guide_screen.dart b/workout-logger/lib/screens/exercise_guide_screen.dart new file mode 100644 index 0000000..310c39f --- /dev/null +++ b/workout-logger/lib/screens/exercise_guide_screen.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../models/models.dart'; +import '../services/exercise_guide_cache.dart'; +import '../theme/app_theme.dart'; +import 'widgets/exercise_demonstration_section.dart'; + +class ExerciseGuideScreen extends StatelessWidget { + const ExerciseGuideScreen({super.key, required this.exercise, this.cache}); + final Exercise exercise; + final ExerciseGuideCache? cache; + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Exercise guide')), + body: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(AppSpacing.md), + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: ExerciseDemonstrationSection( + exercise: exercise, + cache: cache, + ), + ), + ), + ), + ), + ); +} diff --git a/workout-logger/lib/screens/profile_screen.dart b/workout-logger/lib/screens/profile_screen.dart index 2be0e76..c690c05 100644 --- a/workout-logger/lib/screens/profile_screen.dart +++ b/workout-logger/lib/screens/profile_screen.dart @@ -3,6 +3,7 @@ import 'dart:async' show unawaited; import 'dart:convert'; import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; @@ -14,6 +15,7 @@ import 'package:package_info_plus/package_info_plus.dart'; import '../services/workout_provider.dart'; import '../services/settings_provider.dart'; +import '../services/exercise_guide_cache.dart'; import '../services/managers/pr_manager.dart'; import '../services/managers/conversation_manager.dart'; import '../services/ai/gemini_ai_service.dart'; @@ -24,7 +26,9 @@ import '../theme/app_theme.dart'; import 'widgets/profile_sections.dart'; class ProfileScreen extends StatefulWidget { - const ProfileScreen({super.key}); + const ProfileScreen({super.key, this.guideCache}); + + final ExerciseGuideCache? guideCache; @override State createState() => _ProfileScreenState(); @@ -33,6 +37,38 @@ class ProfileScreen extends StatefulWidget { class _ProfileScreenState extends State with WidgetsBindingObserver { bool _isExporting = false; + bool _isDownloadingGuides = false; + int _guidesDownloaded = 0; + + Future _downloadAllGuides() async { + setState(() { + _isDownloadingGuides = true; + _guidesDownloaded = 0; + }); + try { + await (widget.guideCache ?? ExerciseGuideCache.instance).downloadAll( + onProgress: (done, _) { + if (mounted) setState(() => _guidesDownloaded = done); + }, + ); + if (mounted) { + _showSnack( + 'All exercise photo guides are available offline.', + AppColors.success, + ); + } + } catch (_) { + if (mounted) { + _showSnack( + 'Download stopped. $_guidesDownloaded guides saved; tap to retry.', + AppColors.error, + ); + } + } finally { + if (mounted) setState(() => _isDownloadingGuides = false); + } + } + bool _isImporting = false; bool _isRequestingHcPermission = false; bool _isRequestingReadinessPermission = false; @@ -422,6 +458,22 @@ class _ProfileScreenState extends State onImport: _isImporting ? null : _importFromFile, ), const SizedBox(height: AppSpacing.md), + Card( + child: ListTile( + leading: const Icon(Icons.download_for_offline_outlined), + title: const Text('Download all exercise guides'), + subtitle: Text( + _isDownloadingGuides + ? 'Downloading guide ${_guidesDownloaded + 1} of 6…' + : 'Save 12 curated photos for offline viewing', + ), + trailing: _isDownloadingGuides + ? const CircularProgressIndicator() + : const Icon(Icons.chevron_right), + onTap: _isDownloadingGuides ? null : _downloadAllGuides, + ), + ), + const SizedBox(height: AppSpacing.md), const AiSettingsSection(), const SizedBox(height: AppSpacing.md), AboutSection(appVersion: _appVersion), diff --git a/workout-logger/lib/screens/widgets/exercise_animation_player.dart b/workout-logger/lib/screens/widgets/exercise_animation_player.dart new file mode 100644 index 0000000..607bdc9 --- /dev/null +++ b/workout-logger/lib/screens/widgets/exercise_animation_player.dart @@ -0,0 +1,309 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:flutter/widget_previews.dart'; + +import '../../data/exercise_animations.dart'; +import '../../data/exercise_database.dart'; +import '../../models/models.dart'; +import '../../theme/app_theme.dart'; +import 'exercise_movement_painter.dart'; + +/// Offline, user-controlled movement illustrations. Playback is opt-in and +/// stops when hidden, covered by another route, or sent to the background. +class ExerciseAnimationPlayer extends StatefulWidget { + const ExerciseAnimationPlayer({super.key, required this.exercise}); + final Exercise exercise; + + @override + State createState() => + _ExerciseAnimationPlayerState(); +} + +class _ExerciseAnimationPlayerState extends State + with SingleTickerProviderStateMixin, WidgetsBindingObserver { + late final AnimationController _controller; + bool _playing = false; + bool _reducedMotion = false; + double _speed = 1; + ExerciseAnimation? get _animation => + ExerciseAnimation.forExercise(widget.exercise); + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 4), + )..addListener(_checkVisibility); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _reducedMotion = MediaQuery.disableAnimationsOf(context); + if (_reducedMotion || !TickerMode.valuesOf(context).enabled) { + _controller.stop(); + _playing = false; + } + } + + @override + void didUpdateWidget(ExerciseAnimationPlayer oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.exercise.id != oldWidget.exercise.id || + widget.exercise.isCustom != oldWidget.exercise.isCustom) { + _controller.stop(); + _controller.value = 0; + _playing = false; + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) { + _pause(); + } + } + + void _checkVisibility() { + if (!_playing || !mounted) return; + final box = context.findRenderObject(); + if (ModalRoute.of(context)?.isCurrent == false) { + _pause(); + } else if (box is RenderBox && box.hasSize && box.attached) { + final top = box.localToGlobal(Offset.zero).dy; + var viewportTop = 0.0; + var viewportBottom = MediaQuery.sizeOf(context).height; + final viewport = Scrollable.maybeOf(context)?.context.findRenderObject(); + if (viewport is RenderBox && viewport.hasSize && viewport.attached) { + viewportTop = viewport.localToGlobal(Offset.zero).dy; + viewportBottom = viewportTop + viewport.size.height; + } + if (top >= viewportBottom || top + box.size.height <= viewportTop) { + _pause(); + } + } + } + + void _pause() { + _controller.stop(); + if (_playing && mounted) setState(() => _playing = false); + } + + void _toggle() { + if (_playing) { + _pause(); + } else { + setState(() => _playing = true); + _controller.repeat( + period: Duration(milliseconds: (4000 / _speed).round()), + ); + } + } + + void _changeSpeed(double speed) { + setState(() => _speed = speed); + if (_playing) { + _controller.repeat( + period: Duration(milliseconds: (4000 / _speed).round()), + ); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final animation = _animation; + if (animation == null) return const SizedBox.shrink(); + final muscleId = + widget.exercise.muscleActivations.firstOrNull?.muscleGroupId; + final muscleColor = muscleId == null + ? AppColors.primary + : AppColors.muscle(muscleId); + return DecoratedBox( + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.md), + child: AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final progress = _controller.value; + final amount = (1 - math.cos(progress * 2 * math.pi)) / 2; + final returning = progress >= .5; + final cue = returning ? animation.secondCue : animation.firstCue; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.exercise.name, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: AppSpacing.xs), + const Text( + 'Movement illustration', + style: TextStyle(color: AppColors.textSoft), + ), + const SizedBox(height: AppSpacing.sm), + Semantics( + image: true, + label: '${widget.exercise.name} movement illustration. $cue.', + child: RepaintBoundary( + child: SizedBox( + width: double.infinity, + height: 280, + child: CustomPaint( + key: const ValueKey('exercise-movement-canvas'), + painter: ExerciseMovementPainter( + animation: animation, + amount: amount, + muscleColor: muscleColor, + ), + ), + ), + ), + ), + Text( + animation.isHold + ? 'Hold position' + : returning + ? 'Return' + : 'Movement', + style: TextStyle( + color: muscleColor, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: AppSpacing.xs), + Text(cue, style: const TextStyle(color: AppColors.textPrimary)), + if (!animation.isHold) ...[ + Slider( + key: const ValueKey('exercise-movement-slider'), + value: progress, + label: '${(progress * 100).round()}% of repetition', + semanticFormatterCallback: (value) => + '${(value * 100).round()} percent of repetition', + onChangeStart: (_) => _pause(), + onChanged: (value) { + _pause(); + _controller.value = value; + }, + ), + OverflowBar( + spacing: AppSpacing.sm, + overflowSpacing: AppSpacing.sm, + overflowAlignment: OverflowBarAlignment.start, + alignment: MainAxisAlignment.spaceBetween, + children: [ + FilledButton.icon( + onPressed: _reducedMotion ? null : _toggle, + icon: Icon( + _playing + ? Icons.pause_rounded + : Icons.play_arrow_rounded, + ), + label: Text(_playing ? 'Pause' : 'Play'), + style: FilledButton.styleFrom( + minimumSize: const Size(124, 48), + ), + ), + IconButton( + tooltip: 'Restart movement', + onPressed: () { + _pause(); + _controller.value = 0; + }, + icon: const Icon(Icons.replay_rounded), + constraints: const BoxConstraints( + minWidth: 48, + minHeight: 48, + ), + style: IconButton.styleFrom( + side: const BorderSide( + color: AppColors.glassBorderStrong, + ), + ), + ), + PopupMenuButton( + tooltip: 'Playback speed', + initialValue: _speed, + onSelected: _changeSpeed, + itemBuilder: (_) => [ + for (final speed in [.5, 1.0, 1.5]) + PopupMenuItem( + value: speed, + child: Text('$speed× speed'), + ), + ], + child: Container( + constraints: const BoxConstraints(minHeight: 48), + decoration: BoxDecoration( + border: Border.all( + color: AppColors.glassBorderStrong, + ), + borderRadius: BorderRadius.circular(AppRadius.full), + ), + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 14, + ), + child: Text( + '$_speed× speed', + style: const TextStyle( + color: AppColors.textPrimary, + ), + ), + ), + ), + ], + ), + if (_reducedMotion) ...[ + const SizedBox(height: AppSpacing.sm), + const Text( + 'Motion is off. Drag the slider to inspect each position.', + style: TextStyle(color: AppColors.textSoft), + ), + ], + ] else ...[ + const SizedBox(height: AppSpacing.sm), + Text( + animation.secondCue, + style: const TextStyle(color: AppColors.textSoft), + ), + ], + const SizedBox(height: AppSpacing.md), + const Text( + 'Simplified movement diagram. Highlight shows the target muscle group.', + style: TextStyle(color: AppColors.textSoft, fontSize: 12), + ), + ], + ); + }, + ), + ), + ); + } +} + +@Preview(name: 'Squat movement', group: 'Exercise guides', size: Size(360, 600)) +Widget squatMovementPreview() => MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: SingleChildScrollView( + child: ExerciseAnimationPlayer( + exercise: ExerciseDatabase.getAll().firstWhere((e) => e.id == 'squat'), + ), + ), + ), +); diff --git a/workout-logger/lib/screens/widgets/exercise_demonstration_section.dart b/workout-logger/lib/screens/widgets/exercise_demonstration_section.dart new file mode 100644 index 0000000..da0cb2a --- /dev/null +++ b/workout-logger/lib/screens/widgets/exercise_demonstration_section.dart @@ -0,0 +1,217 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../data/exercise_demonstrations.dart'; +import '../../models/models.dart'; +import '../../services/exercise_guide_cache.dart'; +import '../../data/exercise_animations.dart'; +import '../../theme/app_theme.dart'; +import 'exercise_animation_player.dart'; + +class ExerciseDemonstrationSection extends StatefulWidget { + const ExerciseDemonstrationSection({ + super.key, + required this.exercise, + this.cache, + }); + final Exercise exercise; + final ExerciseGuideCache? cache; + + @override + State createState() => + _ExerciseDemonstrationSectionState(); +} + +class _ExerciseDemonstrationSectionState + extends State { + bool _downloading = false; + late Future _downloaded; + ExerciseGuideCache get _cache => widget.cache ?? ExerciseGuideCache.instance; + + @override + void initState() { + super.initState(); + _downloaded = _cache.isDownloaded(widget.exercise.id); + } + + @override + void didUpdateWidget(ExerciseDemonstrationSection oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.exercise.id != oldWidget.exercise.id || + widget.cache != oldWidget.cache) { + _downloaded = _cache.isDownloaded(widget.exercise.id); + _downloading = false; + } + } + + Future _download() async { + final exerciseId = widget.exercise.id; + setState(() => _downloading = true); + try { + await _cache.download(exerciseId); + if (mounted && widget.exercise.id == exerciseId) { + _downloaded = _cache.isDownloaded(exerciseId); + } + } catch (_) { + if (mounted && widget.exercise.id == exerciseId) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Could not download the guide. Check your connection and try again.', + ), + ), + ); + } + } finally { + if (mounted && widget.exercise.id == exerciseId) { + setState(() => _downloading = false); + } + } + } + + @override + Widget build(BuildContext context) { + final guide = widget.exercise.isCustom + ? null + : exerciseDemonstrations[widget.exercise.id]; + final animation = ExerciseAnimation.forExercise(widget.exercise); + if (guide == null && animation == null) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: AppSpacing.md), + child: Text( + 'No movement illustration available for this exercise.', + style: TextStyle(color: AppColors.textSoft), + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (animation != null) + ExerciseAnimationPlayer(exercise: widget.exercise), + if (guide != null) ...[ + const SizedBox(height: AppSpacing.md), + Container( + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: AppColors.card, + borderRadius: BorderRadius.circular(AppRadius.xl), + border: Border.all(color: AppColors.glassBorderStrong), + ), + child: Material( + type: MaterialType.transparency, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Photo reference', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.sm), + Text( + 'Reference variant: ${guide.title}', + style: const TextStyle(color: AppColors.textSoft), + ), + const SizedBox(height: AppSpacing.md), + FutureBuilder( + future: _downloaded, + builder: (context, snapshot) { + if (snapshot.data == true) { + return Row( + children: [ + for ( + var index = 0; + index < guide.images.length; + index++ + ) ...[ + if (index > 0) + const SizedBox(width: AppSpacing.sm), + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular( + AppRadius.md, + ), + child: AspectRatio( + aspectRatio: 1, + child: FutureBuilder( + future: _cache.fileFor( + widget.exercise.id, + index, + ), + builder: (context, fileSnapshot) => + fileSnapshot.hasData + ? Image.file( + fileSnapshot.data!, + fit: BoxFit.contain, + semanticLabel: + '${guide.title}, reference photo ${index + 1}', + errorBuilder: (_, _, _) => + const Center( + child: Text( + 'Photo could not be displayed.', + ), + ), + ) + : const Center( + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ), + ), + ), + ], + ], + ); + } + return OutlinedButton.icon( + onPressed: _downloading ? null : _download, + icon: _downloading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ) + : const Icon(Icons.download_outlined), + label: Text( + _downloading + ? 'Downloading…' + : 'Download photo guide', + ), + ); + }, + ), + const SizedBox(height: AppSpacing.sm), + const Text( + 'Photo guide: Free Exercise DB · Unlicense', + style: TextStyle(color: AppColors.textSoft, fontSize: 12), + ), + const SizedBox(height: AppSpacing.sm), + ExpansionTile( + tilePadding: EdgeInsets.zero, + childrenPadding: EdgeInsets.zero, + title: const Text('Technique instructions'), + children: [ + for (var i = 0; i < guide.instructions.length; i++) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Text( + '${i + 1}. ${guide.instructions[i]}', + style: const TextStyle(color: AppColors.textSoft), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ], + ); + } +} diff --git a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart index e37296b..bc43465 100644 --- a/workout-logger/lib/screens/widgets/exercise_details_sheet.dart +++ b/workout-logger/lib/screens/widgets/exercise_details_sheet.dart @@ -11,6 +11,7 @@ import '../../data/exercise_database.dart'; import '../add_custom_exercise_screen.dart'; import 'rf_widgets.dart'; import 'exercise_muscle_map.dart'; +import 'exercise_demonstration_section.dart'; class ExerciseDetailsSheet extends StatelessWidget { const ExerciseDetailsSheet({ @@ -66,8 +67,8 @@ class ExerciseDetailsSheet extends StatelessWidget { exercise.exerciseType == ExerciseType.timeBased ? Icons.timer_rounded : (exercise.category == 'compound' - ? Icons.fitness_center_rounded - : Icons.accessibility_new_rounded), + ? Icons.fitness_center_rounded + : Icons.accessibility_new_rounded), color: color, size: 26, ), @@ -117,9 +118,8 @@ class ExerciseDetailsSheet extends StatelessWidget { Navigator.of(context).pop(); await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => AddCustomExerciseScreen( - initialExercise: exercise, - ), + builder: (_) => + AddCustomExerciseScreen(initialExercise: exercise), ), ); }, @@ -165,34 +165,49 @@ class ExerciseDetailsSheet extends StatelessWidget { Wrap( spacing: AppSpacing.xs, runSpacing: AppSpacing.xs, - children: exercise.availableHandles!.map((h) => Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: AppColors.cardHigh, - borderRadius: BorderRadius.circular(AppRadius.sm), - border: Border.all(color: AppColors.glassBorder), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.link_rounded, size: 13, color: AppColors.cyan), - const SizedBox(width: 4), - Text( - h, - style: const TextStyle( - color: AppColors.textPrimary, - fontSize: 12, - fontWeight: FontWeight.w500, + children: exercise.availableHandles! + .map( + (h) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 4, + ), + decoration: BoxDecoration( + color: AppColors.cardHigh, + borderRadius: BorderRadius.circular(AppRadius.sm), + border: Border.all(color: AppColors.glassBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + Icons.link_rounded, + size: 13, + color: AppColors.cyan, + ), + const SizedBox(width: 4), + Text( + h, + style: const TextStyle( + color: AppColors.textPrimary, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], ), ), - ], - ), - )).toList(), + ) + .toList(), ), ], const SizedBox(height: AppSpacing.lg), + const RFSectionHeader('Exercise guide'), + const SizedBox(height: AppSpacing.sm), + ExerciseDemonstrationSection(exercise: exercise), + const SizedBox(height: AppSpacing.lg), const RFSectionHeader('Muscle movement'), const SizedBox(height: AppSpacing.sm), ExerciseMuscleMap(exercise: exercise), @@ -273,7 +288,8 @@ class ExerciseDetailsSheet extends StatelessWidget { border: Border.all(color: AppColors.glassBorder), ), child: Text( - (exercise.exerciseType == ExerciseType.timeBased && (s.timeTaken ?? 0) > 0) + (exercise.exerciseType == ExerciseType.timeBased && + (s.timeTaken ?? 0) > 0) ? '${s.timeTaken}s${s.weight > 0 ? " (+${settings.toDisplay(s.weight).toStringAsFixed(0)}${settings.unitLabel})" : ""}' : '${settings.toDisplay(s.weight).toStringAsFixed(settings.toDisplay(s.weight) == settings.toDisplay(s.weight).truncateToDouble() ? 0 : 1)}${settings.unitLabel} × ${s.reps}', style: const TextStyle( diff --git a/workout-logger/lib/screens/widgets/exercise_movement_painter.dart b/workout-logger/lib/screens/widgets/exercise_movement_painter.dart new file mode 100644 index 0000000..a69bcc1 --- /dev/null +++ b/workout-logger/lib/screens/widgets/exercise_movement_painter.dart @@ -0,0 +1,917 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../data/exercise_animations.dart'; +import '../../theme/app_theme.dart'; + +/// A normalized joint diagram. Limbs use two-link inverse kinematics so a +/// moving hand/foot does not stretch the illustrated upper/lower limb. +class ExercisePose { + const ExercisePose({ + required this.shoulder, + required this.hip, + required this.knee, + required this.ankle, + required this.elbow, + required this.hand, + this.otherHand, + this.otherElbow, + this.otherKnee, + this.otherAnkle, + this.front = false, + }); + final Offset shoulder, hip, knee, ankle, elbow, hand; + final Offset? otherHand, otherElbow, otherKnee, otherAnkle; + final bool front; + + Offset get head { + final direction = shoulder - hip; + return shoulder + direction / direction.distance * 24; + } + + static Offset joint( + Offset a, + Offset b, + double upper, + double lower, { + double bend = 1, + }) { + final delta = b - a; + final distance = delta.distance.clamp(0.001, upper + lower - 0.001); + final along = + ((upper * upper - lower * lower + distance * distance) / (2 * distance)) + .clamp(-upper, upper); + final height = math.sqrt(math.max(0, upper * upper - along * along)); + final unit = delta / math.max(delta.distance, 0.001); + return a + unit * along + Offset(-unit.dy, unit.dx) * height * bend; + } + + static ExercisePose at(ExerciseAnimation spec, double amount) { + final t = amount.clamp(0.0, 1.0); + switch (spec.movement) { + case ExerciseMovement.benchPress: + case ExerciseMovement.skullCrusher: + final shoulder = spec.incline + ? const Offset(123, 117) + : const Offset(120, 151); + final hand = spec.movement == ExerciseMovement.skullCrusher + ? Offset.lerp(const Offset(85, 114), const Offset(125, 75), t)! + : Offset.lerp( + shoulder + const Offset(13, -25), + shoulder + const Offset(0, -76), + t, + )!; + final elbow = spec.movement == ExerciseMovement.skullCrusher + ? const Offset(121, 110) + : joint(shoulder, hand, 39, 39, bend: 1); + return ExercisePose( + shoulder: shoulder, + hip: const Offset(200, 155), + knee: const Offset(244, 175), + ankle: const Offset(238, 215), + elbow: elbow, + hand: hand, + ); + case ExerciseMovement.pushUp: + case ExerciseMovement.plank: + final hold = spec.isHold; + final shoulder = Offset(145, hold ? 166 : 124 + 38 * t); + final hip = Offset(235, hold ? 188 : 163 + 20 * t); + final hand = hold ? const Offset(115, 213) : const Offset(137, 213); + return ExercisePose( + shoulder: shoulder, + hip: hip, + knee: Offset.lerp(hip, const Offset(305, 213), .55)!, + ankle: const Offset(305, 213), + elbow: hold + ? const Offset(151, 211) + : joint(shoulder, hand, 46, 46, bend: -1), + hand: hand, + ); + case ExerciseMovement.deadlift: + final hip = Offset(204 - 39 * t, 152 - 30 * t); + final shoulder = Offset(152 + 12 * t, 111 - 57 * t); + final ankle = const Offset(153, 213); + final knee = spec.hinge + ? Offset(160, 167 - 2 * t) + : joint(hip, ankle, 48, 48); + final hand = Offset(150 + 13 * t, 197 - 65 * t); + return ExercisePose( + shoulder: shoulder, + hip: hip, + knee: knee, + ankle: ankle, + elbow: joint(shoulder, hand, 43, 43, bend: -1), + hand: hand, + ); + case ExerciseMovement.squat: + case ExerciseMovement.lunge: + final lunge = spec.movement == ExerciseMovement.lunge; + final hip = Offset( + lunge ? 176 : 167 + 32 * t, + 122 + (lunge ? 41 : 45) * t, + ); + final shoulder = hip + Offset(lunge ? -3 : -8 - 22 * t, -68); + final ankle = Offset(lunge ? 126 : 152, 213); + final knee = joint(hip, ankle, 50, 50); + final hand = lunge + ? shoulder + const Offset(8, 78) + : shoulder + const Offset(24, 14); + return ExercisePose( + shoulder: shoulder, + hip: hip, + knee: knee, + ankle: ankle, + elbow: joint(shoulder, hand, 40, 40), + hand: hand, + otherAnkle: lunge ? const Offset(250, 213) : null, + otherKnee: lunge + ? joint(hip, const Offset(250, 213), 50, 50, bend: 1) + : null, + ); + case ExerciseMovement.legPress: + final ankle = Offset.lerp( + const Offset(213, 123), + const Offset(264, 75), + t, + )!; + const hip = Offset(153, 163); + return ExercisePose( + shoulder: const Offset(112, 113), + hip: hip, + knee: joint(hip, ankle, 67, 65, bend: -1), + ankle: ankle, + elbow: const Offset(115, 153), + hand: const Offset(139, 169), + ); + case ExerciseMovement.legExtension: + final ankle = Offset.lerp( + const Offset(221, 214), + const Offset(277, 158), + t, + )!; + return ExercisePose( + shoulder: const Offset(156, 91), + hip: const Offset(155, 157), + knee: const Offset(219, 158), + ankle: ankle, + elbow: const Offset(146, 131), + hand: const Offset(160, 166), + ); + case ExerciseMovement.legCurl: + final angle = -t * math.pi * .65; + final ankle = + const Offset(231, 150) + + Offset(math.cos(angle), math.sin(angle)) * 59; + return ExercisePose( + shoulder: const Offset(106, 151), + hip: const Offset(176, 150), + knee: const Offset(231, 150), + ankle: ankle, + elbow: const Offset(93, 173), + hand: const Offset(113, 183), + ); + case ExerciseMovement.hipThrust: + final hip = Offset(199, 175 - 36 * t); + return ExercisePose( + shoulder: const Offset(126, 135), + hip: hip, + knee: const Offset(249, 156), + ankle: const Offset(250, 213), + elbow: const Offset(153, 151), + hand: hip, + ); + case ExerciseMovement.crunch: + case ExerciseMovement.legRaise: + case ExerciseMovement.twist: + return _core(spec, t); + default: + return _upright(spec, t); + } + } + + static ExercisePose _core(ExerciseAnimation spec, double t) { + const hip = Offset(183, 193); + if (spec.movement == ExerciseMovement.twist) { + final shoulder = Offset(160 + 12 * t, 131); + final hand = Offset(113 + 112 * t, 154); + return ExercisePose( + shoulder: shoulder, + hip: hip, + knee: const Offset(242, 171), + ankle: const Offset(282, 203), + elbow: joint(shoulder, hand, 35, 35), + hand: hand, + otherHand: hand + const Offset(8, 0), + otherElbow: joint( + shoulder + const Offset(18, 0), + hand + const Offset(8, 0), + 35, + 35, + ), + front: true, + ); + } + final raisingLegs = spec.movement == ExerciseMovement.legRaise; + final angle = -t * math.pi / 2; + final ankle = raisingLegs + ? hip + Offset(math.cos(angle), math.sin(angle)) * 113 + : const Offset(285, 209); + final shoulder = raisingLegs + ? const Offset(105, 193) + : Offset(108 + 15 * t, 193 - 27 * t); + final hand = raisingLegs + ? const Offset(170, 207) + : shoulder + const Offset(-5, -9); + return ExercisePose( + shoulder: shoulder, + hip: hip, + knee: raisingLegs ? Offset.lerp(hip, ankle, .5)! : const Offset(235, 155), + ankle: ankle, + elbow: raisingLegs + ? const Offset(131, 207) + : shoulder + const Offset(29, -9), + hand: hand, + ); + } + + static ExercisePose _upright(ExerciseAnimation spec, double t) { + final front = { + ExerciseMovement.fly, + ExerciseMovement.lateralRaise, + ExerciseMovement.rearFly, + ExerciseMovement.shoulderPress, + ExerciseMovement.facePull, + ExerciseMovement.pullDown, + ExerciseMovement.pullUp, + }.contains(spec.movement); + var hip = spec.seated ? const Offset(164, 150) : const Offset(172, 128); + var shoulder = spec.seated ? const Offset(164, 85) : const Offset(170, 64); + var ankle = spec.seated ? const Offset(226, 213) : const Offset(160, 213); + var knee = spec.seated ? const Offset(218, 154) : const Offset(164, 172); + if (front && !spec.seated) { + knee = const Offset(150, 173); + ankle = const Offset(145, 213); + } + if (front && spec.seated) { + knee = const Offset(145, 173); + ankle = const Offset(145, 213); + } + if (spec.hinge) { + shoulder = const Offset(133, 104); + hip = const Offset(193, 139); + knee = const Offset(159, 174); + } + if (spec.movement == ExerciseMovement.tricepsExtension) { + shoulder += const Offset(0, 16); + hip += const Offset(0, 16); + } + var hand = shoulder + const Offset(8, 78); + Offset? elbow, otherHand, otherElbow; + switch (spec.movement) { + case ExerciseMovement.curl: + final origin = + shoulder + + (spec.supported ? const Offset(-20, 31) : const Offset(1, 40)); + final angle = .35 - t * 2.35; + hand = origin + Offset(math.sin(angle), math.cos(angle)) * 39; + elbow = origin; + case ExerciseMovement.pushDown: + elbow = shoulder + const Offset(5, 39); + hand = + elbow + + Offset(-math.sin((1 - t) * 1.5), math.cos((1 - t) * 1.5)) * 40; + case ExerciseMovement.tricepsExtension: + elbow = shoulder + const Offset(-3, -33); + final angle = (1 - t) * 2.5; + hand = elbow + Offset(math.sin(angle), -math.cos(angle)) * 36; + case ExerciseMovement.shoulderPress: + hand = shoulder + Offset(-54 + 27 * t, -4 - 49 * t); + otherHand = shoulder + Offset(54 - 27 * t, -4 - 49 * t); + case ExerciseMovement.fly: + hand = shoulder + Offset(-75 + 60 * t, 17); + otherHand = shoulder + Offset(75 - 60 * t, 17); + case ExerciseMovement.lateralRaise: + case ExerciseMovement.rearFly: + final angle = .15 + t * math.pi / 2; + hand = shoulder + Offset(-math.sin(angle), math.cos(angle)) * 79; + otherHand = shoulder + Offset(math.sin(angle), math.cos(angle)) * 79; + case ExerciseMovement.frontRaise: + final angle = t * math.pi / 2; + hand = shoulder + Offset(-math.sin(angle), math.cos(angle)) * 79; + case ExerciseMovement.shrug: + shoulder -= Offset(0, 8 * t); + hand = shoulder + const Offset(8, 78); + case ExerciseMovement.calfRaise: + hip -= Offset(0, 10 * t); + shoulder -= Offset(0, 10 * t); + knee -= Offset(0, 10 * t); + ankle -= Offset(0, 10 * t); + hand = shoulder + const Offset(8, 78); + case ExerciseMovement.row: + hand = spec.seated + ? Offset.lerp(const Offset(92, 137), const Offset(146, 145), t)! + : Offset.lerp(const Offset(130, 180), const Offset(178, 146), t)!; + case ExerciseMovement.facePull: + hand = shoulder + Offset(-64 + 24 * t, 6 - 20 * t); + otherHand = shoulder + Offset(64 - 24 * t, 6 - 20 * t); + case ExerciseMovement.pullDown: + case ExerciseMovement.pullUp: + final pullingBody = spec.movement == ExerciseMovement.pullUp; + if (pullingBody) { + shoulder = Offset(170, 94 - 43 * t); + hip = shoulder + const Offset(0, 64); + knee = hip + const Offset(-18, 33); + ankle = knee + const Offset(16, 26); + hand = const Offset(124, 29); + otherHand = const Offset(216, 29); + } else { + hand = shoulder + Offset(-48, -56 + 78 * t); + otherHand = shoulder + Offset(48, -56 + 78 * t); + } + case ExerciseMovement.dip: + shoulder = Offset(168, 63 + 30 * t); + hip = shoulder + const Offset(8, 63); + knee = hip + const Offset(-24, 38); + ankle = knee + const Offset(22, 23); + hand = const Offset(155, 139); + case ExerciseMovement.cableCrunch: + hip = const Offset(179, 155); + shoulder = Offset(167 - 25 * t, 91 + 35 * t); + knee = const Offset(215, 198); + ankle = const Offset(269, 204); + hand = shoulder + const Offset(-9, -12); + default: + break; + } + final armRoot = shoulder + Offset(front ? -13 : 0, 0); + elbow ??= joint(armRoot, hand, 40, 40, bend: front ? -1 : 1); + if (otherHand != null) { + otherElbow = joint(shoulder + const Offset(13, 0), otherHand, 40, 40); + } + return ExercisePose( + shoulder: shoulder, + hip: hip, + knee: knee, + ankle: ankle, + elbow: elbow, + hand: hand, + otherHand: otherHand, + otherElbow: otherElbow, + otherKnee: front && !spec.hinge ? Offset(194, knee.dy) : null, + otherAnkle: front && !spec.hinge ? Offset(195, ankle.dy) : null, + front: front, + ); + } +} + +class ExerciseMovementPainter extends CustomPainter { + ExerciseMovementPainter({ + required this.animation, + required this.amount, + required this.muscleColor, + }); + final ExerciseAnimation animation; + final double amount; + final Color muscleColor; + + @override + void paint(Canvas canvas, Size size) { + final wide = { + ExerciseMovement.benchPress, + ExerciseMovement.skullCrusher, + ExerciseMovement.pushUp, + ExerciseMovement.plank, + ExerciseMovement.legCurl, + ExerciseMovement.hipThrust, + ExerciseMovement.crunch, + ExerciseMovement.legRaise, + }.contains(animation.movement); + final scene = Rect.fromLTWH(wide ? 20 : 40, -10, wide ? 320 : 280, 245); + final scale = math.min( + size.width / scene.width, + size.height / scene.height, + ); + canvas.save(); + canvas.clipRect(Offset.zero & size); + canvas.translate( + (size.width - scene.width * scale) / 2 - scene.left * scale, + (size.height - scene.height * scale) / 2 - scene.top * scale, + ); + canvas.scale(scale); + final pose = ExercisePose.at(animation, amount); + _equipmentBehind(canvas, pose); + _body(canvas, pose); + _equipmentInFront(canvas, pose); + canvas.restore(); + } + + void _line(Canvas c, Offset a, Offset b, Color color, double width) => + c.drawLine( + a, + b, + Paint() + ..color = color + ..strokeWidth = width + ..strokeCap = StrokeCap.round, + ); + void _support(Canvas c, Offset a, Offset b) => + _line(c, a, b, AppColors.textMuted, 5); + + void _equipmentBehind(Canvas c, ExercisePose p) { + _line( + c, + const Offset(45, 221), + const Offset(315, 221), + AppColors.glassBorderStrong, + 1.5, + ); + final movement = animation.movement; + if ({ + ExerciseMovement.benchPress, + ExerciseMovement.skullCrusher, + ExerciseMovement.legCurl, + }.contains(movement)) { + final a = animation.incline + ? const Offset(87, 116) + : const Offset(75, 166); + _line(c, a, const Offset(202, 168), AppColors.textMuted, 10); + _support(c, const Offset(96, 172), const Offset(96, 217)); + _support(c, const Offset(195, 172), const Offset(195, 217)); + } + if (animation.seated || movement == ExerciseMovement.hipThrust) { + final left = movement == ExerciseMovement.hipThrust ? 94.0 : 144.0; + final y = movement == ExerciseMovement.hipThrust ? 143.0 : 169.0; + _line(c, Offset(left, y), Offset(left + 58, y), AppColors.textMuted, 9); + _support(c, Offset(left + 10, y + 5), Offset(left + 10, 217)); + } + if (movement == ExerciseMovement.legPress) { + _line( + c, + const Offset(95, 119), + const Offset(139, 171), + AppColors.textMuted, + 11, + ); + _support(c, const Offset(155, 209), const Offset(291, 66)); + _line( + c, + p.ankle + const Offset(-18, -17), + p.ankle + const Offset(18, 17), + AppColors.textSoft, + 7, + ); + } + if (movement == ExerciseMovement.pullUp) { + _support(c, const Offset(93, 28), const Offset(247, 28)); + _support(c, const Offset(99, 29), const Offset(99, 217)); + _support(c, const Offset(241, 29), const Offset(241, 217)); + } + if (movement == ExerciseMovement.dip) { + _support(c, const Offset(128, 140), const Offset(185, 140)); + _support(c, const Offset(139, 140), const Offset(139, 218)); + } + if (movement == ExerciseMovement.calfRaise) { + _support(c, const Offset(130, 222), const Offset(184, 222)); + _support(c, const Offset(212, 62), const Offset(212, 217)); + _support(c, const Offset(212, 62), p.shoulder + const Offset(12, -4)); + } + if (animation.supported && movement == ExerciseMovement.curl) { + _line( + c, + const Offset(128, 110), + const Offset(147, 147), + AppColors.textMuted, + 9, + ); + _support(c, const Offset(134, 143), const Offset(127, 217)); + } + if (animation.supported && + movement == ExerciseMovement.row && + animation.equipment == ExerciseEquipment.dumbbell) { + _line( + c, + const Offset(208, 137), + const Offset(268, 137), + AppColors.textMuted, + 9, + ); + _support(c, const Offset(250, 142), const Offset(250, 217)); + _line( + c, + p.shoulder + const Offset(8, 0), + const Offset(225, 132), + AppColors.textMuted, + 11, + ); + } + if (animation.equipment == ExerciseEquipment.cable) { + final anchor = switch (movement) { + ExerciseMovement.row => const Offset(55, 139), + ExerciseMovement.fly || + ExerciseMovement.facePull => const Offset(53, 61), + _ => const Offset(128, 14), + }; + _line(c, anchor, p.hand, AppColors.textMuted, 1.5); + c.drawCircle(anchor, 6, Paint()..color = AppColors.textSoft); + if (p.otherHand != null) { + _line(c, const Offset(290, 61), p.otherHand!, AppColors.textMuted, 1.5); + } + } + } + + void _body(Canvas c, ExercisePose p) { + final front = p.front; + final near = AppColors.textPrimary; + final far = near.withValues(alpha: .55); + final legs = { + ExerciseMovement.squat, + ExerciseMovement.lunge, + ExerciseMovement.legPress, + ExerciseMovement.legExtension, + ExerciseMovement.legCurl, + ExerciseMovement.deadlift, + ExerciseMovement.calfRaise, + ExerciseMovement.hipThrust, + }.contains(animation.movement); + final arms = { + ExerciseMovement.curl, + ExerciseMovement.pushDown, + ExerciseMovement.skullCrusher, + ExerciseMovement.tricepsExtension, + ExerciseMovement.shoulderPress, + ExerciseMovement.lateralRaise, + ExerciseMovement.frontRaise, + }.contains(animation.movement); + final calf = animation.movement == ExerciseMovement.calfRaise; + final shift = front ? const Offset(12, 0) : const Offset(10, -2); + final farAnkle = p.otherAnkle ?? p.ankle + shift; + _limb( + c, + p.hip + shift, + p.otherKnee ?? p.knee + shift, + farAnkle, + far, + 24, + highlight: legs ? muscleColor.withValues(alpha: .55) : null, + calf: calf, + ); + _shoe(c, farAnkle, far, calf: calf); + if (p.otherHand != null) { + _limb( + c, + p.shoulder + const Offset(13, 0), + p.otherElbow!, + p.otherHand!, + far, + 16, + highlight: arms ? muscleColor.withValues(alpha: .55) : null, + ); + } else { + _limb( + c, + p.shoulder + shift, + p.elbow + shift, + p.hand + shift, + far, + 16, + highlight: arms ? muscleColor.withValues(alpha: .55) : null, + ); + } + _limb( + c, + p.hip, + p.knee, + p.ankle, + near, + 26, + highlight: legs ? muscleColor : null, + calf: calf, + ); + _shoe(c, p.ankle, near, calf: calf); + + final axis = p.hip - p.shoulder; + final normal = Offset(-axis.dy, axis.dx) / axis.distance; + final upper = p.shoulder + axis * .18; + final waist = p.shoulder + axis * .62; + final torso = Path() + ..moveTo((p.shoulder + normal * 17).dx, (p.shoulder + normal * 17).dy) + ..cubicTo( + (upper + normal * 21).dx, + (upper + normal * 21).dy, + (waist + normal * 11).dx, + (waist + normal * 11).dy, + (p.hip + normal * 16).dx, + (p.hip + normal * 16).dy, + ) + ..quadraticBezierTo( + (p.hip + axis / axis.distance * 10).dx, + (p.hip + axis / axis.distance * 10).dy, + (p.hip - normal * 16).dx, + (p.hip - normal * 16).dy, + ) + ..cubicTo( + (waist - normal * 11).dx, + (waist - normal * 11).dy, + (upper - normal * 21).dx, + (upper - normal * 21).dy, + (p.shoulder - normal * 17).dx, + (p.shoulder - normal * 17).dy, + ) + ..quadraticBezierTo( + p.shoulder.dx, + p.shoulder.dy - 6, + (p.shoulder + normal * 17).dx, + (p.shoulder + normal * 17).dy, + ) + ..close(); + c.drawPath(torso, Paint()..color = AppColors.card); + if (!legs && !arms) { + c.save(); + c.clipPath(torso); + _line( + c, + Offset.lerp(p.shoulder, p.hip, .23)!, + Offset.lerp(p.shoulder, p.hip, .62)!, + muscleColor, + 24, + ); + c.restore(); + } + c.drawPath( + torso, + Paint() + ..color = near + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + _line(c, p.shoulder, p.head, near, 11); + c.drawOval( + Rect.fromCenter(center: p.head, width: 27, height: 31), + Paint()..color = near, + ); + final root = p.shoulder + Offset(front ? -13 : 0, 0); + _limb( + c, + root, + p.elbow, + p.hand, + near, + 18, + highlight: arms ? muscleColor : null, + ); + c.drawCircle(p.hand, 6, Paint()..color = near); + } + + void _limb( + Canvas c, + Offset a, + Offset joint, + Offset end, + Color color, + double width, { + Color? highlight, + bool calf = false, + }) { + _segment( + c, + a, + joint, + width / 2, + width * .36, + color, + highlight: calf ? null : highlight, + ); + _segment( + c, + joint, + end, + width * .36, + width * .24, + color, + highlight: calf ? highlight : null, + ); + c.drawCircle(joint, width * .23, Paint()..color = AppColors.card); + c.drawCircle( + joint, + width * .23, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2, + ); + } + + void _segment( + Canvas c, + Offset a, + Offset b, + double startRadius, + double endRadius, + Color color, { + Color? highlight, + }) { + final delta = b - a; + if (delta.distance < .001) return; + final direction = delta / delta.distance; + final normal = Offset(-direction.dy, direction.dx); + final center = Offset.lerp(a, b, .5)!; + final path = Path() + ..moveTo((a + normal * startRadius).dx, (a + normal * startRadius).dy) + ..quadraticBezierTo( + (center + normal * (startRadius + 3)).dx, + (center + normal * (startRadius + 3)).dy, + (b + normal * endRadius).dx, + (b + normal * endRadius).dy, + ) + ..quadraticBezierTo( + (b + direction * endRadius).dx, + (b + direction * endRadius).dy, + (b - normal * endRadius).dx, + (b - normal * endRadius).dy, + ) + ..quadraticBezierTo( + (center - normal * (startRadius + 3)).dx, + (center - normal * (startRadius + 3)).dy, + (a - normal * startRadius).dx, + (a - normal * startRadius).dy, + ) + ..quadraticBezierTo( + (a - direction * startRadius).dx, + (a - direction * startRadius).dy, + (a + normal * startRadius).dx, + (a + normal * startRadius).dy, + ) + ..close(); + c.drawPath(path, Paint()..color = highlight ?? color); + c.drawPath( + path, + Paint() + ..color = highlight == null ? AppColors.card : color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.4, + ); + } + + void _shoe(Canvas c, Offset ankle, Color color, {bool calf = false}) { + final toe = calf ? Offset(146, 219) : ankle + const Offset(-19, 7); + final path = Path() + ..moveTo(ankle.dx - 5, ankle.dy - 2) + ..quadraticBezierTo(ankle.dx - 10, ankle.dy + 2, toe.dx, toe.dy - 4) + ..quadraticBezierTo(toe.dx - 4, toe.dy, toe.dx, toe.dy + 1) + ..lineTo(ankle.dx + 8, ankle.dy + 8) + ..quadraticBezierTo(ankle.dx + 8, ankle.dy, ankle.dx + 4, ankle.dy - 3) + ..close(); + c.drawPath(path, Paint()..color = AppColors.card); + c.drawPath( + path, + Paint() + ..color = color + ..style = PaintingStyle.stroke + ..strokeWidth = 1.6, + ); + } + + void _weight(Canvas c, Offset hand, {bool vertical = false}) { + final axis = vertical ? const Offset(0, 13) : const Offset(15, 0); + _line(c, hand - axis, hand + axis, AppColors.cyan, 4); + for (final sign in [-1.0, 1.0]) { + final center = hand + axis * sign; + c.drawRRect( + RRect.fromRectAndRadius( + Rect.fromCenter( + center: center, + width: vertical ? 19 : 7, + height: vertical ? 7 : 23, + ), + const Radius.circular(2), + ), + Paint()..color = AppColors.cyan, + ); + } + } + + void _equipmentInFront(Canvas c, ExercisePose p) { + if (animation.movement == ExerciseMovement.row && + animation.supported && + animation.equipment == ExerciseEquipment.barbell) { + const pivot = Offset(292, 217); + _line(c, pivot, p.hand, AppColors.textMuted, 4); + c.drawCircle( + Offset.lerp(p.hand, pivot, .16)!, + 13, + Paint()..color = AppColors.cyan, + ); + _line( + c, + p.hand - const Offset(10, 0), + p.hand + const Offset(10, 0), + AppColors.textPrimary, + 4, + ); + return; + } + if (animation.equipment == ExerciseEquipment.barbell) { + var center = p.hand; + if (animation.movement == ExerciseMovement.squat) { + center = p.shoulder + const Offset(3, -6); + } + if (animation.movement == ExerciseMovement.hipThrust) { + center = p.hip; + } + final half = p.front ? 64.0 : 31.0; + _line( + c, + center - Offset(half, 0), + center + Offset(half, 0), + AppColors.cyan, + 4, + ); + for (final sign in [-1.0, 1.0]) { + final plate = center + Offset(half * .8 * sign, 0); + final rect = Rect.fromCenter( + center: plate, + width: p.front ? 12 : 20, + height: 38, + ); + c.drawOval( + rect.shift(const Offset(3, 0)), + Paint()..color = AppColors.card, + ); + c.drawOval( + rect.shift(const Offset(3, 0)), + Paint() + ..color = AppColors.cyan + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + c.drawOval(rect, Paint()..color = AppColors.card); + c.drawOval( + rect, + Paint() + ..color = AppColors.cyan + ..style = PaintingStyle.stroke + ..strokeWidth = 2, + ); + c.drawCircle(plate, 3, Paint()..color = AppColors.cyan); + } + } else if (animation.equipment == ExerciseEquipment.dumbbell) { + _weight(c, p.hand, vertical: animation.movement == ExerciseMovement.curl); + if (p.otherHand != null) { + _weight(c, p.otherHand!); + } + } else if (animation.equipment == ExerciseEquipment.cable) { + if (animation.movement == ExerciseMovement.pullDown) { + _line( + c, + p.hand - const Offset(10, 0), + p.otherHand! + const Offset(10, 0), + AppColors.cyan, + 4, + ); + } else { + _line( + c, + p.hand - const Offset(5, 4), + p.hand + const Offset(5, 4), + AppColors.cyan, + 4, + ); + } + } else if (animation.movement == ExerciseMovement.fly) { + for (final hand in [p.hand, p.otherHand!]) { + _line( + c, + hand + const Offset(0, -8), + hand + const Offset(0, 8), + AppColors.cyan, + 6, + ); + } + } else if (animation.movement == ExerciseMovement.calfRaise) { + _line( + c, + p.shoulder + const Offset(-12, -4), + p.shoulder + const Offset(12, -4), + AppColors.cyan, + 7, + ); + } else if (animation.movement == ExerciseMovement.legCurl || + animation.movement == ExerciseMovement.legExtension) { + _line( + c, + p.ankle - const Offset(8, 0), + p.ankle + const Offset(8, 0), + AppColors.cyan, + 9, + ); + } + } + + @override + bool shouldRepaint(ExerciseMovementPainter oldDelegate) => + oldDelegate.amount != amount || + oldDelegate.animation != animation || + oldDelegate.muscleColor != muscleColor; +} diff --git a/workout-logger/lib/screens/workout_flow_screen.dart b/workout-logger/lib/screens/workout_flow_screen.dart index 24ba056..8ea8603 100644 --- a/workout-logger/lib/screens/workout_flow_screen.dart +++ b/workout-logger/lib/screens/workout_flow_screen.dart @@ -20,6 +20,9 @@ import 'widgets/rf_dialogs.dart'; import 'widgets/workout_header.dart'; import 'widgets/exercise_input_section.dart'; import 'widgets/rest_timer_view.dart'; +import 'exercise_guide_screen.dart'; +import '../data/exercise_demonstrations.dart'; +import '../data/exercise_animations.dart'; class WorkoutFlowScreen extends StatefulWidget { final Routine? routine; @@ -333,6 +336,22 @@ class _WorkoutFlowScreenState extends State { onSetRestTime: (s) => setState(() => _restSeconds = s), restSeconds: _restSeconds, ), + if (exercise != null && + !exercise.isCustom && + (ExerciseAnimation.forExercise(exercise) != null || + exerciseDemonstrations.containsKey(exercise.id))) + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.play_circle_outline_rounded), + label: const Text('Exercise guide'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ExerciseGuideScreen(exercise: exercise), + ), + ), + ), + ), Expanded( // minHeight + IntrinsicHeight let the section's Spacer do its work. child: LayoutBuilder( diff --git a/workout-logger/lib/services/exercise_guide_cache.dart b/workout-logger/lib/services/exercise_guide_cache.dart new file mode 100644 index 0000000..18f337a --- /dev/null +++ b/workout-logger/lib/services/exercise_guide_cache.dart @@ -0,0 +1,99 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; + +import '../data/exercise_demonstrations.dart'; + +/// App-private cache for the fixed set of curated exercise photos. +class ExerciseGuideCache { + ExerciseGuideCache({ + http.Client? client, + Future Function()? directory, + this.timeout = const Duration(seconds: 20), + }) : _client = client ?? http.Client(), + _ownsClient = client == null, + _directory = directory ?? getApplicationSupportDirectory; + + static final instance = ExerciseGuideCache(); + static const maxImageBytes = 5 * 1024 * 1024; + final http.Client _client; + final bool _ownsClient; + final Future Function() _directory; + final Duration timeout; + + /// Injected clients remain owned by their caller. + void close() { + if (_ownsClient) _client.close(); + } + + Future fileFor(String exerciseId, int index) async { + final guide = exerciseDemonstrations[exerciseId]; + if (guide == null || index < 0 || index >= guide.images.length) { + throw ArgumentError('Unknown guide image'); + } + final root = await _directory(); + return File('${root.path}/exercise_guides/${exerciseId}_$index.jpg'); + } + + Future isDownloaded(String exerciseId) async { + final guide = exerciseDemonstrations[exerciseId]; + if (guide == null) return false; + for (var i = 0; i < guide.images.length; i++) { + if (!await (await fileFor(exerciseId, i)).exists()) return false; + } + return true; + } + + Future download(String exerciseId) async { + final guide = exerciseDemonstrations[exerciseId]; + if (guide == null) throw ArgumentError('Unknown guide'); + for (var i = 0; i < guide.images.length; i++) { + final file = await fileFor(exerciseId, i); + if (await file.exists()) continue; + final uri = Uri.parse(guide.images[i]); + final response = await _client + .send(http.Request('GET', uri)) + .timeout(timeout); + if (response.statusCode != 200) { + throw HttpException( + 'Guide download failed: ${response.statusCode}', + uri: uri, + ); + } + final bytes = []; + await for (final chunk in response.stream.timeout(timeout)) { + bytes.addAll(chunk); + if (bytes.length > maxImageBytes) { + throw const FormatException('Guide image is too large'); + } + } + if (bytes.length < 4 || + bytes[0] != 0xff || + bytes[1] != 0xd8 || + bytes[bytes.length - 2] != 0xff || + bytes.last != 0xd9) { + throw const FormatException('Guide image is not a JPEG'); + } + await file.parent.create(recursive: true); + final temporary = File('${file.path}.part'); + try { + await temporary.writeAsBytes(bytes, flush: true); + await temporary.rename(file.path); + } finally { + if (await temporary.exists()) await temporary.delete(); + } + } + } + + /// Existing images are skipped, so a failed bulk download can be retried. + Future downloadAll({ + void Function(int completed, int total)? onProgress, + }) async { + final ids = exerciseDemonstrations.keys.toList(); + for (var i = 0; i < ids.length; i++) { + await download(ids[i]); + onProgress?.call(i + 1, ids.length); + } + } +} diff --git a/workout-logger/test/exercise_animation_gallery_test.dart b/workout-logger/test/exercise_animation_gallery_test.dart new file mode 100644 index 0000000..76535da --- /dev/null +++ b/workout-logger/test/exercise_animation_gallery_test.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/data/exercise_animations.dart'; +import 'package:repforge/data/exercise_database.dart'; +import 'package:repforge/screens/widgets/exercise_movement_painter.dart'; +import 'package:repforge/theme/app_theme.dart'; +import 'test_utils/animation_fonts.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadAnimationFonts); + testWidgets('catalogue start and end poses visual regression', ( + tester, + ) async { + final exercises = ExerciseDatabase.getAll(); + final rows = (exercises.length / 4).ceil(); + tester.view.physicalSize = Size(1440, rows * 176); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: Scaffold( + body: Column( + children: [ + for (var row = 0; row < rows; row++) + Row( + children: [ + for (final exercise in exercises.skip(row * 4).take(4)) + SizedBox( + width: 360, + height: 176, + child: Column( + children: [ + Text( + exercise.name, + style: const TextStyle( + fontSize: 16, + color: AppColors.textPrimary, + ), + ), + Row( + children: [ + for (final amount in [0.0, 1.0]) + SizedBox( + width: 180, + height: 140, + child: CustomPaint( + painter: ExerciseMovementPainter( + animation: + ExerciseAnimation.forExercise( + exercise, + )!, + amount: amount, + muscleColor: AppColors.muscle( + exercise + .muscleActivations + .first + .muscleGroupId, + ), + ), + ), + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + 'goldens/${Platform.operatingSystem}/exercise_animation_gallery.png', + ), + ); + }); +} diff --git a/workout-logger/test/exercise_animation_player_test.dart b/workout-logger/test/exercise_animation_player_test.dart new file mode 100644 index 0000000..b8feb51 --- /dev/null +++ b/workout-logger/test/exercise_animation_player_test.dart @@ -0,0 +1,336 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/data/exercise_animations.dart'; +import 'package:repforge/data/exercise_database.dart'; +import 'package:repforge/screens/widgets/exercise_animation_player.dart'; +import 'package:repforge/screens/widgets/exercise_movement_painter.dart'; +import 'package:repforge/theme/app_theme.dart'; +import 'test_utils/animation_fonts.dart'; + +final exercises = ExerciseDatabase.getAll(); + +Widget player(String id, {bool reducedMotion = false, double textScale = 1}) => + MaterialApp( + theme: AppTheme.darkTheme, + home: MediaQuery( + data: MediaQueryData( + size: const Size(360, 800), + disableAnimations: reducedMotion, + textScaler: TextScaler.linear(textScale), + ), + child: Scaffold( + body: SingleChildScrollView( + child: ExerciseAnimationPlayer( + exercise: exercises.firstWhere((e) => e.id == id), + ), + ), + ), + ), + ); + +ExerciseMovementPainter painter(WidgetTester tester) => + tester + .widget( + find.byKey(const ValueKey('exercise-movement-canvas')), + ) + .painter! + as ExerciseMovementPainter; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadAnimationFonts); + test( + 'every built-in exercise has an explicit animation; custom IDs never inherit one', + () { + expect( + exerciseAnimations.keys.toSet(), + exercises.map((e) => e.id).toSet(), + ); + for (final exercise in exercises) { + expect( + ExerciseAnimation.forExercise(exercise), + isNotNull, + reason: exercise.id, + ); + expect( + ExerciseAnimation.forExercise(exercise.copyWith(isCustom: true)), + isNull, + ); + } + }, + ); + + test( + 'every movement produces finite, in-frame joints across a repetition', + () { + for (final entry in exerciseAnimations.entries) { + for (var step = 0; step <= 20; step++) { + final pose = ExercisePose.at(entry.value, step / 20); + for (final point in [ + pose.head, + pose.shoulder, + pose.hip, + pose.knee, + pose.ankle, + pose.elbow, + pose.hand, + pose.otherHand, + pose.otherElbow, + ]) { + if (point == null) continue; + expect( + point.dx.isFinite && point.dy.isFinite, + isTrue, + reason: entry.key, + ); + expect( + point.dx, + inInclusiveRange(30, 330), + reason: '${entry.key} x', + ); + expect( + point.dy, + inInclusiveRange(0, 225), + reason: '${entry.key} y', + ); + } + } + } + }, + ); + + testWidgets('starts paused, plays, pauses, and restarts without autoplay', ( + tester, + ) async { + await tester.pumpWidget(player('squat')); + final start = painter(tester).amount; + await tester.pump(const Duration(seconds: 1)); + expect(painter(tester).amount, start); + await tester.tap(find.text('Play')); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(painter(tester).amount, greaterThan(start)); + await tester.tap(find.text('Pause')); + await tester.pump(); + final paused = painter(tester).amount; + await tester.pump(const Duration(seconds: 1)); + expect(painter(tester).amount, paused); + await tester.tap(find.byTooltip('Restart movement')); + await tester.pump(); + expect(painter(tester).amount, 0); + expect(find.text('Play'), findsOneWidget); + }); + + testWidgets('scrubbing pauses playback and exposes the return cue', ( + tester, + ) async { + await tester.pumpWidget(player('bicep_curl')); + await tester.tap(find.text('Play')); + await tester.pump(); + final slider = tester.widget(find.byType(Slider)); + slider.onChangeStart!(.75); + slider.onChanged!(.75); + await tester.pump(); + expect(find.text('Play'), findsOneWidget); + expect(find.text('Return'), findsOneWidget); + expect(find.text('Lower with control'), findsOneWidget); + expect(painter(tester).amount, closeTo(.5, .001)); + }); + + testWidgets('speed changes affect playback duration', (tester) async { + await tester.pumpWidget(player('squat')); + await tester.tap(find.byTooltip('Playback speed')); + await tester.pumpAndSettle(); + await tester.tap(find.text('0.5× speed')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Play')); + await tester.pump(); + await tester.pump(const Duration(seconds: 2)); + expect(tester.widget(find.byType(Slider)).value, closeTo(.25, .02)); + await tester.tap(find.text('Pause')); + await tester.pump(); + }); + + testWidgets('backgrounding pauses and returning does not resume', ( + tester, + ) async { + await tester.pumpWidget(player('bench_press')); + await tester.tap(find.text('Play')); + await tester.pump(); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.paused); + await tester.pump(); + expect(find.text('Play'), findsOneWidget); + tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); + await tester.pump(const Duration(seconds: 1)); + expect(find.text('Play'), findsOneWidget); + }); + + testWidgets('scrolling the guide out of view pauses playback', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: Column( + children: [ + ExerciseAnimationPlayer(exercise: exercises.first), + const SizedBox(height: 1600), + ], + ), + ), + ), + ), + ); + await tester.tap(find.text('Play')); + await tester.pump(); + tester + .state(find.byType(Scrollable).first) + .position + .jumpTo(800); + await tester.pump(const Duration(milliseconds: 100)); + expect(find.text('Play'), findsOneWidget); + final position = painter(tester).amount; + await tester.pump(const Duration(seconds: 1)); + expect(painter(tester).amount, position); + }); + + testWidgets('covering the guide with another route pauses playback', ( + tester, + ) async { + await tester.pumpWidget(player('squat')); + await tester.tap(find.text('Play')); + await tester.pump(); + final navigator = Navigator.of( + tester.element(find.byType(ExerciseAnimationPlayer)), + ); + navigator.push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Another screen')), + ), + ); + await tester.pumpAndSettle(); + navigator.pop(); + await tester.pumpAndSettle(); + expect(find.text('Play'), findsOneWidget); + }); + + testWidgets( + 'pauses above a clipped scroll viewport while still inside the screen', + (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Padding( + padding: const EdgeInsets.only(top: 200), + child: SingleChildScrollView( + child: Column( + children: [ + ExerciseAnimationPlayer(exercise: exercises.first), + const SizedBox(height: 1600), + ], + ), + ), + ), + ), + ), + ); + final toggle = tester.widget(find.byType(FilledButton)); + toggle.onPressed!(); + await tester.pump(); + final height = tester + .getSize(find.byType(ExerciseAnimationPlayer)) + .height; + tester + .state(find.byType(Scrollable).first) + .position + .jumpTo(height + 20); + await tester.pump(const Duration(milliseconds: 100)); + expect( + tester.getBottomLeft(find.byType(ExerciseAnimationPlayer)).dy, + greaterThan(0), + ); + expect(find.text('Play'), findsOneWidget); + }, + ); + + testWidgets( + 'reduced motion disables playback while preserving manual inspection', + (tester) async { + await tester.pumpWidget(player('squat', reducedMotion: true)); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + tester.widget(find.byType(Slider)).onChanged!(.5); + await tester.pump(); + expect(painter(tester).amount, 1); + expect(find.textContaining('Motion is off'), findsOneWidget); + }, + ); + + testWidgets('changing exercises resets position and playback', ( + tester, + ) async { + await tester.pumpWidget(player('squat')); + await tester.tap(find.text('Play')); + await tester.pump(const Duration(seconds: 1)); + await tester.pumpWidget(player('bench_press')); + expect(painter(tester).amount, 0); + expect(painter(tester).animation.movement, ExerciseMovement.benchPress); + expect(find.text('Play'), findsOneWidget); + }); + + testWidgets( + 'plank is a static hold; unsupported exercises have no inferred animation', + (tester) async { + await tester.pumpWidget(player('plank')); + expect(find.text('Hold position'), findsOneWidget); + expect(find.byType(Slider), findsNothing); + expect(find.text('Play'), findsNothing); + await tester.pumpWidget( + MaterialApp( + home: ExerciseAnimationPlayer( + exercise: exercises.first.copyWith(id: 'unknown'), + ), + ), + ); + expect( + find.byKey(const ValueKey('exercise-movement-canvas')), + findsNothing, + ); + }, + ); + + testWidgets('narrow layout and large text remain usable', (tester) async { + tester.view.physicalSize = const Size(320, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget( + player('overhead_tricep_extension', textScale: 1.6), + ); + await tester.ensureVisible(find.text('Play')); + await tester.tap(find.text('Play')); + await tester.pump(); + expect(tester.takeException(), isNull); + await tester.tap(find.text('Pause')); + await tester.pump(); + }); + + testWidgets('player visual regression', (tester) async { + tester.view.physicalSize = const Size(360, 640); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + await tester.pumpWidget(player('squat')); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + 'goldens/${Platform.operatingSystem}/exercise_animation_player.png', + ), + ); + }); +} diff --git a/workout-logger/test/exercise_guide_cache_test.dart b/workout-logger/test/exercise_guide_cache_test.dart new file mode 100644 index 0000000..96ef140 --- /dev/null +++ b/workout-logger/test/exercise_guide_cache_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:repforge/services/exercise_guide_cache.dart'; + +class _StalledClient extends http.BaseClient { + _StalledClient(this.handler); + final Future Function() handler; + @override + Future send(http.BaseRequest request) => handler(); +} + +class _CloseTrackingClient extends MockClient { + _CloseTrackingClient() : super((_) async => http.Response('', 200)); + bool closed = false; + @override + void close() => closed = true; +} + +void main() { + late Directory root; + setUp(() async => root = await Directory.systemTemp.createTemp('guides_')); + tearDown(() async => root.delete(recursive: true)); + + final jpeg = [0xff, 0xd8, 0xff, 0xd9]; + + test('closing a cache leaves an injected client under caller ownership', () { + final client = _CloseTrackingClient(); + final cache = ExerciseGuideCache(client: client); + cache.close(); + expect(client.closed, isFalse); + client.close(); + expect(client.closed, isTrue); + }); + + test( + 'bulk download caches only curated images and skips them on retry', + () async { + var requests = 0; + final cache = ExerciseGuideCache( + directory: () async => root, + client: MockClient((_) async { + requests++; + return http.Response.bytes(jpeg, 200); + }), + ); + final progress = []; + await cache.downloadAll(onProgress: (done, _) => progress.add(done)); + expect(progress, [1, 2, 3, 4, 5, 6]); + expect(requests, 12); + expect(await cache.isDownloaded('bench_press'), isTrue); + await cache.downloadAll(); + expect(requests, 12); + await expectLater(cache.fileFor('../outside', 0), throwsArgumentError); + }, + ); + + test('invalid response is not cached and a later retry succeeds', () async { + var valid = false; + final cache = ExerciseGuideCache( + directory: () async => root, + client: MockClient( + (_) async => http.Response.bytes(valid ? jpeg : [0, 1, 2, 3], 200), + ), + ); + await expectLater(cache.download('push_ups'), throwsFormatException); + expect(await cache.isDownloaded('push_ups'), isFalse); + valid = true; + await cache.download('push_ups'); + expect(await cache.isDownloaded('push_ups'), isTrue); + }); + + test( + 'a stalled request or body times out so the download can be retried', + () async { + final requestCache = ExerciseGuideCache( + directory: () async => root, + timeout: const Duration(milliseconds: 10), + client: _StalledClient(() => Completer().future), + ); + await expectLater( + requestCache.download('push_ups'), + throwsA(isA()), + ); + + final body = StreamController>(); + addTearDown(body.close); + final bodyCache = ExerciseGuideCache( + directory: () async => root, + timeout: const Duration(milliseconds: 10), + client: _StalledClient( + () async => http.StreamedResponse(body.stream, 200), + ), + ); + await expectLater( + bodyCache.download('push_ups'), + throwsA(isA()), + ); + expect(await bodyCache.isDownloaded('push_ups'), isFalse); + }, + ); +} diff --git a/workout-logger/test/exercise_guide_screen_visual_test.dart b/workout-logger/test/exercise_guide_screen_visual_test.dart new file mode 100644 index 0000000..5d6113d --- /dev/null +++ b/workout-logger/test/exercise_guide_screen_visual_test.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/data/exercise_database.dart'; +import 'package:repforge/screens/exercise_guide_screen.dart'; +import 'package:repforge/services/exercise_guide_cache.dart'; +import 'package:repforge/theme/app_theme.dart'; + +import 'test_utils/animation_fonts.dart'; + +class _FixtureGuideCache extends ExerciseGuideCache { + @override + Future isDownloaded(String exerciseId) async => true; + + @override + Future fileFor(String exerciseId, int index) async => + File('test/fixtures/exercise_guides/squat_$index.jpg'); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUpAll(loadAnimationFonts); + testWidgets( + 'full guide displays real reference photos and movement controls', + (tester) async { + tester.view.physicalSize = const Size(390, 1000); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + final cache = _FixtureGuideCache(); + addTearDown(cache.close); + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.darkTheme, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + child: const Text('Open guide'), + onPressed: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ExerciseGuideScreen( + exercise: ExerciseDatabase.getAll().firstWhere( + (e) => e.id == 'squat', + ), + cache: cache, + ), + ), + ), + ), + ), + ), + ), + ); + await tester.runAsync(() async { + final context = tester.element(find.text('Open guide')); + await Future.wait([ + for (var i = 0; i < 2; i++) + precacheImage(FileImage(await cache.fileFor('squat', i)), context), + ]); + }); + await tester.tap(find.text('Open guide')); + await tester.pumpAndSettle(); + expect(find.byType(Image), findsNWidgets(2)); + for (final raw in tester.widgetList(find.byType(RawImage))) { + expect(raw.image, isNotNull); + } + expect(find.text('Exercise guide'), findsOneWidget); + tester.widget(find.byType(Slider)).onChanged!(.4); + await tester.pump(); + await expectLater( + find.byType(Scaffold), + matchesGoldenFile( + 'goldens/${Platform.operatingSystem}/exercise_guide_screen.png', + ), + ); + await tester.tap(find.text('Technique instructions')); + await tester.pumpAndSettle(); + expect( + find.textContaining('This exercise is best performed'), + findsOneWidget, + ); + expect(tester.takeException(), isNull); + }, + ); +} diff --git a/workout-logger/test/exercise_guide_widgets_test.dart b/workout-logger/test/exercise_guide_widgets_test.dart new file mode 100644 index 0000000..8e7b5d5 --- /dev/null +++ b/workout-logger/test/exercise_guide_widgets_test.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:repforge/models/models.dart'; +import 'package:repforge/screens/widgets/exercise_demonstration_section.dart'; +import 'package:repforge/services/exercise_guide_cache.dart'; + +class _GuideCache extends ExerciseGuideCache { + int downloads = 0; + bool fail = false; + + @override + Future isDownloaded(String exerciseId) async => false; + + @override + Future download(String exerciseId) async { + downloads++; + if (fail) throw StateError('Offline'); + } + + @override + Future downloadAll({ + void Function(int completed, int total)? onProgress, + }) async { + downloads++; + if (fail) throw StateError('Offline'); + onProgress?.call(6, 6); + } +} + +void main() { + const bench = Exercise( + id: 'bench_press', + name: 'Bench Press', + muscleActivations: [], + category: 'compound', + ); + + testWidgets('curated guide shows instructions and user-triggered download', ( + tester, + ) async { + final cache = _GuideCache(); + addTearDown(cache.close); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ExerciseDemonstrationSection(exercise: bench, cache: cache), + ), + ), + ), + ); + await tester.pump(); + expect( + find.textContaining('Reference variant: Barbell Bench Press'), + findsOneWidget, + ); + expect(find.text('Download photo guide'), findsOneWidget); + expect(cache.downloads, 0); + await tester.ensureVisible(find.text('Download photo guide')); + await tester.tap(find.text('Download photo guide')); + await tester.pumpAndSettle(); + expect(cache.downloads, 1); + }); + + testWidgets('guide failure reports retry and custom exercise has no guide', ( + tester, + ) async { + final cache = _GuideCache()..fail = true; + addTearDown(cache.close); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: SingleChildScrollView( + child: ExerciseDemonstrationSection(exercise: bench, cache: cache), + ), + ), + ), + ); + await tester.pump(); + await tester.ensureVisible(find.text('Download photo guide')); + await tester.tap(find.text('Download photo guide')); + await tester.pump(); + expect(find.textContaining('Could not download the guide'), findsOneWidget); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: ExerciseDemonstrationSection( + exercise: bench.copyWith(isCustom: true), + cache: cache, + ), + ), + ), + ); + expect(find.text('Download photo guide'), findsNothing); + }); +} diff --git a/workout-logger/test/fixtures/exercise_guides/README.md b/workout-logger/test/fixtures/exercise_guides/README.md new file mode 100644 index 0000000..77e2f7a --- /dev/null +++ b/workout-logger/test/fixtures/exercise_guides/README.md @@ -0,0 +1,10 @@ +# Squat photo fixtures + +The two reference photos come from Free Exercise DB at pinned commit +`f00c92c7dcf1216a928a52c3706c7ce8e2f71ed5`, published under Unlicense: + +- `exercises/Barbell_Full_Squat/0.jpg` +- `exercises/Barbell_Full_Squat/1.jpg` + +They are used only to test and render the optional photo-reference UI. The app +continues to download its curated photos only when requested by the user. diff --git a/workout-logger/test/fixtures/exercise_guides/squat_0.jpg b/workout-logger/test/fixtures/exercise_guides/squat_0.jpg new file mode 100644 index 0000000..9d65fe1 Binary files /dev/null and b/workout-logger/test/fixtures/exercise_guides/squat_0.jpg differ diff --git a/workout-logger/test/fixtures/exercise_guides/squat_1.jpg b/workout-logger/test/fixtures/exercise_guides/squat_1.jpg new file mode 100644 index 0000000..4dec0c8 Binary files /dev/null and b/workout-logger/test/fixtures/exercise_guides/squat_1.jpg differ diff --git a/workout-logger/test/goldens/README.md b/workout-logger/test/goldens/README.md new file mode 100644 index 0000000..e8ffdd2 --- /dev/null +++ b/workout-logger/test/goldens/README.md @@ -0,0 +1,14 @@ +# Exercise UI captures + +The exercise player, full guide, and catalogue pose gallery have exact pixel +regression tests. Host font rendering differs between Windows and Linux, so +each host has its own reviewed baseline rather than a relaxed comparator. + +Regenerate on the matching host after a deliberate visual change: + +```sh +flutter test --update-goldens test/exercise_animation_player_test.dart test/exercise_animation_gallery_test.dart test/exercise_guide_screen_visual_test.dart +``` + +Review the generated images before committing them. CI uploads actual and diff +images as `exercise-visual-failures` when a visual comparison fails. diff --git a/workout-logger/test/goldens/linux/exercise_animation_gallery.png b/workout-logger/test/goldens/linux/exercise_animation_gallery.png new file mode 100644 index 0000000..b882878 Binary files /dev/null and b/workout-logger/test/goldens/linux/exercise_animation_gallery.png differ diff --git a/workout-logger/test/goldens/linux/exercise_animation_player.png b/workout-logger/test/goldens/linux/exercise_animation_player.png new file mode 100644 index 0000000..956a4a0 Binary files /dev/null and b/workout-logger/test/goldens/linux/exercise_animation_player.png differ diff --git a/workout-logger/test/goldens/linux/exercise_guide_screen.png b/workout-logger/test/goldens/linux/exercise_guide_screen.png new file mode 100644 index 0000000..05c67c6 Binary files /dev/null and b/workout-logger/test/goldens/linux/exercise_guide_screen.png differ diff --git a/workout-logger/test/goldens/windows/exercise_animation_gallery.png b/workout-logger/test/goldens/windows/exercise_animation_gallery.png new file mode 100644 index 0000000..0850803 Binary files /dev/null and b/workout-logger/test/goldens/windows/exercise_animation_gallery.png differ diff --git a/workout-logger/test/goldens/windows/exercise_animation_player.png b/workout-logger/test/goldens/windows/exercise_animation_player.png new file mode 100644 index 0000000..611135e Binary files /dev/null and b/workout-logger/test/goldens/windows/exercise_animation_player.png differ diff --git a/workout-logger/test/goldens/windows/exercise_guide_screen.png b/workout-logger/test/goldens/windows/exercise_guide_screen.png new file mode 100644 index 0000000..1985c74 Binary files /dev/null and b/workout-logger/test/goldens/windows/exercise_guide_screen.png differ diff --git a/workout-logger/test/screens/profile_screen_test.dart b/workout-logger/test/screens/profile_screen_test.dart index f8569e9..a215932 100644 --- a/workout-logger/test/screens/profile_screen_test.dart +++ b/workout-logger/test/screens/profile_screen_test.dart @@ -3,19 +3,67 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:repforge/screens/profile_screen.dart'; import 'package:repforge/services/workout_provider.dart'; import 'package:repforge/services/settings_provider.dart'; +import 'package:repforge/services/exercise_guide_cache.dart'; import 'package:repforge/services/managers/program_manager.dart'; import '../test_utils/mock_storage_service.dart'; import '../test_utils/mock_ml_service.dart'; import '../test_utils/test_robot.dart'; +class _ProfileGuideCache extends ExerciseGuideCache { + int calls = 0; + @override + Future downloadAll({ + void Function(int completed, int total)? onProgress, + }) async { + calls++; + onProgress?.call(6, 6); + } +} + void main() { - testWidgets('Renders ProfileScreen with sections', (WidgetTester tester) async { + testWidgets('Profile downloads all curated guides on request', ( + tester, + ) async { + final cache = _ProfileGuideCache(); + addTearDown(cache.close); + final storage = MockStorageService(); + final settings = SettingsProvider(storage); + await settings.init(); + final workout = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); + await workout.init(); + await TestRobot(tester).pumpScreen( + ProfileScreen(guideCache: cache), + storage: storage, + settingsProvider: settings, + workoutProvider: workout, + ); + await tester.ensureVisible(find.text('Download all exercise guides')); + await tester.tap(find.text('Download all exercise guides')); + await tester.pumpAndSettle(); + expect(cache.calls, 1); + expect( + find.text('All exercise photo guides are available offline.'), + findsOneWidget, + ); + }); + + testWidgets('Renders ProfileScreen with sections', ( + WidgetTester tester, + ) async { final robot = TestRobot(tester); final storage = MockStorageService(); final settings = SettingsProvider(storage); await settings.init(); - final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + final workout = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); await workout.init(); await robot.pumpScreen( @@ -41,7 +89,11 @@ void main() { final settings = SettingsProvider(storage); await settings.init(); - final workout = WorkoutProvider(storage, mlService: MockMLService(), programManager: ProgramManager(storage)); + final workout = WorkoutProvider( + storage, + mlService: MockMLService(), + programManager: ProgramManager(storage), + ); await workout.init(); await robot.pumpScreen( diff --git a/workout-logger/test/screens/workout_flow_screen_test.dart b/workout-logger/test/screens/workout_flow_screen_test.dart index 8326b73..00b06a5 100644 --- a/workout-logger/test/screens/workout_flow_screen_test.dart +++ b/workout-logger/test/screens/workout_flow_screen_test.dart @@ -2,10 +2,34 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:repforge/models/models.dart'; import 'package:repforge/screens/workout_flow_screen.dart'; +import 'package:repforge/screens/widgets/exercise_animation_player.dart'; import '../test_utils/test_harness.dart'; void main() { - testWidgets('Renders WorkoutFlowScreen with active exercise details', (WidgetTester tester) async { + testWidgets( + 'active workout opens an offline animation for exercises without photo guides', + (tester) async { + await TestHarness.prepareTester(tester); + final routine = Routine( + id: 'curl', + name: 'Curl session', + exerciseIds: ['hammer_curl'], + ); + await tester.pumpWidget( + TestHarness.wrap(WorkoutFlowScreen(routine: routine)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Exercise guide')); + await tester.pumpAndSettle(); + expect(find.byType(ExerciseAnimationPlayer), findsOneWidget); + expect(find.text('Play'), findsOneWidget); + expect(find.text('Download photo guide'), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + testWidgets('Renders WorkoutFlowScreen with active exercise details', ( + WidgetTester tester, + ) async { await TestHarness.prepareTester(tester); final routine = Routine( @@ -14,9 +38,9 @@ void main() { exerciseIds: ['bench_press', 'incline_dumbbells'], ); - await tester.pumpWidget(TestHarness.wrap( - WorkoutFlowScreen(routine: routine), - )); + await tester.pumpWidget( + TestHarness.wrap(WorkoutFlowScreen(routine: routine)), + ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); @@ -24,12 +48,14 @@ void main() { expect(find.text('Bench Press'), findsOneWidget); }); - testWidgets('Renders WorkoutFlowScreen quick start mode', (WidgetTester tester) async { + testWidgets('Renders WorkoutFlowScreen quick start mode', ( + WidgetTester tester, + ) async { await TestHarness.prepareTester(tester); - await tester.pumpWidget(TestHarness.wrap( - const WorkoutFlowScreen(isQuickStart: true), - )); + await tester.pumpWidget( + TestHarness.wrap(const WorkoutFlowScreen(isQuickStart: true)), + ); await tester.pumpAndSettle(); expect(tester.takeException(), isNull); @@ -39,7 +65,8 @@ void main() { find.byIcon(Icons.add_rounded).evaluate().isNotEmpty || find.textContaining('Exercise').evaluate().isNotEmpty, isTrue, - reason: 'Quick-start mode should show an add-exercise control or empty exercise area', + reason: + 'Quick-start mode should show an add-exercise control or empty exercise area', ); }); } diff --git a/workout-logger/test/test_utils/animation_fonts.dart b/workout-logger/test/test_utils/animation_fonts.dart new file mode 100644 index 0000000..7fee6a5 --- /dev/null +++ b/workout-logger/test/test_utils/animation_fonts.dart @@ -0,0 +1,10 @@ +import 'package:flutter/services.dart'; + +Future loadAnimationFonts() async { + for (final entry in { + 'Geist': 'assets/fonts/Geist-Variable.ttf', + 'MaterialIcons': 'fonts/MaterialIcons-Regular.otf', + }.entries) { + await (FontLoader(entry.key)..addFont(rootBundle.load(entry.value))).load(); + } +}