UI telephone pour #179 (graphes/statistiques par etape, serie, exercice, seance) et #183 (choix des types metier d'exercice, hors pont Health Services). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1238 lines
40 KiB
Dart
1238 lines
40 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 (averageHeartRate != null && maxHeartRate != null) ...[
|
||
const SizedBox(height: 10),
|
||
_HistoryWatchStatsBars(
|
||
minHeartRateBpm: minHeartRate,
|
||
averageHeartRateBpm: averageHeartRate,
|
||
maxHeartRateBpm: maxHeartRate,
|
||
),
|
||
],
|
||
],
|
||
if (distance != null || calories != null) ...[
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
children: [
|
||
if (distance != null)
|
||
Expanded(
|
||
child: _HistoryWatchMetric(
|
||
label: 'Distance',
|
||
value: _formatHistoryDistance(distance),
|
||
),
|
||
),
|
||
if (distance != null && calories != null)
|
||
const SizedBox(width: 8),
|
||
if (calories != null)
|
||
Expanded(
|
||
child: _HistoryWatchMetric(
|
||
label: 'Calories',
|
||
value: '${calories.round()} kcal',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
enum _TelemetryScope { session, exercise, set, step }
|
||
|
||
final class _HistoryWatchTelemetryGraph extends StatefulWidget {
|
||
const _HistoryWatchTelemetryGraph({
|
||
required this.history,
|
||
required this.telemetryUseCases,
|
||
});
|
||
|
||
final WorkoutHistory history;
|
||
final WorkoutTelemetryUseCases telemetryUseCases;
|
||
|
||
@override
|
||
State<_HistoryWatchTelemetryGraph> createState() =>
|
||
_HistoryWatchTelemetryGraphState();
|
||
}
|
||
|
||
final class _HistoryWatchTelemetryGraphState
|
||
extends State<_HistoryWatchTelemetryGraph> {
|
||
_TelemetryScope _scope = _TelemetryScope.session;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FutureBuilder<List<WorkoutTelemetrySample>>(
|
||
future: widget.telemetryUseCases.listSamplesForHistory(widget.history),
|
||
builder: (context, snapshot) {
|
||
if (snapshot.connectionState != ConnectionState.done) {
|
||
return const SizedBox(
|
||
height: 120,
|
||
child: Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
final samples = snapshot.data ?? const <WorkoutTelemetrySample>[];
|
||
if (samples.isEmpty) {
|
||
return const _HistoryWatchMetric(
|
||
label: 'Courbe FC',
|
||
value: 'Donnée indisponible',
|
||
);
|
||
}
|
||
final scopedSamples = _samplesForTelemetryScope(samples, _scope);
|
||
final heartRatePoints = scopedSamples
|
||
.where((sample) => sample.heartRateBpm != null)
|
||
.toList();
|
||
final distance = _scopeDelta(
|
||
scopedSamples
|
||
.map((sample) => sample.distanceMeters)
|
||
.whereType<double>(),
|
||
);
|
||
final calories = _scopeDelta(
|
||
scopedSamples
|
||
.map((sample) => sample.caloriesKcal)
|
||
.whereType<double>(),
|
||
);
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
SegmentedButton<_TelemetryScope>(
|
||
showSelectedIcon: false,
|
||
segments: const [
|
||
ButtonSegment(
|
||
value: _TelemetryScope.session,
|
||
label: Text('Séance'),
|
||
),
|
||
ButtonSegment(
|
||
value: _TelemetryScope.exercise,
|
||
label: Text('Exercice'),
|
||
),
|
||
ButtonSegment(value: _TelemetryScope.set, label: Text('Série')),
|
||
ButtonSegment(
|
||
value: _TelemetryScope.step,
|
||
label: Text('Étape'),
|
||
),
|
||
],
|
||
selected: {_scope},
|
||
onSelectionChanged: (selection) =>
|
||
setState(() => _scope = selection.single),
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
height: 160,
|
||
child: heartRatePoints.length < 2
|
||
? const Center(child: Text('Mesure en attente'))
|
||
: CustomPaint(
|
||
key: const ValueKey('history-watch-stats-graph'),
|
||
painter: _HeartRateTelemetryPainter(
|
||
samples: heartRatePoints,
|
||
color: Theme.of(context).colorScheme.primary,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _HistoryWatchMetric(
|
||
label: 'Distance',
|
||
value: distance == null
|
||
? 'Donnée indisponible'
|
||
: _formatHistoryDistance(distance),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _HistoryWatchMetric(
|
||
label: 'Calories',
|
||
value: calories == null
|
||
? 'Donnée indisponible'
|
||
: '${calories.round()} kcal',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
List<WorkoutTelemetrySample> _samplesForTelemetryScope(
|
||
List<WorkoutTelemetrySample> samples,
|
||
_TelemetryScope scope,
|
||
) {
|
||
int? firstNonNull(int? Function(WorkoutTelemetrySample sample) read) {
|
||
for (final sample in samples) {
|
||
final value = read(sample);
|
||
if (value != null) return value;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
final exercise = firstNonNull((sample) => sample.exerciseIndex);
|
||
final set = firstNonNull((sample) => sample.setIndex);
|
||
final step = firstNonNull((sample) => sample.stepIndex);
|
||
return samples.where((sample) {
|
||
return switch (scope) {
|
||
_TelemetryScope.session => true,
|
||
_TelemetryScope.exercise => sample.exerciseIndex == exercise,
|
||
_TelemetryScope.set =>
|
||
sample.exerciseIndex == exercise && sample.setIndex == set,
|
||
_TelemetryScope.step =>
|
||
sample.exerciseIndex == exercise &&
|
||
sample.setIndex == set &&
|
||
sample.stepIndex == step,
|
||
};
|
||
}).toList();
|
||
}
|
||
|
||
double? _scopeDelta(Iterable<double> values) {
|
||
final list = values.toList();
|
||
if (list.isEmpty) return null;
|
||
return math.max(0, list.last - list.first);
|
||
}
|
||
|
||
final class _HeartRateTelemetryPainter extends CustomPainter {
|
||
const _HeartRateTelemetryPainter({
|
||
required this.samples,
|
||
required this.color,
|
||
});
|
||
|
||
final List<WorkoutTelemetrySample> samples;
|
||
final Color color;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final values = samples
|
||
.map((sample) => (at: sample.capturedAt, bpm: sample.heartRateBpm!))
|
||
.toList();
|
||
final minTime = values.first.at;
|
||
final maxTime = values.last.at.isAfter(minTime)
|
||
? values.last.at
|
||
: minTime.add(const Duration(seconds: 1));
|
||
final minBpm = values.map((value) => value.bpm).reduce(math.min);
|
||
final maxBpm = values.map((value) => value.bpm).reduce(math.max);
|
||
final chart = Rect.fromLTWH(0, 8, size.width, size.height - 16);
|
||
final range = math.max(1, maxBpm - minBpm);
|
||
Offset pointFor(({DateTime at, int bpm}) value) {
|
||
final xRatio =
|
||
value.at.difference(minTime).inMilliseconds /
|
||
maxTime.difference(minTime).inMilliseconds;
|
||
final yRatio = (value.bpm - minBpm) / range;
|
||
return Offset(
|
||
chart.left + chart.width * xRatio.clamp(0, 1),
|
||
chart.bottom - chart.height * yRatio.clamp(0, 1),
|
||
);
|
||
}
|
||
|
||
final paint = Paint()
|
||
..color = color
|
||
..strokeWidth = 2;
|
||
for (var index = 1; index < values.length; index += 1) {
|
||
final start = pointFor(values[index - 1]);
|
||
final end = pointFor(values[index]);
|
||
if (values[index].at.difference(values[index - 1].at) >
|
||
const Duration(minutes: 2)) {
|
||
_drawDashedLine(canvas, start, end, paint);
|
||
} else {
|
||
canvas.drawLine(start, end, paint);
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant _HeartRateTelemetryPainter oldDelegate) =>
|
||
oldDelegate.samples != samples || oldDelegate.color != color;
|
||
}
|
||
|
||
void _drawDashedLine(Canvas canvas, Offset start, Offset end, Paint paint) {
|
||
const dash = 6.0;
|
||
const gap = 4.0;
|
||
final delta = end - start;
|
||
final distance = delta.distance;
|
||
if (distance == 0) return;
|
||
final direction = delta / distance;
|
||
var travelled = 0.0;
|
||
while (travelled < distance) {
|
||
final segmentEnd = math.min(travelled + dash, distance);
|
||
canvas.drawLine(
|
||
start + direction * travelled,
|
||
start + direction * segmentEnd,
|
||
paint,
|
||
);
|
||
travelled += dash + gap;
|
||
}
|
||
}
|
||
|
||
final class _HistoryWatchStatsBars extends StatelessWidget {
|
||
const _HistoryWatchStatsBars({
|
||
required this.minHeartRateBpm,
|
||
required this.averageHeartRateBpm,
|
||
required this.maxHeartRateBpm,
|
||
});
|
||
|
||
final int? minHeartRateBpm;
|
||
final double averageHeartRateBpm;
|
||
final int maxHeartRateBpm;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final max = maxHeartRateBpm.toDouble().clamp(1, double.infinity);
|
||
final values = [
|
||
if (minHeartRateBpm != null) ('Min', minHeartRateBpm!.toDouble()),
|
||
('Moy', averageHeartRateBpm),
|
||
('Max', maxHeartRateBpm.toDouble()),
|
||
];
|
||
return Semantics(
|
||
label: 'Graphique stats montre',
|
||
key: const ValueKey('history-watch-stats-graph'),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
for (final item in values) ...[
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
SizedBox(
|
||
height: 52,
|
||
child: Align(
|
||
alignment: Alignment.bottomCenter,
|
||
child: FractionallySizedBox(
|
||
heightFactor: (item.$2 / max).clamp(0.08, 1),
|
||
widthFactor: 0.62,
|
||
child: DecoratedBox(
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFFC9A24A),
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
item.$1,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: Theme.of(context).textTheme.labelSmall,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (item != values.last) const SizedBox(width: 8),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
final class _HistoryWatchMetric extends StatelessWidget {
|
||
const _HistoryWatchMetric({required this.label, required this.value});
|
||
|
||
final String label;
|
||
final String value;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||
RichText(
|
||
text: TextSpan(
|
||
style: AppTextStyles.scoreNumber(context).copyWith(fontSize: 24),
|
||
children: [TextSpan(text: value)],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
bool _hasWatchStats(WorkoutHistory history) {
|
||
return history.minHeartRateBpm != null ||
|
||
history.averageHeartRateBpm != null ||
|
||
history.maxHeartRateBpm != null ||
|
||
history.totalDistanceMeters != null ||
|
||
history.totalCaloriesKcal != null;
|
||
}
|
||
|
||
String _formatHistoryDistance(double meters) {
|
||
if (meters >= 1000) {
|
||
return '${(meters / 1000).toStringAsFixed(2)} km';
|
||
}
|
||
return '${meters.round()} m';
|
||
}
|
||
|
||
final class HistoryDetailData {
|
||
const HistoryDetailData({required this.programs});
|
||
|
||
factory HistoryDetailData.fromHistory(WorkoutHistory history) {
|
||
final snapshot =
|
||
jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
|
||
final resolved =
|
||
jsonDecode(snapshot['resolvedTemplateSnapshotJson'] as String)
|
||
as Map<String, dynamic>;
|
||
final rawResults = history.results.isNotEmpty
|
||
? history.results.map(_resultFromDomain).toList()
|
||
: (snapshot['results'] as List<dynamic>? ?? const [])
|
||
.cast<Map<String, dynamic>>();
|
||
final rawStepResults = history.stepResults.isNotEmpty
|
||
? history.stepResults.map(_stepResultFromDomain).toList()
|
||
: (snapshot['stepResults'] as List<dynamic>? ?? const [])
|
||
.cast<Map<String, dynamic>>();
|
||
final programs = <HistoryProgramDetail>[];
|
||
final rawPrograms = resolved['programs'] as List<dynamic>? ?? const [];
|
||
for (
|
||
var programIndex = 0;
|
||
programIndex < rawPrograms.length;
|
||
programIndex++
|
||
) {
|
||
final rawProgram = rawPrograms[programIndex] as Map<String, dynamic>;
|
||
final programSnapshot =
|
||
jsonDecode(rawProgram['programSnapshotJson'] as String)
|
||
as Map<String, dynamic>;
|
||
final exercises = <HistoryExerciseDetail>[];
|
||
final rawExercises =
|
||
programSnapshot['exercises'] as List<dynamic>? ?? const [];
|
||
for (
|
||
var exerciseIndex = 0;
|
||
exerciseIndex < rawExercises.length;
|
||
exerciseIndex++
|
||
) {
|
||
final rawExercise = rawExercises[exerciseIndex] as Map<String, dynamic>;
|
||
final exerciseResults = rawResults.where((result) {
|
||
return result['programIndex'] == programIndex &&
|
||
result['exerciseIndex'] == exerciseIndex;
|
||
}).toList();
|
||
final exerciseStepResults = rawStepResults.where((result) {
|
||
return result['programIndex'] == programIndex &&
|
||
result['exerciseIndex'] == exerciseIndex;
|
||
}).toList();
|
||
exercises.add(
|
||
HistoryExerciseDetail(
|
||
name: rawExercise['exerciseNameSnapshot'] as String,
|
||
sets: [
|
||
for (final result in exerciseResults)
|
||
HistorySetDetail.fromSnapshot(
|
||
rawExercise,
|
||
result,
|
||
exerciseStepResults
|
||
.where(
|
||
(stepResult) =>
|
||
stepResult['setIndex'] == result['setIndex'],
|
||
)
|
||
.toList(),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
programs.add(
|
||
HistoryProgramDetail(
|
||
name: rawProgram['programNameSnapshot'] as String,
|
||
exercises: exercises,
|
||
),
|
||
);
|
||
}
|
||
return HistoryDetailData(programs: programs);
|
||
}
|
||
|
||
final List<HistoryProgramDetail> programs;
|
||
}
|
||
|
||
final class HistoryProgramDetail {
|
||
const HistoryProgramDetail({required this.name, required this.exercises});
|
||
|
||
final String name;
|
||
final List<HistoryExerciseDetail> exercises;
|
||
}
|
||
|
||
final class HistoryExerciseDetail {
|
||
const HistoryExerciseDetail({required this.name, required this.sets});
|
||
|
||
final String name;
|
||
final List<HistorySetDetail> sets;
|
||
}
|
||
|
||
final class HistorySetDetail {
|
||
const HistorySetDetail({
|
||
required this.setIndex,
|
||
required this.summary,
|
||
required this.stepPassages,
|
||
});
|
||
|
||
factory HistorySetDetail.fromSnapshot(
|
||
Map<String, dynamic> exercise,
|
||
Map<String, dynamic> result,
|
||
List<Map<String, dynamic>> stepResults,
|
||
) {
|
||
final parts = <String>[];
|
||
if (exercise['timeEnabled'] == true && result['actualTimeMs'] != null) {
|
||
parts.add('Temps ${_formatDurationMs(result['actualTimeMs'] as int)}');
|
||
}
|
||
if (exercise['repsEnabled'] == true && result['actualReps'] != null) {
|
||
parts.add('${result['actualReps']} répétitions');
|
||
}
|
||
if (exercise['scoreEnabled'] == true && result['actualScore'] != null) {
|
||
final unit = exercise['scoreUnitSnapshot'] as String?;
|
||
final value = _formatScoreValue(result['actualScore'] as num);
|
||
parts.add(unit == null ? 'Score $value' : 'Score $value $unit');
|
||
}
|
||
if (exercise['scoreEnabled'] == true &&
|
||
_historyScoreInputMode(exercise, result) == ScoreInputMode.stopwatch &&
|
||
result['actualScoreTimeMs'] != null) {
|
||
final value = _formatScoreStopwatch(
|
||
Duration(milliseconds: result['actualScoreTimeMs'] as int),
|
||
);
|
||
final target = _targetScoreTimeMsFromSnapshot(exercise, result);
|
||
if (target == null) {
|
||
parts.add('Temps réalisé : $value');
|
||
} else {
|
||
parts.add(
|
||
'Temps réalisé : $value / objectif '
|
||
'${_formatScoreStopwatchTarget(Duration(milliseconds: target))}',
|
||
);
|
||
}
|
||
}
|
||
return HistorySetDetail(
|
||
setIndex: result['setIndex'] as int,
|
||
summary: parts.isEmpty ? 'Aucune mesure saisie' : parts.join(' · '),
|
||
stepPassages: _stepPassagesFromResults(stepResults),
|
||
);
|
||
}
|
||
|
||
final int setIndex;
|
||
final String summary;
|
||
final List<HistoryStepPassageDetail> stepPassages;
|
||
}
|
||
|
||
final class HistoryStepPassageDetail {
|
||
const HistoryStepPassageDetail({required this.index, required this.steps});
|
||
|
||
final int index;
|
||
final List<HistoryStepResultDetail> steps;
|
||
}
|
||
|
||
final class HistoryStepResultDetail {
|
||
const HistoryStepResultDetail({
|
||
required this.index,
|
||
required this.name,
|
||
required this.status,
|
||
required this.valueLabel,
|
||
this.scoreLabel,
|
||
});
|
||
|
||
factory HistoryStepResultDetail.fromResult(Map<String, dynamic> result) {
|
||
final status = _setResultStatusFromSnapshot(result['status']);
|
||
return HistoryStepResultDetail(
|
||
index: result['stepIndex'] as int,
|
||
name: result['stepNameSnapshot'] as String,
|
||
status: status,
|
||
valueLabel: status == SetResultStatus.skipped
|
||
? null
|
||
: _historyStepValueLabel(result),
|
||
scoreLabel: status == SetResultStatus.skipped
|
||
? null
|
||
: _historyStepScoreLabel(result),
|
||
);
|
||
}
|
||
|
||
final int index;
|
||
final String name;
|
||
final SetResultStatus status;
|
||
final String? valueLabel;
|
||
final String? scoreLabel;
|
||
}
|
||
|
||
final class _HistoryStepResultsSection extends StatelessWidget {
|
||
const _HistoryStepResultsSection({required this.passages});
|
||
|
||
final List<HistoryStepPassageDetail> passages;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final tokens = courtBlazerTokensOf(context);
|
||
return Padding(
|
||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'Étapes réalisées',
|
||
style: Theme.of(context).textTheme.labelLarge,
|
||
),
|
||
const SizedBox(height: 8),
|
||
for (final passage in passages) ...[
|
||
Text(
|
||
'Passage ${passage.index + 1}',
|
||
style: Theme.of(context).textTheme.labelMedium,
|
||
),
|
||
const SizedBox(height: 4),
|
||
for (final step in passage.steps)
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(child: Text(_historyStepLine(step))),
|
||
if (step.status == SetResultStatus.skipped) ...[
|
||
const SizedBox(width: 8),
|
||
Chip(
|
||
visualDensity: VisualDensity.compact,
|
||
label: const Text('Passée'),
|
||
side: BorderSide(color: tokens.border),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
Map<String, List<WorkoutHistory>> groupHistoriesByPeriod(
|
||
List<WorkoutHistory> histories,
|
||
DateTime now,
|
||
) {
|
||
final today = DateTime(now.year, now.month, now.day);
|
||
final weekStart = today.subtract(Duration(days: today.weekday - 1));
|
||
final groups = <String, List<WorkoutHistory>>{
|
||
"Aujourd'hui": [],
|
||
'Cette semaine': [],
|
||
'Plus ancien': [],
|
||
};
|
||
for (final history in histories) {
|
||
final startedDay = DateTime(
|
||
history.startedAt.year,
|
||
history.startedAt.month,
|
||
history.startedAt.day,
|
||
);
|
||
if (startedDay == today) {
|
||
groups["Aujourd'hui"]!.add(history);
|
||
} else if (startedDay.isAfter(weekStart) || startedDay == weekStart) {
|
||
groups['Cette semaine']!.add(history);
|
||
} else {
|
||
groups['Plus ancien']!.add(history);
|
||
}
|
||
}
|
||
groups.removeWhere((key, value) => value.isEmpty);
|
||
return groups;
|
||
}
|
||
|
||
Map<String, dynamic> _resultFromDomain(WorkoutHistorySetResult result) {
|
||
return {
|
||
'programIndex': result.programIndex,
|
||
'exerciseIndex': result.exerciseIndex,
|
||
'setIndex': result.setIndex,
|
||
'actualTimeMs': result.actualTimeMs,
|
||
'actualReps': result.actualReps,
|
||
'actualScore': result.actualScore,
|
||
'actualScoreTimeMs': result.actualScoreTimeMs,
|
||
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
|
||
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
|
||
};
|
||
}
|
||
|
||
Map<String, dynamic> _stepResultFromDomain(WorkoutHistoryStepResult result) {
|
||
return {
|
||
'programIndex': result.programIndex,
|
||
'exerciseIndex': result.exerciseIndex,
|
||
'setIndex': result.setIndex,
|
||
'passageIndex': result.passageIndex,
|
||
'stepIndex': result.stepIndex,
|
||
'stepNameSnapshot': result.stepNameSnapshot,
|
||
'stepTypeSnapshot': result.stepTypeSnapshot.name,
|
||
'targetValueSnapshot': result.targetValueSnapshot,
|
||
'hasScoreSnapshot': result.hasScoreSnapshot,
|
||
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
|
||
'scoreLabelSnapshot': result.scoreLabelSnapshot,
|
||
'scoreUnitSnapshot': result.scoreUnitSnapshot,
|
||
'actualTimeMs': result.actualTimeMs,
|
||
'actualReps': result.actualReps,
|
||
'actualScore': result.actualScore,
|
||
'actualScoreTimeMs': result.actualScoreTimeMs,
|
||
'status': result.status.name,
|
||
};
|
||
}
|
||
|
||
List<HistoryStepPassageDetail> _stepPassagesFromResults(
|
||
List<Map<String, dynamic>> results,
|
||
) {
|
||
if (results.isEmpty) return const [];
|
||
final byPassage = <int, List<Map<String, dynamic>>>{};
|
||
for (final result in results) {
|
||
final passageIndex = result['passageIndex'] as int;
|
||
byPassage.putIfAbsent(passageIndex, () => []).add(result);
|
||
}
|
||
final passages = byPassage.entries.toList()
|
||
..sort((left, right) => left.key.compareTo(right.key));
|
||
return passages
|
||
.map((entry) {
|
||
final steps = entry.value
|
||
..sort(
|
||
(left, right) =>
|
||
(left['stepIndex'] as int).compareTo(right['stepIndex'] as int),
|
||
);
|
||
return HistoryStepPassageDetail(
|
||
index: entry.key,
|
||
steps: steps.map(HistoryStepResultDetail.fromResult).toList(),
|
||
);
|
||
})
|
||
.toList(growable: false);
|
||
}
|
||
|
||
String _historyStepLine(HistoryStepResultDetail step) {
|
||
final parts = <String>['Étape ${step.index + 1}', step.name];
|
||
if (step.valueLabel != null) {
|
||
parts.add(step.valueLabel!);
|
||
}
|
||
if (step.scoreLabel != null) {
|
||
parts.add(step.scoreLabel!);
|
||
}
|
||
return parts.join(' · ');
|
||
}
|
||
|
||
String? _historyStepValueLabel(Map<String, dynamic> result) {
|
||
final type = result['stepTypeSnapshot'];
|
||
if (type == ExerciseStepType.time.name && result['actualTimeMs'] != null) {
|
||
return 'Temps ${_formatDurationMs(result['actualTimeMs'] as int)}';
|
||
}
|
||
if (type == ExerciseStepType.reps.name && result['actualReps'] != null) {
|
||
return 'Répétitions ${result['actualReps']}';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
String? _historyStepScoreLabel(Map<String, dynamic> result) {
|
||
if (result['hasScoreSnapshot'] != true) return null;
|
||
final mode = _scoreInputModeFromSnapshot(result['scoreInputModeSnapshot']);
|
||
if (mode == ScoreInputMode.stopwatch && result['actualScoreTimeMs'] != null) {
|
||
return 'Score chrono : ${_formatScoreStopwatch(Duration(milliseconds: result['actualScoreTimeMs'] as int))}';
|
||
}
|
||
if (mode == ScoreInputMode.manual && result['actualScore'] != null) {
|
||
final scoreLabel = result['scoreLabelSnapshot'] as String? ?? 'Score';
|
||
final score = _formatScoreValue(result['actualScore'] as num);
|
||
final unit = result['scoreUnitSnapshot'] as String?;
|
||
final suffix = unit == null || unit.trim().isEmpty ? '' : ' $unit';
|
||
return '$scoreLabel : $score$suffix';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
SetResultStatus _setResultStatusFromSnapshot(Object? value) {
|
||
return switch (value) {
|
||
'skipped' => SetResultStatus.skipped,
|
||
_ => SetResultStatus.completed,
|
||
};
|
||
}
|
||
|
||
String _historySummary(WorkoutHistory history) {
|
||
final snapshot =
|
||
jsonDecode(history.historySnapshotJson) as Map<String, dynamic>;
|
||
final rawResults = history.results.isNotEmpty
|
||
? history.results.map(_resultFromDomain).toList()
|
||
: (snapshot['results'] as List<dynamic>? ?? const [])
|
||
.cast<Map<String, dynamic>>();
|
||
final scoreCount = rawResults
|
||
.where(
|
||
(result) =>
|
||
result['actualScore'] != null ||
|
||
result['actualScoreTimeMs'] != null,
|
||
)
|
||
.length;
|
||
return '${rawResults.length} séries · $scoreCount score${scoreCount > 1 ? 's' : ''}';
|
||
}
|
||
|
||
String _formatScoreValue(num value) {
|
||
if (value is int || value == value.roundToDouble()) {
|
||
return value.round().toString();
|
||
}
|
||
return value.toString();
|
||
}
|
||
|
||
String _formatDateTime(DateTime value) {
|
||
final day = value.day.toString().padLeft(2, '0');
|
||
final month = value.month.toString().padLeft(2, '0');
|
||
final hour = value.hour.toString().padLeft(2, '0');
|
||
final minute = value.minute.toString().padLeft(2, '0');
|
||
return '$day/$month/${value.year} $hour:$minute';
|
||
}
|
||
|
||
String _formatDurationMs(int milliseconds) {
|
||
final duration = Duration(milliseconds: milliseconds);
|
||
final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
|
||
final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
|
||
final hours = duration.inHours;
|
||
if (hours > 0) {
|
||
return '$hours:$minutes:$seconds';
|
||
}
|
||
return '$minutes:$seconds';
|
||
}
|
||
|
||
String _formatScoreStopwatch(Duration duration) {
|
||
final totalTenths = (duration.inMilliseconds / 100).floor();
|
||
final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0');
|
||
final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0');
|
||
final tenths = totalTenths % 10;
|
||
return '$minutes:$seconds.$tenths';
|
||
}
|
||
|
||
String _formatScoreStopwatchTarget(Duration duration) {
|
||
if (duration.inMilliseconds % 1000 == 0) {
|
||
return _formatDurationMs(duration.inMilliseconds);
|
||
}
|
||
return _formatScoreStopwatch(duration);
|
||
}
|
||
|
||
ScoreInputMode _scoreInputModeFromSnapshot(Object? value) {
|
||
return switch (value) {
|
||
'stopwatch' => ScoreInputMode.stopwatch,
|
||
_ => ScoreInputMode.manual,
|
||
};
|
||
}
|
||
|
||
int? _targetScoreTimeMsFromSnapshot(
|
||
Map<String, dynamic> exercise,
|
||
Map<String, dynamic> result,
|
||
) {
|
||
return result['targetScoreTimeMsSnapshot'] as int? ??
|
||
(exercise['targetScoreTimeMs'] as int?);
|
||
}
|
||
|
||
ScoreInputMode _historyScoreInputMode(
|
||
Map<String, dynamic> exercise,
|
||
Map<String, dynamic> result,
|
||
) {
|
||
final resultMode = result['scoreInputModeSnapshot'];
|
||
if (resultMode != null) {
|
||
return _scoreInputModeFromSnapshot(resultMode);
|
||
}
|
||
return _scoreInputModeFromSnapshot(exercise['scoreInputModeSnapshot']);
|
||
}
|
||
|
||
final class _CenteredMessage extends StatelessWidget {
|
||
const _CenteredMessage({required this.title, this.message});
|
||
|
||
final String title;
|
||
final String? message;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
title,
|
||
textAlign: TextAlign.center,
|
||
style: Theme.of(context).textTheme.titleMedium,
|
||
),
|
||
if (message != null) ...[
|
||
const SizedBox(height: 8),
|
||
Text(message!, textAlign: TextAlign.center),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|