Files
GameTime/lib/presentation/history_screen.dart
Blomios fe3608cc4a feat(server): implemente synchronisation et serveur avec fixtures (#187)
- Implémente la couche de synchronisation avec le serveur
- Ajoute les fixtures versionnées pour les tests
- Met à jour Drift database et repositories pour le support sync
- Améliore les tests de synchronisation
- Corrige et améliore le watch companion pour la collecte de métriques

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

1889 lines
61 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 (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 { step, set, exercise, session }
enum _TelemetryMetric { heartRate, distance, calories }
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;
_TelemetryMetric _metric = _TelemetryMetric.heartRate;
int? _selectedExerciseOrdinal;
int? _selectedSetOrdinal;
int? _selectedStepOrdinal;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return FutureBuilder<_TelemetryGraphState>(
future: _loadGraphState(),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const SizedBox(
height: 280,
child: Center(child: CircularProgressIndicator()),
);
}
final state = snapshot.data ?? _TelemetryGraphState.empty(_scope);
final metric = _metricSpec(_metric);
final metricPoints = state.points
.where((point) => metric.read(point) != null)
.toList(growable: false);
final displayValue = _displayValueForMetric(
state,
metric,
metricPoints,
);
final selectedExercise = state.selectedExercise;
final selectedSet = state.selectedSet;
final selectedStep = state.selectedStep;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: SegmentedButton<_TelemetryMetric>(
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: _TelemetryMetric.heartRate,
label: Text('FC'),
),
ButtonSegment(
value: _TelemetryMetric.distance,
label: Text('Distance'),
),
ButtonSegment(
value: _TelemetryMetric.calories,
label: Text('Calories'),
),
],
selected: {_metric},
onSelectionChanged: (selection) =>
setState(() => _metric = selection.single),
),
),
const SizedBox(height: 12),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: 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;
_selectedExerciseOrdinal = null;
_selectedSetOrdinal = null;
_selectedStepOrdinal = null;
});
},
),
),
if (_scope != _TelemetryScope.session) ...[
const SizedBox(height: 12),
_TelemetryInstanceSelectors(
scope: _scope,
exerciseInstances: state.exerciseInstances,
setInstances: state.setInstancesForSelectedExercise,
stepInstances: state.stepInstancesForSelectedSet,
selectedExercise: selectedExercise,
selectedSet: selectedSet,
selectedStep: selectedStep,
onExerciseChanged: (descriptor) {
setState(() {
_selectedExerciseOrdinal = descriptor.ordinal;
_selectedSetOrdinal = null;
_selectedStepOrdinal = null;
});
},
onSetChanged: (descriptor) {
setState(() {
_selectedSetOrdinal = descriptor.ordinal;
_selectedStepOrdinal = null;
});
},
onStepChanged: (descriptor) {
setState(() {
_selectedStepOrdinal = descriptor.ordinal;
});
},
),
],
const SizedBox(height: 12),
Text(
'${metric.title} · ${state.scopeLabel} · '
'${_formatDurationMs(state.durationMs)}',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
SizedBox(
height: 220,
child: metricPoints.length < 2
? DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: tokens.border),
borderRadius: BorderRadius.circular(6),
),
child: Center(
child: Text(
'Aucune donnée exploitable pour '
'${metric.title.toLowerCase()} sur ce scope.',
textAlign: TextAlign.center,
),
),
)
: Semantics(
label:
'Graphique ${metric.title}, temps en abscisse, '
'${metric.unit} en ordonnée',
key: const ValueKey('history-watch-stats-graph'),
child: CustomPaint(
painter: _TelemetryChartPainter(
points: metricPoints,
readValue: metric.read,
valueFormatter: metric.axisFormat,
unit: metric.unit,
color: Theme.of(context).colorScheme.primary,
borderColor: tokens.border,
labelColor: tokens.mutedText,
minGuide: _metric == _TelemetryMetric.heartRate
? state.minHeartRateBpm?.toDouble()
: null,
maxGuide: _metric == _TelemetryMetric.heartRate
? state.maxHeartRateBpm?.toDouble()
: null,
markers: state.markers,
),
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
_HistoryWatchCompactIndicator(
label: 'Période',
value: _formatDurationMs(state.durationMs),
),
_HistoryWatchCompactIndicator(
label: 'Focale',
value: state.scopeLabel,
),
_HistoryWatchCompactIndicator(
label: metric.compactLabel,
value: displayValue == null
? 'Indisponible'
: metric.format(displayValue),
),
if (_metric == _TelemetryMetric.heartRate &&
state.minHeartRateBpm != null)
_HistoryWatchCompactIndicator(
label: 'Min',
value: '${state.minHeartRateBpm} bpm',
),
if (_metric == _TelemetryMetric.heartRate &&
state.maxHeartRateBpm != null)
_HistoryWatchCompactIndicator(
label: 'Max',
value: '${state.maxHeartRateBpm} bpm',
),
_HistoryWatchCompactIndicator(
label: 'Points',
value: state.scopeIndicator,
),
if (state.markers.isNotEmpty)
_HistoryWatchCompactIndicator(
label: 'Repères',
value: '${state.markers.length}',
),
],
),
],
);
},
);
}
Future<_TelemetryGraphState> _loadGraphState() async {
final aggregateScope = _aggregateScope(_scope);
final exerciseInstances = await widget.telemetryUseCases
.listScopeInstancesForHistory(
history: widget.history,
scope: WorkoutTelemetryAggregateScope.exercise,
);
final setInstances = await widget.telemetryUseCases
.listScopeInstancesForHistory(
history: widget.history,
scope: WorkoutTelemetryAggregateScope.set,
);
final stepInstances = await widget.telemetryUseCases
.listScopeInstancesForHistory(
history: widget.history,
scope: WorkoutTelemetryAggregateScope.step,
);
final selectedExercise = _selectedOrFirst(
exerciseInstances,
_selectedExerciseOrdinal,
);
final selectedSet = _selectedOrFirst(
_filterChildren(
setInstances,
programIndex: selectedExercise?.programIndex,
exerciseIndex: selectedExercise?.exerciseIndex,
),
_selectedSetOrdinal,
);
final selectedStep = _selectedOrFirst(
_filterChildren(
stepInstances,
programIndex: selectedExercise?.programIndex,
exerciseIndex: selectedExercise?.exerciseIndex,
setIndex: selectedSet?.setIndex,
),
_selectedStepOrdinal,
);
final selectedInstance = switch (_scope) {
_TelemetryScope.session => null,
_TelemetryScope.exercise => selectedExercise,
_TelemetryScope.set => selectedSet,
_TelemetryScope.step => selectedStep,
};
if (_scope != _TelemetryScope.session && selectedInstance == null) {
return _TelemetryGraphState.empty(
_scope,
exerciseInstances: exerciseInstances,
setInstances: setInstances,
stepInstances: stepInstances,
);
}
final series = await widget.telemetryUseCases.readGraphSeriesForHistory(
history: widget.history,
scope: aggregateScope,
programIndex: selectedInstance?.programIndex,
exerciseIndex: selectedInstance?.exerciseIndex,
setIndex: selectedInstance?.setIndex,
passageIndex: selectedInstance?.passageIndex,
stepIndex: selectedInstance?.stepIndex,
);
final markers = await widget.telemetryUseCases.readScopeMarkersForHistory(
history: widget.history,
scope: aggregateScope,
programIndex: selectedInstance?.programIndex,
exerciseIndex: selectedInstance?.exerciseIndex,
setIndex: selectedInstance?.setIndex,
passageIndex: selectedInstance?.passageIndex,
stepIndex: selectedInstance?.stepIndex,
);
return _TelemetryGraphState.fromSeries(
series,
markers: markers,
exerciseInstances: exerciseInstances,
setInstances: setInstances,
stepInstances: stepInstances,
selectedExercise: selectedExercise,
selectedSet: selectedSet,
selectedStep: selectedStep,
);
}
}
final class _TelemetryInstanceSelectors extends StatelessWidget {
const _TelemetryInstanceSelectors({
required this.scope,
required this.exerciseInstances,
required this.setInstances,
required this.stepInstances,
required this.selectedExercise,
required this.selectedSet,
required this.selectedStep,
required this.onExerciseChanged,
required this.onSetChanged,
required this.onStepChanged,
});
final _TelemetryScope scope;
final List<ScopeInstanceDescriptor> exerciseInstances;
final List<ScopeInstanceDescriptor> setInstances;
final List<ScopeInstanceDescriptor> stepInstances;
final ScopeInstanceDescriptor? selectedExercise;
final ScopeInstanceDescriptor? selectedSet;
final ScopeInstanceDescriptor? selectedStep;
final ValueChanged<ScopeInstanceDescriptor> onExerciseChanged;
final ValueChanged<ScopeInstanceDescriptor> onSetChanged;
final ValueChanged<ScopeInstanceDescriptor> onStepChanged;
@override
Widget build(BuildContext context) {
final children = <Widget>[
_TelemetryInstanceDropdown(
label: 'Exercice',
instances: exerciseInstances,
selected: selectedExercise,
format: (instance) => 'Ex. ${instance.ordinal}',
onChanged: onExerciseChanged,
),
];
if (scope == _TelemetryScope.set || scope == _TelemetryScope.step) {
children.add(
_TelemetryInstanceDropdown(
label: 'Série',
instances: setInstances,
selected: selectedSet,
format: (instance) => 'Série ${instance.ordinal}',
onChanged: onSetChanged,
),
);
}
if (scope == _TelemetryScope.step) {
children.add(
_TelemetryInstanceDropdown(
label: 'Étape',
instances: stepInstances,
selected: selectedStep,
format: (instance) => 'Étape ${instance.ordinal}',
onChanged: onStepChanged,
),
);
}
return Wrap(spacing: 8, runSpacing: 8, children: children);
}
}
final class _TelemetryInstanceDropdown extends StatelessWidget {
const _TelemetryInstanceDropdown({
required this.label,
required this.instances,
required this.selected,
required this.format,
required this.onChanged,
});
final String label;
final List<ScopeInstanceDescriptor> instances;
final ScopeInstanceDescriptor? selected;
final String Function(ScopeInstanceDescriptor instance) format;
final ValueChanged<ScopeInstanceDescriptor> onChanged;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 150,
child: DropdownButtonFormField<int>(
key: ValueKey(
'$label:${selected?.ordinal}:'
'${instances.map((instance) => instance.ordinal).join(',')}',
),
isExpanded: true,
decoration: InputDecoration(
labelText: label,
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
initialValue: selected?.ordinal,
items: [
for (final instance in instances)
DropdownMenuItem(
value: instance.ordinal,
child: Text(format(instance), overflow: TextOverflow.ellipsis),
),
],
onChanged: instances.isEmpty
? null
: (ordinal) {
final instance = instances.firstWhere(
(entry) => entry.ordinal == ordinal,
orElse: () => instances.first,
);
onChanged(instance);
},
),
);
}
}
ScopeInstanceDescriptor? _selectedOrFirst(
List<ScopeInstanceDescriptor> instances,
int? selectedOrdinal,
) {
if (instances.isEmpty) {
return null;
}
for (final instance in instances) {
if (instance.ordinal == selectedOrdinal) {
return instance;
}
}
return instances.first;
}
List<ScopeInstanceDescriptor> _filterChildren(
List<ScopeInstanceDescriptor> instances, {
int? programIndex,
int? exerciseIndex,
int? setIndex,
}) {
if (programIndex == null || exerciseIndex == null) {
return const [];
}
return instances
.where(
(instance) =>
instance.programIndex == programIndex &&
instance.exerciseIndex == exerciseIndex &&
(setIndex == null || instance.setIndex == setIndex),
)
.toList(growable: false);
}
WorkoutTelemetryAggregateScope _aggregateScope(_TelemetryScope scope) {
return switch (scope) {
_TelemetryScope.step => WorkoutTelemetryAggregateScope.step,
_TelemetryScope.set => WorkoutTelemetryAggregateScope.set,
_TelemetryScope.exercise => WorkoutTelemetryAggregateScope.exercise,
_TelemetryScope.session => WorkoutTelemetryAggregateScope.session,
};
}
_TelemetryScope _presentationScope(WorkoutTelemetryAggregateScope scope) {
return switch (scope) {
WorkoutTelemetryAggregateScope.step => _TelemetryScope.step,
WorkoutTelemetryAggregateScope.set => _TelemetryScope.set,
WorkoutTelemetryAggregateScope.exercise => _TelemetryScope.exercise,
WorkoutTelemetryAggregateScope.session => _TelemetryScope.session,
};
}
String _scopeLabel(_TelemetryScope scope) {
return switch (scope) {
_TelemetryScope.step => 'Étape',
_TelemetryScope.set => 'Série',
_TelemetryScope.exercise => 'Exercice',
_TelemetryScope.session => 'Séance',
};
}
final class _TelemetryGraphState {
const _TelemetryGraphState({
required this.scope,
required this.points,
required this.markers,
required this.exerciseInstances,
required this.setInstances,
required this.stepInstances,
this.selectedExercise,
this.selectedSet,
this.selectedStep,
this.minHeartRateBpm,
this.averageHeartRateBpm,
this.maxHeartRateBpm,
});
factory _TelemetryGraphState.empty(
_TelemetryScope scope, {
List<ScopeInstanceDescriptor> exerciseInstances = const [],
List<ScopeInstanceDescriptor> setInstances = const [],
List<ScopeInstanceDescriptor> stepInstances = const [],
}) {
return _TelemetryGraphState(
scope: _aggregateScope(scope),
points: const [],
markers: const [],
exerciseInstances: exerciseInstances,
setInstances: setInstances,
stepInstances: stepInstances,
);
}
factory _TelemetryGraphState.fromSeries(
WorkoutTelemetryGraphSeries series, {
required List<ScopeMarker> markers,
required List<ScopeInstanceDescriptor> exerciseInstances,
required List<ScopeInstanceDescriptor> setInstances,
required List<ScopeInstanceDescriptor> stepInstances,
ScopeInstanceDescriptor? selectedExercise,
ScopeInstanceDescriptor? selectedSet,
ScopeInstanceDescriptor? selectedStep,
}) {
final heartRates = series.points
.map((point) => point.heartRateBpm)
.whereType<int>()
.toList(growable: false);
return _TelemetryGraphState(
scope: series.scope,
points: series.points,
markers: markers,
exerciseInstances: exerciseInstances,
setInstances: setInstances,
stepInstances: stepInstances,
selectedExercise: selectedExercise,
selectedSet: selectedSet,
selectedStep: selectedStep,
minHeartRateBpm: series.minHeartRateBpm,
averageHeartRateBpm: heartRates.isEmpty
? null
: heartRates.reduce((left, right) => left + right) /
heartRates.length,
maxHeartRateBpm: series.maxHeartRateBpm,
);
}
final WorkoutTelemetryAggregateScope scope;
final List<WorkoutTelemetryGraphPoint> points;
final List<ScopeMarker> markers;
final List<ScopeInstanceDescriptor> exerciseInstances;
final List<ScopeInstanceDescriptor> setInstances;
final List<ScopeInstanceDescriptor> stepInstances;
final ScopeInstanceDescriptor? selectedExercise;
final ScopeInstanceDescriptor? selectedSet;
final ScopeInstanceDescriptor? selectedStep;
final int? minHeartRateBpm;
final double? averageHeartRateBpm;
final int? maxHeartRateBpm;
List<ScopeInstanceDescriptor> get setInstancesForSelectedExercise {
return _filterChildren(
setInstances,
programIndex: selectedExercise?.programIndex,
exerciseIndex: selectedExercise?.exerciseIndex,
);
}
List<ScopeInstanceDescriptor> get stepInstancesForSelectedSet {
return _filterChildren(
stepInstances,
programIndex: selectedExercise?.programIndex,
exerciseIndex: selectedExercise?.exerciseIndex,
setIndex: selectedSet?.setIndex,
);
}
String get scopeLabel {
final label = _scopeLabel(_presentationScope(scope));
final instance = switch (scope) {
WorkoutTelemetryAggregateScope.session => null,
WorkoutTelemetryAggregateScope.exercise => selectedExercise,
WorkoutTelemetryAggregateScope.set => selectedSet,
WorkoutTelemetryAggregateScope.step => selectedStep,
};
if (instance == null) {
return label;
}
return '$label ${instance.ordinal}';
}
int get durationMs {
if (points.isEmpty) return 0;
return points.last.elapsedMs.clamp(0, double.infinity).toInt();
}
String get scopeIndicator {
final parts = <String>[];
if (points.any((point) => point.heartRateBpm != null)) parts.add('FC');
if (points.any((point) => point.distanceMeters != null)) {
parts.add('distance');
}
if (points.any((point) => point.caloriesKcal != null)) {
parts.add('calories');
}
return parts.isEmpty
? '0 point'
: '${points.length} pts · ${parts.join(' + ')}';
}
}
final class _TelemetryMetricSpec {
const _TelemetryMetricSpec({
required this.title,
required this.compactLabel,
required this.unit,
required this.read,
required this.format,
required this.axisFormat,
});
final String title;
final String compactLabel;
final String unit;
final double? Function(WorkoutTelemetryGraphPoint point) read;
final String Function(double value) format;
final String Function(double value) axisFormat;
}
_TelemetryMetricSpec _metricSpec(_TelemetryMetric metric) {
return switch (metric) {
_TelemetryMetric.heartRate => _TelemetryMetricSpec(
title: 'Fréquence cardiaque',
compactLabel: 'Moyenne',
unit: 'bpm',
read: (point) => point.heartRateBpm?.toDouble(),
format: (value) => '${value.round()} bpm',
axisFormat: (value) => value.round().toString(),
),
_TelemetryMetric.distance => _TelemetryMetricSpec(
title: 'Distance',
compactLabel: 'Distance',
unit: 'm',
read: (point) => point.distanceMeters,
format: _formatHistoryDistance,
axisFormat: (value) => value.round().toString(),
),
_TelemetryMetric.calories => _TelemetryMetricSpec(
title: 'Calories',
compactLabel: 'Calories',
unit: 'kcal',
read: (point) => point.caloriesKcal,
format: (value) => '${value.round()} kcal',
axisFormat: (value) => value.round().toString(),
),
};
}
double? _displayValueForMetric(
_TelemetryGraphState state,
_TelemetryMetricSpec metric,
List<WorkoutTelemetryGraphPoint> points,
) {
if (points.isEmpty) return null;
if (metric.unit == 'bpm' && state.averageHeartRateBpm != null) {
return state.averageHeartRateBpm;
}
return metric.read(points.last);
}
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 _HistoryWatchCompactIndicator extends StatelessWidget {
const _HistoryWatchCompactIndicator({
required this.label,
required this.value,
});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final tokens = courtBlazerTokensOf(context);
return DecoratedBox(
decoration: BoxDecoration(
border: Border.all(color: tokens.border),
borderRadius: BorderRadius.circular(6),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: tokens.mutedText),
),
Text(value, style: Theme.of(context).textTheme.labelMedium),
],
),
),
);
}
}
final class _TelemetryChartPainter extends CustomPainter {
const _TelemetryChartPainter({
required this.points,
required this.readValue,
required this.valueFormatter,
required this.unit,
required this.color,
required this.borderColor,
required this.labelColor,
required this.markers,
this.minGuide,
this.maxGuide,
});
final List<WorkoutTelemetryGraphPoint> points;
final double? Function(WorkoutTelemetryGraphPoint point) readValue;
final String Function(double value) valueFormatter;
final String unit;
final Color color;
final Color borderColor;
final Color labelColor;
final List<ScopeMarker> markers;
final double? minGuide;
final double? maxGuide;
@override
void paint(Canvas canvas, Size size) {
final values = points
.map((point) => (point: point, value: readValue(point)))
.where((entry) => entry.value != null)
.map((entry) => (point: entry.point, value: entry.value!))
.toList(growable: false);
if (values.length < 2) return;
final textPainter = TextPainter(textDirection: TextDirection.ltr);
final labelStyle = TextStyle(color: labelColor, fontSize: 10);
final chart = Rect.fromLTRB(58, 14, size.width - 8, size.height - 32);
final maxElapsed = math.max(1, values.last.point.elapsedMs);
final rawMin = values.map((entry) => entry.value).reduce(math.min);
final rawMax = values.map((entry) => entry.value).reduce(math.max);
final guideValues = [?minGuide, ?maxGuide];
final guideMin = guideValues.isEmpty
? rawMin
: guideValues.reduce(math.min);
final guideMax = guideValues.isEmpty
? rawMax
: guideValues.reduce(math.max);
final minValue = math.min(rawMin, guideMin);
final maxValue = math.max(rawMax, guideMax);
final range = math.max(1, maxValue - minValue);
Offset pointFor(WorkoutTelemetryGraphPoint point, double value) {
final xRatio = point.elapsedMs / maxElapsed;
final yRatio = (value - minValue) / range;
return Offset(
chart.left + chart.width * xRatio.clamp(0, 1),
chart.bottom - chart.height * yRatio.clamp(0, 1),
);
}
final axisPaint = Paint()
..color = borderColor
..strokeWidth = 1;
canvas.drawLine(chart.bottomLeft, chart.bottomRight, axisPaint);
canvas.drawLine(chart.bottomLeft, chart.topLeft, axisPaint);
for (final tick in [0.0, 0.5, 1.0]) {
final y = chart.bottom - chart.height * tick;
canvas.drawLine(
Offset(chart.left - 3, y),
Offset(chart.right, y),
axisPaint,
);
final value = minValue + range * tick;
_paintText(
canvas,
textPainter,
'${valueFormatter(value)} $unit',
Offset(0, y - 7),
labelStyle,
);
}
for (final tick in [0.0, 0.5, 1.0]) {
final x = chart.left + chart.width * tick;
canvas.drawLine(
Offset(x, chart.bottom),
Offset(x, chart.bottom + 3),
axisPaint,
);
_paintText(
canvas,
textPainter,
_formatAxisDuration((maxElapsed * tick).round()),
Offset(x - 14, chart.bottom + 6),
labelStyle,
);
}
_paintText(
canvas,
textPainter,
'temps m:ss',
Offset(math.max(chart.left, chart.right - 56), size.height - 14),
labelStyle,
);
_paintText(
canvas,
textPainter,
unit,
Offset(chart.left + 2, 0),
labelStyle,
);
final guidePaint = Paint()
..color = color.withAlpha(140)
..strokeWidth = 1;
for (final guide in guideValues) {
final y = pointFor(values.first.point, guide).dy;
_drawDashedLine(
canvas,
Offset(chart.left, y),
Offset(chart.right, y),
guidePaint,
);
}
if (markers.isNotEmpty) {
final markerPaint = Paint()
..color = labelColor.withAlpha(110)
..strokeWidth = 1;
for (final marker in markers) {
final xRatio = marker.elapsedMs / maxElapsed;
final x = chart.left + chart.width * xRatio.clamp(0, 1);
_drawDashedLine(
canvas,
Offset(x, chart.top),
Offset(x, chart.bottom),
markerPaint,
);
final labelOffset = marker.boundary == ScopeMarkerBoundary.start
? Offset(
(x + 2).clamp(chart.left, chart.right - 44).toDouble(),
chart.top,
)
: Offset(
(x - 42).clamp(chart.left, chart.right - 44).toDouble(),
chart.top + 12,
);
_paintText(canvas, textPainter, marker.label, labelOffset, labelStyle);
}
}
final linePaint = Paint()
..color = color
..strokeWidth = 2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
for (var index = 1; index < values.length; index += 1) {
final previous = values[index - 1];
final current = values[index];
final start = pointFor(previous.point, previous.value);
final end = pointFor(current.point, current.value);
if (current.point.startsAfterGap) {
_drawDashedLine(canvas, start, end, linePaint);
} else {
canvas.drawLine(start, end, linePaint);
}
}
}
@override
bool shouldRepaint(covariant _TelemetryChartPainter oldDelegate) {
return oldDelegate.points != points ||
oldDelegate.color != color ||
oldDelegate.borderColor != borderColor ||
oldDelegate.labelColor != labelColor ||
oldDelegate.minGuide != minGuide ||
oldDelegate.maxGuide != maxGuide ||
oldDelegate.markers != markers;
}
}
void _paintText(
Canvas canvas,
TextPainter textPainter,
String value,
Offset offset,
TextStyle style,
) {
textPainter
..text = TextSpan(text: value, style: style)
..layout(maxWidth: 72);
textPainter.paint(canvas, offset);
}
String _formatAxisDuration(int milliseconds) {
final duration = Duration(milliseconds: milliseconds);
if (duration.inHours > 0) {
return '${duration.inHours}:'
'${duration.inMinutes.remainder(60).toString().padLeft(2, '0')}';
}
return '${duration.inMinutes}:'
'${duration.inSeconds.remainder(60).toString().padLeft(2, '0')}';
}
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),
],
],
),
),
);
}
}