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 createState() => _HistoryListScreenState(); } final class _HistoryListScreenState extends State { late Future> _histories; @override void initState() { super.initState(); _histories = widget.historyUseCases.listActive(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Historique'), actions: [ if (widget.onOpenProgression != null) TextButton( onPressed: widget.onOpenProgression, child: const Text('Progression'), ), ], ), body: FutureBuilder>( future: _histories, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } final histories = snapshot.data ?? const []; if (histories.isEmpty) { return const _CenteredMessage( title: 'Aucune séance terminée', message: 'Les séances terminées apparaîtront ici.', ); } final groups = groupHistoriesByPeriod(histories, DateTime.now()); return ListView( children: [ for (final group in groups.entries) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 20, 16, 8), child: Text( group.key, style: Theme.of(context).textTheme.titleMedium, ), ), for (final history in group.value) CourtBlazerAccentPanel( margin: const EdgeInsets.fromLTRB(12, 0, 12, 8), child: ListTile( title: Text(history.nameSnapshot), subtitle: Text( '${_formatDateTime(history.startedAt)} · ' '${_formatDurationMs(history.totalActiveMs)} · ' '${_historySummary(history)}', ), trailing: const Icon(Icons.chevron_right), onTap: () => _openDetail(history), ), ), ], ], ); }, ), ); } Future _openDetail(WorkoutHistory history) async { final deleted = await Navigator.of(context).push( MaterialPageRoute( builder: (context) => HistoryDetailScreen( history: history, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, sensorUseCases: widget.sensorUseCases, 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 _restart(BuildContext context) async { final hasBlockingOpenSession = await _hasBlockingOpenSession(); if (!context.mounted) return; if (hasBlockingOpenSession) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Une séance est déjà en cours. Termine-la ou reprends-la avant ' 'd’en lancer une nouvelle.', ), ), ); return; } ActiveWorkoutSession session; final sourceId = history.sourceWorkoutTemplateId; try { if (sourceId != null && await workoutTemplateUseCases.findById(sourceId) != null) { session = await activeUseCases.startFromTemplate(sourceId); } else { session = await activeUseCases.startFromHistory(history); if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( "La séance originale n'existe plus. Une copie va être utilisée.", ), ), ); } } on DomainException { if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text( 'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.', ), ), ); return; } if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (context) => WorkoutExecutionScreen( initialSession: session, activeUseCases: activeUseCases, stepUseCases: stepUseCases, sensorUseCases: sensorUseCases, closeUseCase: closeUseCase, historyUseCases: historyUseCases, workoutTemplateUseCases: workoutTemplateUseCases, performanceReferenceUseCase: performanceReferenceUseCase, mediaUseCases: mediaUseCases, watchAlertPublisher: watchAlertPublisher, ), ), ); } Future _hasBlockingOpenSession() async { final openSession = await activeUseCases.findOpen(); if (openSession == null) { return false; } if (WorkoutExecutionPlan.tryFromSession(openSession) != null) { return true; } try { await activeUseCases.abandon(openSession.metadata.id); } on DomainException { // Legacy invalid sessions must not block a restart. } return false; } Future _confirmDelete(BuildContext context) async { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: const Text("Supprimer de l'historique ?"), content: const Text('Cette action est définitive.'), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(context).pop(true), child: const Text('Supprimer'), ), ], ), ); if (confirmed != true) return; await historyUseCases.delete(history.metadata.id); if (!context.mounted) return; Navigator.of(context).pop(true); } } final class _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 exerciseInstances; final List setInstances; final List stepInstances; final ScopeInstanceDescriptor? selectedExercise; final ScopeInstanceDescriptor? selectedSet; final ScopeInstanceDescriptor? selectedStep; final ValueChanged onExerciseChanged; final ValueChanged onSetChanged; final ValueChanged onStepChanged; @override Widget build(BuildContext context) { final children = [ _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 instances; final ScopeInstanceDescriptor? selected; final String Function(ScopeInstanceDescriptor instance) format; final ValueChanged onChanged; @override Widget build(BuildContext context) { return SizedBox( width: 150, child: DropdownButtonFormField( 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 instances, int? selectedOrdinal, ) { if (instances.isEmpty) { return null; } for (final instance in instances) { if (instance.ordinal == selectedOrdinal) { return instance; } } return instances.first; } List _filterChildren( List 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 exerciseInstances = const [], List setInstances = const [], List stepInstances = const [], }) { return _TelemetryGraphState( scope: _aggregateScope(scope), points: const [], markers: const [], exerciseInstances: exerciseInstances, setInstances: setInstances, stepInstances: stepInstances, ); } factory _TelemetryGraphState.fromSeries( WorkoutTelemetryGraphSeries series, { required List markers, required List exerciseInstances, required List setInstances, required List stepInstances, ScopeInstanceDescriptor? selectedExercise, ScopeInstanceDescriptor? selectedSet, ScopeInstanceDescriptor? selectedStep, }) { final heartRates = series.points .map((point) => point.heartRateBpm) .whereType() .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 points; final List markers; final List exerciseInstances; final List setInstances; final List stepInstances; final ScopeInstanceDescriptor? selectedExercise; final ScopeInstanceDescriptor? selectedSet; final ScopeInstanceDescriptor? selectedStep; final int? minHeartRateBpm; final double? averageHeartRateBpm; final int? maxHeartRateBpm; List get setInstancesForSelectedExercise { return _filterChildren( setInstances, programIndex: selectedExercise?.programIndex, exerciseIndex: selectedExercise?.exerciseIndex, ); } List 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 = []; 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 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 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 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; final resolved = jsonDecode(snapshot['resolvedTemplateSnapshotJson'] as String) as Map; final rawResults = history.results.isNotEmpty ? history.results.map(_resultFromDomain).toList() : (snapshot['results'] as List? ?? const []) .cast>(); final rawStepResults = history.stepResults.isNotEmpty ? history.stepResults.map(_stepResultFromDomain).toList() : (snapshot['stepResults'] as List? ?? const []) .cast>(); final programs = []; final rawPrograms = resolved['programs'] as List? ?? const []; for ( var programIndex = 0; programIndex < rawPrograms.length; programIndex++ ) { final rawProgram = rawPrograms[programIndex] as Map; final programSnapshot = jsonDecode(rawProgram['programSnapshotJson'] as String) as Map; final exercises = []; final rawExercises = programSnapshot['exercises'] as List? ?? const []; for ( var exerciseIndex = 0; exerciseIndex < rawExercises.length; exerciseIndex++ ) { final rawExercise = rawExercises[exerciseIndex] as Map; final exerciseResults = rawResults.where((result) { return result['programIndex'] == programIndex && result['exerciseIndex'] == exerciseIndex; }).toList(); final exerciseStepResults = rawStepResults.where((result) { return result['programIndex'] == programIndex && result['exerciseIndex'] == exerciseIndex; }).toList(); exercises.add( HistoryExerciseDetail( name: rawExercise['exerciseNameSnapshot'] as String, sets: [ for (final result in exerciseResults) HistorySetDetail.fromSnapshot( rawExercise, result, exerciseStepResults .where( (stepResult) => stepResult['setIndex'] == result['setIndex'], ) .toList(), ), ], ), ); } programs.add( HistoryProgramDetail( name: rawProgram['programNameSnapshot'] as String, exercises: exercises, ), ); } return HistoryDetailData(programs: programs); } final List programs; } final class HistoryProgramDetail { const HistoryProgramDetail({required this.name, required this.exercises}); final String name; final List exercises; } final class HistoryExerciseDetail { const HistoryExerciseDetail({required this.name, required this.sets}); final String name; final List sets; } final class HistorySetDetail { const HistorySetDetail({ required this.setIndex, required this.summary, required this.stepPassages, }); factory HistorySetDetail.fromSnapshot( Map exercise, Map result, List> stepResults, ) { final parts = []; if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) { parts.add('Temps ${_formatDurationMs(result['actualTimeMs'] as int)}'); } if (exercise['repsEnabled'] == true && result['actualReps'] != null) { parts.add('${result['actualReps']} répétitions'); } if (exercise['scoreEnabled'] == true && result['actualScore'] != null) { final unit = exercise['scoreUnitSnapshot'] as String?; final value = _formatScoreValue(result['actualScore'] as num); parts.add(unit == null ? 'Score $value' : 'Score $value $unit'); } if (exercise['scoreEnabled'] == true && _historyScoreInputMode(exercise, result) == ScoreInputMode.stopwatch && result['actualScoreTimeMs'] != null) { final value = _formatScoreStopwatch( Duration(milliseconds: result['actualScoreTimeMs'] as int), ); final target = _targetScoreTimeMsFromSnapshot(exercise, result); if (target == null) { parts.add('Temps réalisé : $value'); } else { parts.add( 'Temps réalisé : $value / objectif ' '${_formatScoreStopwatchTarget(Duration(milliseconds: target))}', ); } } return HistorySetDetail( setIndex: result['setIndex'] as int, summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '), stepPassages: _stepPassagesFromResults(stepResults), ); } final int setIndex; final String summary; final List stepPassages; } final class HistoryStepPassageDetail { const HistoryStepPassageDetail({required this.index, required this.steps}); final int index; final List steps; } final class HistoryStepResultDetail { const HistoryStepResultDetail({ required this.index, required this.name, required this.status, required this.valueLabel, this.scoreLabel, }); factory HistoryStepResultDetail.fromResult(Map result) { final status = _setResultStatusFromSnapshot(result['status']); return HistoryStepResultDetail( index: result['stepIndex'] as int, name: result['stepNameSnapshot'] as String, status: status, valueLabel: status == SetResultStatus.skipped ? null : _historyStepValueLabel(result), scoreLabel: status == SetResultStatus.skipped ? null : _historyStepScoreLabel(result), ); } final int index; final String name; final SetResultStatus status; final String? valueLabel; final String? scoreLabel; } final class _HistoryStepResultsSection extends StatelessWidget { const _HistoryStepResultsSection({required this.passages}); final List passages; @override Widget build(BuildContext context) { final tokens = courtBlazerTokensOf(context); return Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Étapes réalisées', style: Theme.of(context).textTheme.labelLarge, ), const SizedBox(height: 8), for (final passage in passages) ...[ Text( 'Passage ${passage.index + 1}', style: Theme.of(context).textTheme.labelMedium, ), const SizedBox(height: 4), for (final step in passage.steps) Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Text(_historyStepLine(step))), if (step.status == SetResultStatus.skipped) ...[ const SizedBox(width: 8), Chip( visualDensity: VisualDensity.compact, label: const Text('Passée'), side: BorderSide(color: tokens.border), ), ], ], ), ), const SizedBox(height: 8), ], ], ), ); } } Map> groupHistoriesByPeriod( List histories, DateTime now, ) { final today = DateTime(now.year, now.month, now.day); final weekStart = today.subtract(Duration(days: today.weekday - 1)); final groups = >{ "Aujourd'hui": [], 'Cette semaine': [], 'Plus ancien': [], }; for (final history in histories) { final startedDay = DateTime( history.startedAt.year, history.startedAt.month, history.startedAt.day, ); if (startedDay == today) { groups["Aujourd'hui"]!.add(history); } else if (startedDay.isAfter(weekStart) || startedDay == weekStart) { groups['Cette semaine']!.add(history); } else { groups['Plus ancien']!.add(history); } } groups.removeWhere((key, value) => value.isEmpty); return groups; } Map _resultFromDomain(WorkoutHistorySetResult result) { return { 'programIndex': result.programIndex, 'exerciseIndex': result.exerciseIndex, 'setIndex': result.setIndex, 'actualTimeMs': result.actualTimeMs, 'actualReps': result.actualReps, 'actualScore': result.actualScore, 'actualScoreTimeMs': result.actualScoreTimeMs, 'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name, 'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot, }; } Map _stepResultFromDomain(WorkoutHistoryStepResult result) { return { 'programIndex': result.programIndex, 'exerciseIndex': result.exerciseIndex, 'setIndex': result.setIndex, 'passageIndex': result.passageIndex, 'stepIndex': result.stepIndex, 'stepNameSnapshot': result.stepNameSnapshot, 'stepTypeSnapshot': result.stepTypeSnapshot.name, 'targetValueSnapshot': result.targetValueSnapshot, 'hasScoreSnapshot': result.hasScoreSnapshot, 'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name, 'scoreLabelSnapshot': result.scoreLabelSnapshot, 'scoreUnitSnapshot': result.scoreUnitSnapshot, 'actualTimeMs': result.actualTimeMs, 'actualReps': result.actualReps, 'actualScore': result.actualScore, 'actualScoreTimeMs': result.actualScoreTimeMs, 'status': result.status.name, }; } List _stepPassagesFromResults( List> results, ) { if (results.isEmpty) return const []; final byPassage = >>{}; for (final result in results) { final passageIndex = result['passageIndex'] as int; byPassage.putIfAbsent(passageIndex, () => []).add(result); } final passages = byPassage.entries.toList() ..sort((left, right) => left.key.compareTo(right.key)); return passages .map((entry) { final steps = entry.value ..sort( (left, right) => (left['stepIndex'] as int).compareTo(right['stepIndex'] as int), ); return HistoryStepPassageDetail( index: entry.key, steps: steps.map(HistoryStepResultDetail.fromResult).toList(), ); }) .toList(growable: false); } String _historyStepLine(HistoryStepResultDetail step) { final parts = ['Étape ${step.index + 1}', step.name]; if (step.valueLabel != null) { parts.add(step.valueLabel!); } if (step.scoreLabel != null) { parts.add(step.scoreLabel!); } return parts.join(' · '); } String? _historyStepValueLabel(Map result) { final type = result['stepTypeSnapshot']; if (type == ExerciseStepType.time.name && result['actualTimeMs'] != null) { return 'Temps ${_formatDurationMs(result['actualTimeMs'] as int)}'; } if (type == ExerciseStepType.reps.name && result['actualReps'] != null) { return 'Répétitions ${result['actualReps']}'; } return null; } String? _historyStepScoreLabel(Map result) { if (result['hasScoreSnapshot'] != true) return null; final mode = _scoreInputModeFromSnapshot(result['scoreInputModeSnapshot']); if (mode == ScoreInputMode.stopwatch && result['actualScoreTimeMs'] != null) { return 'Score chrono : ${_formatScoreStopwatch(Duration(milliseconds: result['actualScoreTimeMs'] as int))}'; } if (mode == ScoreInputMode.manual && result['actualScore'] != null) { final scoreLabel = result['scoreLabelSnapshot'] as String? ?? 'Score'; final score = _formatScoreValue(result['actualScore'] as num); final unit = result['scoreUnitSnapshot'] as String?; final suffix = unit == null || unit.trim().isEmpty ? '' : ' $unit'; return '$scoreLabel : $score$suffix'; } return null; } SetResultStatus _setResultStatusFromSnapshot(Object? value) { return switch (value) { 'skipped' => SetResultStatus.skipped, _ => SetResultStatus.completed, }; } String _historySummary(WorkoutHistory history) { final snapshot = jsonDecode(history.historySnapshotJson) as Map; final rawResults = history.results.isNotEmpty ? history.results.map(_resultFromDomain).toList() : (snapshot['results'] as List? ?? const []) .cast>(); final scoreCount = rawResults .where( (result) => result['actualScore'] != null || result['actualScoreTimeMs'] != null, ) .length; return '${rawResults.length} séries · $scoreCount score${scoreCount > 1 ? 's' : ''}'; } String _formatScoreValue(num value) { if (value is int || value == value.roundToDouble()) { return value.round().toString(); } return value.toString(); } String _formatDateTime(DateTime value) { final day = value.day.toString().padLeft(2, '0'); final month = value.month.toString().padLeft(2, '0'); final hour = value.hour.toString().padLeft(2, '0'); final minute = value.minute.toString().padLeft(2, '0'); return '$day/$month/${value.year} $hour:$minute'; } String _formatDurationMs(int milliseconds) { final duration = Duration(milliseconds: milliseconds); final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); final hours = duration.inHours; if (hours > 0) { return '$hours:$minutes:$seconds'; } return '$minutes:$seconds'; } String _formatScoreStopwatch(Duration duration) { final totalTenths = (duration.inMilliseconds / 100).floor(); final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0'); final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0'); final tenths = totalTenths % 10; return '$minutes:$seconds.$tenths'; } String _formatScoreStopwatchTarget(Duration duration) { if (duration.inMilliseconds % 1000 == 0) { return _formatDurationMs(duration.inMilliseconds); } return _formatScoreStopwatch(duration); } ScoreInputMode _scoreInputModeFromSnapshot(Object? value) { return switch (value) { 'stopwatch' => ScoreInputMode.stopwatch, _ => ScoreInputMode.manual, }; } int? _targetScoreTimeMsFromSnapshot( Map exercise, Map result, ) { return result['targetScoreTimeMsSnapshot'] as int? ?? (exercise['targetScoreTimeMs'] as int?); } ScoreInputMode _historyScoreInputMode( Map exercise, Map result, ) { final resultMode = result['scoreInputModeSnapshot']; if (resultMode != null) { return _scoreInputModeFromSnapshot(resultMode); } return _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']); } final class _CenteredMessage extends StatelessWidget { const _CenteredMessage({required this.title, this.message}); final String title; final String? message; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( title, textAlign: TextAlign.center, style: Theme.of(context).textTheme.titleMedium, ), if (message != null) ...[ const SizedBox(height: 8), Text(message!, textAlign: TextAlign.center), ], ], ), ), ); } }