merge(develop): intègre l'écran Historique des séances (ticket #10)

Fusionne feature/#10-ecran-historique — analyze propre, 18/18 tests
verts, build APK debug validé. Clôture le périmètre des écrans Flutter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-17 19:18:02 +02:00
14 changed files with 1033 additions and 10 deletions

View File

@ -1,7 +1,7 @@
--- ---
issueRef: "#10" issueRef: "#10"
version: 4 version: 6
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} 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). 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).

View File

@ -2,7 +2,7 @@
id: "4c2a9393-bf0f-4f41-a243-9ce0b4f0c4e1" id: "4c2a9393-bf0f-4f41-a243-9ce0b4f0c4e1"
number: 10 number: 10
title: "[DevFrontend] Écran Historique des séances" title: "[DevFrontend] Écran Historique des séances"
status: "open" status: "closed"
priority: "medium" priority: "medium"
sprint: null sprint: null
links: [{"target":"#4","kind":"dependsOn"},{"target":"#9","kind":"dependsOn"}] 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"} createdBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784301309677 createdAt: 1784301309677
updatedAt: 1784301394213 updatedAt: 1784308662161
version: 4 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. 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.

View File

@ -113,13 +113,13 @@
"issueRef": "#10", "issueRef": "#10",
"path": "10", "path": "10",
"title": "[DevFrontend] Écran Historique des séances", "title": "[DevFrontend] Écran Historique des séances",
"status": "open", "status": "closed",
"priority": "medium", "priority": "medium",
"sprint": null, "sprint": null,
"assignedAgentIds": [ "assignedAgentIds": [
"9933c93a-b8a1-4164-a3bb-7063fdad747d" "9933c93a-b8a1-4164-a3bb-7063fdad747d"
], ],
"updatedAt": 1784301394213 "updatedAt": 1784308662161
}, },
{ {
"issueRef": "#11", "issueRef": "#11",

View File

@ -10,6 +10,7 @@ final class AppBootstrap {
required this.workoutTemplateUseCases, required this.workoutTemplateUseCases,
required this.activeWorkoutSessionUseCases, required this.activeWorkoutSessionUseCases,
required this.closeWorkoutSessionUseCase, required this.closeWorkoutSessionUseCase,
required this.workoutHistoryUseCases,
}); });
final AppDatabase database; final AppDatabase database;
@ -19,6 +20,7 @@ final class AppBootstrap {
final WorkoutTemplateUseCases workoutTemplateUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases; final ActiveWorkoutSessionUseCases activeWorkoutSessionUseCases;
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
final WorkoutHistoryUseCases workoutHistoryUseCases;
static Future<AppBootstrap> create() async { static Future<AppBootstrap> create() async {
final database = AppDatabase.open(); final database = AppDatabase.open();
@ -77,6 +79,10 @@ final class AppBootstrap {
ids: ids, ids: ids,
originDeviceId: originDeviceId, originDeviceId: originDeviceId,
), ),
workoutHistoryUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
); );
} }

View File

@ -85,6 +85,9 @@ abstract interface class ActiveSessionRepository {
} }
abstract interface class WorkoutHistoryRepository { abstract interface class WorkoutHistoryRepository {
Future<WorkoutHistory?> findById(String id);
Future<List<WorkoutHistory>> listActive();
Future<void> save(WorkoutHistory history); Future<void> save(WorkoutHistory history);
Future<void> saveSetResult(WorkoutHistorySetResult result); Future<void> saveSetResult(WorkoutHistorySetResult result);
Future<void> delete(String id, DateTime deletedAt);
} }

View File

@ -638,6 +638,28 @@ final class ActiveWorkoutSessionUseCases {
return session; return session;
} }
Future<ActiveWorkoutSession> startFromHistory(WorkoutHistory history) async {
final snapshot =
jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
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<ActiveWorkoutSession> pause(String sessionId) async { Future<ActiveWorkoutSession> pause(String sessionId) async {
final session = await _requiredSession(sessionId); final session = await _requiredSession(sessionId);
final paused = session.pause(clock.now()); 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<WorkoutHistory?> findById(String id) => repository.findById(id);
Future<List<WorkoutHistory>> listActive() => repository.listActive();
Future<void> delete(String id) => repository.delete(id, clock.now());
}
EntityMetadata _newMetadata( EntityMetadata _newMetadata(
IdGenerator ids, IdGenerator ids,
String originDeviceId, String originDeviceId,

View File

@ -428,6 +428,52 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
final db.AppDatabase database; final db.AppDatabase database;
@override
Future<domain.WorkoutHistory?> 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<List<domain.WorkoutHistory>> listActive() async {
final rows =
await (database.select(database.workoutHistories)
..where((table) => table.deletedAt.isNull())
..orderBy([(table) => OrderingTerm.desc(table.startedAt)]))
.get();
final histories = <domain.WorkoutHistory>[];
for (final row in rows) {
final history = await findById(row.id);
if (history != null) {
histories.add(history);
}
}
return histories;
}
@override @override
Future<void> save(domain.WorkoutHistory history) async { Future<void> save(domain.WorkoutHistory history) async {
await database await database
@ -444,6 +490,28 @@ final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
.into(database.workoutHistorySetResults) .into(database.workoutHistorySetResults)
.insertOnConflictUpdate(_workoutHistorySetResultCompanion(result)); .insertOnConflictUpdate(_workoutHistorySetResultCompanion(result));
} }
@override
Future<void> 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) { domain.EntityMetadata _metadataFromRow(dynamic row) {
@ -986,6 +1054,53 @@ db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
); );
} }
domain.WorkoutHistory _workoutHistoryFromRow(
db.WorkoutHistory row,
List<domain.WorkoutHistorySetResult> 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) { String _syncStateToDb(domain.SyncState state) => switch (state) {
domain.SyncState.localOnly => 'localOnly', domain.SyncState.localOnly => 'localOnly',
domain.SyncState.dirty => 'dirty', domain.SyncState.dirty => 'dirty',

View File

@ -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<HistoryListScreen> createState() => _HistoryListScreenState();
}
final class _HistoryListScreenState extends State<HistoryListScreen> {
late Future<List<WorkoutHistory>> _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<List<WorkoutHistory>>(
future: _histories,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(child: CircularProgressIndicator());
}
final histories = snapshot.data ?? const <WorkoutHistory>[];
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<void> _openDetail(WorkoutHistory history) async {
final deleted = await Navigator.of(context).push<bool>(
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<void> _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<void> _confirmDelete(BuildContext context) async {
final confirmed = await showDialog<bool>(
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<String, dynamic>;
final resolved =
jsonDecode(snapshot['resolvedTemplateSnapshotJson'] as String)
as Map<String, dynamic>;
final rawResults = history.results.isNotEmpty
? history.results.map(_resultFromDomain).toList()
: (snapshot['results'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
final programs = <HistoryProgramDetail>[];
final rawPrograms = resolved['programs'] as List<dynamic>? ?? const [];
for (
var programIndex = 0;
programIndex < rawPrograms.length;
programIndex++
) {
final rawProgram = rawPrograms[programIndex] as Map<String, dynamic>;
final programSnapshot =
jsonDecode(rawProgram['programSnapshotJson'] as String)
as Map<String, dynamic>;
final exercises = <HistoryExerciseDetail>[];
final rawExercises =
programSnapshot['exercises'] as List<dynamic>? ?? const [];
for (
var exerciseIndex = 0;
exerciseIndex < rawExercises.length;
exerciseIndex++
) {
final rawExercise = rawExercises[exerciseIndex] as Map<String, dynamic>;
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<HistoryProgramDetail> programs;
}
final class HistoryProgramDetail {
const HistoryProgramDetail({required this.name, required this.exercises});
final String name;
final List<HistoryExerciseDetail> exercises;
}
final class HistoryExerciseDetail {
const HistoryExerciseDetail({required this.name, required this.sets});
final String name;
final List<HistorySetDetail> sets;
}
final class HistorySetDetail {
const HistorySetDetail({required this.setIndex, required this.summary});
factory HistorySetDetail.fromSnapshot(
Map<String, dynamic> exercise,
Map<String, dynamic> result,
) {
final parts = <String>[];
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<String, List<WorkoutHistory>> groupHistoriesByPeriod(
List<WorkoutHistory> histories,
DateTime now,
) {
final today = DateTime(now.year, now.month, now.day);
final weekStart = today.subtract(Duration(days: today.weekday - 1));
final groups = <String, List<WorkoutHistory>>{
"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<String, dynamic> _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<String, dynamic>;
final rawResults = history.results.isNotEmpty
? history.results.map(_resultFromDomain).toList()
: (snapshot['results'] as List<dynamic>? ?? const [])
.cast<Map<String, dynamic>>();
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),
],
],
),
),
);
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../application/app_bootstrap.dart'; import '../application/app_bootstrap.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'exercise_library_screen.dart'; import 'exercise_library_screen.dart';
import 'history_screen.dart';
import 'program_screen.dart'; import 'program_screen.dart';
import 'workout_execution_screen.dart'; import 'workout_execution_screen.dart';
import 'workout_template_screen.dart'; import 'workout_template_screen.dart';
@ -97,6 +98,23 @@ final class _HomeScreenState extends State<HomeScreen> {
programUseCases: widget.bootstrap.programUseCases, programUseCases: widget.bootstrap.programUseCases,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, 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<HomeScreen> {
initialSession: session, initialSession: session,
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases,
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,
), ),
), ),
); );

View File

@ -1,5 +1,6 @@
export 'game_time_app.dart'; export 'game_time_app.dart';
export 'exercise_library_screen.dart'; export 'exercise_library_screen.dart';
export 'history_screen.dart';
export 'home_screen.dart'; export 'home_screen.dart';
export 'program_screen.dart'; export 'program_screen.dart';
export 'workout_execution_screen.dart'; export 'workout_execution_screen.dart';

View File

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import '../application/application.dart'; import '../application/application.dart';
import '../domain/domain.dart'; import '../domain/domain.dart';
import 'history_screen.dart';
enum WorkoutExecutionMode { active, rest, paused, finished } enum WorkoutExecutionMode { active, rest, paused, finished }
@ -13,12 +14,16 @@ final class WorkoutExecutionScreen extends StatefulWidget {
required this.initialSession, required this.initialSession,
required this.activeUseCases, required this.activeUseCases,
required this.closeUseCase, required this.closeUseCase,
required this.historyUseCases,
required this.workoutTemplateUseCases,
super.key, super.key,
}); });
final ActiveWorkoutSession initialSession; final ActiveWorkoutSession initialSession;
final ActiveWorkoutSessionUseCases activeUseCases; final ActiveWorkoutSessionUseCases activeUseCases;
final CloseWorkoutSessionUseCase closeUseCase; final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
@override @override
State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState(); State<WorkoutExecutionScreen> createState() => _WorkoutExecutionScreenState();
@ -30,6 +35,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
late WorkoutExecutionMode _mode; late WorkoutExecutionMode _mode;
Timer? _ticker; Timer? _ticker;
Timer? _restTicker; Timer? _restTicker;
WorkoutHistory? _completedHistory;
var _reps = 0; var _reps = 0;
final _scoreController = TextEditingController(); final _scoreController = TextEditingController();
var _remainingRestSeconds = 0; var _remainingRestSeconds = 0;
@ -232,9 +238,12 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Text('Temps total : ${_formatDuration(elapsed)}'), Text('Temps total : ${_formatDuration(elapsed)}'),
const SizedBox(height: 24), const SizedBox(height: 24),
OutlinedButton(onPressed: () {}, child: const Text('Voir le détail')),
OutlinedButton( 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'), child: const Text('Relancer cette séance'),
), ),
FilledButton( FilledButton(
@ -367,7 +376,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
Future<void> _complete() async { Future<void> _complete() async {
_session = await widget.activeUseCases.complete(_session.metadata.id); _session = await widget.activeUseCases.complete(_session.metadata.id);
await widget.closeUseCase.close( _completedHistory = await widget.closeUseCase.close(
sessionId: _session.metadata.id, sessionId: _session.metadata.id,
nameSnapshot: _plan.name, nameSnapshot: _plan.name,
completed: true, completed: true,
@ -375,6 +384,56 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
if (!mounted) return; if (!mounted) return;
setState(() => _mode = WorkoutExecutionMode.finished); setState(() => _mode = WorkoutExecutionMode.finished);
} }
Future<void> _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<void> _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 { final class SetMeasureInput extends StatelessWidget {

View File

@ -12,6 +12,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
required this.programUseCases, required this.programUseCases,
required this.activeUseCases, required this.activeUseCases,
required this.closeUseCase, required this.closeUseCase,
required this.historyUseCases,
super.key, super.key,
}); });
@ -19,6 +20,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
final ProgramUseCases programUseCases; final ProgramUseCases programUseCases;
final ActiveWorkoutSessionUseCases activeUseCases; final ActiveWorkoutSessionUseCases activeUseCases;
final CloseWorkoutSessionUseCase closeUseCase; final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases;
@override @override
State<WorkoutTemplateListScreen> createState() => State<WorkoutTemplateListScreen> createState() =>
@ -119,6 +121,8 @@ final class _WorkoutTemplateListScreenState
initialSession: session, initialSession: session,
activeUseCases: widget.activeUseCases, activeUseCases: widget.activeUseCases,
closeUseCase: widget.closeUseCase, closeUseCase: widget.closeUseCase,
historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases,
), ),
), ),
); );

View File

@ -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 = <String>[];
@override
Future<void> delete(String id, DateTime deletedAt) async {
deletedIds.add(id);
}
@override
Future<WorkoutHistory?> findById(String id) async => null;
@override
Future<List<WorkoutHistory>> listActive() async => const [];
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeProgramRepository implements ProgramRepository {
@override
Future<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<void> save(Program program) async {}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<ActiveWorkoutSession?> findById(String id) async => null;
@override
Future<ActiveWorkoutSession?> findOpen() async => null;
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return const [];
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return const [];
}
@override
Future<void> save(ActiveWorkoutSession session) async {}
@override
Future<void> saveRestState(ActiveRestState restState) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}

View File

@ -33,6 +33,8 @@ void main() {
clock, clock,
), ),
closeUseCase: _closeUseCase(_FakeActiveSessionRepository(), 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}) { String _sessionSnapshot({int setsCount = 3, int restSeconds = 0}) {
return jsonEncode({ return jsonEncode({
'name': 'Séance jambes', 'name': 'Séance jambes',
@ -260,9 +279,35 @@ final class _FakeWorkoutTemplateRepository
} }
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> delete(String id, DateTime deletedAt) async {}
@override
Future<WorkoutHistory?> findById(String id) async => null;
@override
Future<List<WorkoutHistory>> listActive() async => const [];
@override @override
Future<void> save(WorkoutHistory history) async {} Future<void> save(WorkoutHistory history) async {}
@override @override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {} Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
} }
final class _FakeProgramRepository implements ProgramRepository {
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> save(Program program) async {}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}