feat(ui): écran Exécution de séance
Ajoute l'écran d'exécution de séance (presentation/workout_execution_screen.dart), avec les ajustements application/domaine/repositories nécessaires et la navigation depuis l'accueil et l'écran séance-modèle. flutter analyze propre, 15/15 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
667
lib/presentation/workout_execution_screen.dart
Normal file
667
lib/presentation/workout_execution_screen.dart
Normal file
@ -0,0 +1,667 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../application/application.dart';
|
||||
import '../domain/domain.dart';
|
||||
|
||||
enum WorkoutExecutionMode { active, rest, paused, finished }
|
||||
|
||||
final class WorkoutExecutionScreen extends StatefulWidget {
|
||||
const WorkoutExecutionScreen({
|
||||
required this.initialSession,
|
||||
required this.activeUseCases,
|
||||
required this.closeUseCase,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ActiveWorkoutSession initialSession;
|
||||
final ActiveWorkoutSessionUseCases activeUseCases;
|
||||
final CloseWorkoutSessionUseCase closeUseCase;
|
||||
|
||||
@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;
|
||||
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;
|
||||
_ticker = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ticker?.cancel();
|
||||
_restTicker?.cancel();
|
||||
_scoreController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(_plan.name),
|
||||
actions: [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: Theme.of(context).textTheme.displayLarge,
|
||||
),
|
||||
),
|
||||
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: () => setState(() {
|
||||
_remainingRestSeconds = (_remainingRestSeconds - 15)
|
||||
.clamp(0, 9999)
|
||||
.toInt();
|
||||
}),
|
||||
child: const Text('-15 s'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => setState(() {
|
||||
_remainingRestSeconds += 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: () {}, child: const Text('Voir le détail')),
|
||||
OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('Relancer cette séance'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||
},
|
||||
child: const Text('Retour à l’accueil'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
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> _finishSet({required bool skipped}) async {
|
||||
final score = double.tryParse(_scoreController.text.trim());
|
||||
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 ? 0 : null),
|
||||
);
|
||||
|
||||
final next = _plan.nextPosition(_position);
|
||||
if (next == null) {
|
||||
await _complete();
|
||||
return;
|
||||
}
|
||||
final shouldRest = _plan.shouldShowRestAfter(_position);
|
||||
if (shouldRest) {
|
||||
await widget.activeUseCases.startRestAfterSet(
|
||||
sessionId: _session.metadata.id,
|
||||
afterProgramIndex: _position.programIndex,
|
||||
afterExerciseIndex: _position.exerciseIndex,
|
||||
afterSetIndex: _position.setIndex,
|
||||
plannedRestSeconds: _exercise.restSeconds,
|
||||
);
|
||||
if (!mounted) return;
|
||||
_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(_moveTo(next));
|
||||
} else {
|
||||
setState(() => _remainingRestSeconds -= 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _skipRest() async {
|
||||
_restTicker?.cancel();
|
||||
final next = _plan.nextPosition(_position);
|
||||
if (next == null) {
|
||||
await _complete();
|
||||
return;
|
||||
}
|
||||
await _moveTo(next);
|
||||
}
|
||||
|
||||
Future<void> _moveTo(ExecutionPosition position) async {
|
||||
_scoreController.clear();
|
||||
_reps = 0;
|
||||
_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);
|
||||
await widget.closeUseCase.close(
|
||||
sessionId: _session.metadata.id,
|
||||
nameSnapshot: _plan.name,
|
||||
completed: true,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() => _mode = WorkoutExecutionMode.finished);
|
||||
}
|
||||
}
|
||||
|
||||
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: Theme.of(context).textTheme.displaySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
IconButton(
|
||||
onPressed: () => onRepsChanged(reps + 1),
|
||||
icon: const Icon(Icons.add),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (exercise.scoreEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: scoreController,
|
||||
decoration: InputDecoration(
|
||||
labelText: exercise.scoreUnit == null
|
||||
? 'Score'
|
||||
: 'Score (${exercise.scoreUnit})',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(elapsedLabel, style: Theme.of(context).textTheme.headlineSmall),
|
||||
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';
|
||||
}
|
||||
Reference in New Issue
Block a user