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

@ -361,7 +361,7 @@ final class ExerciseListTile extends StatelessWidget {
spacing: 6, spacing: 6,
runSpacing: 6, runSpacing: 6,
children: [ children: [
_CategoryBadge(category: exercise.category), _BusinessTypeBadge(types: exercise.effectiveBusinessTypes),
...exercise.availableMeasures.map( ...exercise.availableMeasures.map(
(measure) => MeasureBadge( (measure) => MeasureBadge(
measure: measure, measure: measure,
@ -398,17 +398,19 @@ final class ExerciseListTile extends StatelessWidget {
} }
} }
final class _CategoryBadge extends StatelessWidget { final class _BusinessTypeBadge extends StatelessWidget {
const _CategoryBadge({required this.category}); const _BusinessTypeBadge({required this.types});
final ExerciseCategory category; final List<BusinessExerciseType> types;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Chip( final label = types.isEmpty
visualDensity: VisualDensity.compact, ? 'Libre / autre'
label: Text(category.label), : types.length == 1
); ? types.single.label
: '${types.first.label} +${types.length - 1}';
return Chip(visualDensity: VisualDensity.compact, label: Text(label));
} }
} }
@ -487,6 +489,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
var _hasReps = false; var _hasReps = false;
var _hasScore = false; var _hasScore = false;
var _scoreInputMode = ScoreInputMode.manual; var _scoreInputMode = ScoreInputMode.manual;
var _businessTypes = <BusinessExerciseType>[];
var _saving = false; var _saving = false;
var _importingImage = false; var _importingImage = false;
var _importingVideo = false; var _importingVideo = false;
@ -529,6 +532,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
_hasReps = exercise?.hasRepsMeasure ?? false; _hasReps = exercise?.hasRepsMeasure ?? false;
_hasScore = exercise?.hasScoreMeasure ?? false; _hasScore = exercise?.hasScoreMeasure ?? false;
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual; _scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
_businessTypes = List<BusinessExerciseType>.of(
exercise?.effectiveBusinessTypes ?? const [],
);
_stepsEnabled = exercise?.steps.isNotEmpty ?? false; _stepsEnabled = exercise?.steps.isNotEmpty ?? false;
_autoStartNextTimedStep = exercise?.autoStartNextTimedStep ?? true; _autoStartNextTimedStep = exercise?.autoStartNextTimedStep ?? true;
_tags = List<String>.of(exercise?.tags ?? const []); _tags = List<String>.of(exercise?.tags ?? const []);
@ -781,6 +787,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
], ],
const SizedBox(height: 24), const SizedBox(height: 24),
_buildExerciseStepsSection(context), _buildExerciseStepsSection(context),
const SizedBox(height: 16),
_buildBusinessTypesSection(context),
if (_warning != null) ...[ if (_warning != null) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
MaterialBanner( MaterialBanner(
@ -811,6 +819,51 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
); );
} }
Widget _buildBusinessTypesSection(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Type dexercice', style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
FilterChip(
label: const Text('Libre / autre'),
selected: _businessTypes.isEmpty,
onSelected: (_) => setState(() => _businessTypes = []),
),
const SizedBox(width: 8),
for (final type in BusinessExerciseType.values) ...[
FilterChip(
label: Text(type.label),
selected: _businessTypes.contains(type),
onSelected: (selected) {
setState(() {
final next = List<BusinessExerciseType>.of(
_businessTypes,
);
if (selected) {
if (!next.contains(type)) {
next.add(type);
}
} else {
next.remove(type);
}
_businessTypes = next;
});
},
),
const SizedBox(width: 8),
],
],
),
),
],
);
}
Widget _buildExerciseStepsSection(BuildContext context) { Widget _buildExerciseStepsSection(BuildContext context) {
final reachedLimit = _stepDrafts.length >= 8; final reachedLimit = _stepDrafts.length >= 8;
return Column( return Column(
@ -1467,6 +1520,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetScore: defaultTargetScore, defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: _autoStartNextTimedStep, autoStartNextTimedStep: _autoStartNextTimedStep,
businessTypes: _businessTypes,
steps: steps, steps: steps,
tags: _tags, tags: _tags,
); );
@ -1489,6 +1543,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
defaultTargetScore: defaultTargetScore, defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: _autoStartNextTimedStep, autoStartNextTimedStep: _autoStartNextTimedStep,
businessTypes: _businessTypes,
steps: steps, steps: steps,
tags: _tags, tags: _tags,
); );
@ -2022,17 +2077,18 @@ extension on WorkoutMeasure {
} }
} }
extension on ExerciseCategory { extension on BusinessExerciseType {
String get label { String get label {
return switch (this) { return switch (this) {
ExerciseCategory.shoot => 'Tir', BusinessExerciseType.shoot => 'Tir',
ExerciseCategory.freeThrows => 'Lancers francs', BusinessExerciseType.freeThrows => 'Lancers francs',
ExerciseCategory.dribble => 'Dribble', BusinessExerciseType.dribble => 'Dribble',
ExerciseCategory.finishing => 'Finition', BusinessExerciseType.finishing => 'Finition',
ExerciseCategory.conditioning => 'Condition physique', BusinessExerciseType.conditioning => 'Condition physique',
ExerciseCategory.defense => 'Défense', BusinessExerciseType.defense => 'Défense',
ExerciseCategory.mobility => 'Mobilité', BusinessExerciseType.mobility => 'Mobilité',
ExerciseCategory.uncategorized => 'Non catégorisé', BusinessExerciseType.highIntensity => 'Haute intensité',
BusinessExerciseType.recovery => 'Récupération',
}; };
} }
} }

View File

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

View File

@ -162,6 +162,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases, stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases, sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
telemetryUseCases: widget.bootstrap.workoutTelemetryUseCases,
performanceReferenceUseCase: performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase, widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
@ -188,6 +189,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases, stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases, sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
telemetryUseCases: widget.bootstrap.workoutTelemetryUseCases,
performanceReferenceUseCase: performanceReferenceUseCase:
widget.bootstrap.exercisePerformanceReferenceUseCase, widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
@ -273,6 +275,7 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases,
stepUseCases: widget.bootstrap.activeExerciseStepUseCases, stepUseCases: widget.bootstrap.activeExerciseStepUseCases,
sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases, sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases,
telemetryUseCases: widget.bootstrap.workoutTelemetryUseCases,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
historyUseCases: widget.bootstrap.workoutHistoryUseCases, historyUseCases: widget.bootstrap.workoutHistoryUseCases,
workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases, workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases,

View File

@ -30,6 +30,7 @@ final class WorkoutExecutionScreen extends StatefulWidget {
this.videoMediaBuilder, this.videoMediaBuilder,
this.stepUseCases, this.stepUseCases,
this.sensorUseCases, this.sensorUseCases,
this.telemetryUseCases,
this.stepAudioCuePlayer, this.stepAudioCuePlayer,
this.watchAlertPublisher, this.watchAlertPublisher,
super.key, super.key,
@ -46,6 +47,7 @@ final class WorkoutExecutionScreen extends StatefulWidget {
final Future<MediaAsset?> Function(String id)? mediaAssetLoader; final Future<MediaAsset?> Function(String id)? mediaAssetLoader;
final VideoMediaBuilder? videoMediaBuilder; final VideoMediaBuilder? videoMediaBuilder;
final ActiveWorkoutSensorUseCases? sensorUseCases; final ActiveWorkoutSensorUseCases? sensorUseCases;
final WorkoutTelemetryUseCases? telemetryUseCases;
final ExerciseStepAudioCuePlayer? stepAudioCuePlayer; final ExerciseStepAudioCuePlayer? stepAudioCuePlayer;
final WatchAlertPublisher? watchAlertPublisher; final WatchAlertPublisher? watchAlertPublisher;
@ -274,11 +276,11 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
: null, : null,
), ),
), ),
if (_hasLiveSensorMetrics(_sensorState)) ...[ if (_shouldShowLiveSensorBar) ...[
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: _LiveSensorBar( child: _LiveSensorBar(
sensorState: _sensorState!, sensorState: _sensorState,
muted: _isSensorStateStale(_sensorState), muted: _isSensorStateStale(_sensorState),
), ),
), ),
@ -405,10 +407,10 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const Text('Repos avant la prochaine série'), const Text('Repos avant la prochaine série'),
if (_hasLiveSensorMetrics(_sensorState)) ...[ if (_shouldShowLiveSensorBar) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_LiveSensorBar( _LiveSensorBar(
sensorState: _sensorState!, sensorState: _sensorState,
muted: _isSensorStateStale(_sensorState), muted: _isSensorStateStale(_sensorState),
), ),
], ],
@ -485,10 +487,10 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text('Pause', style: Theme.of(context).textTheme.headlineMedium), Text('Pause', style: Theme.of(context).textTheme.headlineMedium),
if (_hasLiveSensorMetrics(_sensorState)) ...[ if (_shouldShowLiveSensorBar) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
_LiveSensorBar( _LiveSensorBar(
sensorState: _sensorState!, sensorState: _sensorState,
muted: _isSensorStateStale(_sensorState), muted: _isSensorStateStale(_sensorState),
), ),
], ],
@ -638,6 +640,8 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
return hasGlobalTimer && !_hasSetExecutionStarted; return hasGlobalTimer && !_hasSetExecutionStarted;
} }
bool get _shouldShowLiveSensorBar => widget.sensorUseCases != null;
bool get _usesSetTimerForStopwatchScore { bool get _usesSetTimerForStopwatchScore {
return _exercise.timeEnabled && _exercise.stopwatchScoreEnabled; return _exercise.timeEnabled && _exercise.stopwatchScoreEnabled;
} }
@ -1837,6 +1841,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
workoutTemplateUseCases: widget.workoutTemplateUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases,
activeUseCases: widget.activeUseCases, activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases, stepUseCases: widget.stepUseCases,
telemetryUseCases: widget.telemetryUseCases,
closeUseCase: widget.closeUseCase, closeUseCase: widget.closeUseCase,
), ),
), ),
@ -1894,6 +1899,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
initialSession: session, initialSession: session,
activeUseCases: widget.activeUseCases, activeUseCases: widget.activeUseCases,
stepUseCases: widget.stepUseCases, stepUseCases: widget.stepUseCases,
telemetryUseCases: widget.telemetryUseCases,
closeUseCase: widget.closeUseCase, closeUseCase: widget.closeUseCase,
historyUseCases: widget.historyUseCases, historyUseCases: widget.historyUseCases,
workoutTemplateUseCases: widget.workoutTemplateUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases,
@ -2306,7 +2312,7 @@ final class _ExecutionAppBarTitle extends StatelessWidget {
final class _LiveSensorBar extends StatelessWidget { final class _LiveSensorBar extends StatelessWidget {
const _LiveSensorBar({required this.sensorState, required this.muted}); const _LiveSensorBar({required this.sensorState, required this.muted});
final ActiveWorkoutSensorState sensorState; final ActiveWorkoutSensorState? sensorState;
final bool muted; final bool muted;
@override @override
@ -2318,23 +2324,20 @@ final class _LiveSensorBar extends StatelessWidget {
runSpacing: 4, runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center, crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
if (sensorState.latestHeartRateBpm case final heartRate?)
_LiveSensorPill( _LiveSensorPill(
icon: Icons.favorite, icon: Icons.favorite,
label: 'FC $heartRate bpm', label: _heartRateMetricLabel(sensorState),
muted: false, muted: sensorState?.latestHeartRateBpm == null,
), ),
if (_distanceLabel(sensorState) case final distanceLabel?)
_LiveSensorPill( _LiveSensorPill(
icon: Icons.directions_run, icon: Icons.directions_run,
label: distanceLabel, label: _distanceMetricLabel(sensorState),
muted: false, muted: _distanceLabel(sensorState) == null,
), ),
if (_caloriesLabel(sensorState) case final caloriesLabel?)
_LiveSensorPill( _LiveSensorPill(
icon: Icons.local_fire_department, icon: Icons.local_fire_department,
label: caloriesLabel, label: _caloriesMetricLabel(sensorState),
muted: false, muted: _caloriesLabel(sensorState) == null,
), ),
], ],
), ),
@ -5320,6 +5323,29 @@ bool _isSensorStateStale(ActiveWorkoutSensorState? state) {
const Duration(seconds: 15); const Duration(seconds: 15);
} }
String _heartRateMetricLabel(ActiveWorkoutSensorState? state) {
final heartRate = state?.latestHeartRateBpm;
if (heartRate != null) {
return 'FC $heartRate bpm';
}
return _sensorUnavailableLabel(state);
}
String _distanceMetricLabel(ActiveWorkoutSensorState? state) {
return _distanceLabel(state) ?? _sensorUnavailableLabel(state);
}
String _caloriesMetricLabel(ActiveWorkoutSensorState? state) {
return _caloriesLabel(state) ?? _sensorUnavailableLabel(state);
}
String _sensorUnavailableLabel(ActiveWorkoutSensorState? state) {
if (state == null) {
return 'Mesure en attente';
}
return 'Donnée indisponible';
}
String? _distanceLabel(ActiveWorkoutSensorState? state) { String? _distanceLabel(ActiveWorkoutSensorState? state) {
final meters = state?.latestDistanceMeters; final meters = state?.latestDistanceMeters;
if (meters == null || meters < 0) { if (meters == null || meters < 0) {
@ -5339,13 +5365,6 @@ String? _caloriesLabel(ActiveWorkoutSensorState? state) {
return null; return null;
} }
bool _hasLiveSensorMetrics(ActiveWorkoutSensorState? state) {
return state != null &&
(state.latestHeartRateBpm != null ||
state.latestDistanceMeters != null ||
_caloriesLabel(state) != null);
}
String _formatStepCountdown(Duration duration) { String _formatStepCountdown(Duration duration) {
final totalSeconds = (duration.inMilliseconds / 1000) final totalSeconds = (duration.inMilliseconds / 1000)
.ceil() .ceil()

View File

@ -20,6 +20,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
this.mediaUseCases, this.mediaUseCases,
this.stepUseCases, this.stepUseCases,
this.sensorUseCases, this.sensorUseCases,
this.telemetryUseCases,
this.performanceReferenceUseCase, this.performanceReferenceUseCase,
this.shareUseCases, this.shareUseCases,
this.authUseCases, this.authUseCases,
@ -33,6 +34,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget {
final ActiveWorkoutSessionUseCases activeUseCases; final ActiveWorkoutSessionUseCases activeUseCases;
final ActiveExerciseStepUseCases? stepUseCases; final ActiveExerciseStepUseCases? stepUseCases;
final ActiveWorkoutSensorUseCases? sensorUseCases; final ActiveWorkoutSensorUseCases? sensorUseCases;
final WorkoutTelemetryUseCases? telemetryUseCases;
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase; final CloseWorkoutSessionUseCase closeUseCase;
final WorkoutHistoryUseCases historyUseCases; final WorkoutHistoryUseCases historyUseCases;
@ -313,6 +315,7 @@ final class _WorkoutTemplateListScreenState
performanceReferenceUseCase: widget.performanceReferenceUseCase, performanceReferenceUseCase: widget.performanceReferenceUseCase,
mediaUseCases: widget.mediaUseCases, mediaUseCases: widget.mediaUseCases,
sensorUseCases: widget.sensorUseCases, sensorUseCases: widget.sensorUseCases,
telemetryUseCases: widget.telemetryUseCases,
watchAlertPublisher: widget.watchAlertPublisher, watchAlertPublisher: widget.watchAlertPublisher,
), ),
), ),

View File

@ -128,6 +128,54 @@ void main() {
expect(exerciseRepository.saved.single.tags, ['match']); expect(exerciseRepository.saved.single.tags, ['match']);
}); });
testWidgets('choisir des types métier les sauvegarde avec exercice', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
MaterialApp(
home: ExerciseFormScreen(
exerciseUseCases: _exerciseUseCases(exerciseRepository),
mediaUseCases: _mediaUseCases(exerciseRepository),
),
),
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Départ');
await tester.ensureVisible(find.text('Type dexercice'));
await tester.dragUntilVisible(
find.widgetWithText(FilterChip, 'Dribble'),
find.byType(SingleChildScrollView).last,
const Offset(-180, 0),
);
await tester.tap(find.widgetWithText(FilterChip, 'Dribble'));
await tester.dragUntilVisible(
find.widgetWithText(FilterChip, 'Haute intensité'),
find.byType(SingleChildScrollView).last,
const Offset(-250, 0),
);
await tester.tap(find.widgetWithText(FilterChip, 'Haute intensité'));
await tester.ensureVisible(
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
expect(exerciseRepository.saved.single.businessTypes, [
BusinessExerciseType.dribble,
BusinessExerciseType.highIntensity,
]);
});
testWidgets('filtre les exercices par tag et efface tous les filtres', ( testWidgets('filtre les exercices par tag et efface tous les filtres', (
tester, tester,
) async { ) async {
@ -414,7 +462,7 @@ void main() {
await _pumpExerciseForm(tester, exerciseRepository); await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs'); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs');
await tester.enterText( await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'), find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30', '30',
); );
await tester.tap(find.widgetWithText(SwitchListTile, 'Score')); await tester.tap(find.widgetWithText(SwitchListTile, 'Score'));
@ -614,7 +662,7 @@ void main() {
await _pumpExerciseForm(tester, exerciseRepository); await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo'); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText( await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'), find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30', '30',
); );
await tester.tap( await tester.tap(
@ -655,7 +703,7 @@ void main() {
await _pumpExerciseForm(tester, exerciseRepository); await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo'); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText( await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'), find.widgetWithText(TextFormField, 'Durée par défaut (s)'),
'30', '30',
); );

View File

@ -940,6 +940,19 @@ final class _FakeWorkoutTelemetryRepository
return const []; return const [];
} }
@override
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
required String sessionId,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
}) async {
return const [];
}
@override @override
Future<void> replaceAggregatesForSession({ Future<void> replaceAggregatesForSession({
required String sessionId, required String sessionId,

View File

@ -237,7 +237,7 @@ void main() {
expect(find.text('186 kcal'), findsOneWidget); expect(find.text('186 kcal'), findsOneWidget);
}); });
testWidgets('naffiche aucun indicateur capteur sans donnée FC', ( testWidgets('affiche les états capteur explicites avant la première mesure', (
tester, tester,
) async { ) async {
final now = DateTime.now().toUtc(); final now = DateTime.now().toUtc();
@ -272,10 +272,10 @@ void main() {
), ),
); );
expect(find.textContaining('bpm'), findsNothing); expect(find.text('Mesure en attente'), findsNWidgets(3));
expect(find.textContaining('kcal'), findsNothing); expect(find.byIcon(Icons.favorite), findsOneWidget);
expect(find.byIcon(Icons.favorite), findsNothing); expect(find.byIcon(Icons.directions_run), findsOneWidget);
expect(find.byIcon(Icons.local_fire_department), findsNothing); expect(find.byIcon(Icons.local_fire_department), findsOneWidget);
}); });
testWidgets('ne fabrique pas de calories sans mesure montre native', ( testWidgets('ne fabrique pas de calories sans mesure montre native', (
@ -331,6 +331,7 @@ void main() {
expect(find.text('FC 124 bpm'), findsOneWidget); expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.textContaining('kcal'), findsNothing); expect(find.textContaining('kcal'), findsNothing);
expect(find.text('Donnée indisponible'), findsNWidgets(2));
}); });
testWidgets('atténue la fréquence cardiaque périmée', (tester) async { testWidgets('atténue la fréquence cardiaque périmée', (tester) async {