- 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>
1889 lines
61 KiB
Dart
1889 lines
61 KiB
Dart
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 '
|
||
'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<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),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|