import 'dart:math' as math; import 'package:flutter/material.dart'; import '../application/application.dart'; import 'history_screen.dart'; import 'theme.dart'; final class ProgressionScreen extends StatefulWidget { const ProgressionScreen({ required this.progressionStatsUseCase, required this.historyUseCases, required this.workoutTemplateUseCases, required this.activeUseCases, required this.closeUseCase, this.mediaUseCases, this.stepUseCases, this.performanceReferenceUseCase, this.watchAlertPublisher, super.key, }); final ProgressionStatsUseCase progressionStatsUseCase; final WorkoutHistoryUseCases historyUseCases; final WorkoutTemplateUseCases workoutTemplateUseCases; final ActiveWorkoutSessionUseCases activeUseCases; final ActiveExerciseStepUseCases? stepUseCases; final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final CloseWorkoutSessionUseCase closeUseCase; final MediaUseCases? mediaUseCases; final WatchAlertPublisher? watchAlertPublisher; @override State createState() => _ProgressionScreenState(); } final class _ProgressionScreenState extends State { var _period = ProgressionPeriod.fourWeeks; late Future _overview; String? _selectedExerciseKey; ProgressionMeasure? _selectedMeasure; Future? _series; @override void initState() { super.initState(); _overview = _loadOverview(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('Progression')), body: FutureBuilder( future: _overview, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return _CenteredProgressionMessage( title: 'Progression indisponible', message: snapshot.error.toString(), ); } final overview = snapshot.data!; return ListView( padding: const EdgeInsets.all(16), children: [ _PeriodSelector( period: _period, onChanged: (period) => _changePeriod(period), ), const SizedBox(height: 16), if (!overview.hasAnyCompletedHistory) _GlobalEmptyState(onOpenHistory: _openHistoryList) else ...[ _VolumeSection(overview: overview), const SizedBox(height: 12), _RegularitySection(overview: overview), const SizedBox(height: 12), _ExerciseProgressionSection( overview: overview, selectedExerciseKey: _selectedExerciseKey, selectedMeasure: _selectedMeasure, series: _series, onExerciseChanged: (exercise) => _selectExercise(overview, exercise), onMeasureChanged: (measure) => _selectMeasure(overview, measure), onPointTap: _showPointDetail, ), ], ], ); }, ), ); } Future _loadOverview() async { final overview = await widget.progressionStatsUseCase.getOverview(_period); _syncSelection(overview); return overview; } void _changePeriod(ProgressionPeriod period) { if (period == _period) return; setState(() { _period = period; _selectedExerciseKey = null; _selectedMeasure = null; _series = null; _overview = _loadOverview(); }); } void _syncSelection(ProgressionOverview overview) { if (overview.exerciseOptions.isEmpty) { _selectedExerciseKey = null; _selectedMeasure = null; _series = null; return; } final exercise = overview.exerciseOptions.firstWhere( (option) => option.exerciseKey == _selectedExerciseKey, orElse: () => overview.exerciseOptions.first, ); _selectedExerciseKey = exercise.exerciseKey; _selectedMeasure = _resolveMeasure(exercise, _selectedMeasure); final measure = _selectedMeasure; if (measure == null) { _series = null; return; } _series = widget.progressionStatsUseCase.getExerciseSeries( period: _period, exerciseKey: exercise.exerciseKey, measure: measure, ); } void _selectExercise( ProgressionOverview overview, ProgressionExerciseOption exercise, ) { setState(() { _selectedExerciseKey = exercise.exerciseKey; _selectedMeasure = _resolveMeasure(exercise, null); final measure = _selectedMeasure; _series = measure == null ? null : widget.progressionStatsUseCase.getExerciseSeries( period: _period, exerciseKey: exercise.exerciseKey, measure: measure, ); }); } void _selectMeasure( ProgressionOverview overview, ProgressionMeasure measure, ) { final exercise = _selectedExercise(overview); if (exercise == null) return; setState(() { _selectedMeasure = measure; _series = widget.progressionStatsUseCase.getExerciseSeries( period: _period, exerciseKey: exercise.exerciseKey, measure: measure, ); }); } ProgressionMeasure? _resolveMeasure( ProgressionExerciseOption exercise, ProgressionMeasure? preferred, ) { final options = exercise.measures; if (options.isEmpty) return null; if (preferred != null && options.any((option) => option.measure == preferred)) { return preferred; } return options.first.measure; } ProgressionExerciseOption? _selectedExercise(ProgressionOverview overview) { final selectedKey = _selectedExerciseKey; if (selectedKey == null) return null; for (final option in overview.exerciseOptions) { if (option.exerciseKey == selectedKey) { return option; } } return null; } Future _showPointDetail(ProgressionExerciseSeries series, int index) { final point = series.points[index]; return showModalBottomSheet( context: context, showDragHandle: true, builder: (context) { return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _formatDate(point.startedAt), style: Theme.of(context).textTheme.titleMedium, ), const SizedBox(height: 8), Text('Séance : ${point.workoutNameSnapshot}'), const SizedBox(height: 8), Text(_pointDetailLabel(series, point)), const SizedBox(height: 16), Align( alignment: Alignment.centerRight, child: TextButton( onPressed: () => _openHistoryDetailFromPoint( context, point.workoutHistoryId, ), child: const Text('Voir la séance'), ), ), ], ), ), ); }, ); } Future _openHistoryList() { return Navigator.of(context).push( MaterialPageRoute( builder: (context) => HistoryListScreen( historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, performanceReferenceUseCase: widget.performanceReferenceUseCase, closeUseCase: widget.closeUseCase, mediaUseCases: widget.mediaUseCases, watchAlertPublisher: widget.watchAlertPublisher, ), ), ); } Future _openHistoryDetailFromPoint( BuildContext bottomSheetContext, String workoutHistoryId, ) async { final navigator = Navigator.of(context); Navigator.of(bottomSheetContext).pop(); final history = await widget.historyUseCases.findById(workoutHistoryId); if (history == null) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text("Cette séance n'est plus disponible.")), ); return; } if (!mounted) return; await navigator.push( MaterialPageRoute( builder: (context) => HistoryDetailScreen( history: history, historyUseCases: widget.historyUseCases, workoutTemplateUseCases: widget.workoutTemplateUseCases, activeUseCases: widget.activeUseCases, stepUseCases: widget.stepUseCases, performanceReferenceUseCase: widget.performanceReferenceUseCase, closeUseCase: widget.closeUseCase, mediaUseCases: widget.mediaUseCases, watchAlertPublisher: widget.watchAlertPublisher, ), ), ); } } final class _PeriodSelector extends StatelessWidget { const _PeriodSelector({required this.period, required this.onChanged}); final ProgressionPeriod period; final ValueChanged onChanged; @override Widget build(BuildContext context) { return SegmentedButton( segments: const [ ButtonSegment( value: ProgressionPeriod.fourWeeks, label: Text('4 semaines'), ), ButtonSegment( value: ProgressionPeriod.threeMonths, label: Text('3 mois'), ), ButtonSegment(value: ProgressionPeriod.all, label: Text('Tout')), ], selected: {period}, onSelectionChanged: (selection) => onChanged(selection.first), ); } } final class _VolumeSection extends StatelessWidget { const _VolumeSection({required this.overview}); final ProgressionOverview overview; @override Widget build(BuildContext context) { return CourtBlazerAccentPanel( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle('Volume'), const SizedBox(height: 12), Row( children: [ Expanded( child: _MetricTile( label: 'Séances', value: overview.completedSessionCount.toString(), ), ), const SizedBox(width: 10), Expanded( child: _MetricTile( label: 'Temps total', value: _formatVolumeDuration(overview.totalActiveMs), ), ), ], ), if (overview.rangeLabelKind == ProgressionRangeLabelKind.sinceFirstSession) ...[ const SizedBox(height: 12), Text( 'Depuis ta première séance', style: Theme.of(context).textTheme.bodySmall, ), ], ], ), ); } } final class _RegularitySection extends StatelessWidget { const _RegularitySection({required this.overview}); final ProgressionOverview overview; @override Widget build(BuildContext context) { final text = overview.totalWeekCount == null ? '${overview.activeWeekCount} semaines actives au total' : overview.activeWeekCount == 1 ? '1 semaine active' : '${overview.activeWeekCount} / ${overview.totalWeekCount} semaines actives'; return Card( child: Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle('Régularité'), const SizedBox(height: 10), Text( 'Rythme récent', style: Theme.of(context).textTheme.titleSmall, ), const SizedBox(height: 6), Text(text, style: AppTextStyles.scoreNumber(context)), const SizedBox(height: 6), Text( 'Une semaine active contient au moins une séance terminée.', style: Theme.of(context).textTheme.bodySmall, ), ], ), ), ); } } final class _ExerciseProgressionSection extends StatelessWidget { const _ExerciseProgressionSection({ required this.overview, required this.selectedExerciseKey, required this.selectedMeasure, required this.series, required this.onExerciseChanged, required this.onMeasureChanged, required this.onPointTap, }); final ProgressionOverview overview; final String? selectedExerciseKey; final ProgressionMeasure? selectedMeasure; final Future? series; final ValueChanged onExerciseChanged; final ValueChanged onMeasureChanged; final void Function(ProgressionExerciseSeries series, int index) onPointTap; @override Widget build(BuildContext context) { final exercise = _selectedExercise; return CourtBlazerAccentPanel( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SectionTitle('Par exercice'), const SizedBox(height: 12), if (overview.exerciseOptions.isEmpty) const _InlineProgressionMessage( title: 'Pas encore de mesure exploitable', message: 'Les graphiques apparaissent quand un exercice contient des répétitions, du temps ou un score numérique enregistré.', ) else ...[ DropdownButtonFormField( initialValue: exercise?.exerciseKey, decoration: const InputDecoration( labelText: 'Choisir un exercice', ), items: [ for (final option in overview.exerciseOptions) DropdownMenuItem( value: option.exerciseKey, child: _ExerciseOptionLabel(option: option), ), ], onChanged: (value) { final selected = overview.exerciseOptions.firstWhere( (option) => option.exerciseKey == value, ); onExerciseChanged(selected); }, ), if (exercise != null) ...[ const SizedBox(height: 12), _MeasureSelector( exercise: exercise, selectedMeasure: selectedMeasure, onChanged: onMeasureChanged, ), const SizedBox(height: 12), if (series == null) const _InlineProgressionMessage( title: 'Pas encore de mesure exploitable', message: 'Les graphiques apparaissent quand un exercice contient des répétitions, du temps ou un score numérique enregistré.', ) else FutureBuilder( future: series, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const SizedBox( height: 220, child: Center(child: CircularProgressIndicator()), ); } if (snapshot.hasError) { return _InlineProgressionMessage( title: 'Progression indisponible', message: snapshot.error.toString(), ); } return _SeriesBody( series: snapshot.data!, onPointTap: onPointTap, ); }, ), ], ], ], ), ); } ProgressionExerciseOption? get _selectedExercise { final key = selectedExerciseKey; if (key == null) return null; for (final option in overview.exerciseOptions) { if (option.exerciseKey == key) return option; } return null; } } final class _MeasureSelector extends StatelessWidget { const _MeasureSelector({ required this.exercise, required this.selectedMeasure, required this.onChanged, }); final ProgressionExerciseOption exercise; final ProgressionMeasure? selectedMeasure; final ValueChanged onChanged; @override Widget build(BuildContext context) { final options = exercise.measures; if (options.length <= 1) { final option = options.firstOrNull; if (option == null) { return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(option.label, style: Theme.of(context).textTheme.labelLarge), _MeasureHint(option: option), ], ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SegmentedButton( segments: [ for (final option in options) ButtonSegment(value: option.measure, label: Text(option.label)), ], selected: {selectedMeasure ?? options.first.measure}, onSelectionChanged: (selection) => onChanged(selection.first), ), const SizedBox(height: 8), _MeasureHint( option: options.firstWhere( (option) => option.measure == (selectedMeasure ?? options.first.measure), orElse: () => options.first, ), ), ], ); } } final class _MeasureHint extends StatelessWidget { const _MeasureHint({required this.option}); final ProgressionMeasureOption option; @override Widget build(BuildContext context) { if (!option.lowerIsBetter) { return const SizedBox.shrink(); } return Text( 'Plus bas = mieux', style: Theme.of(context).textTheme.bodySmall, ); } } final class _SeriesBody extends StatelessWidget { const _SeriesBody({required this.series, required this.onPointTap}); final ProgressionExerciseSeries series; final void Function(ProgressionExerciseSeries series, int index) onPointTap; @override Widget build(BuildContext context) { final stateMessage = _stateMessage(series.state); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (stateMessage != null) ...[ _InlineProgressionMessage( title: stateMessage.$1, message: stateMessage.$2, ), const SizedBox(height: 12), ], if (series.state != ProgressionSeriesState.noGraphableMeasure && series.state != ProgressionSeriesState.textScoreOnly) ...[ _ProgressionChart(series: series, onPointTap: onPointTap), const SizedBox(height: 12), _SeriesSummaryView(series: series), ], ], ); } } final class _ProgressionChart extends StatelessWidget { const _ProgressionChart({required this.series, required this.onPointTap}); final ProgressionExerciseSeries series; final void Function(ProgressionExerciseSeries series, int index) onPointTap; @override Widget build(BuildContext context) { final tokens = courtBlazerTokensOf(context); return SizedBox( height: 220, child: LayoutBuilder( builder: (context, constraints) { return Stack( children: [ CustomPaint( size: Size(constraints.maxWidth, constraints.maxHeight), painter: _ProgressionChartPainter( series: series, theme: Theme.of(context), ), ), for (var index = 0; index < series.points.length; index++) Positioned.fromRect( rect: _pointHitRect( constraints.biggest, series.points, index, ), child: Tooltip( message: _formatDate(series.points[index].startedAt), child: InkWell( key: ValueKey('progression-point-$index'), borderRadius: BorderRadius.circular(18), onTap: () => onPointTap(series, index), child: DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all(color: tokens.border), ), ), ), ), ), ], ); }, ), ); } } final class _ProgressionChartPainter extends CustomPainter { const _ProgressionChartPainter({required this.series, required this.theme}); final ProgressionExerciseSeries series; final ThemeData theme; @override void paint(Canvas canvas, Size size) { final tokens = theme.extension() ?? CourtBlazerTokens( accent: theme.colorScheme.secondary, border: theme.dividerColor, mutedText: theme.colorScheme.onSurfaceVariant, success: Colors.green, ); final chartRect = Rect.fromLTWH(8, 10, size.width - 16, size.height - 28); final gridPaint = Paint() ..color = tokens.border ..strokeWidth = 1; for (var index = 0; index < 4; index++) { final y = chartRect.top + chartRect.height * index / 3; canvas.drawLine( Offset(chartRect.left, y), Offset(chartRect.right, y), gridPaint, ); } if (series.points.isEmpty) return; final values = series.points.map((point) => point.value).toList(); final minValue = values.reduce(math.min); final maxValue = values.reduce(math.max); final range = maxValue == minValue ? 1 : maxValue - minValue; final offsets = [ for (var index = 0; index < series.points.length; index++) Offset( chartRect.left + (series.points.length == 1 ? chartRect.width / 2 : chartRect.width * index / (series.points.length - 1)), chartRect.bottom - ((series.points[index].value - minValue) / range) * chartRect.height, ), ]; if (offsets.length > 1) { final path = Path()..moveTo(offsets.first.dx, offsets.first.dy); for (final offset in offsets.skip(1)) { path.lineTo(offset.dx, offset.dy); } canvas.drawPath( path, Paint() ..color = theme.colorScheme.primary ..strokeWidth = 2.5 ..style = PaintingStyle.stroke, ); } for (final offset in offsets) { canvas.drawCircle(offset, 5, Paint()..color = tokens.accent); canvas.drawCircle( offset, 5, Paint() ..color = theme.colorScheme.surface ..strokeWidth = 1.5 ..style = PaintingStyle.stroke, ); } } @override bool shouldRepaint(covariant _ProgressionChartPainter oldDelegate) { return oldDelegate.series != series || oldDelegate.theme != theme; } } final class _SeriesSummaryView extends StatelessWidget { const _SeriesSummaryView({required this.series}); final ProgressionExerciseSeries series; @override Widget build(BuildContext context) { final labels = switch (series.summary.kind) { ProgressionSeriesSummaryKind.bestLast => [ ('Meilleur', series.summary.best), ('Dernier', series.summary.last), ], ProgressionSeriesSummaryKind.totals => [ ('Total récent', series.summary.recentTotal), ('Dernière séance', series.summary.lastSessionTotal), ], ProgressionSeriesSummaryKind.none => const <(String, Object?)>[], }; if (labels.isEmpty) return const SizedBox.shrink(); return Wrap( spacing: 10, runSpacing: 10, children: [ for (final item in labels) _SummaryPill( label: item.$1, value: _formatMeasureValue(series.measure, item.$2), ), ], ); } } final class _MetricTile extends StatelessWidget { const _MetricTile({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { final tokens = courtBlazerTokensOf(context); return DecoratedBox( decoration: BoxDecoration( border: Border.all(color: tokens.border), borderRadius: BorderRadius.circular(6), ), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: Theme.of(context).textTheme.labelMedium), const SizedBox(height: 6), Text( value, style: AppTextStyles.scoreNumber( context, ).copyWith(color: Theme.of(context).colorScheme.primary), ), ], ), ), ); } } final class _SummaryPill extends StatelessWidget { const _SummaryPill({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { final tokens = courtBlazerTokensOf(context); return DecoratedBox( decoration: BoxDecoration( border: Border.all(color: tokens.border), borderRadius: BorderRadius.circular(6), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: Text('$label : $value'), ), ); } } final class _ExerciseOptionLabel extends StatelessWidget { const _ExerciseOptionLabel({required this.option}); final ProgressionExerciseOption option; @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ Flexible(child: Text(option.nameSnapshot)), if (option.isArchived) ...[ const SizedBox(width: 8), const Chip( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity(horizontal: -4, vertical: -4), label: Text('Exercice archivé'), ), ], ], ); } } final class _SectionTitle extends StatelessWidget { const _SectionTitle(this.label); final String label; @override Widget build(BuildContext context) { return Text(label, style: Theme.of(context).textTheme.titleMedium); } } final class _GlobalEmptyState extends StatelessWidget { const _GlobalEmptyState({required this.onOpenHistory}); final VoidCallback onOpenHistory; @override Widget build(BuildContext context) { return _CenteredProgressionMessage( title: 'Aucune progression à afficher', message: 'Termine une séance pour voir ton volume, ton rythme et tes résultats évoluer ici.', action: OutlinedButton( onPressed: onOpenHistory, child: const Text('Voir les séances'), ), ); } } final class _CenteredProgressionMessage extends StatelessWidget { const _CenteredProgressionMessage({ required this.title, required this.message, this.action, }); final String title; final String message; final Widget? action; @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text(title, style: Theme.of(context).textTheme.titleMedium), const SizedBox(height: 8), Text( message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), if (action != null) ...[const SizedBox(height: 16), action!], ], ), ), ); } } final class _InlineProgressionMessage extends StatelessWidget { const _InlineProgressionMessage({required this.title, required this.message}); final String title; final String message; @override Widget build(BuildContext context) { final tokens = courtBlazerTokensOf(context); return DecoratedBox( decoration: BoxDecoration( border: Border.all(color: tokens.border), borderRadius: BorderRadius.circular(6), ), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 6), Text(message, style: Theme.of(context).textTheme.bodySmall), ], ), ), ); } } (String, String)? _stateMessage(ProgressionSeriesState state) { return switch (state) { ProgressionSeriesState.ready => null, ProgressionSeriesState.singlePoint => ( 'Encore un point de départ', 'Termine cet exercice dans une autre séance pour voir son évolution.', ), ProgressionSeriesState.emptyForPeriod => ( 'Pas encore de mesure exploitable', 'Les graphiques apparaissent quand un exercice contient des répétitions, du temps ou un score numérique enregistré.', ), ProgressionSeriesState.emptyButHasAllTimeData => ( 'Peu de données sur cette période', 'Passe sur "Tout" pour voir plus d\'historique.', ), ProgressionSeriesState.noGraphableMeasure => ( 'Pas encore de mesure exploitable', 'Les graphiques apparaissent quand un exercice contient des répétitions, du temps ou un score numérique enregistré.', ), ProgressionSeriesState.textScoreOnly => ( 'Score non graphique', 'Ce score est enregistré comme texte. Tu peux le retrouver dans le détail de l\'historique.', ), }; } Rect _pointHitRect(Size size, List points, int index) { const hitSize = 44.0; final chartRect = Rect.fromLTWH(8, 10, size.width - 16, size.height - 28); if (points.isEmpty) return Rect.zero; final values = points.map((point) => point.value).toList(); final minValue = values.reduce(math.min); final maxValue = values.reduce(math.max); final range = maxValue == minValue ? 1 : maxValue - minValue; final x = chartRect.left + (points.length == 1 ? chartRect.width / 2 : chartRect.width * index / (points.length - 1)); final y = chartRect.bottom - ((points[index].value - minValue) / range) * chartRect.height; return Rect.fromCenter(center: Offset(x, y), width: hitSize, height: hitSize); } String _pointDetailLabel( ProgressionExerciseSeries series, ProgressionPoint point, ) { final label = switch (series.measure.measure) { ProgressionMeasure.manualScore => 'Meilleur score', ProgressionMeasure.stopwatchScore => 'Meilleur temps', ProgressionMeasure.reps => 'Répétitions', ProgressionMeasure.time => 'Temps', }; return '$label : ${_formatMeasureValue(series.measure, point.rawValue)}'; } String _formatMeasureValue(ProgressionMeasureOption measure, Object? rawValue) { if (rawValue == null) return '-'; return switch (measure.measure) { ProgressionMeasure.manualScore => '${_formatScore(rawValue as double)}${_scoreSuffix(measure.scoreUnit)}', ProgressionMeasure.stopwatchScore => _formatStopwatch(rawValue as int), ProgressionMeasure.reps => '${rawValue as int} répétitions', ProgressionMeasure.time => _formatVolumeDuration(rawValue as int), }; } String _formatScore(double value) { if (value == value.roundToDouble()) { return value.round().toString(); } return value.toStringAsFixed(1); } String _scoreSuffix(String? unit) { final value = unit?.trim(); if (value == null || value.isEmpty) return ''; return ' $value'; } String _formatVolumeDuration(int milliseconds) { final duration = Duration(milliseconds: milliseconds); final hours = duration.inHours; final minutes = duration.inMinutes.remainder(60); if (hours > 0) { return minutes == 0 ? '$hours h' : '$hours h ${minutes.toString().padLeft(2, '0')}'; } return '${minutes.toString().padLeft(2, '0')} min'; } String _formatStopwatch(int milliseconds) { final totalTenths = (milliseconds / 100).floor(); final minutes = (totalTenths ~/ 600).toString().padLeft(2, '0'); final seconds = ((totalTenths ~/ 10) % 60).toString().padLeft(2, '0'); final tenths = totalTenths % 10; return '$minutes:$seconds.$tenths'; } String _formatDate(DateTime value) { const months = [ 'janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre', ]; return '${value.day} ${months[value.month - 1]}'; }