From 36691643d7858623155bbf42e37f3eada408acd5 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 30 Jul 2026 14:55:52 +0200 Subject: [PATCH] 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 --- lib/presentation/exercise_library_screen.dart | 90 +++++-- lib/presentation/history_screen.dart | 249 +++++++++++++++++- lib/presentation/home_screen.dart | 3 + .../workout_execution_screen.dart | 83 +++--- lib/presentation/workout_template_screen.dart | 3 + .../exercise_library_screen_test.dart | 54 +++- test/presentation/home_screen_test.dart | 13 + .../workout_execution_screen_test.dart | 11 +- 8 files changed, 447 insertions(+), 59 deletions(-) diff --git a/lib/presentation/exercise_library_screen.dart b/lib/presentation/exercise_library_screen.dart index c29eadd..c0e2102 100644 --- a/lib/presentation/exercise_library_screen.dart +++ b/lib/presentation/exercise_library_screen.dart @@ -361,7 +361,7 @@ final class ExerciseListTile extends StatelessWidget { spacing: 6, runSpacing: 6, children: [ - _CategoryBadge(category: exercise.category), + _BusinessTypeBadge(types: exercise.effectiveBusinessTypes), ...exercise.availableMeasures.map( (measure) => MeasureBadge( measure: measure, @@ -398,17 +398,19 @@ final class ExerciseListTile extends StatelessWidget { } } -final class _CategoryBadge extends StatelessWidget { - const _CategoryBadge({required this.category}); +final class _BusinessTypeBadge extends StatelessWidget { + const _BusinessTypeBadge({required this.types}); - final ExerciseCategory category; + final List types; @override Widget build(BuildContext context) { - return Chip( - visualDensity: VisualDensity.compact, - label: Text(category.label), - ); + final label = types.isEmpty + ? 'Libre / autre' + : 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 { var _hasReps = false; var _hasScore = false; var _scoreInputMode = ScoreInputMode.manual; + var _businessTypes = []; var _saving = false; var _importingImage = false; var _importingVideo = false; @@ -529,6 +532,9 @@ final class _ExerciseFormScreenState extends State { _hasReps = exercise?.hasRepsMeasure ?? false; _hasScore = exercise?.hasScoreMeasure ?? false; _scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual; + _businessTypes = List.of( + exercise?.effectiveBusinessTypes ?? const [], + ); _stepsEnabled = exercise?.steps.isNotEmpty ?? false; _autoStartNextTimedStep = exercise?.autoStartNextTimedStep ?? true; _tags = List.of(exercise?.tags ?? const []); @@ -781,6 +787,8 @@ final class _ExerciseFormScreenState extends State { ], const SizedBox(height: 24), _buildExerciseStepsSection(context), + const SizedBox(height: 16), + _buildBusinessTypesSection(context), if (_warning != null) ...[ const SizedBox(height: 16), MaterialBanner( @@ -811,6 +819,51 @@ final class _ExerciseFormScreenState extends State { ); } + Widget _buildBusinessTypesSection(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Type d’exercice', 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.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) { final reachedLimit = _stepDrafts.length >= 8; return Column( @@ -1467,6 +1520,7 @@ final class _ExerciseFormScreenState extends State { defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, autoStartNextTimedStep: _autoStartNextTimedStep, + businessTypes: _businessTypes, steps: steps, tags: _tags, ); @@ -1489,6 +1543,7 @@ final class _ExerciseFormScreenState extends State { defaultTargetScore: defaultTargetScore, defaultTargetScoreTimeMs: defaultTargetScoreTimeMs, autoStartNextTimedStep: _autoStartNextTimedStep, + businessTypes: _businessTypes, steps: steps, tags: _tags, ); @@ -2022,17 +2077,18 @@ extension on WorkoutMeasure { } } -extension on ExerciseCategory { +extension on BusinessExerciseType { String get label { return switch (this) { - ExerciseCategory.shoot => 'Tir', - ExerciseCategory.freeThrows => 'Lancers francs', - ExerciseCategory.dribble => 'Dribble', - ExerciseCategory.finishing => 'Finition', - ExerciseCategory.conditioning => 'Condition physique', - ExerciseCategory.defense => 'Défense', - ExerciseCategory.mobility => 'Mobilité', - ExerciseCategory.uncategorized => 'Non catégorisé', + BusinessExerciseType.shoot => 'Tir', + BusinessExerciseType.freeThrows => 'Lancers francs', + BusinessExerciseType.dribble => 'Dribble', + BusinessExerciseType.finishing => 'Finition', + BusinessExerciseType.conditioning => 'Condition physique', + BusinessExerciseType.defense => 'Défense', + BusinessExerciseType.mobility => 'Mobilité', + BusinessExerciseType.highIntensity => 'Haute intensité', + BusinessExerciseType.recovery => 'Récupération', }; } } diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index 5626f1b..d78c808 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -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 { 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>( + 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 []; + 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(), + ); + final calories = _scopeDelta( + scopedSamples + .map((sample) => sample.caloriesKcal) + .whereType(), + ); + 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 _samplesForTelemetryScope( + List 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 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 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, diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index f9a50c8..bf7e7ad 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -162,6 +162,7 @@ final class _HomeScreenState extends State with RouteAware { activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, stepUseCases: widget.bootstrap.activeExerciseStepUseCases, sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases, + telemetryUseCases: widget.bootstrap.workoutTelemetryUseCases, performanceReferenceUseCase: widget.bootstrap.exercisePerformanceReferenceUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, @@ -188,6 +189,7 @@ final class _HomeScreenState extends State with RouteAware { activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, stepUseCases: widget.bootstrap.activeExerciseStepUseCases, sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases, + telemetryUseCases: widget.bootstrap.workoutTelemetryUseCases, performanceReferenceUseCase: widget.bootstrap.exercisePerformanceReferenceUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, @@ -273,6 +275,7 @@ final class _HomeScreenState extends State with RouteAware { activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, stepUseCases: widget.bootstrap.activeExerciseStepUseCases, sensorUseCases: widget.bootstrap.activeWorkoutSensorUseCases, + telemetryUseCases: widget.bootstrap.workoutTelemetryUseCases, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, historyUseCases: widget.bootstrap.workoutHistoryUseCases, workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases, diff --git a/lib/presentation/workout_execution_screen.dart b/lib/presentation/workout_execution_screen.dart index 94bf1d2..b421384 100644 --- a/lib/presentation/workout_execution_screen.dart +++ b/lib/presentation/workout_execution_screen.dart @@ -30,6 +30,7 @@ final class WorkoutExecutionScreen extends StatefulWidget { this.videoMediaBuilder, this.stepUseCases, this.sensorUseCases, + this.telemetryUseCases, this.stepAudioCuePlayer, this.watchAlertPublisher, super.key, @@ -46,6 +47,7 @@ final class WorkoutExecutionScreen extends StatefulWidget { final Future Function(String id)? mediaAssetLoader; final VideoMediaBuilder? videoMediaBuilder; final ActiveWorkoutSensorUseCases? sensorUseCases; + final WorkoutTelemetryUseCases? telemetryUseCases; final ExerciseStepAudioCuePlayer? stepAudioCuePlayer; final WatchAlertPublisher? watchAlertPublisher; @@ -274,11 +276,11 @@ final class _WorkoutExecutionScreenState extends State { : null, ), ), - if (_hasLiveSensorMetrics(_sensorState)) ...[ + if (_shouldShowLiveSensorBar) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), child: _LiveSensorBar( - sensorState: _sensorState!, + sensorState: _sensorState, muted: _isSensorStateStale(_sensorState), ), ), @@ -405,10 +407,10 @@ final class _WorkoutExecutionScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const Text('Repos avant la prochaine série'), - if (_hasLiveSensorMetrics(_sensorState)) ...[ + if (_shouldShowLiveSensorBar) ...[ const SizedBox(height: 12), _LiveSensorBar( - sensorState: _sensorState!, + sensorState: _sensorState, muted: _isSensorStateStale(_sensorState), ), ], @@ -485,10 +487,10 @@ final class _WorkoutExecutionScreenState extends State { crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text('Pause', style: Theme.of(context).textTheme.headlineMedium), - if (_hasLiveSensorMetrics(_sensorState)) ...[ + if (_shouldShowLiveSensorBar) ...[ const SizedBox(height: 12), _LiveSensorBar( - sensorState: _sensorState!, + sensorState: _sensorState, muted: _isSensorStateStale(_sensorState), ), ], @@ -638,6 +640,8 @@ final class _WorkoutExecutionScreenState extends State { return hasGlobalTimer && !_hasSetExecutionStarted; } + bool get _shouldShowLiveSensorBar => widget.sensorUseCases != null; + bool get _usesSetTimerForStopwatchScore { return _exercise.timeEnabled && _exercise.stopwatchScoreEnabled; } @@ -1837,6 +1841,7 @@ final class _WorkoutExecutionScreenState extends State { workoutTemplateUseCases: widget.workoutTemplateUseCases, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, + telemetryUseCases: widget.telemetryUseCases, closeUseCase: widget.closeUseCase, ), ), @@ -1894,6 +1899,7 @@ final class _WorkoutExecutionScreenState extends State { initialSession: session, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, + telemetryUseCases: widget.telemetryUseCases, closeUseCase: widget.closeUseCase, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, @@ -2306,7 +2312,7 @@ final class _ExecutionAppBarTitle extends StatelessWidget { final class _LiveSensorBar extends StatelessWidget { const _LiveSensorBar({required this.sensorState, required this.muted}); - final ActiveWorkoutSensorState sensorState; + final ActiveWorkoutSensorState? sensorState; final bool muted; @override @@ -2318,24 +2324,21 @@ final class _LiveSensorBar extends StatelessWidget { runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [ - if (sensorState.latestHeartRateBpm case final heartRate?) - _LiveSensorPill( - icon: Icons.favorite, - label: 'FC $heartRate bpm', - muted: false, - ), - if (_distanceLabel(sensorState) case final distanceLabel?) - _LiveSensorPill( - icon: Icons.directions_run, - label: distanceLabel, - muted: false, - ), - if (_caloriesLabel(sensorState) case final caloriesLabel?) - _LiveSensorPill( - icon: Icons.local_fire_department, - label: caloriesLabel, - muted: false, - ), + _LiveSensorPill( + icon: Icons.favorite, + label: _heartRateMetricLabel(sensorState), + muted: sensorState?.latestHeartRateBpm == null, + ), + _LiveSensorPill( + icon: Icons.directions_run, + label: _distanceMetricLabel(sensorState), + muted: _distanceLabel(sensorState) == null, + ), + _LiveSensorPill( + icon: Icons.local_fire_department, + label: _caloriesMetricLabel(sensorState), + muted: _caloriesLabel(sensorState) == null, + ), ], ), ); @@ -5320,6 +5323,29 @@ bool _isSensorStateStale(ActiveWorkoutSensorState? state) { 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) { final meters = state?.latestDistanceMeters; if (meters == null || meters < 0) { @@ -5339,13 +5365,6 @@ String? _caloriesLabel(ActiveWorkoutSensorState? state) { return null; } -bool _hasLiveSensorMetrics(ActiveWorkoutSensorState? state) { - return state != null && - (state.latestHeartRateBpm != null || - state.latestDistanceMeters != null || - _caloriesLabel(state) != null); -} - String _formatStepCountdown(Duration duration) { final totalSeconds = (duration.inMilliseconds / 1000) .ceil() diff --git a/lib/presentation/workout_template_screen.dart b/lib/presentation/workout_template_screen.dart index 2181cc5..ec7e898 100644 --- a/lib/presentation/workout_template_screen.dart +++ b/lib/presentation/workout_template_screen.dart @@ -20,6 +20,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget { this.mediaUseCases, this.stepUseCases, this.sensorUseCases, + this.telemetryUseCases, this.performanceReferenceUseCase, this.shareUseCases, this.authUseCases, @@ -33,6 +34,7 @@ final class WorkoutTemplateListScreen extends StatefulWidget { final ActiveWorkoutSessionUseCases activeUseCases; final ActiveExerciseStepUseCases? stepUseCases; final ActiveWorkoutSensorUseCases? sensorUseCases; + final WorkoutTelemetryUseCases? telemetryUseCases; final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final CloseWorkoutSessionUseCase closeUseCase; final WorkoutHistoryUseCases historyUseCases; @@ -313,6 +315,7 @@ final class _WorkoutTemplateListScreenState performanceReferenceUseCase: widget.performanceReferenceUseCase, mediaUseCases: widget.mediaUseCases, sensorUseCases: widget.sensorUseCases, + telemetryUseCases: widget.telemetryUseCases, watchAlertPublisher: widget.watchAlertPublisher, ), ), diff --git a/test/presentation/exercise_library_screen_test.dart b/test/presentation/exercise_library_screen_test.dart index 3cc546b..c8d04c4 100644 --- a/test/presentation/exercise_library_screen_test.dart +++ b/test/presentation/exercise_library_screen_test.dart @@ -128,6 +128,54 @@ void main() { 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 d’exercice')); + 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', ( tester, ) async { @@ -414,7 +462,7 @@ void main() { await _pumpExerciseForm(tester, exerciseRepository); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs'); await tester.enterText( - find.widgetWithText(TextFormField, 'Temps par défaut (s)'), + find.widgetWithText(TextFormField, 'Durée par défaut (s)'), '30', ); await tester.tap(find.widgetWithText(SwitchListTile, 'Score')); @@ -614,7 +662,7 @@ void main() { await _pumpExerciseForm(tester, exerciseRepository); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo'); await tester.enterText( - find.widgetWithText(TextFormField, 'Temps par défaut (s)'), + find.widgetWithText(TextFormField, 'Durée par défaut (s)'), '30', ); await tester.tap( @@ -655,7 +703,7 @@ void main() { await _pumpExerciseForm(tester, exerciseRepository); await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo'); await tester.enterText( - find.widgetWithText(TextFormField, 'Temps par défaut (s)'), + find.widgetWithText(TextFormField, 'Durée par défaut (s)'), '30', ); diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 0986616..bb7cac7 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -940,6 +940,19 @@ final class _FakeWorkoutTelemetryRepository return const []; } + @override + Future> listSamplesForScope({ + required String sessionId, + required WorkoutTelemetryAggregateScope scope, + int? programIndex, + int? exerciseIndex, + int? setIndex, + int? passageIndex, + int? stepIndex, + }) async { + return const []; + } + @override Future replaceAggregatesForSession({ required String sessionId, diff --git a/test/presentation/workout_execution_screen_test.dart b/test/presentation/workout_execution_screen_test.dart index 1c4a041..c83e399 100644 --- a/test/presentation/workout_execution_screen_test.dart +++ b/test/presentation/workout_execution_screen_test.dart @@ -237,7 +237,7 @@ void main() { expect(find.text('186 kcal'), findsOneWidget); }); - testWidgets('n’affiche aucun indicateur capteur sans donnée FC', ( + testWidgets('affiche les états capteur explicites avant la première mesure', ( tester, ) async { final now = DateTime.now().toUtc(); @@ -272,10 +272,10 @@ void main() { ), ); - expect(find.textContaining('bpm'), findsNothing); - expect(find.textContaining('kcal'), findsNothing); - expect(find.byIcon(Icons.favorite), findsNothing); - expect(find.byIcon(Icons.local_fire_department), findsNothing); + expect(find.text('Mesure en attente'), findsNWidgets(3)); + expect(find.byIcon(Icons.favorite), findsOneWidget); + expect(find.byIcon(Icons.directions_run), findsOneWidget); + expect(find.byIcon(Icons.local_fire_department), findsOneWidget); }); 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.textContaining('kcal'), findsNothing); + expect(find.text('Donnée indisponible'), findsNWidgets(2)); }); testWidgets('atténue la fréquence cardiaque périmée', (tester) async {