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:
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user