feat(ui): écran Historique des séances
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 <noreply@anthropic.com>
This commit is contained in:
@ -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,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
430
lib/presentation/history_screen.dart
Normal file
430
lib/presentation/history_screen.dart
Normal 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),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
305
test/presentation/history_screen_test.dart
Normal file
305
test/presentation/history_screen_test.dart
Normal 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 {}
|
||||||
|
}
|
||||||
@ -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 {}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user