Ajoute le lecteur de bips d'étapes (presentation/exercise_step_audio.dart, port ExerciseStepAudioCuePlayer injectable en test, jamais le vrai audioplayers en test) et les fichiers audio associés (assets/audio/step_countdown_short.wav, step_completion_long.wav). Câble ActiveExerciseStepUseCases dans app_bootstrap.dart et l'expose à WorkoutExecutionScreen. Les appels audio réels sont protégés par un try/catch qui log sans jamais bloquer la progression d'étape. flutter pub get OK (ajout audioplayers), analyze propre (mêmes infos préexistantes + 2 nouvelles use_build_context_synchronously, non bloquantes), 100/100 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
511 lines
16 KiB
Dart
511 lines
16 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,
|
|
super.key,
|
|
});
|
|
|
|
final WorkoutHistoryUseCases historyUseCases;
|
|
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
|
final ActiveWorkoutSessionUseCases activeUseCases;
|
|
final ActiveExerciseStepUseCases? stepUseCases;
|
|
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,
|
|
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,
|
|
super.key,
|
|
});
|
|
|
|
final WorkoutHistory history;
|
|
final WorkoutHistoryUseCases historyUseCases;
|
|
final WorkoutTemplateUseCases workoutTemplateUseCases;
|
|
final ActiveWorkoutSessionUseCases activeUseCases;
|
|
final ActiveExerciseStepUseCases? stepUseCases;
|
|
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)
|
|
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,
|
|
stepUseCases: stepUseCases,
|
|
closeUseCase: closeUseCase,
|
|
historyUseCases: historyUseCases,
|
|
workoutTemplateUseCases: workoutTemplateUseCases,
|
|
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 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');
|
|
}
|
|
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(' · '),
|
|
);
|
|
}
|
|
|
|
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,
|
|
'actualScoreTimeMs': result.actualScoreTimeMs,
|
|
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
|
|
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
|
|
};
|
|
}
|
|
|
|
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),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|