From 34b3638ce3066322e92e71624da9946344ff4b87 Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 19:17:55 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ui):=20=C3=A9cran=20Historique=20des?= =?UTF-8?q?=20s=C3=A9ances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute l'écran d'historique des séances (presentation/history_screen.dart), avec les ajustements application/domaine/repositories nécessaires et la navigation depuis l'accueil. Complète le dernier écran du périmètre UI. flutter analyze propre, 18/18 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 --- lib/application/app_bootstrap.dart | 6 + lib/application/ports.dart | 3 + lib/application/use_cases.dart | 35 ++ .../local/drift_repositories.dart | 115 +++++ lib/presentation/history_screen.dart | 430 ++++++++++++++++++ lib/presentation/home_screen.dart | 20 + lib/presentation/presentation.dart | 1 + .../workout_execution_screen.dart | 65 ++- lib/presentation/workout_template_screen.dart | 4 + test/presentation/history_screen_test.dart | 305 +++++++++++++ .../workout_execution_screen_test.dart | 45 ++ 11 files changed, 1026 insertions(+), 3 deletions(-) create mode 100644 lib/presentation/history_screen.dart create mode 100644 test/presentation/history_screen_test.dart diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 9a71208..76a6116 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -10,6 +10,7 @@ final class AppBootstrap { required this.workoutTemplateUseCases, required this.activeWorkoutSessionUseCases, required this.closeWorkoutSessionUseCase, + required this.workoutHistoryUseCases, }); final AppDatabase database; @@ -19,6 +20,7 @@ final class AppBootstrap { final WorkoutTemplateUseCases workoutTemplateUseCases; final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; + final WorkoutHistoryUseCases workoutHistoryUseCases; static Future create() async { final database = AppDatabase.open(); @@ -77,6 +79,10 @@ final class AppBootstrap { ids: ids, originDeviceId: originDeviceId, ), + workoutHistoryUseCases: WorkoutHistoryUseCases( + repository: historyRepository, + clock: clock, + ), ); } diff --git a/lib/application/ports.dart b/lib/application/ports.dart index 4b10da3..691a615 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -85,6 +85,9 @@ abstract interface class ActiveSessionRepository { } abstract interface class WorkoutHistoryRepository { + Future findById(String id); + Future> listActive(); Future save(WorkoutHistory history); Future saveSetResult(WorkoutHistorySetResult result); + Future delete(String id, DateTime deletedAt); } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index 53586b6..f446db4 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -638,6 +638,28 @@ final class ActiveWorkoutSessionUseCases { return session; } + Future startFromHistory(WorkoutHistory history) async { + final snapshot = + jsonDecode(history.historySnapshotJson) as Map; + final resolvedTemplateSnapshotJson = + snapshot['resolvedTemplateSnapshotJson'] as String; + final now = clock.now(); + final session = ActiveWorkoutSession( + metadata: _newMetadata(ids, originDeviceId, now), + sourceWorkoutTemplateId: null, + status: ActiveWorkoutStatus.running, + startedAt: now, + lastPersistedAt: now, + elapsedActiveMs: 0, + currentProgramIndex: 0, + currentExerciseIndex: 0, + currentSetIndex: 0, + resolvedTemplateSnapshotJson: resolvedTemplateSnapshotJson, + ); + await sessionRepository.save(session); + return session; + } + Future pause(String sessionId) async { final session = await _requiredSession(sessionId); final paused = session.pause(clock.now()); @@ -851,6 +873,19 @@ final class CloseWorkoutSessionUseCase { } } +final class WorkoutHistoryUseCases { + const WorkoutHistoryUseCases({required this.repository, required this.clock}); + + final WorkoutHistoryRepository repository; + final Clock clock; + + Future findById(String id) => repository.findById(id); + + Future> listActive() => repository.listActive(); + + Future delete(String id) => repository.delete(id, clock.now()); +} + EntityMetadata _newMetadata( IdGenerator ids, String originDeviceId, diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 223f35f..ccd64b3 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -428,6 +428,52 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { final db.AppDatabase database; + @override + Future findById(String id) async { + final row = + await (database.select( + database.workoutHistories, + )..where((table) => table.id.equals(id) & table.deletedAt.isNull())) + .getSingleOrNull(); + if (row == null) { + return null; + } + final results = + await (database.select(database.workoutHistorySetResults) + ..where( + (table) => + table.workoutHistoryId.equals(id) & + table.deletedAt.isNull(), + ) + ..orderBy([ + (table) => OrderingTerm.asc(table.programIndex), + (table) => OrderingTerm.asc(table.exerciseIndex), + (table) => OrderingTerm.asc(table.setIndex), + ])) + .get(); + return _workoutHistoryFromRow( + row, + results.map(_workoutHistorySetResultFromRow).toList(), + ); + } + + @override + Future> listActive() async { + final rows = + await (database.select(database.workoutHistories) + ..where((table) => table.deletedAt.isNull()) + ..orderBy([(table) => OrderingTerm.desc(table.startedAt)])) + .get(); + final histories = []; + for (final row in rows) { + final history = await findById(row.id); + if (history != null) { + histories.add(history); + } + } + return histories; + } + @override Future save(domain.WorkoutHistory history) async { await database @@ -444,6 +490,28 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { .into(database.workoutHistorySetResults) .insertOnConflictUpdate(_workoutHistorySetResultCompanion(result)); } + + @override + Future delete(String id, DateTime deletedAt) async { + await database.transaction(() async { + await (database.update( + database.workoutHistories, + )..where((table) => table.id.equals(id))).write( + db.WorkoutHistoriesCompanion( + deletedAt: Value(deletedAt), + updatedAt: Value(deletedAt), + ), + ); + await (database.update( + database.workoutHistorySetResults, + )..where((table) => table.workoutHistoryId.equals(id))).write( + db.WorkoutHistorySetResultsCompanion( + deletedAt: Value(deletedAt), + updatedAt: Value(deletedAt), + ), + ); + }); + } } domain.EntityMetadata _metadataFromRow(dynamic row) { @@ -986,6 +1054,53 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion( ); } +domain.WorkoutHistory _workoutHistoryFromRow( + db.WorkoutHistory row, + List results, +) { + return domain.WorkoutHistory( + metadata: _metadataFromRow(row), + sourceWorkoutTemplateId: row.sourceWorkoutTemplateId, + sourceActiveWorkoutSessionId: row.sourceActiveWorkoutSessionId, + nameSnapshot: row.nameSnapshot, + startedAt: row.startedAt, + endedAt: row.endedAt, + totalActiveMs: row.totalActiveMs, + completed: row.completed, + historySnapshotJson: row.historySnapshotJson, + results: results, + ); +} + +domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow( + db.WorkoutHistorySetResult row, +) { + return domain.WorkoutHistorySetResult( + metadata: _metadataFromRow(row), + workoutHistoryId: row.workoutHistoryId, + programSnapshotId: row.programSnapshotId, + exerciseSnapshotId: row.exerciseSnapshotId, + programIndex: row.programIndex, + exerciseIndex: row.exerciseIndex, + setIndex: row.setIndex, + programNameSnapshot: row.programNameSnapshot, + exerciseNameSnapshot: row.exerciseNameSnapshot, + timeEnabledSnapshot: row.timeEnabledSnapshot, + repsEnabledSnapshot: row.repsEnabledSnapshot, + scoreEnabledSnapshot: row.scoreEnabledSnapshot, + targetTimeSecondsSnapshot: row.targetTimeSecondsSnapshot, + targetRepsSnapshot: row.targetRepsSnapshot, + targetScoreSnapshot: row.targetScoreSnapshot, + actualTimeMs: row.actualTimeMs, + actualReps: row.actualReps, + actualScore: row.actualScore, + scoreLabelSnapshot: row.scoreLabelSnapshot, + scoreUnitSnapshot: row.scoreUnitSnapshot, + startedAt: row.startedAt, + completedAt: row.completedAt, + ); +} + String _syncStateToDb(domain.SyncState state) => switch (state) { domain.SyncState.localOnly => 'localOnly', domain.SyncState.dirty => 'dirty', diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart new file mode 100644 index 0000000..9b29925 --- /dev/null +++ b/lib/presentation/history_screen.dart @@ -0,0 +1,430 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../application/application.dart'; +import '../domain/domain.dart'; +import 'workout_execution_screen.dart'; + +final class HistoryListScreen extends StatefulWidget { + const HistoryListScreen({ + required this.historyUseCases, + required this.workoutTemplateUseCases, + required this.activeUseCases, + required this.closeUseCase, + super.key, + }); + + final WorkoutHistoryUseCases historyUseCases; + final WorkoutTemplateUseCases workoutTemplateUseCases; + final ActiveWorkoutSessionUseCases activeUseCases; + final CloseWorkoutSessionUseCase closeUseCase; + + @override + State createState() => _HistoryListScreenState(); +} + +final class _HistoryListScreenState extends State { + late Future> _histories; + + @override + void initState() { + super.initState(); + _histories = widget.historyUseCases.listActive(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Historique')), + body: FutureBuilder>( + future: _histories, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final histories = snapshot.data ?? const []; + if (histories.isEmpty) { + return const _CenteredMessage( + title: 'Aucune séance terminée', + message: 'Les séances terminées apparaîtront ici.', + ); + } + final groups = groupHistoriesByPeriod(histories, DateTime.now()); + return ListView( + children: [ + for (final group in groups.entries) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 8), + child: Text( + group.key, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + for (final history in group.value) + ListTile( + title: Text(history.nameSnapshot), + subtitle: Text( + '${_formatDateTime(history.startedAt)} · ' + '${_formatDurationMs(history.totalActiveMs)} · ' + '${_historySummary(history)}', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openDetail(history), + ), + ], + ], + ); + }, + ), + ); + } + + Future _openDetail(WorkoutHistory history) async { + final deleted = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => HistoryDetailScreen( + history: history, + historyUseCases: widget.historyUseCases, + workoutTemplateUseCases: widget.workoutTemplateUseCases, + activeUseCases: widget.activeUseCases, + closeUseCase: widget.closeUseCase, + ), + ), + ); + if (deleted == true) { + setState(() { + _histories = widget.historyUseCases.listActive(); + }); + } + } +} + +final class HistoryDetailScreen extends StatelessWidget { + const HistoryDetailScreen({ + required this.history, + required this.historyUseCases, + required this.workoutTemplateUseCases, + required this.activeUseCases, + required this.closeUseCase, + super.key, + }); + + final WorkoutHistory history; + final WorkoutHistoryUseCases historyUseCases; + final WorkoutTemplateUseCases workoutTemplateUseCases; + final ActiveWorkoutSessionUseCases activeUseCases; + final CloseWorkoutSessionUseCase closeUseCase; + + @override + Widget build(BuildContext context) { + final detail = HistoryDetailData.fromHistory(history); + return Scaffold( + appBar: AppBar(title: Text(history.nameSnapshot)), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(_formatDateTime(history.startedAt)), + const SizedBox(height: 4), + Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'), + const SizedBox(height: 16), + for (final program in detail.programs) ...[ + Text(program.name, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + for (final exercise in program.exercises) ...[ + Text( + exercise.name, + style: Theme.of(context).textTheme.titleSmall, + ), + for (final set in exercise.sets) + ListTile( + dense: true, + title: Text('Série ${set.setIndex + 1}'), + subtitle: Text(set.summary), + ), + ], + const SizedBox(height: 12), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: () => _restart(context), + child: const Text('Relancer cette séance'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: () => _confirmDelete(context), + child: const Text("Supprimer de l'historique"), + ), + ], + ), + ); + } + + Future _restart(BuildContext context) async { + ActiveWorkoutSession session; + final sourceId = history.sourceWorkoutTemplateId; + if (sourceId != null && + await workoutTemplateUseCases.findById(sourceId) != null) { + session = await activeUseCases.startFromTemplate(sourceId); + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "La séance originale n'existe plus. Une copie va être utilisée.", + ), + ), + ); + } + session = await activeUseCases.startFromHistory(history); + } + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => WorkoutExecutionScreen( + initialSession: session, + activeUseCases: activeUseCases, + closeUseCase: closeUseCase, + historyUseCases: historyUseCases, + workoutTemplateUseCases: workoutTemplateUseCases, + ), + ), + ); + } + + Future _confirmDelete(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("Supprimer de l'historique ?"), + 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('Supprimer'), + ), + ], + ), + ); + if (confirmed != true) return; + await historyUseCases.delete(history.metadata.id); + if (!context.mounted) return; + Navigator.of(context).pop(true); + } +} + +final class HistoryDetailData { + const HistoryDetailData({required this.programs}); + + factory HistoryDetailData.fromHistory(WorkoutHistory history) { + final snapshot = + jsonDecode(history.historySnapshotJson) as Map; + final resolved = + jsonDecode(snapshot['resolvedTemplateSnapshotJson'] as String) + as Map; + final rawResults = history.results.isNotEmpty + ? history.results.map(_resultFromDomain).toList() + : (snapshot['results'] as List? ?? const []) + .cast>(); + final programs = []; + final rawPrograms = resolved['programs'] as List? ?? const []; + for ( + var programIndex = 0; + programIndex < rawPrograms.length; + programIndex++ + ) { + final rawProgram = rawPrograms[programIndex] as Map; + final programSnapshot = + jsonDecode(rawProgram['programSnapshotJson'] as String) + as Map; + final exercises = []; + final rawExercises = + programSnapshot['exercises'] as List? ?? const []; + for ( + var exerciseIndex = 0; + exerciseIndex < rawExercises.length; + exerciseIndex++ + ) { + final rawExercise = rawExercises[exerciseIndex] as Map; + final exerciseResults = rawResults.where((result) { + return result['programIndex'] == programIndex && + result['exerciseIndex'] == exerciseIndex; + }).toList(); + exercises.add( + HistoryExerciseDetail( + name: rawExercise['exerciseNameSnapshot'] as String, + sets: [ + for (final result in exerciseResults) + HistorySetDetail.fromSnapshot(rawExercise, result), + ], + ), + ); + } + programs.add( + HistoryProgramDetail( + name: rawProgram['programNameSnapshot'] as String, + exercises: exercises, + ), + ); + } + return HistoryDetailData(programs: programs); + } + + final List programs; +} + +final class HistoryProgramDetail { + const HistoryProgramDetail({required this.name, required this.exercises}); + + final String name; + final List exercises; +} + +final class HistoryExerciseDetail { + const HistoryExerciseDetail({required this.name, required this.sets}); + + final String name; + final List sets; +} + +final class HistorySetDetail { + const HistorySetDetail({required this.setIndex, required this.summary}); + + factory HistorySetDetail.fromSnapshot( + Map exercise, + Map result, + ) { + final parts = []; + if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) { + parts.add('Temps ${_formatDurationMs(result['actualTimeMs'] as int)}'); + } + if (exercise['repsEnabled'] == true && result['actualReps'] != null) { + parts.add('${result['actualReps']} répétitions'); + } + if (exercise['scoreEnabled'] == true && result['actualScore'] != null) { + final unit = exercise['scoreUnitSnapshot'] as String?; + final value = _formatScoreValue(result['actualScore'] as num); + parts.add(unit == null ? 'Score $value' : 'Score $value $unit'); + } + return HistorySetDetail( + setIndex: result['setIndex'] as int, + summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '), + ); + } + + final int setIndex; + final String summary; +} + +Map> groupHistoriesByPeriod( + List histories, + DateTime now, +) { + final today = DateTime(now.year, now.month, now.day); + final weekStart = today.subtract(Duration(days: today.weekday - 1)); + final groups = >{ + "Aujourd'hui": [], + 'Cette semaine': [], + 'Plus ancien': [], + }; + for (final history in histories) { + final startedDay = DateTime( + history.startedAt.year, + history.startedAt.month, + history.startedAt.day, + ); + if (startedDay == today) { + groups["Aujourd'hui"]!.add(history); + } else if (startedDay.isAfter(weekStart) || startedDay == weekStart) { + groups['Cette semaine']!.add(history); + } else { + groups['Plus ancien']!.add(history); + } + } + groups.removeWhere((key, value) => value.isEmpty); + return groups; +} + +Map _resultFromDomain(WorkoutHistorySetResult result) { + return { + 'programIndex': result.programIndex, + 'exerciseIndex': result.exerciseIndex, + 'setIndex': result.setIndex, + 'actualTimeMs': result.actualTimeMs, + 'actualReps': result.actualReps, + 'actualScore': result.actualScore, + }; +} + +String _historySummary(WorkoutHistory history) { + final snapshot = + jsonDecode(history.historySnapshotJson) as Map; + final rawResults = history.results.isNotEmpty + ? history.results.map(_resultFromDomain).toList() + : (snapshot['results'] as List? ?? const []) + .cast>(); + final scoreCount = rawResults + .where((result) => result['actualScore'] != null) + .length; + return '${rawResults.length} séries · $scoreCount score${scoreCount > 1 ? 's' : ''}'; +} + +String _formatScoreValue(num value) { + if (value is int || value == value.roundToDouble()) { + return value.round().toString(); + } + return value.toString(); +} + +String _formatDateTime(DateTime value) { + final day = value.day.toString().padLeft(2, '0'); + final month = value.month.toString().padLeft(2, '0'); + final hour = value.hour.toString().padLeft(2, '0'); + final minute = value.minute.toString().padLeft(2, '0'); + return '$day/$month/${value.year} $hour:$minute'; +} + +String _formatDurationMs(int milliseconds) { + final duration = Duration(milliseconds: milliseconds); + 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'; +} + +final class _CenteredMessage extends StatelessWidget { + const _CenteredMessage({required this.title, this.message}); + + final String title; + final String? message; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.titleMedium, + ), + if (message != null) ...[ + const SizedBox(height: 8), + Text(message!, textAlign: TextAlign.center), + ], + ], + ), + ), + ); + } +} diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 310b0f3..36b4d0a 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../application/app_bootstrap.dart'; import '../domain/domain.dart'; import 'exercise_library_screen.dart'; +import 'history_screen.dart'; import 'program_screen.dart'; import 'workout_execution_screen.dart'; import 'workout_template_screen.dart'; @@ -97,6 +98,23 @@ final class _HomeScreenState extends State { programUseCases: widget.bootstrap.programUseCases, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, + historyUseCases: widget.bootstrap.workoutHistoryUseCases, + ), + ), + ), + ), + ListTile( + leading: const Icon(Icons.history), + title: const Text('Historique'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => HistoryListScreen( + historyUseCases: widget.bootstrap.workoutHistoryUseCases, + workoutTemplateUseCases: + widget.bootstrap.workoutTemplateUseCases, + activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, + closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, ), ), ), @@ -113,6 +131,8 @@ final class _HomeScreenState extends State { initialSession: session, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, + historyUseCases: widget.bootstrap.workoutHistoryUseCases, + workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases, ), ), ); diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index 1492629..056e35d 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -1,5 +1,6 @@ export 'game_time_app.dart'; export 'exercise_library_screen.dart'; +export 'history_screen.dart'; export 'home_screen.dart'; export 'program_screen.dart'; export 'workout_execution_screen.dart'; diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index e79dd0e..ef73d91 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import '../application/application.dart'; import '../domain/domain.dart'; +import 'history_screen.dart'; enum WorkoutExecutionMode { active, rest, paused, finished } @@ -13,12 +14,16 @@ final class WorkoutExecutionScreen extends StatefulWidget { required this.initialSession, required this.activeUseCases, required this.closeUseCase, + required this.historyUseCases, + required this.workoutTemplateUseCases, super.key, }); final ActiveWorkoutSession initialSession; final ActiveWorkoutSessionUseCases activeUseCases; final CloseWorkoutSessionUseCase closeUseCase; + final WorkoutHistoryUseCases historyUseCases; + final WorkoutTemplateUseCases workoutTemplateUseCases; @override State createState() => _WorkoutExecutionScreenState(); @@ -30,6 +35,7 @@ final class _WorkoutExecutionScreenState extends State { late WorkoutExecutionMode _mode; Timer? _ticker; Timer? _restTicker; + WorkoutHistory? _completedHistory; var _reps = 0; final _scoreController = TextEditingController(); var _remainingRestSeconds = 0; @@ -232,9 +238,12 @@ final class _WorkoutExecutionScreenState extends State { const SizedBox(height: 12), Text('Temps total : ${_formatDuration(elapsed)}'), const SizedBox(height: 24), - OutlinedButton(onPressed: () {}, child: const Text('Voir le détail')), OutlinedButton( - onPressed: () {}, + onPressed: _completedHistory == null ? null : _openHistoryDetail, + child: const Text('Voir le détail'), + ), + OutlinedButton( + onPressed: _completedHistory == null ? null : _restartCompleted, child: const Text('Relancer cette séance'), ), FilledButton( @@ -367,7 +376,7 @@ final class _WorkoutExecutionScreenState extends State { Future _complete() async { _session = await widget.activeUseCases.complete(_session.metadata.id); - await widget.closeUseCase.close( + _completedHistory = await widget.closeUseCase.close( sessionId: _session.metadata.id, nameSnapshot: _plan.name, completed: true, @@ -375,6 +384,56 @@ final class _WorkoutExecutionScreenState extends State { if (!mounted) return; setState(() => _mode = WorkoutExecutionMode.finished); } + + Future _openHistoryDetail() async { + final history = _completedHistory; + if (history == null) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => HistoryDetailScreen( + history: history, + historyUseCases: widget.historyUseCases, + workoutTemplateUseCases: widget.workoutTemplateUseCases, + activeUseCases: widget.activeUseCases, + closeUseCase: widget.closeUseCase, + ), + ), + ); + } + + Future _restartCompleted() async { + final history = _completedHistory; + if (history == null) return; + ActiveWorkoutSession session; + final sourceId = history.sourceWorkoutTemplateId; + if (sourceId != null && + await widget.workoutTemplateUseCases.findById(sourceId) != null) { + session = await widget.activeUseCases.startFromTemplate(sourceId); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + "La séance originale n'existe plus. Une copie va être utilisée.", + ), + ), + ); + } + session = await widget.activeUseCases.startFromHistory(history); + } + if (!mounted) return; + await Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => WorkoutExecutionScreen( + initialSession: session, + activeUseCases: widget.activeUseCases, + closeUseCase: widget.closeUseCase, + historyUseCases: widget.historyUseCases, + workoutTemplateUseCases: widget.workoutTemplateUseCases, + ), + ), + ); + } } final class SetMeasureInput extends StatelessWidget { diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index 77daea4..f71490c 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -12,6 +12,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget { required this.programUseCases, required this.activeUseCases, required this.closeUseCase, + required this.historyUseCases, super.key, }); @@ -19,6 +20,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget { final ProgramUseCases programUseCases; final ActiveWorkoutSessionUseCases activeUseCases; final CloseWorkoutSessionUseCase closeUseCase; + final WorkoutHistoryUseCases historyUseCases; @override State createState() => @@ -119,6 +121,8 @@ final class _WorkoutTemplateListScreenState initialSession: session, activeUseCases: widget.activeUseCases, closeUseCase: widget.closeUseCase, + historyUseCases: widget.historyUseCases, + workoutTemplateUseCases: widget.workoutTemplateUseCases, ), ), ); diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart new file mode 100644 index 0000000..483b552 --- /dev/null +++ b/test/presentation/history_screen_test.dart @@ -0,0 +1,305 @@ +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('la liste groupe les séances par période', (tester) async { + final groups = groupHistoriesByPeriod([ + _history(id: 'today', startedAt: DateTime.utc(2026, 7, 17, 9)), + _history(id: 'week', startedAt: DateTime.utc(2026, 7, 15, 9)), + _history(id: 'old', startedAt: DateTime.utc(2026, 7, 1, 9)), + ], DateTime.utc(2026, 7, 17, 12)); + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column(children: [for (final key in groups.keys) Text(key)]), + ), + ), + ); + + expect(find.text("Aujourd'hui"), findsOneWidget); + expect(find.text('Cette semaine'), findsOneWidget); + expect(find.text('Plus ancien'), findsOneWidget); + }); + + testWidgets('le détail affiche temps répétitions et score avec unité', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + home: HistoryDetailScreen( + history: _history(id: 'history-1'), + historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), + workoutTemplateUseCases: _workoutTemplateUseCases(), + activeUseCases: _activeUseCases(), + closeUseCase: _closeUseCase(), + ), + ), + ); + + expect(find.text('Programme jambes'), findsOneWidget); + expect(find.text('Squat'), findsOneWidget); + expect( + find.text('Temps 00:45 · 10 répétitions · Score 80 kg'), + findsOneWidget, + ); + }); + + testWidgets('la suppression demande une confirmation', (tester) async { + final repository = _FakeWorkoutHistoryRepository(); + + await tester.pumpWidget( + MaterialApp( + home: HistoryDetailScreen( + history: _history(id: 'history-1'), + historyUseCases: _historyUseCases(repository), + workoutTemplateUseCases: _workoutTemplateUseCases(), + activeUseCases: _activeUseCases(), + closeUseCase: _closeUseCase(), + ), + ), + ); + + await tester.tap(find.text("Supprimer de l'historique")); + await tester.pumpAndSettle(); + + expect(repository.deletedIds, isEmpty); + expect(find.text("Supprimer de l'historique ?"), findsOneWidget); + + await tester.tap(find.text('Annuler')); + await tester.pumpAndSettle(); + + expect(repository.deletedIds, isEmpty); + + await tester.tap(find.text("Supprimer de l'historique")); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Supprimer')); + await tester.pumpAndSettle(); + + expect(repository.deletedIds, ['history-1']); + }); +} + +WorkoutHistory _history({required String id, DateTime? startedAt}) { + final start = startedAt ?? DateTime.utc(2026, 7, 17, 10); + return WorkoutHistory( + metadata: _metadata(id), + sourceWorkoutTemplateId: 'template-1', + sourceActiveWorkoutSessionId: 'session-1', + nameSnapshot: 'Séance jambes', + startedAt: start, + endedAt: start.add(const Duration(minutes: 30)), + totalActiveMs: 1800000, + completed: true, + historySnapshotJson: jsonEncode({ + 'sessionId': 'session-1', + 'resolvedTemplateSnapshotJson': _resolvedSnapshot(), + 'results': [ + { + 'programSnapshotId': 'program-snapshot-1', + 'exerciseSnapshotId': 'exercise-snapshot-1', + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'actualTimeMs': 45000, + 'actualReps': 10, + 'actualScore': 80, + 'scoreLabelSnapshot': 'Charge', + 'scoreUnitSnapshot': 'kg', + }, + ], + }), + ); +} + +String _resolvedSnapshot() { + return jsonEncode({ + 'name': 'Séance jambes', + 'programs': [ + { + 'id': 'program-snapshot-1', + 'programNameSnapshot': 'Programme jambes', + 'programSnapshotJson': jsonEncode({ + 'exercises': [ + { + 'id': 'exercise-snapshot-1', + 'exerciseNameSnapshot': 'Squat', + 'setsCount': 1, + 'timeEnabled': true, + 'repsEnabled': true, + 'scoreEnabled': true, + 'targetTimeSeconds': 45, + 'targetReps': 10, + 'targetScore': 80, + 'scoreLabelSnapshot': 'Charge', + 'scoreUnitSnapshot': 'kg', + 'restSecondsOverride': 0, + }, + ], + }), + }, + ], + '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', + ); +} + +WorkoutHistoryUseCases _historyUseCases( + _FakeWorkoutHistoryRepository repository, +) { + return WorkoutHistoryUseCases( + repository: repository, + clock: _FakeClock(DateTime.utc(2026, 7, 17)), + ); +} + +WorkoutTemplateUseCases _workoutTemplateUseCases() { + return WorkoutTemplateUseCases( + templateRepository: _FakeWorkoutTemplateRepository(), + programRepository: _FakeProgramRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + +ActiveWorkoutSessionUseCases _activeUseCases() { + return ActiveWorkoutSessionUseCases( + sessionRepository: _FakeActiveSessionRepository(), + templateRepository: _FakeWorkoutTemplateRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + +CloseWorkoutSessionUseCase _closeUseCase() { + return CloseWorkoutSessionUseCase( + sessionRepository: _FakeActiveSessionRepository(), + historyRepository: _FakeWorkoutHistoryRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17)), + ids: _FakeIds(), + 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 _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { + final deletedIds = []; + + @override + Future delete(String id, DateTime deletedAt) async { + deletedIds.add(id); + } + + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future save(WorkoutHistory history) async {} + + @override + Future saveSetResult(WorkoutHistorySetResult result) async {} +} + +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 _FakeProgramRepository implements ProgramRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceExercises(Program program, DateTime deletedAt) async {} + + @override + Future save(Program program) async {} + + @override + Future saveExercise(ProgramExercise exercise) async {} +} + +final class _FakeActiveSessionRepository implements ActiveSessionRepository { + @override + Future findById(String id) async => null; + + @override + Future findOpen() async => null; + + @override + Future> listRestStates(String sessionId) async { + return const []; + } + + @override + Future> listSetResults(String sessionId) async { + return const []; + } + + @override + Future save(ActiveWorkoutSession session) async {} + + @override + Future saveRestState(ActiveRestState restState) async {} + + @override + Future saveSetResult(ActiveSetResult result) async {} +} diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 81a7a76..4166c8f 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -33,6 +33,8 @@ void main() { clock, ), closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), clock), + historyUseCases: _historyUseCases(clock), + workoutTemplateUseCases: _workoutTemplateUseCases(), ), ), ); @@ -141,6 +143,23 @@ CloseWorkoutSessionUseCase _closeUseCase( ); } +WorkoutHistoryUseCases _historyUseCases(_FakeClock clock) { + return WorkoutHistoryUseCases( + repository: _FakeWorkoutHistoryRepository(), + clock: clock, + ); +} + +WorkoutTemplateUseCases _workoutTemplateUseCases() { + return WorkoutTemplateUseCases( + templateRepository: _FakeWorkoutTemplateRepository(), + programRepository: _FakeProgramRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); +} + String _sessionSnapshot({int setsCount = 3, int restSeconds = 0}) { return jsonEncode({ 'name': 'Séance jambes', @@ -260,9 +279,35 @@ final class _FakeWorkoutTemplateRepository } final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { + @override + Future delete(String id, DateTime deletedAt) async {} + + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + @override Future save(WorkoutHistory history) async {} @override Future saveSetResult(WorkoutHistorySetResult result) async {} } + +final class _FakeProgramRepository implements ProgramRepository { + @override + Future replaceExercises(Program program, DateTime deletedAt) async {} + + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future save(Program program) async {} + + @override + Future saveExercise(ProgramExercise exercise) async {} +} From b7d94200a4453bd26817a2683bb45616af066e4b Mon Sep 17 00:00:00 2001 From: Blomios Date: Fri, 17 Jul 2026 19:17:58 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(ideai):=20met=20=C3=A0=20jour=20le=20t?= =?UTF-8?q?icket=20#10=20apr=C3=A8s=20l'=C3=A9cran=20Historique=20des=20s?= =?UTF-8?q?=C3=A9ances?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- .ideai/tickets/10/carnet.md | 4 ++-- .ideai/tickets/10/issue.md | 6 +++--- .ideai/tickets/index.json | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.ideai/tickets/10/carnet.md b/.ideai/tickets/10/carnet.md index 026e172..4ad9f0f 100644 --- a/.ideai/tickets/10/carnet.md +++ b/.ideai/tickets/10/carnet.md @@ -1,7 +1,7 @@ --- issueRef: "#10" -version: 4 +version: 6 updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} -updatedAt: 1784301394213 +updatedAt: 1784308662161 --- Référence : mémoire "gametime-ux-conception" section 5. Relance : utiliser la séance-modèle source si elle existe encore ; sinon relancer depuis le snapshot historique avec un message explicite ("La séance originale n'existe plus. Une copie va être utilisée."). L'historique doit rester lisible même si exercices/programmes/séances-modèles sources ont été supprimés depuis (snapshot autonome, cf. ticket #3/#4). \ No newline at end of file diff --git a/.ideai/tickets/10/issue.md b/.ideai/tickets/10/issue.md index 27247d5..a6d4525 100644 --- a/.ideai/tickets/10/issue.md +++ b/.ideai/tickets/10/issue.md @@ -2,7 +2,7 @@ id: "4c2a9393-bf0f-4f41-a243-9ce0b4f0c4e1" number: 10 title: "[DevFrontend] Écran Historique des séances" -status: "open" +status: "closed" priority: "medium" sprint: null links: [{"target":"#4","kind":"dependsOn"},{"target":"#9","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: 1784301309677 -updatedAt: 1784301394213 -version: 4 +updatedAt: 1784308662161 +version: 6 --- Liste groupée par période (Aujourd'hui/Cette semaine/Plus ancien) avec résumé par séance. Détail par programme puis exercice puis série (temps/répétitions/score si suivis). Relance : utilise la séance-modèle associée si elle existe encore, sinon relance depuis le snapshot historique avec message explicite. Suppression d'historique (action destructive confirmée). Cf. mémoire "gametime-ux-conception" section 5. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 325fd7a..a2e069b 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -113,13 +113,13 @@ "issueRef": "#10", "path": "10", "title": "[DevFrontend] Écran Historique des séances", - "status": "open", + "status": "closed", "priority": "medium", "sprint": null, "assignedAgentIds": [ "9933c93a-b8a1-4164-a3bb-7063fdad747d" ], - "updatedAt": 1784301394213 + "updatedAt": 1784308662161 }, { "issueRef": "#11",