import 'dart:async'; import 'dart:math' as math; import 'package:clock/clock.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../design/number_stepper.dart'; import '../../design/zen_style.dart'; import '../../exercises/exercise_library.dart'; import '../../exercises/views/flip_animation.dart'; import '../../exercises/views/library_view.dart'; import '../../history/session_repository.dart'; import '../../localization/app_localizations.dart'; import '../../plans/models/plan.dart'; import '../../plans/plan_labels.dart'; import '../../plans/plan_repository.dart'; import '../../settings/settings_controller.dart'; import '../player_effects.dart'; import '../segments.dart'; import '../session_runner.dart'; import 'summary_view.dart'; /// Runs a Plan one segment at a time: Stretching holds and Transitions count /// down and move on by themselves; Workout sets wait for "Set done" and the /// Rest Screen counts up. The screen stays on while it is open. class PlayerView extends StatefulWidget { const PlayerView({super.key, required this.planId}); static const routeName = '/play'; final String planId; @override State createState() => _PlayerViewState(); } class _PlayerViewState extends State { SessionRunner? _runner; Timer? _ticker; var _leaving = false; late final PlayerEffects _effects = context.read(); @override void initState() { super.initState(); _effects.keepScreenOn(true); _start(); } Future _start() async { final plan = await context.read().get(widget.planId); if (plan == null || !mounted) return; final runner = SessionRunner(plan, startedAt: clock.now(), onCue: _chime)..addListener(_onChanged); setState(() => _runner = runner); _ticker = Timer.periodic(const Duration(milliseconds: 200), (_) { runner.tick(clock.now()); if (mounted) setState(() {}); }); } void _chime() { final runner = _runner; if (runner != null && !context.read().muted) _effects.chime(runner.plan.mode); } void _onChanged() { if (_runner!.isFinished) _leave(); } /// Saves the Session and shows its summary. A Session in which no hold or /// set was actually done (ended at once, or skipped through) is not worth /// keeping and just closes the Player. Future _leave() async { if (_leaving) return; _leaving = true; _ticker?.cancel(); final runner = _runner!; final navigator = Navigator.of(context); if (!runner.hasProgress) { navigator.pop(); return; } final session = await context.read().save(runner.result(clock.now())); navigator.pushReplacementNamed(SummaryView.routeName, arguments: session.id); } Future _confirmEnd() async { final runner = _runner; if (runner == null) { Navigator.of(context).pop(); return; } final l10n = AppLocalizations.of(context); final end = await showDialog( context: context, builder: (context) => AlertDialog( title: Text(l10n.endSessionTitle), content: Text(l10n.endSessionBody), actions: [ TextButton(onPressed: () => Navigator.of(context).pop(false), child: Text(l10n.keepGoing)), FilledButton(onPressed: () => Navigator.of(context).pop(true), child: Text(l10n.endSession)), ], ), ); if (end ?? false) runner.end(clock.now()); } @override void dispose() { _ticker?.cancel(); _runner?.dispose(); _effects.keepScreenOn(false); super.dispose(); } @override Widget build(BuildContext context) { final runner = _runner; final s = ZenStyle.of(context); return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (!didPop && !_leaving) _confirmEnd(); }, child: Scaffold( body: runner == null ? const SizedBox.shrink() : Stack( children: [ _Pulse(on: _pulsing(runner), color: s.accent), SafeArea( child: Column( children: [ _TopBar(runner: runner, onClose: _confirmEnd), Expanded( child: AnimatedSwitcher( duration: s.motion, switchInCurve: s.curve, switchOutCurve: s.curve, child: KeyedSubtree(key: ValueKey(runner.index), child: _content(runner)), ), ), Padding( padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), child: _Controls(runner: runner), ), ], ), ), ], ), ), ); } /// The last three seconds of a running countdown. bool _pulsing(SessionRunner runner) { if (runner.countdown == null || runner.isPaused || runner.isWaitingToStart) return false; final left = runner.remaining(clock.now()); return left > Duration.zero && left <= const Duration(seconds: 3) && (left.inMilliseconds ~/ 500).isEven; } Widget _content(SessionRunner runner) => switch (runner.current) { final HoldSegment hold => _HoldContent(runner: runner, hold: hold), final TransitionSegment transition => _TransitionContent(runner: runner, transition: transition), final SetSegment set => _SetContent(runner: runner, set: set), RestSegment() => _RestContent(runner: runner), }; } class _Pulse extends StatelessWidget { const _Pulse({required this.on, required this.color}); final bool on; final Color color; @override Widget build(BuildContext context) => Positioned.fill( child: IgnorePointer( child: AnimatedOpacity( opacity: on ? 0.16 : 0, duration: const Duration(milliseconds: 300), child: ColoredBox(color: color), ), ), ); } class _TopBar extends StatelessWidget { const _TopBar({required this.runner, required this.onClose}); final SessionRunner runner; final VoidCallback onClose; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final s = ZenStyle.of(context); final settings = context.watch(); final main = [ for (final (i, seg) in runner.segments.indexed) if (seg is HoldSegment || seg is SetSegment) i, ]; final done = main.where((i) => i < runner.index).length; return Column( children: [ Row( children: [ IconButton(tooltip: l10n.playerClose, onPressed: onClose, icon: const Icon(Icons.close_rounded)), Expanded( child: Text( runner.plan.name, textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.titleSmall?.copyWith(color: s.inkMuted), ), ), IconButton( tooltip: settings.muted ? l10n.unmute : l10n.mute, onPressed: () => settings.updateMuted(!settings.muted), icon: Icon(settings.muted ? Icons.volume_off_rounded : Icons.volume_up_rounded), ), ], ), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: ClipRRect( borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 3)), child: LinearProgressIndicator( value: main.isEmpty ? 0 : done / main.length, minHeight: 6, color: s.accent, backgroundColor: s.surfaceSoft, ), ), ), ], ); } } /// Image, name and a line of context above the segment's main figure. class _ExerciseHeader extends StatelessWidget { const _ExerciseHeader({required this.exerciseId, required this.caption}); final String exerciseId; final String caption; @override Widget build(BuildContext context) { final s = ZenStyle.of(context); final text = Theme.of(context).textTheme; final exercise = context.read().byId(exerciseId); return Column( children: [ if (exercise != null) ConstrainedBox( constraints: BoxConstraints(maxHeight: MediaQuery.sizeOf(context).height * 0.28), child: FlipAnimation(exercise: exercise), ), const SizedBox(height: 16), Text( exercise?.name ?? exerciseId, textAlign: TextAlign.center, style: text.headlineSmall, maxLines: 2, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text(caption, style: text.bodyLarge?.copyWith(color: s.inkMuted)), ], ); } } class _HoldContent extends StatelessWidget { const _HoldContent({required this.runner, required this.hold}); final SessionRunner runner; final HoldSegment hold; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final caption = [ if (hold.side != null) hold.side == Side.left ? l10n.sideLeft : l10n.sideRight, if (hold.rounds > 1) l10n.roundOf(hold.round, hold.rounds), ].join(' · '); return _Centered( children: [ _ExerciseHeader(exerciseId: hold.exerciseId, caption: caption), const SizedBox(height: 24), _CountdownRing(runner: runner, size: 190), ], ); } } class _TransitionContent extends StatelessWidget { const _TransitionContent({required this.runner, required this.transition}); final SessionRunner runner; final TransitionSegment transition; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final s = ZenStyle.of(context); final text = Theme.of(context).textTheme; final next = runner.next; final title = switch (transition.kind) { TransitionKind.getReady => l10n.transitionGetReady, TransitionKind.switchSides => l10n.transitionSwitchSides, TransitionKind.nextRound => l10n.transitionNextRound(next is HoldSegment ? next.round : 1), TransitionKind.nextExercise => l10n.transitionNextExercise, }; final exercise = next?.exerciseId == null ? null : context.read().byId(next!.exerciseId!); return _Centered( children: [ Text( title, style: text.displaySmall?.copyWith(color: s.accent), textAlign: TextAlign.center, ), const SizedBox(height: 24), if (exercise != null) ...[ ExerciseThumbnail(exercise: exercise, size: 120), const SizedBox(height: 12), Text(exercise.name, style: text.titleLarge, textAlign: TextAlign.center), ], const SizedBox(height: 28), _CountdownRing(runner: runner, size: 120), ], ); } } class _SetContent extends StatelessWidget { const _SetContent({required this.runner, required this.set}); final SessionRunner runner; final SetSegment set; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final s = ZenStyle.of(context); final text = Theme.of(context).textTheme; final target = [ targetLabel(l10n, set.target), if (set.weightKg != null) l10n.weightKg(kgNumber(set.weightKg!)), ].join(' · '); return _Centered( children: [ _ExerciseHeader(exerciseId: set.exerciseId, caption: l10n.setOf(set.set, set.sets)), const SizedBox(height: 24), if (set.target.isTimed && !runner.isWaitingToStart) _CountdownRing(runner: runner, size: 170) else Text( target, style: text.displaySmall?.copyWith(color: s.accent), textAlign: TextAlign.center, ), ], ); } } class _RestContent extends StatelessWidget { const _RestContent({required this.runner}); final SessionRunner runner; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final s = ZenStyle.of(context); final text = Theme.of(context).textTheme; final next = runner.next; final exercise = next is SetSegment ? context.read().byId(next.exerciseId) : null; return _Centered( children: [ Text(l10n.restTitle, style: text.headlineMedium?.copyWith(color: s.inkMuted)), const SizedBox(height: 8), Text( clockText(runner.elapsed(clock.now())), style: text.displayLarge?.copyWith( fontFamily: s.headingFont, fontWeight: FontWeight.w800, fontFeatures: const [FontFeature.tabularFigures()], ), ), const SizedBox(height: 32), if (next is SetSegment && exercise != null) Card( child: Padding( padding: const EdgeInsets.all(12), child: Row( children: [ ExerciseThumbnail(exercise: exercise, size: 64), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.restNext, style: text.bodySmall?.copyWith(color: s.inkMuted)), Text(exercise.name, style: text.titleMedium, maxLines: 2, overflow: TextOverflow.ellipsis), Text( '${l10n.setOf(next.set, next.sets)} · ${targetLabel(l10n, next.target)}', style: text.bodySmall?.copyWith(color: s.inkMuted), ), ], ), ), ], ), ), ), ], ); } } class _Centered extends StatelessWidget { const _Centered({required this.children}); final List children; @override Widget build(BuildContext context) => Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Column(mainAxisSize: MainAxisSize.min, children: children), ), ); } /// "1:05". String clockText(Duration d) => '${d.inMinutes}:${(d.inSeconds % 60).toString().padLeft(2, '0')}'; /// The current countdown as a draining ring with the seconds left inside. class _CountdownRing extends StatelessWidget { const _CountdownRing({required this.runner, required this.size}); final SessionRunner runner; final double size; @override Widget build(BuildContext context) { final s = ZenStyle.of(context); final total = runner.countdown ?? Duration.zero; final left = runner.remaining(clock.now()); final fraction = total == Duration.zero ? 0.0 : left.inMilliseconds / total.inMilliseconds; final seconds = (left.inMilliseconds / 1000).ceil(); return SizedBox.square( dimension: size, child: CustomPaint( painter: _RingPainter( fraction: fraction, track: s.surfaceSoft, colors: [s.accent, s.accentEnd], round: s.controlRadius > 20, ), child: Center( child: Text( seconds >= 60 ? clockText(Duration(seconds: seconds)) : '$seconds', style: TextStyle( fontFamily: s.headingFont, fontWeight: FontWeight.w800, fontSize: size * 0.3, color: runner.isPaused ? s.inkMuted : s.ink, fontFeatures: const [FontFeature.tabularFigures()], ), ), ), ), ); } } class _RingPainter extends CustomPainter { _RingPainter({required this.fraction, required this.track, required this.colors, required this.round}); final double fraction; final Color track; final List colors; final bool round; @override void paint(Canvas canvas, Size size) { final stroke = size.width * 0.07; final rect = Offset.zero & size; final arc = rect.deflate(stroke / 2); canvas.drawArc( arc, 0, math.pi * 2, false, Paint() ..style = PaintingStyle.stroke ..strokeWidth = stroke ..color = track, ); if (fraction <= 0) return; canvas.drawArc( arc, -math.pi / 2, math.pi * 2 * fraction, false, Paint() ..style = PaintingStyle.stroke ..strokeWidth = stroke ..strokeCap = round ? StrokeCap.round : StrokeCap.butt ..shader = SweepGradient( colors: colors, startAngle: -math.pi / 2, endAngle: math.pi * 1.5, transform: const GradientRotation(-math.pi / 2), ).createShader(rect), ); } @override bool shouldRepaint(_RingPainter old) => old.fraction != fraction || old.track != track || old.colors != colors; } class _Controls extends StatelessWidget { const _Controls({required this.runner}); final SessionRunner runner; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final segment = runner.current; final back = IconButton.filledTonal( tooltip: l10n.previous, onPressed: () => runner.back(clock.now()), icon: const Icon(Icons.skip_previous_rounded), ); final skip = IconButton.filledTonal( tooltip: l10n.skip, onPressed: () => runner.skip(clock.now()), icon: const Icon(Icons.skip_next_rounded), ); final Widget main = switch (segment) { RestSegment() => _BigButton(label: l10n.startNextSet, onPressed: () => runner.startNext(clock.now())), SetSegment() when runner.isWaitingToStart => _BigButton( label: l10n.start, onPressed: () => runner.startTimer(clock.now()), ), final SetSegment set when !set.target.isTimed => _BigButton( label: l10n.setDone, onPressed: () => _logRepSet(context, set), ), _ => _PauseButton(runner: runner), }; return Column( mainAxisSize: MainAxisSize.min, children: [ if (segment is SetSegment && segment.target.isTimed && !runner.isWaitingToStart) TextButton( onPressed: () => runner.completeSet( seconds: runner.elapsed(clock.now()).inSeconds, weightKg: segment.weightKg, now: clock.now(), ), child: Text(l10n.doneEarly), ), Row( children: [ back, const SizedBox(width: 16), Expanded(child: Center(child: main)), const SizedBox(width: 16), if (segment is RestSegment) const SizedBox(width: 40) else skip, ], ), ], ); } Future _logRepSet(BuildContext context, SetSegment set) async { final logged = await showModalBottomSheet<(int, double?)>( context: context, showDragHandle: true, isScrollControlled: true, builder: (context) => _SetLogSheet(set: set), ); if (logged != null && !runner.isFinished && runner.current == set) { runner.completeSet(reps: logged.$1, weightKg: logged.$2, now: clock.now()); } } } class _BigButton extends StatelessWidget { const _BigButton({required this.label, required this.onPressed}); final String label; final VoidCallback onPressed; @override Widget build(BuildContext context) { final s = ZenStyle.of(context); return SizedBox( width: double.infinity, height: 60, child: DecoratedBox( decoration: BoxDecoration( gradient: s.accentGradient, borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 30)), ), child: FilledButton( style: FilledButton.styleFrom(backgroundColor: Colors.transparent, shadowColor: Colors.transparent), onPressed: onPressed, child: Text( label, style: TextStyle(fontFamily: s.headingFont, fontWeight: FontWeight.w800, fontSize: 20), ), ), ), ); } } class _PauseButton extends StatelessWidget { const _PauseButton({required this.runner}); final SessionRunner runner; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final s = ZenStyle.of(context); final paused = runner.isPaused; return Semantics( button: true, label: paused ? l10n.resume : l10n.pause, child: Material( color: s.accent, borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 36)), child: InkWell( borderRadius: BorderRadius.circular(s.controlRadius.clamp(0, 36)), onTap: () => paused ? runner.resume(clock.now()) : runner.pause(clock.now()), child: Tooltip( message: paused ? l10n.resume : l10n.pause, child: SizedBox.square( dimension: 72, child: Icon(paused ? Icons.play_arrow_rounded : Icons.pause_rounded, size: 40, color: s.onAccent), ), ), ), ), ); } } /// Confirms a rep set: the target reps and weight come prefilled, so hitting /// the target is a single tap. class _SetLogSheet extends StatefulWidget { const _SetLogSheet({required this.set}); final SetSegment set; @override State<_SetLogSheet> createState() => _SetLogSheetState(); } class _SetLogSheetState extends State<_SetLogSheet> { late int _reps = widget.set.target.reps ?? 10; late double? _kg = widget.set.weightKg; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final text = Theme.of(context).textTheme; final kg = _kg; return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.logSetTitle, style: text.titleLarge), const SizedBox(height: 8), NumberStepper( label: l10n.stepReps, value: '$_reps', onDecrement: _reps > 0 ? () => setState(() => _reps--) : null, onIncrement: _reps < 200 ? () => setState(() => _reps++) : null, ), if (kg != null) NumberStepper( label: l10n.stepWeight, value: l10n.weightKg(kgNumber(kg)), onDecrement: kg >= 2.5 ? () => setState(() => _kg = kg - 2.5) : null, onIncrement: () => setState(() => _kg = kg + 2.5), ) else Padding( padding: const EdgeInsets.only(top: 8), child: OutlinedButton.icon( onPressed: () => setState(() => _kg = 20), icon: const Icon(Icons.add_rounded), label: Text(l10n.logWeight), ), ), const SizedBox(height: 16), SizedBox( width: double.infinity, height: 52, child: FilledButton(onPressed: () => Navigator.of(context).pop((_reps, _kg)), child: Text(l10n.saveSet)), ), ], ), ), ); } } /// Lets the editor and the Plans list start a Plan, unless it has no Steps. void startPlan(BuildContext context, Plan plan) { if (plan.items.isEmpty) return; Navigator.of(context).pushNamed(PlayerView.routeName, arguments: plan.id); }