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:
2026-07-17 19:08:23 +02:00
parent 6bc09fe1ca
commit bfd727a6a2
9 changed files with 1159 additions and 8 deletions

View File

@ -8,6 +8,8 @@ final class AppBootstrap {
required this.mediaUseCases, required this.mediaUseCases,
required this.programUseCases, required this.programUseCases,
required this.workoutTemplateUseCases, required this.workoutTemplateUseCases,
required this.activeWorkoutSessionUseCases,
required this.closeWorkoutSessionUseCase,
}); });
final AppDatabase database; final AppDatabase database;
@ -15,6 +17,8 @@ final class AppBootstrap {
final MediaUseCases mediaUseCases; final MediaUseCases mediaUseCases;
final ProgramUseCases programUseCases; final ProgramUseCases programUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
static Future<AppBootstrap> create() async { static Future<AppBootstrap> create() async {
final database = AppDatabase.open(); final database = AppDatabase.open();
@ -23,6 +27,8 @@ final class AppBootstrap {
final mediaRepository = DriftMediaAssetRepository(database); final mediaRepository = DriftMediaAssetRepository(database);
final programRepository = DriftProgramRepository(database); final programRepository = DriftProgramRepository(database);
final templateRepository = DriftWorkoutTemplateRepository(database); final templateRepository = DriftWorkoutTemplateRepository(database);
final activeSessionRepository = DriftActiveSessionRepository(database);
final historyRepository = DriftWorkoutHistoryRepository(database);
final ids = LocalIdGenerator(); final ids = LocalIdGenerator();
const clock = SystemClock(); const clock = SystemClock();
const originDeviceId = 'local-device'; const originDeviceId = 'local-device';
@ -57,6 +63,20 @@ final class AppBootstrap {
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
), ),
activeWorkoutSessionUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeSessionRepository,
templateRepository: templateRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
),
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeSessionRepository,
historyRepository: historyRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,
),
); );
} }

View File

@ -76,6 +76,7 @@ abstract interface class WorkoutTemplateRepository {
abstract interface class ActiveSessionRepository { abstract interface class ActiveSessionRepository {
Future<ActiveWorkoutSession?> findById(String id); Future<ActiveWorkoutSession?> findById(String id);
Future<ActiveWorkoutSession?> findOpen();
Future<void> save(ActiveWorkoutSession session); Future<void> save(ActiveWorkoutSession session);
Future<void> saveSetResult(ActiveSetResult result); Future<void> saveSetResult(ActiveSetResult result);
Future<void> saveRestState(ActiveRestState restState); Future<void> saveRestState(ActiveRestState restState);

View File

@ -590,6 +590,8 @@ final class ActiveWorkoutSessionUseCases {
final IdGenerator ids; final IdGenerator ids;
final String originDeviceId; final String originDeviceId;
Future<ActiveWorkoutSession?> findOpen() => sessionRepository.findOpen();
Future<ActiveWorkoutSession> startFromTemplate(String templateId) async { Future<ActiveWorkoutSession> startFromTemplate(String templateId) async {
final template = await templateRepository.findById(templateId); final template = await templateRepository.findById(templateId);
if (template == null) { if (template == null) {
@ -659,6 +661,97 @@ final class ActiveWorkoutSessionUseCases {
return result; return result;
} }
Future<ActiveSetResult> recordCurrentSetResult({
required String sessionId,
required String programSnapshotId,
required String exerciseSnapshotId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
int? actualTimeMs,
int? actualReps,
double? actualScore,
String? scoreLabelSnapshot,
String? scoreUnitSnapshot,
}) async {
final now = clock.now();
final result = ActiveSetResult(
metadata: _newMetadata(ids, originDeviceId, now),
activeWorkoutSessionId: sessionId,
programSnapshotId: programSnapshotId,
exerciseSnapshotId: exerciseSnapshotId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
completedAt: now,
actualTimeMs: actualTimeMs,
actualReps: actualReps,
actualScore: actualScore,
scoreLabelSnapshot: scoreLabelSnapshot,
scoreUnitSnapshot: scoreUnitSnapshot,
);
await sessionRepository.saveSetResult(result);
return result;
}
Future<ActiveWorkoutSession> updateProgress({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final session = await _requiredSession(sessionId);
final updated = session.copyWith(
currentProgramIndex: programIndex,
currentExerciseIndex: exerciseIndex,
currentSetIndex: setIndex,
metadata: session.metadata.touch(clock.now()),
);
await sessionRepository.save(updated);
return updated;
}
Future<ActiveWorkoutSession> quitAndSave(String sessionId) async {
final session = await _requiredSession(sessionId);
final now = clock.now();
final saved = session.copyWith(
status: ActiveWorkoutStatus.savedExit,
lastPersistedAt: now,
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
metadata: session.metadata.touch(now),
);
await sessionRepository.save(saved);
return saved;
}
Future<ActiveWorkoutSession> abandon(String sessionId) async {
final session = await _requiredSession(sessionId);
final now = clock.now();
final abandoned = session.copyWith(
status: ActiveWorkoutStatus.abandoned,
endedAt: now,
lastPersistedAt: now,
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
metadata: session.metadata.touch(now),
);
await sessionRepository.save(abandoned);
return abandoned;
}
Future<ActiveWorkoutSession> complete(String sessionId) async {
final session = await _requiredSession(sessionId);
final now = clock.now();
final completed = session.copyWith(
status: ActiveWorkoutStatus.completed,
endedAt: now,
lastPersistedAt: now,
elapsedActiveMs: session.elapsedActiveMillisecondsAt(now),
metadata: session.metadata.touch(now),
);
await sessionRepository.save(completed);
return completed;
}
Future<ActiveRestState> startRestAfterSet({ Future<ActiveRestState> startRestAfterSet({
required String sessionId, required String sessionId,
required int afterProgramIndex, required int afterProgramIndex,

View File

@ -362,6 +362,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
return row == null ? null : _activeWorkoutSessionFromRow(row); return row == null ? null : _activeWorkoutSessionFromRow(row);
} }
@override
Future<domain.ActiveWorkoutSession?> findOpen() async {
final row =
await (database.select(database.activeWorkoutSessions)
..where(
(table) =>
table.deletedAt.isNull() &
table.status.isIn(['running', 'paused', 'savedExit']),
)
..orderBy([(table) => OrderingTerm.desc(table.updatedAt)])
..limit(1))
.getSingleOrNull();
return row == null ? null : _activeWorkoutSessionFromRow(row);
}
@override @override
Future<void> save(domain.ActiveWorkoutSession session) async { Future<void> save(domain.ActiveWorkoutSession session) async {
await database await database

View File

@ -1,21 +1,64 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../application/app_bootstrap.dart'; import '../application/app_bootstrap.dart';
import '../domain/domain.dart';
import 'exercise_library_screen.dart'; import 'exercise_library_screen.dart';
import 'program_screen.dart'; import 'program_screen.dart';
import 'workout_execution_screen.dart';
import 'workout_template_screen.dart'; import 'workout_template_screen.dart';
final class HomeScreen extends StatelessWidget { final class HomeScreen extends StatefulWidget {
const HomeScreen({required this.bootstrap, super.key}); const HomeScreen({required this.bootstrap, super.key});
final AppBootstrap bootstrap; final AppBootstrap bootstrap;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
final class _HomeScreenState extends State<HomeScreen> {
late Future<ActiveWorkoutSession?> _openSession;
@override
void initState() {
super.initState();
_openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('GameTime')), appBar: AppBar(title: const Text('GameTime')),
body: ListView( body: ListView(
children: [ children: [
FutureBuilder<ActiveWorkoutSession?>(
future: _openSession,
builder: (context, snapshot) {
final session = snapshot.data;
if (session == null) {
return const SizedBox.shrink();
}
final plan = WorkoutExecutionPlan.fromSession(session);
final position = ExecutionPosition(
programIndex: session.currentProgramIndex,
exerciseIndex: session.currentExerciseIndex,
setIndex: session.currentSetIndex,
);
final exercise = plan.exerciseAt(position);
return MaterialBanner(
content: Text(
'Séance en cours — Reprendre · ${exercise.name}, '
'série ${position.setIndex + 1}/${exercise.setsCount}',
),
actions: [
TextButton(
onPressed: () => _resume(session),
child: const Text('Reprendre'),
),
],
);
},
),
ListTile( ListTile(
leading: const Icon(Icons.fitness_center), leading: const Icon(Icons.fitness_center),
title: const Text('Exercices'), title: const Text('Exercices'),
@ -23,8 +66,8 @@ final class HomeScreen extends StatelessWidget {
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ExerciseLibraryScreen( builder: (context) => ExerciseLibraryScreen(
exerciseUseCases: bootstrap.exerciseUseCases, exerciseUseCases: widget.bootstrap.exerciseUseCases,
mediaUseCases: bootstrap.mediaUseCases, mediaUseCases: widget.bootstrap.mediaUseCases,
), ),
), ),
), ),
@ -36,8 +79,8 @@ final class HomeScreen extends StatelessWidget {
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => ProgramListScreen( builder: (context) => ProgramListScreen(
programUseCases: bootstrap.programUseCases, programUseCases: widget.bootstrap.programUseCases,
exerciseUseCases: bootstrap.exerciseUseCases, exerciseUseCases: widget.bootstrap.exerciseUseCases,
), ),
), ),
), ),
@ -49,8 +92,11 @@ final class HomeScreen extends StatelessWidget {
onTap: () => Navigator.of(context).push( onTap: () => Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => WorkoutTemplateListScreen( builder: (context) => WorkoutTemplateListScreen(
workoutTemplateUseCases: bootstrap.workoutTemplateUseCases, workoutTemplateUseCases:
programUseCases: bootstrap.programUseCases, widget.bootstrap.workoutTemplateUseCases,
programUseCases: widget.bootstrap.programUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
), ),
), ),
), ),
@ -59,4 +105,20 @@ final class HomeScreen extends StatelessWidget {
), ),
); );
} }
Future<void> _resume(ActiveWorkoutSession session) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
),
),
);
if (!mounted) return;
setState(() {
_openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen();
});
}
} }

View File

@ -2,4 +2,5 @@ export 'game_time_app.dart';
export 'exercise_library_screen.dart'; export 'exercise_library_screen.dart';
export 'home_screen.dart'; export 'home_screen.dart';
export 'program_screen.dart'; export 'program_screen.dart';
export 'workout_execution_screen.dart';
export 'workout_template_screen.dart'; export 'workout_template_screen.dart';

View 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 à laccueil'),
),
],
),
);
}
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';
}

View File

@ -4,16 +4,21 @@ import 'package:flutter/material.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'workout_execution_screen.dart';
final class WorkoutTemplateListScreen extends StatefulWidget { final class WorkoutTemplateListScreen extends StatefulWidget {
const WorkoutTemplateListScreen({ const WorkoutTemplateListScreen({
required this.workoutTemplateUseCases, required this.workoutTemplateUseCases,
required this.programUseCases, required this.programUseCases,
required this.activeUseCases,
required this.closeUseCase,
super.key, super.key,
}); });
final WorkoutTemplateUseCases workoutTemplateUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases;
final ProgramUseCases programUseCases; final ProgramUseCases programUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final CloseWorkoutSessionUseCase closeUseCase;
@override @override
State<WorkoutTemplateListScreen> createState() => State<WorkoutTemplateListScreen> createState() =>
@ -65,7 +70,10 @@ final class _WorkoutTemplateListScreenState
trailing: Wrap( trailing: Wrap(
spacing: 8, spacing: 8,
children: [ children: [
TextButton(onPressed: () {}, child: const Text('Lancer')), TextButton(
onPressed: () => _start(template),
child: const Text('Lancer'),
),
const Icon(Icons.chevron_right), const Icon(Icons.chevron_right),
], ],
), ),
@ -99,6 +107,22 @@ final class _WorkoutTemplateListScreenState
_reload(); _reload();
} }
} }
Future<void> _start(WorkoutTemplate template) async {
final session = await widget.activeUseCases.startFromTemplate(
template.metadata.id,
);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => WorkoutExecutionScreen(
initialSession: session,
activeUseCases: widget.activeUseCases,
closeUseCase: widget.closeUseCase,
),
),
);
}
} }
final class WorkoutTemplateFormScreen extends StatefulWidget { final class WorkoutTemplateFormScreen extends StatefulWidget {

View File

@ -0,0 +1,268 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/presentation.dart';
void main() {
testWidgets('le temps affiché est recalculé depuis les horodatages', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 30000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(
_FakeActiveSessionRepository(),
clock,
),
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock),
),
),
);
expect(find.text('00:45'), findsOneWidget);
});
testWidgets('les mesures cumulées respectent la hiérarchie visuelle', (
tester,
) async {
final scoreController = TextEditingController();
addTearDown(scoreController.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: SetMeasureInput(
exercise: const ExecutionExercise(
id: 'exercise-1',
name: 'Squat',
setsCount: 3,
timeEnabled: true,
repsEnabled: true,
scoreEnabled: true,
restSeconds: 0,
scoreUnit: 'kg',
),
reps: 0,
scoreController: scoreController,
onRepsChanged: (_) {},
),
),
),
);
final timeY = tester.getTopLeft(find.text('Temps')).dy;
final repsY = tester.getTopLeft(find.text('Répétitions')).dy;
final scoreY = tester.getTopLeft(find.text('Score (kg)')).dy;
expect(timeY, lessThan(repsY));
expect(repsY, lessThan(scoreY));
});
testWidgets('aucun repos ne saffiche après la toute dernière série', (
tester,
) async {
final plan = WorkoutExecutionPlan.fromSession(
ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 1,
restSeconds: 60,
),
),
);
const position = ExecutionPosition(
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Text(
plan.shouldShowRestAfter(position) ? 'Repos' : 'Pas de repos',
),
),
);
expect(find.text('Pas de repos'), findsOneWidget);
});
}
ActiveWorkoutSessionUseCases _activeUseCases(
_FakeActiveSessionRepository repository,
_FakeClock clock,
) {
return ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
CloseWorkoutSessionUseCase _closeUseCase(
_FakeActiveSessionRepository repository,
_FakeClock clock,
) {
return CloseWorkoutSessionUseCase(
sessionRepository: repository,
historyRepository: _FakeWorkoutHistoryRepository(),
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
}
String _sessionSnapshot({int setsCount = 3, int restSeconds = 0}) {
return jsonEncode({
'name': 'Séance jambes',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme jambes',
'programSnapshotJson': jsonEncode({
'exercises': [
{
'id': 'exercise-1',
'exerciseNameSnapshot': 'Squat',
'setsCount': setsCount,
'timeEnabled': true,
'repsEnabled': true,
'scoreEnabled': true,
'targetTimeSeconds': 45,
'targetReps': 10,
'targetScore': 80,
'scoreUnitSnapshot': 'kg',
'restSecondsOverride': restSeconds,
},
],
}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
createdAt: DateTime.utc(2026, 7, 17),
updatedAt: DateTime.utc(2026, 7, 17),
originDeviceId: 'device-1',
);
}
final class _FakeClock implements Clock {
_FakeClock(this.value);
DateTime value;
@override
DateTime now() => value;
}
final class _FakeIds implements IdGenerator {
var _next = 0;
@override
String newId() {
_next += 1;
return 'id-$_next';
}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
ActiveWorkoutSession? session;
final results = <ActiveSetResult>[];
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return const [];
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results;
}
@override
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveRestState(ActiveRestState restState) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
}