Files
GameTime/lib/presentation/history_screen.dart
Blomios 0813327bef feat(exécution): affichage dernière performance et meilleur score (ticket #81)
Affiche la dernière performance et le meilleur score par exercice
pendant l'exécution de séance (écran d'exécution), ainsi que les
points d'accès associés dans l'accueil, la séance-modèle et
l'historique, en s'appuyant sur ExercisePerformanceReferenceUseCase.

Validé GO par Main : dart analyze propre, 178 tests (4 échecs
préexistants sans rapport, identiques à ceux déjà documentés sur #80).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 08:03:00 +02:00

733 lines
24 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import '../application/application.dart';
import '../domain/domain.dart';
import 'theme.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,
this.mediaUseCases,
this.stepUseCases,
this.performanceReferenceUseCase,
super.key,
});
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@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)
CourtBlazerAccentPanel(
margin: const EdgeInsets.fromLTRB(12, 0, 12, 8),
child: 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,
stepUseCases: widget.stepUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases,
),
),
);
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,
this.mediaUseCases,
this.stepUseCases,
this.performanceReferenceUseCase,
super.key,
});
final WorkoutHistory history;
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@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)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text('Série ${set.setIndex + 1}'),
subtitle: Text(set.summary),
),
if (set.stepPassages.isNotEmpty)
_HistoryStepResultsSection(passages: set.stepPassages),
],
),
),
],
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,
stepUseCases: stepUseCases,
closeUseCase: closeUseCase,
historyUseCases: historyUseCases,
workoutTemplateUseCases: workoutTemplateUseCases,
performanceReferenceUseCase: performanceReferenceUseCase,
mediaUseCases: mediaUseCases,
),
),
);
}
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 rawStepResults = history.stepResults.isNotEmpty
? history.stepResults.map(_stepResultFromDomain).toList()
: (snapshot['stepResults'] 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();
final exerciseStepResults = rawStepResults.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,
exerciseStepResults
.where(
(stepResult) =>
stepResult['setIndex'] == result['setIndex'],
)
.toList(),
),
],
),
);
}
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,
required this.stepPassages,
});
factory HistorySetDetail.fromSnapshot(
Map<String, dynamic> exercise,
Map<String, dynamic> result,
List<Map<String, dynamic>> stepResults,
) {
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');
}
if (exercise['scoreEnabled'] == true &&
_historyScoreInputMode(exercise, result) == ScoreInputMode.stopwatch &&
result['actualScoreTimeMs'] != null) {
final value = _formatScoreStopwatch(
Duration(milliseconds: result['actualScoreTimeMs'] as int),
);
final target = _targetScoreTimeMsFromSnapshot(exercise, result);
if (target == null) {
parts.add('Temps réalisé : $value');
} else {
parts.add(
'Temps réalisé : $value / objectif '
'${_formatScoreStopwatchTarget(Duration(milliseconds: target))}',
);
}
}
return HistorySetDetail(
setIndex: result['setIndex'] as int,
summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '),
stepPassages: _stepPassagesFromResults(stepResults),
);
}
final int setIndex;
final String summary;
final List<HistoryStepPassageDetail> stepPassages;
}
final class HistoryStepPassageDetail {
const HistoryStepPassageDetail({required this.index, required this.steps});
final int index;
final List<HistoryStepResultDetail> steps;
}
final class HistoryStepResultDetail {
const HistoryStepResultDetail({
required this.index,
required this.name,
required this.status,
required this.valueLabel,
this.scoreLabel,
});
factory HistoryStepResultDetail.fromResult(Map<String, dynamic> result) {
final status = _setResultStatusFromSnapshot(result['status']);
return HistoryStepResultDetail(
index: result['stepIndex'] as int,
name: result['stepNameSnapshot'] as String,
status: status,
valueLabel: status == SetResultStatus.skipped
? null
: _historyStepValueLabel(result),
scoreLabel: status == SetResultStatus.skipped
? null
: _historyStepScoreLabel(result),
);
}
final int index;
final String name;
final SetResultStatus status;
final String? valueLabel;
final String? scoreLabel;
}
final class _HistoryStepResultsSection extends StatelessWidget {
const _HistoryStepResultsSection({required this.passages});
final List<HistoryStepPassageDetail> passages;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Étapes réalisées',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
for (final passage in passages) ...[
Text(
'Passage ${passage.index + 1}',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
for (final step in passage.steps)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(_historyStepLine(step))),
if (step.status == SetResultStatus.skipped) ...[
const SizedBox(width: 8),
Chip(
visualDensity: VisualDensity.compact,
label: const Text('Passée'),
side: BorderSide(color: tokens.border),
),
],
],
),
),
const SizedBox(height: 8),
],
],
),
);
}
}
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,
'actualScoreTimeMs': result.actualScoreTimeMs,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
};
}
Map<String, dynamic> _stepResultFromDomain(WorkoutHistoryStepResult result) {
return {
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'passageIndex': result.passageIndex,
'stepIndex': result.stepIndex,
'stepNameSnapshot': result.stepNameSnapshot,
'stepTypeSnapshot': result.stepTypeSnapshot.name,
'targetValueSnapshot': result.targetValueSnapshot,
'hasScoreSnapshot': result.hasScoreSnapshot,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'status': result.status.name,
};
}
List<HistoryStepPassageDetail> _stepPassagesFromResults(
List<Map<String, dynamic>> results,
) {
if (results.isEmpty) return const [];
final byPassage = <int, List<Map<String, dynamic>>>{};
for (final result in results) {
final passageIndex = result['passageIndex'] as int;
byPassage.putIfAbsent(passageIndex, () => []).add(result);
}
final passages = byPassage.entries.toList()
..sort((left, right) => left.key.compareTo(right.key));
return passages
.map((entry) {
final steps = entry.value
..sort(
(left, right) =>
(left['stepIndex'] as int).compareTo(right['stepIndex'] as int),
);
return HistoryStepPassageDetail(
index: entry.key,
steps: steps.map(HistoryStepResultDetail.fromResult).toList(),
);
})
.toList(growable: false);
}
String _historyStepLine(HistoryStepResultDetail step) {
final parts = <String>['Étape ${step.index + 1}', step.name];
if (step.valueLabel != null) {
parts.add(step.valueLabel!);
}
if (step.scoreLabel != null) {
parts.add(step.scoreLabel!);
}
return parts.join(' · ');
}
String? _historyStepValueLabel(Map<String, dynamic> result) {
final type = result['stepTypeSnapshot'];
if (type == ExerciseStepType.time.name && result['actualTimeMs'] != null) {
return 'Temps ${_formatDurationMs(result['actualTimeMs'] as int)}';
}
if (type == ExerciseStepType.reps.name && result['actualReps'] != null) {
return 'Répétitions ${result['actualReps']}';
}
return null;
}
String? _historyStepScoreLabel(Map<String, dynamic> result) {
if (result['hasScoreSnapshot'] != true) return null;
final mode = _scoreInputModeFromSnapshot(result['scoreInputModeSnapshot']);
if (mode == ScoreInputMode.stopwatch && result['actualScoreTimeMs'] != null) {
return 'Score chrono : ${_formatScoreStopwatch(Duration(milliseconds: result['actualScoreTimeMs'] as int))}';
}
if (mode == ScoreInputMode.manual && result['actualScore'] != null) {
final scoreLabel = result['scoreLabelSnapshot'] as String? ?? 'Score';
final score = _formatScoreValue(result['actualScore'] as num);
final unit = result['scoreUnitSnapshot'] as String?;
final suffix = unit == null || unit.trim().isEmpty ? '' : ' $unit';
return '$scoreLabel : $score$suffix';
}
return null;
}
SetResultStatus _setResultStatusFromSnapshot(Object? value) {
return switch (value) {
'skipped' => SetResultStatus.skipped,
_ => SetResultStatus.completed,
};
}
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 ||
result['actualScoreTimeMs'] != 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';
}
String _formatScoreStopwatch(Duration duration) {
final totalTenths = (duration.inMilliseconds / 100).floor();
final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0');
final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0');
final tenths = totalTenths % 10;
return '$minutes:$seconds.$tenths';
}
String _formatScoreStopwatchTarget(Duration duration) {
if (duration.inMilliseconds % 1000 == 0) {
return _formatDurationMs(duration.inMilliseconds);
}
return _formatScoreStopwatch(duration);
}
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
return switch (value) {
'stopwatch' => ScoreInputMode.stopwatch,
_ => ScoreInputMode.manual,
};
}
int? _targetScoreTimeMsFromSnapshot(
Map<String, dynamic> exercise,
Map<String, dynamic> result,
) {
return result['targetScoreTimeMsSnapshot'] as int? ??
(exercise['targetScoreTimeMs'] as int?);
}
ScoreInputMode _historyScoreInputMode(
Map<String, dynamic> exercise,
Map<String, dynamic> result,
) {
final resultMode = result['scoreInputModeSnapshot'];
if (resultMode != null) {
return _scoreInputModeFromSnapshot(resultMode);
}
return _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']);
}
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),
],
],
),
),
);
}
}