729 lines
23 KiB
Dart
729 lines
23 KiB
Dart
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);
|
||
|
|
}
|