From bfd727a6a2d4f143ed5c8df80399c6eef5069564 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 19:08:23 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=C3=A9cran=20Ex=C3=A9cution=20de=20?= =?UTF-8?q?s=C3=A9ance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/application/app_bootstrap.dart | 20 + lib/application/ports.dart | 1 + lib/application/use_cases.dart | 93 +++ .../local/drift_repositories.dart | 15 + lib/presentation/home_screen.dart | 76 +- lib/presentation/presentation.dart | 1 + .../workout_execution_screen.dart | 667 ++++++++++++++++++ lib/presentation/workout_template_screen.dart | 26 +- .../workout_execution_screen_test.dart | 268 +++++++ 9 files changed, 1159 insertions(+), 8 deletions(-) create mode 100644 lib/presentation/workout_execution_screen.dart create mode 100644 test/presentation/workout_execution_screen_test.dart diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 2422369..9a71208 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -8,6 +8,8 @@ final class AppBootstrap { required this.mediaUseCases, required this.programUseCases, required this.workoutTemplateUseCases, + required this.activeWorkoutSessionUseCases, + required this.closeWorkoutSessionUseCase, }); final AppDatabase database; @@ -15,6 +17,8 @@ final class AppBootstrap { final MediaUseCases mediaUseCases; final ProgramUseCases programUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases; + final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; + final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; static Future create() async { final database = AppDatabase.open(); @@ -23,6 +27,8 @@ final class AppBootstrap { final mediaRepository = DriftMediaAssetRepository(database); final programRepository = DriftProgramRepository(database); final templateRepository = DriftWorkoutTemplateRepository(database); + final activeSessionRepository = DriftActiveSessionRepository(database); + final historyRepository = DriftWorkoutHistoryRepository(database); final ids = LocalIdGenerator(); const clock = SystemClock(); const originDeviceId = 'local-device'; @@ -57,6 +63,20 @@ final class AppBootstrap { ids: ids, 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, + ), ); } diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 0085593..4b10da3 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -76,6 +76,7 @@ abstract interface class WorkoutTemplateRepository { abstract interface class ActiveSessionRepository { Future findById(String id); + Future findOpen(); Future save(ActiveWorkoutSession session); Future saveSetResult(ActiveSetResult result); Future saveRestState(ActiveRestState restState); diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index df3630c..53586b6 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -590,6 +590,8 @@ final class ActiveWorkoutSessionUseCases { final IdGenerator ids; final String originDeviceId; + Future findOpen() => sessionRepository.findOpen(); + Future startFromTemplate(String templateId) async { final template = await templateRepository.findById(templateId); if (template == null) { @@ -659,6 +661,97 @@ final class ActiveWorkoutSessionUseCases { return result; } + Future 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 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 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 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 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 startRestAfterSet({ required String sessionId, required int afterProgramIndex, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 7e7d5b7..223f35f 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -362,6 +362,21 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository { return row == null ? null : _activeWorkoutSessionFromRow(row); } + @override + Future 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 Future save(domain.ActiveWorkoutSession session) async { await database diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 15051c3..310b0f3 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -1,21 +1,64 @@ import 'package:flutter/material.dart'; import '../application/app_bootstrap.dart'; +import '../domain/domain.dart'; import 'exercise_library_screen.dart'; import 'program_screen.dart'; +import 'workout_execution_screen.dart'; import 'workout_template_screen.dart'; -final class HomeScreen extends StatelessWidget { +final class HomeScreen extends StatefulWidget { const HomeScreen({required this.bootstrap, super.key}); final AppBootstrap bootstrap; + @override + State createState() => _HomeScreenState(); +} + +final class _HomeScreenState extends State { + late Future _openSession; + + @override + void initState() { + super.initState(); + _openSession = widget.bootstrap.activeWorkoutSessionUseCases.findOpen(); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('GameTime')), body: ListView( children: [ + FutureBuilder( + 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( leading: const Icon(Icons.fitness_center), title: const Text('Exercices'), @@ -23,8 +66,8 @@ final class HomeScreen extends StatelessWidget { onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (context) => ExerciseLibraryScreen( - exerciseUseCases: bootstrap.exerciseUseCases, - mediaUseCases: bootstrap.mediaUseCases, + exerciseUseCases: widget.bootstrap.exerciseUseCases, + mediaUseCases: widget.bootstrap.mediaUseCases, ), ), ), @@ -36,8 +79,8 @@ final class HomeScreen extends StatelessWidget { onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (context) => ProgramListScreen( - programUseCases: bootstrap.programUseCases, - exerciseUseCases: bootstrap.exerciseUseCases, + programUseCases: widget.bootstrap.programUseCases, + exerciseUseCases: widget.bootstrap.exerciseUseCases, ), ), ), @@ -49,8 +92,11 @@ final class HomeScreen extends StatelessWidget { onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (context) => WorkoutTemplateListScreen( - workoutTemplateUseCases: bootstrap.workoutTemplateUseCases, - programUseCases: bootstrap.programUseCases, + workoutTemplateUseCases: + 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 _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(); + }); + } } diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index 955b583..1492629 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -2,4 +2,5 @@ export 'game_time_app.dart'; export 'exercise_library_screen.dart'; export 'home_screen.dart'; export 'program_screen.dart'; +export 'workout_execution_screen.dart'; export 'workout_template_screen.dart'; diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart new file mode 100644 index 0000000..e79dd0e --- /dev/null +++ b/lib/presentation/workout_execution_screen.dart @@ -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 createState() => _WorkoutExecutionScreenState(); +} + +final class _WorkoutExecutionScreenState extends State { + 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 _pause() async { + _restTicker?.cancel(); + _session = await widget.activeUseCases.pause(_session.metadata.id); + if (!mounted) return; + setState(() => _mode = WorkoutExecutionMode.paused); + } + + Future _resume() async { + _session = await widget.activeUseCases.resume(_session.metadata.id); + if (!mounted) return; + setState(() => _mode = WorkoutExecutionMode.active); + } + + Future _quitAndSave() async { + await widget.activeUseCases.quitAndSave(_session.metadata.id); + if (!mounted) return; + Navigator.of(context).popUntil((route) => route.isFirst); + } + + Future _confirmAbandon() async { + final confirmed = await showDialog( + 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 _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 _skipRest() async { + _restTicker?.cancel(); + final next = _plan.nextPosition(_position); + if (next == null) { + await _complete(); + return; + } + await _moveTo(next); + } + + Future _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 _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 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; + final overrides = snapshot['overrides'] as List? ?? const []; + final programs = (snapshot['programs'] as List? ?? const []).map(( + rawProgram, + ) { + final program = rawProgram as Map; + final programSnapshot = + jsonDecode(program['programSnapshotJson'] as String) + as Map; + 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 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 snapshot, + required List overrides, + }) { + final exercises = (snapshot['exercises'] as List? ?? const []).map( + (raw) { + final exercise = raw as Map; + Map? override; + for (final rawOverride in overrides) { + final item = rawOverride as Map; + 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 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 exercise, + Map? 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'; +} diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index ebbf0f8..77daea4 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -4,16 +4,21 @@ import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; +import 'workout_execution_screen.dart'; final class WorkoutTemplateListScreen extends StatefulWidget { const WorkoutTemplateListScreen({ required this.workoutTemplateUseCases, required this.programUseCases, + required this.activeUseCases, + required this.closeUseCase, super.key, }); final WorkoutTemplateUseCases workoutTemplateUseCases; final ProgramUseCases programUseCases; + final ActiveWorkoutSessionUseCases activeUseCases; + final CloseWorkoutSessionUseCase closeUseCase; @override State createState() => @@ -65,7 +70,10 @@ final class _WorkoutTemplateListScreenState trailing: Wrap( spacing: 8, children: [ - TextButton(onPressed: () {}, child: const Text('Lancer')), + TextButton( + onPressed: () => _start(template), + child: const Text('Lancer'), + ), const Icon(Icons.chevron_right), ], ), @@ -99,6 +107,22 @@ final class _WorkoutTemplateListScreenState _reload(); } } + + Future _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 { diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart new file mode 100644 index 0000000..81a7a76 --- /dev/null +++ b/test/presentation/workout_execution_screen_test.dart @@ -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 s’affiche 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 = []; + + @override + Future findById(String id) async { + return session?.metadata.id == id ? session : null; + } + + @override + Future findOpen() async => session; + + @override + Future> listRestStates(String sessionId) async { + return const []; + } + + @override + Future> listSetResults(String sessionId) async { + return results; + } + + @override + Future save(ActiveWorkoutSession session) async { + this.session = session; + } + + @override + Future saveRestState(ActiveRestState restState) async {} + + @override + Future saveSetResult(ActiveSetResult result) async { + results.add(result); + } +} + +final class _FakeWorkoutTemplateRepository + implements WorkoutTemplateRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceComposition( + WorkoutTemplate template, + DateTime deletedAt, + ) async {} + + @override + Future save(WorkoutTemplate template) async {} + + @override + Future saveOverride(WorkoutTemplateExerciseOverride override) async {} + + @override + Future saveProgram(WorkoutTemplateProgram program) async {} +} + +final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { + @override + Future save(WorkoutHistory history) async {} + + @override + Future saveSetResult(WorkoutHistorySetResult result) async {} +}