feat(ui): integre affichage stats par scope et selection des types metier d'exercice cote telephone

UI telephone pour #179 (graphes/statistiques par etape, serie, exercice, seance) et #183 (choix des types metier d'exercice, hors pont Health Services).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 14:55:52 +02:00
parent fdaa518fa9
commit 36691643d7
8 changed files with 447 additions and 59 deletions

View File

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
@ -16,6 +17,7 @@ final class HistoryListScreen extends StatefulWidget {
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.telemetryUseCases,
this.performanceReferenceUseCase,
this.onOpenProgression,
this.watchAlertPublisher,
@ -27,6 +29,7 @@ final class HistoryListScreen extends StatefulWidget {
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final WorkoutTelemetryUseCases? telemetryUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -115,6 +118,7 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases,
sensorUseCases: widget.sensorUseCases,
telemetryUseCases: widget.telemetryUseCases,
performanceReferenceUseCase: widget.performanceReferenceUseCase,
closeUseCase: widget.closeUseCase,
mediaUseCases: widget.mediaUseCases,
@ -140,6 +144,7 @@ final class HistoryDetailScreen extends StatelessWidget {
this.mediaUseCases,
this.stepUseCases,
this.sensorUseCases,
this.telemetryUseCases,
this.performanceReferenceUseCase,
this.watchAlertPublisher,
super.key,
@ -151,6 +156,7 @@ final class HistoryDetailScreen extends StatelessWidget {
final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases;
final WorkoutTelemetryUseCases? telemetryUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases;
@ -169,7 +175,10 @@ final class HistoryDetailScreen extends StatelessWidget {
Text('Durée : ${_formatDurationMs(history.totalActiveMs)}'),
if (_hasWatchStats(history)) ...[
const SizedBox(height: 16),
_HistoryWatchStatsSummary(history: history),
_HistoryWatchStatsSummary(
history: history,
telemetryUseCases: telemetryUseCases,
),
],
const SizedBox(height: 16),
for (final program in detail.programs) ...[
@ -318,9 +327,13 @@ final class HistoryDetailScreen extends StatelessWidget {
}
final class _HistoryWatchStatsSummary extends StatelessWidget {
const _HistoryWatchStatsSummary({required this.history});
const _HistoryWatchStatsSummary({
required this.history,
required this.telemetryUseCases,
});
final WorkoutHistory history;
final WorkoutTelemetryUseCases? telemetryUseCases;
@override
Widget build(BuildContext context) {
@ -335,6 +348,13 @@ final class _HistoryWatchStatsSummary extends StatelessWidget {
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 ||
@ -409,6 +429,231 @@ final class _HistoryWatchStatsSummary extends StatelessWidget {
}
}
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,