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:
1 parent
bbdca95541
commit
971ff64117
28 files changed
+3465
-84
No files matched your search
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user