Stage 4: guided Player for both Modes and the Session summary

- buildSegments lays a Plan out as holds with Transitions (get ready,
  switch sides, next round, next exercise) or sets with count-up Rests;
  Supersets alternate members and rest per round unless asked otherwise
- SessionRunner drives segments against an injected clock: auto-advance
  with a chime, pause, skip, back, rep sets confirmed with logged reps
  and kg, timed sets started on demand, early end
- Player screen: countdown ring, accent pulse in the last 3 s, Flip
  Animation, next-up cards, Rest Screen, mute toggle, screen kept on
- Sessions table (schema v2 migration) keeps a Plan snapshot and Set
  Logs; nothing is saved when no hold or set was done
- Summary with time, exercises or sets logged, and every Set Log
- Synthesised quiet cue sounds (tools/sounds), status bar per Mode

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CedricandClaude Opus 5.5 committed 2026-10-29 00:20:16 +01:00
1 parent bbdca95541
commit 971ff64117
28 files changed
+3465 -84

No files matched your search

+729
View File
@@ -0,0 +1,729 @@
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<PlayerView> createState() => _PlayerViewState();
}
class _PlayerViewState extends State<PlayerView> {
SessionRunner? _runner;
Timer? _ticker;
var _leaving = false;
late final PlayerEffects _effects = context.read<PlayerEffects>();
@override
void initState() {
super.initState();
_effects.keepScreenOn(true);
_start();
}
Future<void> _start() async {
final plan = await context.read<PlanRepository>().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<SettingsController>().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<void> _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<SessionRepository>().save(runner.result(clock.now()));
navigator.pushReplacementNamed(SummaryView.routeName, arguments: session.id);
}
Future<void> _confirmEnd() async {
final runner = _runner;
if (runner == null) {
Navigator.of(context).pop();
return;
}
final l10n = AppLocalizations.of(context);
final end = await showDialog<bool>(
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<SettingsController>();
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<ExerciseLibrary>().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<ExerciseLibrary>().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<ExerciseLibrary>().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<Widget> 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<Color> 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<void> _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);
}