Files
GameTime/lib/presentation/history_screen.dart
Blomios 36691643d7 feat(ui): integre affichage stats par scope et selection des types metier d'exercice cote telephone
UI telephone pour #179 (graphes/statistiques par etape, serie, exercice, seance) et #183 (choix des types metier d'exercice, hors pont Health Services).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:55:52 +02:00

1238 lines
40 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:convert';
import 'dart:math' as math;
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.telemetryUseCases,
this.performanceReferenceUseCase,
this.onOpenProgression,
this.watchAlertPublisher,
super.key,
});
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final WorkoutTelemetryUseCases? telemetryUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
final VoidCallback? onOpenProgression;
final WatchAlertPublisher? watchAlertPublisher;
@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'),
actions: [
if (widget.onOpenProgression != null)
TextButton(
onPressed: widget.onOpenProgression,
child: const Text('Progression'),
),
],
),
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,
sensorUseCases: widget.sensorUseCases,
telemetryUseCases: widget.telemetryUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases,
watchAlertPublisher: widget.watchAlertPublisher,
),
),
);
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.telemetryUseCases,
this.performanceReferenceUseCase,
this.watchAlertPublisher,
super.key,
});
final WorkoutHistory history;
final WorkoutHistoryUseCases historyUseCases;
final WorkoutTemplateUseCases workoutTemplateUseCases;
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final WorkoutTelemetryUseCases? telemetryUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
final WatchAlertPublisher? watchAlertPublisher;
@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 (_hasWatchStats(history)) ...[
const SizedBox(height: 16),
_HistoryWatchStatsSummary(
history: history,
telemetryUseCases: telemetryUseCases,
),
],
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 {
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 '
'den 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,
watchAlertPublisher: watchAlertPublisher,
),
),
);
}
Future<bool> _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<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 _HistoryWatchStatsSummary extends StatelessWidget {
const _HistoryWatchStatsSummary({
required this.history,
required this.telemetryUseCases,
});
final WorkoutHistory history;
final WorkoutTelemetryUseCases? telemetryUseCases;
@override
Widget build(BuildContext context) {
final minHeartRate = history.minHeartRateBpm;
final averageHeartRate = history.averageHeartRateBpm;
final maxHeartRate = history.maxHeartRateBpm;
final distance = history.totalDistanceMeters;
final calories = history.totalCaloriesKcal;
return CourtBlazerAccentPanel(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Stats montre', style: Theme.of(context).textTheme.titleSmall),
if (telemetryUseCases != null) ...[
const SizedBox(height: 12),
_HistoryWatchTelemetryGraph(
history: history,
telemetryUseCases: telemetryUseCases!,
),
],
const SizedBox(height: 8),
if (minHeartRate != null ||
averageHeartRate != null ||
maxHeartRate != null) ...[
Text('Fréquence cardiaque'),
const SizedBox(height: 8),
Row(
children: [
if (minHeartRate != null)
Expanded(
child: _HistoryWatchMetric(
label: 'Min',
value: '$minHeartRate bpm',
),
),
if (minHeartRate != null && averageHeartRate != null)
const SizedBox(width: 8),
if (averageHeartRate != null)
Expanded(
child: _HistoryWatchMetric(
label: 'Moyenne',
value: '${averageHeartRate.round()} bpm',
),
),
if ((minHeartRate != null || averageHeartRate != null) &&
maxHeartRate != null)
const SizedBox(width: 8),
if (maxHeartRate != null)
Expanded(
child: _HistoryWatchMetric(
label: 'Max',
value: '$maxHeartRate bpm',
),
),
],
),
if (averageHeartRate != null && maxHeartRate != null) ...[
const SizedBox(height: 10),
_HistoryWatchStatsBars(
minHeartRateBpm: minHeartRate,
averageHeartRateBpm: averageHeartRate,
maxHeartRateBpm: maxHeartRate,
),
],
],
if (distance != null || calories != null) ...[
const SizedBox(height: 12),
Row(
children: [
if (distance != null)
Expanded(
child: _HistoryWatchMetric(
label: 'Distance',
value: _formatHistoryDistance(distance),
),
),
if (distance != null && calories != null)
const SizedBox(width: 8),
if (calories != null)
Expanded(
child: _HistoryWatchMetric(
label: 'Calories',
value: '${calories.round()} kcal',
),
),
],
),
],
],
),
);
}
}
enum _TelemetryScope { session, exercise, set, step }
final class _HistoryWatchTelemetryGraph extends StatefulWidget {
const _HistoryWatchTelemetryGraph({
required this.history,
required this.telemetryUseCases,
});
final WorkoutHistory history;
final WorkoutTelemetryUseCases telemetryUseCases;
@override
State<_HistoryWatchTelemetryGraph> createState() =>
_HistoryWatchTelemetryGraphState();
}
final class _HistoryWatchTelemetryGraphState
extends State<_HistoryWatchTelemetryGraph> {
_TelemetryScope _scope = _TelemetryScope.session;
@override
Widget build(BuildContext context) {
return FutureBuilder<List<WorkoutTelemetrySample>>(
future: widget.telemetryUseCases.listSamplesForHistory(widget.history),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 120,
child: Center(child: CircularProgressIndicator()),
);
}
final samples = snapshot.data ?? const <WorkoutTelemetrySample>[];
if (samples.isEmpty) {
return const _HistoryWatchMetric(
label: 'Courbe FC',
value: 'Donnée indisponible',
);
}
final scopedSamples = _samplesForTelemetryScope(samples, _scope);
final heartRatePoints = scopedSamples
.where((sample) => sample.heartRateBpm != null)
.toList();
final distance = _scopeDelta(
scopedSamples
.map((sample) => sample.distanceMeters)
.whereType<double>(),
);
final calories = _scopeDelta(
scopedSamples
.map((sample) => sample.caloriesKcal)
.whereType<double>(),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<_TelemetryScope>(
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: _TelemetryScope.session,
label: Text('Séance'),
),
ButtonSegment(
value: _TelemetryScope.exercise,
label: Text('Exercice'),
),
ButtonSegment(value: _TelemetryScope.set, label: Text('Série')),
ButtonSegment(
value: _TelemetryScope.step,
label: Text('Étape'),
),
],
selected: {_scope},
onSelectionChanged: (selection) =>
setState(() => _scope = selection.single),
),
const SizedBox(height: 12),
SizedBox(
height: 160,
child: heartRatePoints.length < 2
? const Center(child: Text('Mesure en attente'))
: CustomPaint(
key: const ValueKey('history-watch-stats-graph'),
painter: _HeartRateTelemetryPainter(
samples: heartRatePoints,
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _HistoryWatchMetric(
label: 'Distance',
value: distance == null
? 'Donnée indisponible'
: _formatHistoryDistance(distance),
),
),
const SizedBox(width: 8),
Expanded(
child: _HistoryWatchMetric(
label: 'Calories',
value: calories == null
? 'Donnée indisponible'
: '${calories.round()} kcal',
),
),
],
),
],
);
},
);
}
}
List<WorkoutTelemetrySample> _samplesForTelemetryScope(
List<WorkoutTelemetrySample> samples,
_TelemetryScope scope,
) {
int? firstNonNull(int? Function(WorkoutTelemetrySample sample) read) {
for (final sample in samples) {
final value = read(sample);
if (value != null) return value;
}
return null;
}
final exercise = firstNonNull((sample) => sample.exerciseIndex);
final set = firstNonNull((sample) => sample.setIndex);
final step = firstNonNull((sample) => sample.stepIndex);
return samples.where((sample) {
return switch (scope) {
_TelemetryScope.session => true,
_TelemetryScope.exercise => sample.exerciseIndex == exercise,
_TelemetryScope.set =>
sample.exerciseIndex == exercise && sample.setIndex == set,
_TelemetryScope.step =>
sample.exerciseIndex == exercise &&
sample.setIndex == set &&
sample.stepIndex == step,
};
}).toList();
}
double? _scopeDelta(Iterable<double> values) {
final list = values.toList();
if (list.isEmpty) return null;
return math.max(0, list.last - list.first);
}
final class _HeartRateTelemetryPainter extends CustomPainter {
const _HeartRateTelemetryPainter({
required this.samples,
required this.color,
});
final List<WorkoutTelemetrySample> samples;
final Color color;
@override
void paint(Canvas canvas, Size size) {
final values = samples
.map((sample) => (at: sample.capturedAt, bpm: sample.heartRateBpm!))
.toList();
final minTime = values.first.at;
final maxTime = values.last.at.isAfter(minTime)
? values.last.at
: minTime.add(const Duration(seconds: 1));
final minBpm = values.map((value) => value.bpm).reduce(math.min);
final maxBpm = values.map((value) => value.bpm).reduce(math.max);
final chart = Rect.fromLTWH(0, 8, size.width, size.height - 16);
final range = math.max(1, maxBpm - minBpm);
Offset pointFor(({DateTime at, int bpm}) value) {
final xRatio =
value.at.difference(minTime).inMilliseconds /
maxTime.difference(minTime).inMilliseconds;
final yRatio = (value.bpm - minBpm) / range;
return Offset(
chart.left + chart.width * xRatio.clamp(0, 1),
chart.bottom - chart.height * yRatio.clamp(0, 1),
);
}
final paint = Paint()
..color = color
..strokeWidth = 2;
for (var index = 1; index < values.length; index += 1) {
final start = pointFor(values[index - 1]);
final end = pointFor(values[index]);
if (values[index].at.difference(values[index - 1].at) >
const Duration(minutes: 2)) {
_drawDashedLine(canvas, start, end, paint);
} else {
canvas.drawLine(start, end, paint);
}
}
}
@override
bool shouldRepaint(covariant _HeartRateTelemetryPainter oldDelegate) =>
oldDelegate.samples != samples || oldDelegate.color != color;
}
void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) {
const dash = 6.0;
const gap = 4.0;
final delta = end - start;
final distance = delta.distance;
if (distance == 0) return;
final direction = delta / distance;
var travelled = 0.0;
while (travelled < distance) {
final segmentEnd = math.min(travelled + dash, distance);
canvas.drawLine(
start + direction * travelled,
start + direction * segmentEnd,
paint,
);
travelled += dash + gap;
}
}
final class _HistoryWatchStatsBars extends StatelessWidget {
const _HistoryWatchStatsBars({
required this.minHeartRateBpm,
required this.averageHeartRateBpm,
required this.maxHeartRateBpm,
});
final int? minHeartRateBpm;
final double averageHeartRateBpm;
final int maxHeartRateBpm;
@override
Widget build(BuildContext context) {
final max = maxHeartRateBpm.toDouble().clamp(1, double.infinity);
final values = [
if (minHeartRateBpm != null) ('Min', minHeartRateBpm!.toDouble()),
('Moy', averageHeartRateBpm),
('Max', maxHeartRateBpm.toDouble()),
];
return Semantics(
label: 'Graphique stats montre',
key: const ValueKey('history-watch-stats-graph'),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final item in values) ...[
Expanded(
child: Column(
children: [
SizedBox(
height: 52,
child: Align(
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
heightFactor: (item.$2 / max).clamp(0.08, 1),
widthFactor: 0.62,
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFFC9A24A),
borderRadius: BorderRadius.circular(4),
),
),
),
),
),
const SizedBox(height: 4),
Text(
item.$1,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall,
),
],
),
),
if (item != values.last) const SizedBox(width: 8),
],
],
),
);
}
}
final class _HistoryWatchMetric extends StatelessWidget {
const _HistoryWatchMetric({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).copyWith(fontSize: 24),
children: [TextSpan(text: value)],
),
),
],
);
}
}
bool _hasWatchStats(WorkoutHistory history) {
return history.minHeartRateBpm != null ||
history.averageHeartRateBpm != null ||
history.maxHeartRateBpm != null ||
history.totalDistanceMeters != null ||
history.totalCaloriesKcal != null;
}
String _formatHistoryDistance(double meters) {
if (meters >= 1000) {
return '${(meters / 1000).toStringAsFixed(2)} km';
}
return '${meters.round()} m';
}
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),
],
],
),
),
);
}
}