diff --git a/.ideai/tickets/9/carnet.md b/.ideai/tickets/9/carnet.md index 0e687b7..75512e8 100644 --- a/.ideai/tickets/9/carnet.md +++ b/.ideai/tickets/9/carnet.md @@ -1,7 +1,7 @@ --- issueRef: "#9" -version: 4 +version: 6 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784301392430 +updatedAt: 1784308091100 --- Référence : mémoire "gametime-ux-conception" section 4. Écran le plus sensible du produit : grandes zones tactiles (44-48px mini), utilisable à l'effort, une main. Hiérarchie de saisie si mesures cumulées : Temps (bloc principal) > Répétitions (stepper) > Score (champ + unité, clavier adapté au type). Écran "Repos" séparé avec +15s/-15s et "Ignorer le repos". Pause : "Quitter et sauvegarder" comme action sûre recommandée, "Abandonner" comme action destructive confirmée séparément. Reprise doit fonctionner même après fermeture complète de l'app (bandeau "Séance en cours" au retour). Fin de séance écrit dans WorkoutHistory (ticket #4) avec temps total + scores par série. \ No newline at end of file diff --git a/.ideai/tickets/9/issue.md b/.ideai/tickets/9/issue.md index 1881872..1a24e01 100644 --- a/.ideai/tickets/9/issue.md +++ b/.ideai/tickets/9/issue.md @@ -2,7 +2,7 @@ id: "311cfb2b-0a09-4c18-ba66-4053994bc109" number: 9 title: "[DevFrontend] Écran Exécution de séance" -status: "open" +status: "closed" priority: "critical" sprint: null links: [{"target":"#4","kind":"dependsOn"},{"target":"#8","kind":"dependsOn"}] @@ -10,7 +10,7 @@ agentRefs: [{"agentId":"9933c93a-b8a1-4164-a3bb-7063fdad747d","role":"assigned"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} createdAt: 1784301307130 -updatedAt: 1784301392430 -version: 4 +updatedAt: 1784308091100 +version: 6 --- Surface critique optimisée pour l'usage à l'effort (grandes zones tactiles, une main). En-tête temps écoulé + pause. Progression Programme/Exercice/Série. Saisie hiérarchisée quand mesures cumulées (Temps > Répétitions > Score avec unité). Écran "Repos" dédié entre séries avec ajustement ±15s et "Ignorer le repos". Pause avec "Quitter et sauvegarder" vs "Abandonner" (destructif confirmé). Reprise après interruption/fermeture app, robuste à l'arrière-plan (recalcul depuis horodatages, pas depuis un état mémoire). Écran de fin de séance (récap + relance) → écriture dans l'historique. Cf. mémoire "gametime-ux-conception" section 4. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index dd99be1..325fd7a 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -101,13 +101,13 @@ "issueRef": "#9", "path": "9", "title": "[DevFrontend] Écran Exécution de séance", - "status": "open", + "status": "closed", "priority": "critical", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784301392430 + "updatedAt": 1784308091100 }, { "issueRef": "#10", 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 {} +}