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.sensorUseCases, this.performanceReferenceUseCase, this.onOpenProgression, super.key, }); final WorkoutHistoryUseCases historyUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases; final ActiveWorkoutSessionUseCases activeUseCases; final ActiveExerciseStepUseCases? stepUseCases; final ActiveWorkoutSensorUseCases? sensorUseCases; final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final CloseWorkoutSessionUseCase closeUseCase; final MediaUseCases? mediaUseCases; final VoidCallback? onOpenProgression; @override State createState() => _HistoryListScreenState(); } final class _HistoryListScreenState extends State { late Future> _histories; @override void initState() { super.initState(); _histories = widget.historyUseCases.listActive(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Historique'), actions: [ if (widget.onOpenProgression != null) TextButton( onPressed: widget.onOpenProgression, child: const Text('Progression'), ), ], ), body: FutureBuilder>( future: _histories, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final histories = snapshot.data ?? const []; if (histories.isEmpty) { return const _CenteredMessage( title: 'Aucune séance terminée', message: 'Les séances terminées apparaîtront ici.', ); } final groups = groupHistoriesByPeriod(histories, DateTime.now()); return ListView( children: [ for (final group in groups.entries) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 8), child: Text( group.key, style: Theme.of(context).textTheme.titleMedium, ), ), for (final history in group.value) 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 _openDetail(WorkoutHistory history) async { final deleted = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => HistoryDetailScreen( history: history, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, sensorUseCases: widget.sensorUseCases, 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.sensorUseCases, this.performanceReferenceUseCase, super.key, }); final WorkoutHistory history; final WorkoutHistoryUseCases historyUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases; final ActiveWorkoutSessionUseCases activeUseCases; final ActiveExerciseStepUseCases? stepUseCases; final ActiveWorkoutSensorUseCases? sensorUseCases; 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)}'), if (history.averageHeartRateBpm != null && history.maxHeartRateBpm != null) ...[ const SizedBox(height: 16), _HistoryHeartRateSummary( averageHeartRateBpm: history.averageHeartRateBpm!, maxHeartRateBpm: history.maxHeartRateBpm!, ), ], 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 _restart(BuildContext context) async { final hasBlockingOpenSession = await _hasBlockingOpenSession(); if (!context.mounted) return; if (hasBlockingOpenSession) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Une séance est déjà en cours. Termine-la ou reprends-la avant ' 'd’en lancer une nouvelle.', ), ), ); return; } ActiveWorkoutSession session; final sourceId = history.sourceWorkoutTemplateId; try { if (sourceId != null && await workoutTemplateUseCases.findById(sourceId) != null) { session = await activeUseCases.startFromTemplate(sourceId); } else { session = await activeUseCases.startFromHistory(history); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( "La séance originale n'existe plus. Une copie va être utilisée.", ), ), ); } } on DomainException { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.', ), ), ); return; } if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (context) => WorkoutExecutionScreen( initialSession: session, activeUseCases: activeUseCases, stepUseCases: stepUseCases, sensorUseCases: sensorUseCases, closeUseCase: closeUseCase, historyUseCases: historyUseCases, workoutTemplateUseCases: workoutTemplateUseCases, performanceReferenceUseCase: performanceReferenceUseCase, mediaUseCases: mediaUseCases, ), ), ); } Future _hasBlockingOpenSession() async { final openSession = await activeUseCases.findOpen(); if (openSession == null) { return false; } if (WorkoutExecutionPlan.tryFromSession(openSession) != null) { return true; } try { await activeUseCases.abandon(openSession.metadata.id); } on DomainException { // Legacy invalid sessions must not block a restart. } return false; } Future _confirmDelete(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text("Supprimer de l'historique ?"), content: const Text('Cette action est définitive.'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Supprimer'), ), ], ), ); if (confirmed != true) return; await historyUseCases.delete(history.metadata.id); if (!context.mounted) return; Navigator.of(context).pop(true); } } final class _HistoryHeartRateSummary extends StatelessWidget { const _HistoryHeartRateSummary({ required this.averageHeartRateBpm, required this.maxHeartRateBpm, }); final double averageHeartRateBpm; final int maxHeartRateBpm; @override Widget build(BuildContext context) { return CourtBlazerAccentPanel( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( 'Fréquence cardiaque', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 8), Row( children: [ Expanded( child: _HistoryHeartRateMetric( label: 'Moyenne', value: '${averageHeartRateBpm.round()}', ), ), const SizedBox(width: 12), Expanded( child: _HistoryHeartRateMetric( label: 'Max', value: '$maxHeartRateBpm', ), ), ], ), ], ), ); } } final class _HistoryHeartRateMetric extends StatelessWidget { const _HistoryHeartRateMetric({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: Theme.of(context).textTheme.labelMedium), RichText( text: TextSpan( style: AppTextStyles.scoreNumber(context), children: [ TextSpan(text: value), TextSpan( text: ' bpm', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ], ); } } final class HistoryDetailData { const HistoryDetailData({required this.programs}); factory HistoryDetailData.fromHistory(WorkoutHistory history) { final snapshot = jsonDecode(history.historySnapshotJson) as Map; final resolved = jsonDecode(snapshot['resolvedTemplateSnapshotJson'] as String) as Map; final rawResults = history.results.isNotEmpty ? history.results.map(_resultFromDomain).toList() : (snapshot['results'] as List? ?? const []) .cast>(); final rawStepResults = history.stepResults.isNotEmpty ? history.stepResults.map(_stepResultFromDomain).toList() : (snapshot['stepResults'] as List? ?? const []) .cast>(); final programs = []; final rawPrograms = resolved['programs'] as List? ?? const []; for ( var programIndex = 0; programIndex < rawPrograms.length; programIndex++ ) { final rawProgram = rawPrograms[programIndex] as Map; final programSnapshot = jsonDecode(rawProgram['programSnapshotJson'] as String) as Map; final exercises = []; final rawExercises = programSnapshot['exercises'] as List? ?? const []; for ( var exerciseIndex = 0; exerciseIndex < rawExercises.length; exerciseIndex++ ) { final rawExercise = rawExercises[exerciseIndex] as Map; final exerciseResults = rawResults.where((result) { return result['programIndex'] == programIndex && result['exerciseIndex'] == exerciseIndex; }).toList(); 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 programs; } final class HistoryProgramDetail { const HistoryProgramDetail({required this.name, required this.exercises}); final String name; final List exercises; } final class HistoryExerciseDetail { const HistoryExerciseDetail({required this.name, required this.sets}); final String name; final List sets; } final class HistorySetDetail { const HistorySetDetail({ required this.setIndex, required this.summary, required this.stepPassages, }); factory HistorySetDetail.fromSnapshot( Map exercise, Map result, List> stepResults, ) { final parts = []; if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) { parts.add('Temps ${_formatDurationMs(result['actualTimeMs'] as int)}'); } if (exercise['repsEnabled'] == true && result['actualReps'] != null) { parts.add('${result['actualReps']} répétitions'); } if (exercise['scoreEnabled'] == true && result['actualScore'] != null) { final unit = exercise['scoreUnitSnapshot'] as String?; final value = _formatScoreValue(result['actualScore'] as num); parts.add(unit == null ? 'Score $value' : 'Score $value $unit'); } 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 stepPassages; } final class HistoryStepPassageDetail { const HistoryStepPassageDetail({required this.index, required this.steps}); final int index; final List steps; } final class HistoryStepResultDetail { const HistoryStepResultDetail({ required this.index, required this.name, required this.status, required this.valueLabel, this.scoreLabel, }); factory HistoryStepResultDetail.fromResult(Map 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 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> groupHistoriesByPeriod( List histories, DateTime now, ) { final today = DateTime(now.year, now.month, now.day); final weekStart = today.subtract(Duration(days: today.weekday - 1)); final groups = >{ "Aujourd'hui": [], 'Cette semaine': [], 'Plus ancien': [], }; for (final history in histories) { final startedDay = DateTime( history.startedAt.year, history.startedAt.month, history.startedAt.day, ); if (startedDay == today) { groups["Aujourd'hui"]!.add(history); } else if (startedDay.isAfter(weekStart) || startedDay == weekStart) { groups['Cette semaine']!.add(history); } else { groups['Plus ancien']!.add(history); } } groups.removeWhere((key, value) => value.isEmpty); return groups; } Map _resultFromDomain(WorkoutHistorySetResult result) { return { 'programIndex': result.programIndex, 'exerciseIndex': result.exerciseIndex, 'setIndex': result.setIndex, 'actualTimeMs': result.actualTimeMs, 'actualReps': result.actualReps, 'actualScore': result.actualScore, 'actualScoreTimeMs': result.actualScoreTimeMs, 'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name, 'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot, }; } Map _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 _stepPassagesFromResults( List> results, ) { if (results.isEmpty) return const []; final byPassage = >>{}; 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 = ['É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 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 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; final rawResults = history.results.isNotEmpty ? history.results.map(_resultFromDomain).toList() : (snapshot['results'] as List? ?? const []) .cast>(); 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 exercise, Map result, ) { return result['targetScoreTimeMsSnapshot'] as int? ?? (exercise['targetScoreTimeMs'] as int?); } ScoreInputMode _historyScoreInputMode( Map exercise, Map 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), ], ], ), ), ); } }