Corrige la flèche de retour sur workout_execution_screen.dart : elle ouvre désormais le choix pause/abandon ou quitter-et-sauvegarder au lieu de ne rien faire. flutter analyze propre, 36/36 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1444 lines
44 KiB
Dart
1444 lines
44 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
|
||
import 'package:flutter/material.dart';
|
||
|
||
import '../application/application.dart';
|
||
import '../domain/domain.dart';
|
||
import 'history_screen.dart';
|
||
import 'theme.dart';
|
||
|
||
enum WorkoutExecutionMode { active, rest, paused, finished }
|
||
|
||
final class WorkoutExecutionScreen extends StatefulWidget {
|
||
const WorkoutExecutionScreen({
|
||
required this.initialSession,
|
||
required this.activeUseCases,
|
||
required this.closeUseCase,
|
||
required this.historyUseCases,
|
||
required this.workoutTemplateUseCases,
|
||
super.key,
|
||
});
|
||
|
||
final ActiveWorkoutSession initialSession;
|
||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||
final CloseWorkoutSessionUseCase closeUseCase;
|
||
final WorkoutHistoryUseCases historyUseCases;
|
||
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
||
|
||
@override
|
||
State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState();
|
||
}
|
||
|
||
final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||
late ActiveWorkoutSession _session;
|
||
late final WorkoutExecutionPlan _plan;
|
||
late WorkoutExecutionMode _mode;
|
||
Timer? _ticker;
|
||
Timer? _restTicker;
|
||
WorkoutHistory? _completedHistory;
|
||
String? _activeRestStateId;
|
||
late DateTime _seriesStartedAt;
|
||
var _reps = 0;
|
||
final _scoreController = TextEditingController();
|
||
var _remainingRestSeconds = 0;
|
||
|
||
ExecutionPosition get _position => ExecutionPosition(
|
||
programIndex: _session.currentProgramIndex,
|
||
exerciseIndex: _session.currentExerciseIndex,
|
||
setIndex: _session.currentSetIndex,
|
||
);
|
||
|
||
ExecutionExercise get _exercise => _plan.exerciseAt(_position);
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_session = widget.initialSession;
|
||
_plan = WorkoutExecutionPlan.fromSession(_session);
|
||
_mode = _session.status == ActiveWorkoutStatus.running
|
||
? WorkoutExecutionMode.active
|
||
: WorkoutExecutionMode.paused;
|
||
_seriesStartedAt = DateTime.now().toUtc();
|
||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (mounted) setState(() {});
|
||
});
|
||
unawaited(_restoreActiveRest());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ticker?.cancel();
|
||
_restTicker?.cancel();
|
||
_scoreController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return PopScope(
|
||
canPop: false,
|
||
onPopInvoked: _handleSystemBack,
|
||
child: Scaffold(
|
||
appBar: AppBar(
|
||
automaticallyImplyLeading: false,
|
||
leading: _canPauseFromNavigation
|
||
? IconButton(
|
||
tooltip: 'Mettre en pause',
|
||
onPressed: () => unawaited(_pause()),
|
||
icon: const Icon(Icons.arrow_back),
|
||
)
|
||
: null,
|
||
title: Text(_plan.name),
|
||
actions: [
|
||
IconButton(
|
||
tooltip: 'Voir le plan',
|
||
onPressed: _openWorkoutPlan,
|
||
icon: const Icon(Icons.list_alt),
|
||
),
|
||
TextButton(onPressed: _pause, child: const Text('Pause')),
|
||
],
|
||
),
|
||
body: switch (_mode) {
|
||
WorkoutExecutionMode.active => _buildActive(),
|
||
WorkoutExecutionMode.rest => _buildRest(),
|
||
WorkoutExecutionMode.paused => _buildPaused(),
|
||
WorkoutExecutionMode.finished => _buildFinished(),
|
||
},
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildActive() {
|
||
return ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
_Header(
|
||
elapsedLabel: _formatDuration(
|
||
Duration(
|
||
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(
|
||
_session,
|
||
),
|
||
),
|
||
),
|
||
progressLabel: _plan.progressLabel(_position),
|
||
),
|
||
const SizedBox(height: 24),
|
||
Text(_exercise.name, style: Theme.of(context).textTheme.headlineMedium),
|
||
const SizedBox(height: 16),
|
||
SetMeasureInput(
|
||
exercise: _exercise,
|
||
reps: _reps,
|
||
scoreController: _scoreController,
|
||
onRepsChanged: (value) => setState(() => _reps = value),
|
||
),
|
||
const SizedBox(height: 24),
|
||
FilledButton(
|
||
onPressed: () => _finishSet(skipped: false),
|
||
style: FilledButton.styleFrom(minimumSize: const Size.fromHeight(56)),
|
||
child: const Text('Terminer la série'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
OutlinedButton(
|
||
onPressed: () => _finishSet(skipped: true),
|
||
style: OutlinedButton.styleFrom(
|
||
minimumSize: const Size.fromHeight(52),
|
||
),
|
||
child: const Text('Passer'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildRest() {
|
||
final next = _plan.nextPosition(_position);
|
||
final nextExercise = next == null ? null : _plan.exerciseAt(next);
|
||
return Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Text('Repos avant la prochaine série'),
|
||
const SizedBox(height: 24),
|
||
Center(
|
||
child: Text(
|
||
_formatDuration(Duration(seconds: _remainingRestSeconds)),
|
||
style: AppTextStyles.timer(context),
|
||
),
|
||
),
|
||
if (next != null && nextExercise != null) ...[
|
||
const SizedBox(height: 24),
|
||
Text(
|
||
'Ensuite : ${nextExercise.name} · '
|
||
'Série ${next.setIndex + 1}/${nextExercise.setsCount}',
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
const Spacer(),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: () => _adjustRest(-15),
|
||
child: const Text('-15 s'),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: OutlinedButton(
|
||
onPressed: () => _adjustRest(15),
|
||
child: const Text('+15 s'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
FilledButton(
|
||
onPressed: _skipRest,
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size.fromHeight(52),
|
||
),
|
||
child: const Text('Ignorer le repos'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextButton(onPressed: _pause, child: const Text('Pause')),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPaused() {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text('Pause', style: Theme.of(context).textTheme.headlineMedium),
|
||
const SizedBox(height: 24),
|
||
FilledButton(
|
||
onPressed: _resume,
|
||
style: FilledButton.styleFrom(
|
||
minimumSize: const Size.fromHeight(56),
|
||
),
|
||
child: const Text('Reprendre'),
|
||
),
|
||
const SizedBox(height: 12),
|
||
OutlinedButton(
|
||
onPressed: _quitAndSave,
|
||
child: const Text('Quitter et sauvegarder'),
|
||
),
|
||
const Spacer(),
|
||
TextButton(
|
||
onPressed: _confirmAbandon,
|
||
child: const Text('Abandonner la séance'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildFinished() {
|
||
final elapsed = Duration(
|
||
milliseconds: widget.activeUseCases.elapsedActiveMilliseconds(_session),
|
||
);
|
||
return Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Text(
|
||
'Séance terminée',
|
||
style: Theme.of(context).textTheme.headlineMedium,
|
||
),
|
||
const SizedBox(height: 12),
|
||
Text('Temps total : ${_formatDuration(elapsed)}'),
|
||
const SizedBox(height: 24),
|
||
OutlinedButton(
|
||
onPressed: _completedHistory == null ? null : _openHistoryDetail,
|
||
child: const Text('Voir le détail'),
|
||
),
|
||
OutlinedButton(
|
||
onPressed: _completedHistory == null ? null : _restartCompleted,
|
||
child: const Text('Relancer cette séance'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () {
|
||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||
},
|
||
child: const Text('Retour à l’accueil'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
bool get _canPauseFromNavigation {
|
||
return _mode == WorkoutExecutionMode.active ||
|
||
_mode == WorkoutExecutionMode.rest;
|
||
}
|
||
|
||
void _handleSystemBack(bool didPop) {
|
||
if (didPop || !_canPauseFromNavigation) return;
|
||
unawaited(_pause());
|
||
}
|
||
|
||
Future<void> _pause() async {
|
||
_restTicker?.cancel();
|
||
_session = await widget.activeUseCases.pause(_session.metadata.id);
|
||
if (!mounted) return;
|
||
setState(() => _mode = WorkoutExecutionMode.paused);
|
||
}
|
||
|
||
Future<void> _resume() async {
|
||
_session = await widget.activeUseCases.resume(_session.metadata.id);
|
||
if (!mounted) return;
|
||
final restoredRest = await _restoreActiveRest();
|
||
if (!mounted || restoredRest) return;
|
||
setState(() => _mode = WorkoutExecutionMode.active);
|
||
}
|
||
|
||
Future<void> _quitAndSave() async {
|
||
await widget.activeUseCases.quitAndSave(_session.metadata.id);
|
||
if (!mounted) return;
|
||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||
}
|
||
|
||
Future<void> _confirmAbandon() async {
|
||
final confirmed = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Abandonner la séance ?'),
|
||
content: const Text('Cette action est définitive.'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(false),
|
||
child: const Text('Annuler'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(context).pop(true),
|
||
child: const Text('Abandonner'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (confirmed != true) return;
|
||
await widget.activeUseCases.abandon(_session.metadata.id);
|
||
if (!mounted) return;
|
||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||
}
|
||
|
||
Future<void> _openWorkoutPlan() async {
|
||
final setStates = await widget.activeUseCases.listSetResults(
|
||
_session.metadata.id,
|
||
);
|
||
final activeRest = await widget.activeUseCases.findActiveRest(
|
||
sessionId: _session.metadata.id,
|
||
);
|
||
if (!mounted) return;
|
||
await showModalBottomSheet<void>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
useSafeArea: true,
|
||
builder: (context) => FractionallySizedBox(
|
||
heightFactor: 1,
|
||
child: _WorkoutPlanSheet(
|
||
sessionId: _session.metadata.id,
|
||
plan: _plan,
|
||
currentPosition: _position,
|
||
setStates: setStates,
|
||
activeRest: activeRest,
|
||
activeUseCases: widget.activeUseCases,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _finishSet({required bool skipped}) async {
|
||
final score = double.tryParse(_scoreController.text.trim());
|
||
final actualTimeMs = DateTime.now()
|
||
.toUtc()
|
||
.difference(_seriesStartedAt)
|
||
.inMilliseconds;
|
||
await widget.activeUseCases.recordCurrentSetResult(
|
||
sessionId: _session.metadata.id,
|
||
programSnapshotId: _plan.programAt(_position).id,
|
||
exerciseSnapshotId: _exercise.id,
|
||
programIndex: _position.programIndex,
|
||
exerciseIndex: _position.exerciseIndex,
|
||
setIndex: _position.setIndex,
|
||
actualReps: skipped ? null : (_exercise.repsEnabled ? _reps : null),
|
||
actualScore: skipped ? null : (_exercise.scoreEnabled ? score : null),
|
||
scoreUnitSnapshot: _exercise.scoreUnit,
|
||
actualTimeMs: skipped
|
||
? null
|
||
: (_exercise.timeEnabled ? actualTimeMs : null),
|
||
);
|
||
|
||
final next = _plan.nextPosition(_position);
|
||
if (next == null) {
|
||
await _complete();
|
||
return;
|
||
}
|
||
final shouldRest = _plan.shouldShowRestAfter(_position);
|
||
if (shouldRest) {
|
||
final rest = await widget.activeUseCases.startRestAfterSet(
|
||
sessionId: _session.metadata.id,
|
||
afterProgramIndex: _position.programIndex,
|
||
afterExerciseIndex: _position.exerciseIndex,
|
||
afterSetIndex: _position.setIndex,
|
||
plannedRestSeconds: _exercise.restSeconds,
|
||
);
|
||
if (!mounted) return;
|
||
_activeRestStateId = rest.metadata.id;
|
||
_remainingRestSeconds = _exercise.restSeconds;
|
||
_startRestTicker(next);
|
||
setState(() => _mode = WorkoutExecutionMode.rest);
|
||
return;
|
||
}
|
||
await _moveTo(next);
|
||
}
|
||
|
||
void _startRestTicker(ExecutionPosition next) {
|
||
_restTicker?.cancel();
|
||
_restTicker = Timer.periodic(const Duration(seconds: 1), (timer) {
|
||
if (!mounted) return;
|
||
if (_remainingRestSeconds <= 1) {
|
||
timer.cancel();
|
||
unawaited(_finishRestAndMove(next));
|
||
} else {
|
||
setState(() => _remainingRestSeconds -= 1);
|
||
}
|
||
});
|
||
}
|
||
|
||
Future<bool> _restoreActiveRest() async {
|
||
final rest = await widget.activeUseCases.findActiveRest(
|
||
sessionId: _session.metadata.id,
|
||
);
|
||
if (!mounted || rest == null) {
|
||
return false;
|
||
}
|
||
final after = ExecutionPosition(
|
||
programIndex: rest.afterProgramIndex,
|
||
exerciseIndex: rest.afterExerciseIndex,
|
||
setIndex: rest.afterSetIndex,
|
||
);
|
||
final next = _plan.nextPosition(after);
|
||
final remaining = _remainingSecondsFor(rest);
|
||
_activeRestStateId = rest.metadata.id;
|
||
if (next == null || remaining <= 0) {
|
||
await _finishRestAndMove(next);
|
||
return true;
|
||
}
|
||
_remainingRestSeconds = remaining;
|
||
_startRestTicker(next);
|
||
if (!mounted) return true;
|
||
setState(() => _mode = WorkoutExecutionMode.rest);
|
||
return true;
|
||
}
|
||
|
||
int _remainingSecondsFor(ActiveRestState rest) {
|
||
final elapsedSeconds = DateTime.now()
|
||
.toUtc()
|
||
.difference(rest.startedAt.toUtc())
|
||
.inSeconds;
|
||
return (rest.adjustedRestSeconds - elapsedSeconds).clamp(0, 9999).toInt();
|
||
}
|
||
|
||
Future<void> _adjustRest(int deltaSeconds) async {
|
||
final restStateId = _activeRestStateId;
|
||
setState(() {
|
||
_remainingRestSeconds = (_remainingRestSeconds + deltaSeconds)
|
||
.clamp(0, 9999)
|
||
.toInt();
|
||
});
|
||
if (restStateId == null) return;
|
||
await widget.activeUseCases.adjustRestSeconds(
|
||
restStateId: restStateId,
|
||
deltaSeconds: deltaSeconds,
|
||
);
|
||
}
|
||
|
||
Future<void> _skipRest() async {
|
||
_restTicker?.cancel();
|
||
final next = _plan.nextPosition(_position);
|
||
await _finishRestAndMove(next);
|
||
}
|
||
|
||
Future<void> _finishRestAndMove(ExecutionPosition? next) async {
|
||
final restStateId = _activeRestStateId;
|
||
_activeRestStateId = null;
|
||
if (restStateId != null) {
|
||
await widget.activeUseCases.skipRest(restStateId: restStateId);
|
||
}
|
||
if (next == null) {
|
||
await _complete();
|
||
return;
|
||
}
|
||
await _moveTo(next);
|
||
}
|
||
|
||
Future<void> _moveTo(ExecutionPosition position) async {
|
||
_scoreController.clear();
|
||
_reps = 0;
|
||
_activeRestStateId = null;
|
||
_seriesStartedAt = DateTime.now().toUtc();
|
||
_session = await widget.activeUseCases.updateProgress(
|
||
sessionId: _session.metadata.id,
|
||
programIndex: position.programIndex,
|
||
exerciseIndex: position.exerciseIndex,
|
||
setIndex: position.setIndex,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() => _mode = WorkoutExecutionMode.active);
|
||
}
|
||
|
||
Future<void> _complete() async {
|
||
_session = await widget.activeUseCases.complete(_session.metadata.id);
|
||
_completedHistory = await widget.closeUseCase.close(
|
||
sessionId: _session.metadata.id,
|
||
nameSnapshot: _plan.name,
|
||
completed: true,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() => _mode = WorkoutExecutionMode.finished);
|
||
}
|
||
|
||
Future<void> _openHistoryDetail() async {
|
||
final history = _completedHistory;
|
||
if (history == null) return;
|
||
await Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (context) => HistoryDetailScreen(
|
||
history: history,
|
||
historyUseCases: widget.historyUseCases,
|
||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||
activeUseCases: widget.activeUseCases,
|
||
closeUseCase: widget.closeUseCase,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _restartCompleted() async {
|
||
final history = _completedHistory;
|
||
if (history == null) return;
|
||
ActiveWorkoutSession session;
|
||
final sourceId = history.sourceWorkoutTemplateId;
|
||
if (sourceId != null &&
|
||
await widget.workoutTemplateUseCases.findById(sourceId) != null) {
|
||
session = await widget.activeUseCases.startFromTemplate(sourceId);
|
||
} else {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
"La séance originale n'existe plus. Une copie va être utilisée.",
|
||
),
|
||
),
|
||
);
|
||
}
|
||
session = await widget.activeUseCases.startFromHistory(history);
|
||
}
|
||
if (!mounted) return;
|
||
await Navigator.of(context).pushReplacement(
|
||
MaterialPageRoute(
|
||
builder: (context) => WorkoutExecutionScreen(
|
||
initialSession: session,
|
||
activeUseCases: widget.activeUseCases,
|
||
closeUseCase: widget.closeUseCase,
|
||
historyUseCases: widget.historyUseCases,
|
||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class SetMeasureInput extends StatelessWidget {
|
||
const SetMeasureInput({
|
||
required this.exercise,
|
||
required this.reps,
|
||
required this.scoreController,
|
||
required this.onRepsChanged,
|
||
super.key,
|
||
});
|
||
|
||
final ExecutionExercise exercise;
|
||
final int reps;
|
||
final TextEditingController scoreController;
|
||
final ValueChanged<int> onRepsChanged;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (exercise.timeEnabled)
|
||
Card(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
children: [
|
||
const Text('Temps'),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
exercise.targetTimeSeconds == null
|
||
? 'Chronométrer'
|
||
: '${exercise.targetTimeSeconds} s',
|
||
style: AppTextStyles.scoreNumber(context),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
if (exercise.repsEnabled) ...[
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
const Expanded(child: Text('Répétitions')),
|
||
IconButton(
|
||
onPressed: () =>
|
||
onRepsChanged((reps - 1).clamp(0, 999).toInt()),
|
||
icon: const Icon(Icons.remove),
|
||
),
|
||
Text('$reps', style: AppTextStyles.scoreNumber(context)),
|
||
IconButton(
|
||
onPressed: () => onRepsChanged(reps + 1),
|
||
icon: const Icon(Icons.add),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
if (exercise.scoreEnabled) ...[
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: scoreController,
|
||
style: AppTextStyles.scoreNumber(context),
|
||
decoration: InputDecoration(
|
||
labelText: exercise.scoreUnit == null
|
||
? 'Score'
|
||
: 'Score (${exercise.scoreUnit})',
|
||
),
|
||
keyboardType: TextInputType.number,
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _WorkoutPlanSheet extends StatefulWidget {
|
||
const _WorkoutPlanSheet({
|
||
required this.sessionId,
|
||
required this.plan,
|
||
required this.currentPosition,
|
||
required this.setStates,
|
||
required this.activeUseCases,
|
||
this.activeRest,
|
||
});
|
||
|
||
final String sessionId;
|
||
final WorkoutExecutionPlan plan;
|
||
final ExecutionPosition currentPosition;
|
||
final List<SetResultPositionState> setStates;
|
||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||
final ActiveRestState? activeRest;
|
||
|
||
@override
|
||
State<_WorkoutPlanSheet> createState() => _WorkoutPlanSheetState();
|
||
}
|
||
|
||
final class _WorkoutPlanSheetState extends State<_WorkoutPlanSheet> {
|
||
Timer? _timer;
|
||
late int _remainingRestSeconds;
|
||
late List<SetResultPositionState> _setStates;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_setStates = widget.setStates;
|
||
_remainingRestSeconds = _remainingSecondsForRest(widget.activeRest);
|
||
if (widget.activeRest != null && _remainingRestSeconds > 0) {
|
||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_remainingRestSeconds = (_remainingRestSeconds - 1)
|
||
.clamp(0, 9999)
|
||
.toInt();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_timer?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final statesByPosition = {
|
||
for (final state in _setStates) _stateKey(state): state,
|
||
};
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text('Plan de séance'),
|
||
leading: IconButton(
|
||
tooltip: 'Fermer',
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
icon: const Icon(Icons.close),
|
||
),
|
||
),
|
||
body: ListView(
|
||
padding: const EdgeInsets.all(16),
|
||
children: [
|
||
const Text('Touchez une série pour voir ses valeurs.'),
|
||
if (widget.activeRest != null) ...[
|
||
const SizedBox(height: 12),
|
||
_RestPlanBanner(remainingSeconds: _remainingRestSeconds),
|
||
],
|
||
const SizedBox(height: 24),
|
||
for (
|
||
var programIndex = 0;
|
||
programIndex < widget.plan.programs.length;
|
||
programIndex++
|
||
) ...[
|
||
Text(
|
||
widget.plan.programs[programIndex].name,
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
const SizedBox(height: 8),
|
||
for (
|
||
var exerciseIndex = 0;
|
||
exerciseIndex <
|
||
widget.plan.programs[programIndex].exercises.length;
|
||
exerciseIndex++
|
||
) ...[
|
||
Text(
|
||
widget
|
||
.plan
|
||
.programs[programIndex]
|
||
.exercises[exerciseIndex]
|
||
.name,
|
||
style: Theme.of(context).textTheme.titleSmall,
|
||
),
|
||
const SizedBox(height: 4),
|
||
for (
|
||
var setIndex = 0;
|
||
setIndex <
|
||
widget
|
||
.plan
|
||
.programs[programIndex]
|
||
.exercises[exerciseIndex]
|
||
.setsCount;
|
||
setIndex++
|
||
)
|
||
_WorkoutPlanSetTile(
|
||
position: ExecutionPosition(
|
||
programIndex: programIndex,
|
||
exerciseIndex: exerciseIndex,
|
||
setIndex: setIndex,
|
||
),
|
||
currentPosition: widget.currentPosition,
|
||
exercise: widget
|
||
.plan
|
||
.programs[programIndex]
|
||
.exercises[exerciseIndex],
|
||
state:
|
||
statesByPosition[_positionKey(
|
||
programIndex,
|
||
exerciseIndex,
|
||
setIndex,
|
||
)],
|
||
onUpdated: _refreshAfterEdit,
|
||
activeUseCases: widget.activeUseCases,
|
||
sessionId: widget.sessionId,
|
||
activeRest: widget.activeRest,
|
||
),
|
||
const SizedBox(height: 16),
|
||
],
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _refreshAfterEdit() async {
|
||
final updated = await widget.activeUseCases.listSetResults(
|
||
widget.sessionId,
|
||
);
|
||
if (!mounted) return;
|
||
setState(() => _setStates = updated);
|
||
ScaffoldMessenger.of(
|
||
context,
|
||
).showSnackBar(const SnackBar(content: Text('Série mise à jour')));
|
||
}
|
||
}
|
||
|
||
final class _RestPlanBanner extends StatelessWidget {
|
||
const _RestPlanBanner({required this.remainingSeconds});
|
||
|
||
final int remainingSeconds;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final label = remainingSeconds <= 0
|
||
? 'Repos terminé · Reprendre'
|
||
: 'Repos en cours · ${_formatDuration(Duration(seconds: remainingSeconds))}';
|
||
return CourtBlazerAccentPanel(
|
||
padding: const EdgeInsets.all(12),
|
||
child: Text(label),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _WorkoutPlanSetTile extends StatelessWidget {
|
||
const _WorkoutPlanSetTile({
|
||
required this.position,
|
||
required this.currentPosition,
|
||
required this.exercise,
|
||
required this.onUpdated,
|
||
required this.activeUseCases,
|
||
required this.sessionId,
|
||
this.activeRest,
|
||
this.state,
|
||
});
|
||
|
||
final ExecutionPosition position;
|
||
final ExecutionPosition currentPosition;
|
||
final ExecutionExercise exercise;
|
||
final SetResultPositionState? state;
|
||
final Future<void> Function() onUpdated;
|
||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||
final String sessionId;
|
||
final ActiveRestState? activeRest;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final planState = _planSetState(position, currentPosition, state);
|
||
final tappable = planState != _PlanSetStatus.todo;
|
||
final VoidCallback? onTap = tappable
|
||
? () => unawaited(_handleTap(context, planState))
|
||
: null;
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return Card(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(6),
|
||
side: BorderSide(
|
||
color: planState == _PlanSetStatus.current
|
||
? Theme.of(context).colorScheme.primary
|
||
: tokens.border,
|
||
),
|
||
),
|
||
child: ListTile(
|
||
enabled: tappable,
|
||
title: Text('Série ${position.setIndex + 1}'),
|
||
subtitle: Text(_planSetSubtitle(planState, state?.result)),
|
||
trailing: _PlanStatusBadge(status: planState, onTap: onTap),
|
||
onTap: onTap,
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _handleTap(BuildContext context, _PlanSetStatus status) async {
|
||
if (status == _PlanSetStatus.current) {
|
||
Navigator.of(context).pop();
|
||
return;
|
||
}
|
||
await showModalBottomSheet<void>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
useSafeArea: true,
|
||
builder: (context) => _EditSetResultSheet(
|
||
sessionId: sessionId,
|
||
position: position,
|
||
exercise: exercise,
|
||
state: state,
|
||
status: status,
|
||
activeUseCases: activeUseCases,
|
||
activeRest: activeRest,
|
||
onSaved: onUpdated,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _EditSetResultSheet extends StatefulWidget {
|
||
const _EditSetResultSheet({
|
||
required this.sessionId,
|
||
required this.position,
|
||
required this.exercise,
|
||
required this.state,
|
||
required this.status,
|
||
required this.activeUseCases,
|
||
required this.onSaved,
|
||
this.activeRest,
|
||
});
|
||
|
||
final String sessionId;
|
||
final ExecutionPosition position;
|
||
final ExecutionExercise exercise;
|
||
final SetResultPositionState? state;
|
||
final _PlanSetStatus status;
|
||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||
final Future<void> Function() onSaved;
|
||
final ActiveRestState? activeRest;
|
||
|
||
@override
|
||
State<_EditSetResultSheet> createState() => _EditSetResultSheetState();
|
||
}
|
||
|
||
final class _EditSetResultSheetState extends State<_EditSetResultSheet> {
|
||
final _timeController = TextEditingController();
|
||
final _repsController = TextEditingController();
|
||
final _scoreController = TextEditingController();
|
||
Timer? _timer;
|
||
late int _remainingRestSeconds;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final result = widget.state?.result;
|
||
final actualTimeMs = result?.actualTimeMs;
|
||
if (actualTimeMs != null) {
|
||
_timeController.text = (actualTimeMs / 1000).round().toString();
|
||
}
|
||
final actualReps = result?.actualReps;
|
||
if (actualReps != null) {
|
||
_repsController.text = actualReps.toString();
|
||
}
|
||
final actualScore = result?.actualScore;
|
||
if (actualScore != null) {
|
||
_scoreController.text = _formatScore(actualScore);
|
||
}
|
||
_remainingRestSeconds = _remainingSecondsForRest(widget.activeRest);
|
||
if (widget.activeRest != null && _remainingRestSeconds > 0) {
|
||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_remainingRestSeconds = (_remainingRestSeconds - 1)
|
||
.clamp(0, 9999)
|
||
.toInt();
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_timer?.cancel();
|
||
_timeController.dispose();
|
||
_repsController.dispose();
|
||
_scoreController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isCompleted = widget.status == _PlanSetStatus.completed;
|
||
return Padding(
|
||
padding: EdgeInsets.only(
|
||
left: 16,
|
||
right: 16,
|
||
top: 16,
|
||
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||
),
|
||
child: ListView(
|
||
shrinkWrap: true,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
'Modifier la série',
|
||
style: Theme.of(context).textTheme.titleLarge,
|
||
),
|
||
),
|
||
IconButton(
|
||
tooltip: 'Annuler',
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
icon: const Icon(Icons.close),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'${widget.exercise.name} · '
|
||
'Série ${widget.position.setIndex + 1}/${widget.exercise.setsCount}',
|
||
),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
'État actuel : ${isCompleted ? 'Terminée' : 'Passée'}',
|
||
style: Theme.of(context).textTheme.bodySmall,
|
||
),
|
||
if (widget.activeRest != null) ...[
|
||
const SizedBox(height: 12),
|
||
_RestPlanBanner(remainingSeconds: _remainingRestSeconds),
|
||
],
|
||
const SizedBox(height: 24),
|
||
if (widget.exercise.timeEnabled) ...[
|
||
TextField(
|
||
controller: _timeController,
|
||
decoration: const InputDecoration(labelText: 'Temps réalisé (s)'),
|
||
keyboardType: TextInputType.number,
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
if (widget.exercise.repsEnabled) ...[
|
||
TextField(
|
||
controller: _repsController,
|
||
decoration: const InputDecoration(labelText: 'Répétitions'),
|
||
keyboardType: TextInputType.number,
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
if (widget.exercise.scoreEnabled) ...[
|
||
TextField(
|
||
controller: _scoreController,
|
||
decoration: InputDecoration(
|
||
labelText: widget.exercise.scoreUnit == null
|
||
? 'Score'
|
||
: 'Score (${widget.exercise.scoreUnit})',
|
||
),
|
||
keyboardType: TextInputType.number,
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
const SizedBox(height: 12),
|
||
FilledButton(
|
||
onPressed: _saveCompleted,
|
||
child: Text(
|
||
isCompleted
|
||
? 'Enregistrer les corrections'
|
||
: 'Enregistrer le résultat',
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
OutlinedButton(
|
||
onPressed: _markSkipped,
|
||
child: const Text('Marquer comme passée'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(),
|
||
child: const Text('Annuler'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _saveCompleted() async {
|
||
if (!_hasAnyValue()) {
|
||
if (widget.status == _PlanSetStatus.completed) {
|
||
final confirmed = await _confirmMarkSkipped();
|
||
if (confirmed != true) return;
|
||
}
|
||
await _saveSkipped();
|
||
return;
|
||
}
|
||
await widget.activeUseCases.upsertSetResultAtPosition(
|
||
sessionId: widget.sessionId,
|
||
programIndex: widget.position.programIndex,
|
||
exerciseIndex: widget.position.exerciseIndex,
|
||
setIndex: widget.position.setIndex,
|
||
status: SetResultStatus.completed,
|
||
actualTimeMs: widget.exercise.timeEnabled
|
||
? _secondsToMilliseconds(_timeController.text)
|
||
: null,
|
||
actualReps: widget.exercise.repsEnabled
|
||
? int.tryParse(_repsController.text.trim())
|
||
: null,
|
||
actualScore: widget.exercise.scoreEnabled
|
||
? double.tryParse(_scoreController.text.trim())
|
||
: null,
|
||
scoreUnitSnapshot: widget.exercise.scoreUnit,
|
||
);
|
||
await _finishSave();
|
||
}
|
||
|
||
Future<void> _markSkipped() async {
|
||
await _saveSkipped();
|
||
}
|
||
|
||
Future<void> _saveSkipped() async {
|
||
await widget.activeUseCases.upsertSetResultAtPosition(
|
||
sessionId: widget.sessionId,
|
||
programIndex: widget.position.programIndex,
|
||
exerciseIndex: widget.position.exerciseIndex,
|
||
setIndex: widget.position.setIndex,
|
||
status: SetResultStatus.skipped,
|
||
);
|
||
await _finishSave();
|
||
}
|
||
|
||
Future<void> _finishSave() async {
|
||
await widget.onSaved();
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop();
|
||
}
|
||
|
||
bool _hasAnyValue() {
|
||
return _timeController.text.trim().isNotEmpty ||
|
||
_repsController.text.trim().isNotEmpty ||
|
||
_scoreController.text.trim().isNotEmpty;
|
||
}
|
||
|
||
Future<bool?> _confirmMarkSkipped() {
|
||
return showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text('Supprimer le résultat de cette série ?'),
|
||
content: const Text(
|
||
'La série restera dans le plan comme passée, sans résultat saisi.',
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(context).pop(false),
|
||
child: const Text('Annuler'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(context).pop(true),
|
||
child: const Text('Marquer comme passée'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _PlanStatusBadge extends StatelessWidget {
|
||
const _PlanStatusBadge({required this.status, this.onTap});
|
||
|
||
final _PlanSetStatus status;
|
||
final VoidCallback? onTap;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final tokens = courtBlazerTokensOf(context);
|
||
final label = switch (status) {
|
||
_PlanSetStatus.todo => 'À faire',
|
||
_PlanSetStatus.current => 'En cours',
|
||
_PlanSetStatus.completed => 'Terminée',
|
||
_PlanSetStatus.passed => 'Passée',
|
||
};
|
||
final color = switch (status) {
|
||
_PlanSetStatus.completed => tokens.success,
|
||
_PlanSetStatus.current => Theme.of(context).colorScheme.primary,
|
||
_ => tokens.border,
|
||
};
|
||
return InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(16),
|
||
child: Chip(
|
||
label: Text(label),
|
||
backgroundColor: color.withAlpha(35),
|
||
side: BorderSide(color: color),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
enum _PlanSetStatus { todo, current, completed, passed }
|
||
|
||
_PlanSetStatus _planSetState(
|
||
ExecutionPosition position,
|
||
ExecutionPosition currentPosition,
|
||
SetResultPositionState? state,
|
||
) {
|
||
if (_comparePosition(position, currentPosition) == 0) {
|
||
return _PlanSetStatus.current;
|
||
}
|
||
if (state?.status == SetPositionStatus.completed) {
|
||
return _PlanSetStatus.completed;
|
||
}
|
||
if (state?.status == SetPositionStatus.skipped ||
|
||
_comparePosition(position, currentPosition) < 0) {
|
||
return _PlanSetStatus.passed;
|
||
}
|
||
return _PlanSetStatus.todo;
|
||
}
|
||
|
||
String _planSetSubtitle(_PlanSetStatus status, ActiveSetResult? result) {
|
||
if (status == _PlanSetStatus.completed && result != null) {
|
||
return _setResultSummary(result);
|
||
}
|
||
if (status == _PlanSetStatus.passed) {
|
||
return 'Aucun résultat';
|
||
}
|
||
return '';
|
||
}
|
||
|
||
String _setResultSummary(ActiveSetResult result) {
|
||
final parts = <String>[];
|
||
if (result.actualTimeMs != null) {
|
||
parts.add(_formatDuration(Duration(milliseconds: result.actualTimeMs!)));
|
||
}
|
||
if (result.actualReps != null) {
|
||
parts.add('${result.actualReps} reps');
|
||
}
|
||
if (result.actualScore != null) {
|
||
final value = _formatScore(result.actualScore!);
|
||
final unit = result.scoreUnitSnapshot?.trim();
|
||
parts.add(unit == null || unit.isEmpty ? 'Score $value' : '$value $unit');
|
||
}
|
||
return parts.isEmpty ? 'Aucun résultat' : parts.join(' · ');
|
||
}
|
||
|
||
String _formatScore(double value) {
|
||
return value == value.roundToDouble()
|
||
? value.round().toString()
|
||
: value.toString();
|
||
}
|
||
|
||
int? _secondsToMilliseconds(String rawValue) {
|
||
final seconds = int.tryParse(rawValue.trim());
|
||
return seconds == null ? null : seconds * 1000;
|
||
}
|
||
|
||
int _remainingSecondsForRest(ActiveRestState? rest) {
|
||
if (rest == null) return 0;
|
||
final elapsedSeconds = DateTime.now()
|
||
.toUtc()
|
||
.difference(rest.startedAt.toUtc())
|
||
.inSeconds;
|
||
return (rest.adjustedRestSeconds - elapsedSeconds).clamp(0, 9999).toInt();
|
||
}
|
||
|
||
String _stateKey(SetResultPositionState state) {
|
||
return _positionKey(state.programIndex, state.exerciseIndex, state.setIndex);
|
||
}
|
||
|
||
String _positionKey(int programIndex, int exerciseIndex, int setIndex) {
|
||
return '$programIndex:$exerciseIndex:$setIndex';
|
||
}
|
||
|
||
int _comparePosition(ExecutionPosition left, ExecutionPosition right) {
|
||
final program = left.programIndex.compareTo(right.programIndex);
|
||
if (program != 0) return program;
|
||
final exercise = left.exerciseIndex.compareTo(right.exerciseIndex);
|
||
if (exercise != 0) return exercise;
|
||
return left.setIndex.compareTo(right.setIndex);
|
||
}
|
||
|
||
final class WorkoutExecutionPlan {
|
||
const WorkoutExecutionPlan({required this.name, required this.programs});
|
||
|
||
factory WorkoutExecutionPlan.fromSession(ActiveWorkoutSession session) {
|
||
final snapshot =
|
||
jsonDecode(session.resolvedTemplateSnapshotJson)
|
||
as Map<String, dynamic>;
|
||
final overrides = snapshot['overrides'] as List<dynamic>? ?? const [];
|
||
final programs = (snapshot['programs'] as List<dynamic>? ?? const []).map((
|
||
rawProgram,
|
||
) {
|
||
final program = rawProgram as Map<String, dynamic>;
|
||
final programSnapshot =
|
||
jsonDecode(program['programSnapshotJson'] as String)
|
||
as Map<String, dynamic>;
|
||
return ExecutionProgram.fromSnapshot(
|
||
id: program['id'] as String,
|
||
name: program['programNameSnapshot'] as String,
|
||
snapshot: programSnapshot,
|
||
overrides: overrides,
|
||
);
|
||
}).toList();
|
||
return WorkoutExecutionPlan(
|
||
name: snapshot['name'] as String? ?? 'Séance',
|
||
programs: programs,
|
||
);
|
||
}
|
||
|
||
final String name;
|
||
final List<ExecutionProgram> programs;
|
||
|
||
ExecutionProgram programAt(ExecutionPosition position) {
|
||
return programs[position.programIndex];
|
||
}
|
||
|
||
ExecutionExercise exerciseAt(ExecutionPosition position) {
|
||
return programAt(position).exercises[position.exerciseIndex];
|
||
}
|
||
|
||
ExecutionPosition? nextPosition(ExecutionPosition position) {
|
||
final exercise = exerciseAt(position);
|
||
if (position.setIndex + 1 < exercise.setsCount) {
|
||
return position.copyWith(setIndex: position.setIndex + 1);
|
||
}
|
||
final program = programAt(position);
|
||
if (position.exerciseIndex + 1 < program.exercises.length) {
|
||
return ExecutionPosition(
|
||
programIndex: position.programIndex,
|
||
exerciseIndex: position.exerciseIndex + 1,
|
||
setIndex: 0,
|
||
);
|
||
}
|
||
if (position.programIndex + 1 < programs.length) {
|
||
return ExecutionPosition(
|
||
programIndex: position.programIndex + 1,
|
||
exerciseIndex: 0,
|
||
setIndex: 0,
|
||
);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
bool shouldShowRestAfter(ExecutionPosition position) {
|
||
return nextPosition(position) != null &&
|
||
exerciseAt(position).restSeconds > 0;
|
||
}
|
||
|
||
String progressLabel(ExecutionPosition position) {
|
||
final program = programAt(position);
|
||
final exercise = exerciseAt(position);
|
||
return 'Programme ${position.programIndex + 1}/${programs.length} · '
|
||
'Exercice ${position.exerciseIndex + 1}/${program.exercises.length} · '
|
||
'Série ${position.setIndex + 1}/${exercise.setsCount}';
|
||
}
|
||
}
|
||
|
||
final class ExecutionProgram {
|
||
const ExecutionProgram({
|
||
required this.id,
|
||
required this.name,
|
||
required this.exercises,
|
||
});
|
||
|
||
factory ExecutionProgram.fromSnapshot({
|
||
required String id,
|
||
required String name,
|
||
required Map<String, dynamic> snapshot,
|
||
required List<dynamic> overrides,
|
||
}) {
|
||
final exercises = (snapshot['exercises'] as List<dynamic>? ?? const []).map(
|
||
(raw) {
|
||
final exercise = raw as Map<String, dynamic>;
|
||
Map<String, dynamic>? override;
|
||
for (final rawOverride in overrides) {
|
||
final item = rawOverride as Map<String, dynamic>;
|
||
if (item['workoutTemplateProgramId'] == id &&
|
||
item['snapshotProgramExerciseId'] == exercise['id']) {
|
||
override = item;
|
||
break;
|
||
}
|
||
}
|
||
return ExecutionExercise.fromSnapshot(exercise, override);
|
||
},
|
||
).toList();
|
||
return ExecutionProgram(id: id, name: name, exercises: exercises);
|
||
}
|
||
|
||
final String id;
|
||
final String name;
|
||
final List<ExecutionExercise> exercises;
|
||
}
|
||
|
||
final class ExecutionExercise {
|
||
const ExecutionExercise({
|
||
required this.id,
|
||
required this.name,
|
||
required this.setsCount,
|
||
required this.timeEnabled,
|
||
required this.repsEnabled,
|
||
required this.scoreEnabled,
|
||
required this.restSeconds,
|
||
this.targetTimeSeconds,
|
||
this.targetReps,
|
||
this.targetScore,
|
||
this.scoreUnit,
|
||
});
|
||
|
||
factory ExecutionExercise.fromSnapshot(
|
||
Map<String, dynamic> exercise,
|
||
Map<String, dynamic>? override,
|
||
) {
|
||
return ExecutionExercise(
|
||
id: exercise['id'] as String,
|
||
name: exercise['exerciseNameSnapshot'] as String,
|
||
setsCount:
|
||
(override?['setsCountOverride'] as int?) ??
|
||
(exercise['setsCount'] as int),
|
||
timeEnabled: exercise['timeEnabled'] as bool,
|
||
repsEnabled: exercise['repsEnabled'] as bool,
|
||
scoreEnabled: exercise['scoreEnabled'] as bool,
|
||
restSeconds: exercise['restSecondsOverride'] as int? ?? 0,
|
||
targetTimeSeconds:
|
||
(override?['targetTimeSecondsOverride'] as int?) ??
|
||
(exercise['targetTimeSeconds'] as int?),
|
||
targetReps:
|
||
(override?['targetRepsOverride'] as int?) ??
|
||
(exercise['targetReps'] as int?),
|
||
targetScore:
|
||
(override?['targetScoreOverride'] as num?)?.toDouble() ??
|
||
(exercise['targetScore'] as num?)?.toDouble(),
|
||
scoreUnit: exercise['scoreUnitSnapshot'] as String?,
|
||
);
|
||
}
|
||
|
||
final String id;
|
||
final String name;
|
||
final int setsCount;
|
||
final bool timeEnabled;
|
||
final bool repsEnabled;
|
||
final bool scoreEnabled;
|
||
final int restSeconds;
|
||
final int? targetTimeSeconds;
|
||
final int? targetReps;
|
||
final double? targetScore;
|
||
final String? scoreUnit;
|
||
}
|
||
|
||
final class ExecutionPosition {
|
||
const ExecutionPosition({
|
||
required this.programIndex,
|
||
required this.exerciseIndex,
|
||
required this.setIndex,
|
||
});
|
||
|
||
final int programIndex;
|
||
final int exerciseIndex;
|
||
final int setIndex;
|
||
|
||
ExecutionPosition copyWith({int? setIndex}) {
|
||
return ExecutionPosition(
|
||
programIndex: programIndex,
|
||
exerciseIndex: exerciseIndex,
|
||
setIndex: setIndex ?? this.setIndex,
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _Header extends StatelessWidget {
|
||
const _Header({required this.elapsedLabel, required this.progressLabel});
|
||
|
||
final String elapsedLabel;
|
||
final String progressLabel;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return CourtBlazerAccentPanel(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(elapsedLabel, style: AppTextStyles.scoreNumber(context)),
|
||
const SizedBox(height: 4),
|
||
Text(progressLabel),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
String _formatDuration(Duration duration) {
|
||
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||
final hours = duration.inHours;
|
||
if (hours > 0) {
|
||
return '$hours:$minutes:$seconds';
|
||
}
|
||
return '$minutes:$seconds';
|
||
}
|