Files
GameTime/lib/presentation/progression_screen.dart
Blomios 3b7d904953 feat(statistiques): implémentation frontend statistiques de progression (ticket #82)
Lots F1+F2 : écran progression_screen.dart, intégration home/history/navigation, tests associés (193 tests suite complète, 4 échecs préexistants sans rapport confirmés).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 08:58:16 +02:00

1056 lines
32 KiB
Dart

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,
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;
@override
State<ProgressionScreen> createState() => _ProgressionScreenState();
}
final class _ProgressionScreenState extends State<ProgressionScreen> {
var _period = ProgressionPeriod.fourWeeks;
late Future<ProgressionOverview> _overview;
String? _selectedExerciseKey;
ProgressionMeasure? _selectedMeasure;
Future<ProgressionExerciseSeries>? _series;
@override
void initState() {
super.initState();
_overview = _loadOverview();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Progression')),
body: FutureBuilder<ProgressionOverview>(
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<ProgressionOverview> _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<void> _showPointDetail(ProgressionExerciseSeries series, int index) {
final point = series.points[index];
return showModalBottomSheet<void>(
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<void> _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,
),
),
);
}
Future<void> _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,
),
),
);
}
}
final class _PeriodSelector extends StatelessWidget {
const _PeriodSelector({required this.period, required this.onChanged});
final ProgressionPeriod period;
final ValueChanged<ProgressionPeriod> onChanged;
@override
Widget build(BuildContext context) {
return SegmentedButton<ProgressionPeriod>(
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<ProgressionExerciseSeries>? series;
final ValueChanged<ProgressionExerciseOption> onExerciseChanged;
final ValueChanged<ProgressionMeasure> 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<String>(
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<ProgressionExerciseSeries>(
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<ProgressionMeasure> 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<ProgressionMeasure>(
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>() ??
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 = <Offset>[
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<ProgressionPoint> 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]}';
}