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:
@ -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<BusinessExerciseType> 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<ExerciseFormScreen> {
|
||||
var _hasReps = false;
|
||||
var _hasScore = false;
|
||||
var _scoreInputMode = ScoreInputMode.manual;
|
||||
var _businessTypes = <BusinessExerciseType>[];
|
||||
var _saving = false;
|
||||
var _importingImage = false;
|
||||
var _importingVideo = false;
|
||||
@ -529,6 +532,9 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
_hasReps = exercise?.hasRepsMeasure ?? false;
|
||||
_hasScore = exercise?.hasScoreMeasure ?? false;
|
||||
_scoreInputMode = exercise?.scoreInputMode ?? ScoreInputMode.manual;
|
||||
_businessTypes = List<BusinessExerciseType>.of(
|
||||
exercise?.effectiveBusinessTypes ?? const [],
|
||||
);
|
||||
_stepsEnabled = exercise?.steps.isNotEmpty ?? false;
|
||||
_autoStartNextTimedStep = exercise?.autoStartNextTimedStep ?? true;
|
||||
_tags = List<String>.of(exercise?.tags ?? const []);
|
||||
@ -781,6 +787,8 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
],
|
||||
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<ExerciseFormScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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<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) {
|
||||
final reachedLimit = _stepDrafts.length >= 8;
|
||||
return Column(
|
||||
@ -1467,6 +1520,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
autoStartNextTimedStep: _autoStartNextTimedStep,
|
||||
businessTypes: _businessTypes,
|
||||
steps: steps,
|
||||
tags: _tags,
|
||||
);
|
||||
@ -1489,6 +1543,7 @@ final class _ExerciseFormScreenState extends State<ExerciseFormScreen> {
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -162,6 +162,7 @@ final class _HomeScreenState extends State<HomeScreen> 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<HomeScreen> 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<HomeScreen> 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,
|
||||
|
||||
@ -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<MediaAsset?> 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<WorkoutExecutionScreen> {
|
||||
: 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<WorkoutExecutionScreen> {
|
||||
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<WorkoutExecutionScreen> {
|
||||
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<WorkoutExecutionScreen> {
|
||||
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<WorkoutExecutionScreen> {
|
||||
workoutTemplateUseCases: widget.workoutTemplateUseCases,
|
||||
activeUseCases: widget.activeUseCases,
|
||||
stepUseCases: widget.stepUseCases,
|
||||
telemetryUseCases: widget.telemetryUseCases,
|
||||
closeUseCase: widget.closeUseCase,
|
||||
),
|
||||
),
|
||||
@ -1894,6 +1899,7 @@ final class _WorkoutExecutionScreenState extends State<WorkoutExecutionScreen> {
|
||||
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()
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user