From 3b7d904953c7a60a05610a8d76f414a619b9dc3d Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 08:58:16 +0200 Subject: [PATCH] =?UTF-8?q?feat(statistiques):=20impl=C3=A9mentation=20fro?= =?UTF-8?q?ntend=20statistiques=20de=20progression=20(ticket=20#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/presentation/history_screen.dart | 13 +- lib/presentation/home_screen.dart | 29 + lib/presentation/presentation.dart | 1 + lib/presentation/progression_screen.dart | 1055 +++++++++++++++++ test/presentation/history_screen_test.dart | 21 + test/presentation/home_screen_test.dart | 25 + .../presentation/progression_screen_test.dart | 602 ++++++++++ 7 files changed, 1745 insertions(+), 1 deletion(-) create mode 100644 lib/presentation/progression_screen.dart create mode 100644 test/presentation/progression_screen_test.dart diff --git a/lib/presentation/history_screen.dart b/lib/presentation/history_screen.dart index 152acba..e326cb0 100644 --- a/lib/presentation/history_screen.dart +++ b/lib/presentation/history_screen.dart @@ -16,6 +16,7 @@ final class HistoryListScreen extends StatefulWidget { this.mediaUseCases, this.stepUseCases, this.performanceReferenceUseCase, + this.onOpenProgression, super.key, }); @@ -26,6 +27,7 @@ final class HistoryListScreen extends StatefulWidget { final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final CloseWorkoutSessionUseCase closeUseCase; final MediaUseCases? mediaUseCases; + final VoidCallback? onOpenProgression; @override State createState() => _HistoryListScreenState(); @@ -43,7 +45,16 @@ final class _HistoryListScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Historique')), + appBar: AppBar( + title: const Text('Historique'), + actions: [ + if (widget.onOpenProgression != null) + TextButton( + onPressed: widget.onOpenProgression, + child: const Text('Progression'), + ), + ], + ), body: FutureBuilder>( future: _histories, builder: (context, snapshot) { diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 8611751..715f2e0 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -5,6 +5,7 @@ import '../domain/domain.dart'; import 'exercise_library_screen.dart'; import 'history_screen.dart'; import 'profile_screen.dart'; +import 'progression_screen.dart'; import 'program_screen.dart'; import 'theme.dart'; import 'workout_execution_screen.dart'; @@ -185,10 +186,20 @@ final class _HomeScreenState extends State with RouteAware { widget.bootstrap.exercisePerformanceReferenceUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, mediaUseCases: widget.bootstrap.mediaUseCases, + onOpenProgression: () => _openProgression(context), ), ), ), ), + ListTile( + leading: const Icon(Icons.show_chart), + title: const Text('Progression'), + subtitle: const Text( + 'Volume, régularité et évolution par exercice', + ), + trailing: const Icon(Icons.chevron_right), + onTap: () => _openProgression(context), + ), ListTile( leading: FutureBuilder( future: widget.bootstrap.authUseCases.currentSession(), @@ -225,6 +236,24 @@ final class _HomeScreenState extends State with RouteAware { ); } + Future _openProgression(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => ProgressionScreen( + progressionStatsUseCase: widget.bootstrap.progressionStatsUseCase, + historyUseCases: widget.bootstrap.workoutHistoryUseCases, + workoutTemplateUseCases: widget.bootstrap.workoutTemplateUseCases, + activeUseCases: widget.bootstrap.activeWorkoutSessionUseCases, + stepUseCases: widget.bootstrap.activeExerciseStepUseCases, + performanceReferenceUseCase: + widget.bootstrap.exercisePerformanceReferenceUseCase, + closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, + mediaUseCases: widget.bootstrap.mediaUseCases, + ), + ), + ); + } + Future _resume(ActiveWorkoutSession session) async { await Navigator.of(context).push( MaterialPageRoute( diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index e563c69..06eb082 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -5,6 +5,7 @@ export 'exercise_library_screen.dart'; export 'history_screen.dart'; export 'home_screen.dart'; export 'profile_screen.dart'; +export 'progression_screen.dart'; export 'program_screen.dart'; export 'share_inbox_screen.dart'; export 'share_screen.dart'; diff --git a/lib/presentation/progression_screen.dart b/lib/presentation/progression_screen.dart new file mode 100644 index 0000000..35475f0 --- /dev/null +++ b/lib/presentation/progression_screen.dart @@ -0,0 +1,1055 @@ +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 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, + ), + ), + ); + } + + 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, + ), + ), + ); + } +} + +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]}'; +} diff --git a/test/presentation/history_screen_test.dart b/test/presentation/history_screen_test.dart index 20e4e5b..1b8f533 100644 --- a/test/presentation/history_screen_test.dart +++ b/test/presentation/history_screen_test.dart @@ -7,6 +7,27 @@ import 'package:gametime/domain/domain.dart'; import 'package:gametime/presentation/presentation.dart'; void main() { + testWidgets('la liste expose le lien secondaire Progression', (tester) async { + var opened = false; + + await tester.pumpWidget( + MaterialApp( + home: HistoryListScreen( + historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()), + workoutTemplateUseCases: _workoutTemplateUseCases(), + activeUseCases: _activeUseCases(), + closeUseCase: _closeUseCase(), + onOpenProgression: () => opened = true, + ), + ), + ); + + await tester.tap(find.text('Progression')); + await tester.pump(); + + expect(opened, isTrue); + }); + testWidgets('la liste groupe les séances par période', (tester) async { final groups = groupHistoriesByPeriod([ _history(id: 'today', startedAt: DateTime.utc(2026, 7, 17, 9)), diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index 292d4f8..0799667 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -9,6 +9,31 @@ import 'package:gametime/domain/domain.dart'; import 'package:gametime/presentation/home_screen.dart'; void main() { + testWidgets('l’entrée Progression est présente après Historique', ( + tester, + ) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [homeRouteObserver], + home: HomeScreen( + bootstrap: _FakeBootstrap(_FakeActiveSessionRepository()), + ), + ), + ); + await tester.pump(); + + expect(find.text('Historique'), findsOneWidget); + expect(find.text('Progression'), findsOneWidget); + expect( + find.text('Volume, régularité et évolution par exercice'), + findsOneWidget, + ); + expect( + tester.getTopLeft(find.text('Progression')).dy, + greaterThan(tester.getTopLeft(find.text('Historique')).dy), + ); + }); + testWidgets('l’entrée Profil est présente sur l’accueil', (tester) async { await tester.pumpWidget( MaterialApp( diff --git a/test/presentation/progression_screen_test.dart b/test/presentation/progression_screen_test.dart new file mode 100644 index 0000000..1fe3e28 --- /dev/null +++ b/test/presentation/progression_screen_test.dart @@ -0,0 +1,602 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gametime/application/application.dart'; +import 'package:gametime/domain/domain.dart'; +import 'package:gametime/presentation/presentation.dart'; + +void main() { + testWidgets('Progression affiche la période par défaut et l’état vide global', ( + tester, + ) async { + final repository = _FakeProgressionStatsRepository(); + + await tester.pumpWidget(_app(repository: repository)); + await tester.pumpAndSettle(); + + expect(find.text('Progression'), findsOneWidget); + expect(find.text('4 semaines'), findsOneWidget); + expect(find.text('3 mois'), findsOneWidget); + expect(find.text('Tout'), findsOneWidget); + expect(find.text('Aucune progression à afficher'), findsOneWidget); + expect( + find.text( + 'Termine une séance pour voir ton volume, ton rythme et tes résultats évoluer ici.', + ), + findsOneWidget, + ); + expect(find.text('Voir les séances'), findsOneWidget); + expect(repository.periods, [ProgressionPeriod.fourWeeks]); + }); + + testWidgets('Progression affiche Volume et Régularité', (tester) async { + final repository = _FakeProgressionStatsRepository() + ..overview = ProgressionGlobalStatsData( + completedSessionCount: 8, + totalActiveMs: 20400000, + activeWeekStarts: [ + DateTime.utc(2026, 7, 6), + DateTime.utc(2026, 7, 13), + DateTime.utc(2026, 7, 20), + ], + hasAnyCompletedHistory: true, + firstCompletedSessionAt: DateTime.utc(2026, 7, 1), + ); + + await tester.pumpWidget(_app(repository: repository)); + await tester.pumpAndSettle(); + + expect(find.text('Volume'), findsOneWidget); + expect(find.text('Séances'), findsOneWidget); + expect(find.text('8'), findsOneWidget); + expect(find.text('Temps total'), findsOneWidget); + expect(find.text('5 h 40'), findsOneWidget); + expect(find.text('Régularité'), findsOneWidget); + expect(find.text('Rythme récent'), findsOneWidget); + expect(find.text('3 / 4 semaines actives'), findsOneWidget); + expect( + find.text('Une semaine active contient au moins une séance terminée.'), + findsOneWidget, + ); + }); + + testWidgets('Par exercice affiche un exercice archivé et un point unique', ( + tester, + ) async { + final repository = _FakeProgressionStatsRepository() + ..overview = ProgressionGlobalStatsData( + completedSessionCount: 1, + totalActiveMs: 1800000, + activeWeekStarts: [DateTime.utc(2026, 7, 13)], + hasAnyCompletedHistory: true, + ) + ..exerciseOptions = [ + ProgressionExerciseOptionData( + exerciseKey: 'shoot', + nameSnapshot: 'Shoot ligne', + isArchived: true, + lastPerformedAt: DateTime.utc(2026, 7, 17), + ), + ] + ..measuresByExercise = const { + 'shoot': [ + ProgressionMeasureOptionData( + measure: ProgressionMeasure.reps, + label: 'Répétitions', + lowerIsBetter: false, + ), + ], + } + ..seriesByExerciseMeasure = { + 'shoot:${ProgressionMeasure.reps.name}': ProgressionExerciseSeriesData( + exerciseKey: 'shoot', + exerciseNameSnapshot: 'Shoot ligne', + measure: ProgressionMeasureOptionData( + measure: ProgressionMeasure.reps, + label: 'Répétitions', + lowerIsBetter: false, + ), + points: [ + ProgressionPointData( + workoutHistoryId: 'history-1', + workoutNameSnapshot: 'Prépa match', + startedAt: DateTime.utc(2026, 7, 17), + value: 45, + rawValue: 45, + ), + ], + hasAnyAllTimeData: true, + hasAnyCompletedExerciseResult: true, + ), + }; + + await tester.pumpWidget(_app(repository: repository)); + await tester.pumpAndSettle(); + + expect(find.text('Par exercice'), findsOneWidget); + expect(find.text('Choisir un exercice'), findsOneWidget); + expect(find.text('Shoot ligne'), findsOneWidget); + expect(find.text('Exercice archivé'), findsOneWidget); + expect(find.text('Encore un point de départ'), findsOneWidget); + expect( + find.text( + 'Termine cet exercice dans une autre séance pour voir son évolution.', + ), + findsOneWidget, + ); + expect(find.text('Dernière séance : 45 répétitions'), findsOneWidget); + }); + + testWidgets('Par exercice affiche Temps réalisé et Plus bas = mieux', ( + tester, + ) async { + final repository = _progressionRepositoryWithSeries( + measure: const ProgressionMeasureOptionData( + measure: ProgressionMeasure.stopwatchScore, + label: 'Temps réalisé', + lowerIsBetter: true, + ), + rawValues: [45100, 42800], + values: [45100, 42800], + ); + + await tester.pumpWidget(_app(repository: repository)); + await tester.pumpAndSettle(); + + expect(find.text('Temps réalisé'), findsOneWidget); + expect(find.text('Plus bas = mieux'), findsOneWidget); + expect(find.text('Meilleur : 00:42.8'), findsOneWidget); + expect(find.text('Dernier : 00:42.8'), findsOneWidget); + }); + + testWidgets('tap sur un point affiche le détail et ouvre la séance', ( + tester, + ) async { + final historyRepository = _FakeWorkoutHistoryRepository() + ..histories['history-2'] = _history(id: 'history-2'); + final repository = _progressionRepositoryWithSeries( + measure: const ProgressionMeasureOptionData( + measure: ProgressionMeasure.manualScore, + label: 'Score', + scoreLabel: 'Réussites', + scoreUnit: 'paniers', + lowerIsBetter: false, + ), + rawValues: [12.0, 14.0], + values: [12, 14], + ); + + await tester.pumpWidget( + _app(repository: repository, historyRepository: historyRepository), + ); + await tester.pumpAndSettle(); + + final pointFinder = find.byKey(const ValueKey('progression-point-1')); + await tester.ensureVisible(pointFinder); + await tester.pumpAndSettle(); + await tester.tap(pointFinder); + await tester.pumpAndSettle(); + + expect(find.text('12 juillet'), findsOneWidget); + expect(find.text('Séance : Prépa match'), findsOneWidget); + expect(find.text('Meilleur score : 14 paniers'), findsOneWidget); + + await tester.tap(find.text('Voir la séance')); + await tester.pumpAndSettle(); + + expect(historyRepository.requestedIds, ['history-2']); + expect(find.text('Séance jambes'), findsWidgets); + }); +} + +Widget _app({ + required _FakeProgressionStatsRepository repository, + _FakeWorkoutHistoryRepository? historyRepository, +}) { + final clock = _FakeClock(DateTime.utc(2026, 7, 22, 12)); + final activeRepository = _FakeActiveSessionRepository(); + final resolvedHistoryRepository = + historyRepository ?? _FakeWorkoutHistoryRepository(); + return MaterialApp( + theme: courtBlazerLightTheme(), + home: ProgressionScreen( + progressionStatsUseCase: ProgressionStatsUseCase( + repository: repository, + clock: clock, + ), + historyUseCases: WorkoutHistoryUseCases( + repository: resolvedHistoryRepository, + clock: clock, + ), + workoutTemplateUseCases: WorkoutTemplateUseCases( + templateRepository: _FakeWorkoutTemplateRepository(), + programRepository: _FakeProgramRepository(), + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + activeUseCases: ActiveWorkoutSessionUseCases( + sessionRepository: activeRepository, + templateRepository: _FakeWorkoutTemplateRepository(), + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + closeUseCase: CloseWorkoutSessionUseCase( + sessionRepository: activeRepository, + historyRepository: resolvedHistoryRepository, + clock: clock, + ids: _FakeIds(), + originDeviceId: 'device-1', + ), + ), + ); +} + +_FakeProgressionStatsRepository _progressionRepositoryWithSeries({ + required ProgressionMeasureOptionData measure, + required List rawValues, + required List values, +}) { + return _FakeProgressionStatsRepository() + ..overview = ProgressionGlobalStatsData( + completedSessionCount: 2, + totalActiveMs: 3600000, + activeWeekStarts: [DateTime.utc(2026, 7, 6), DateTime.utc(2026, 7, 13)], + hasAnyCompletedHistory: true, + ) + ..exerciseOptions = [ + ProgressionExerciseOptionData( + exerciseKey: 'shoot', + nameSnapshot: 'Shoot ligne', + isArchived: false, + lastPerformedAt: DateTime.utc(2026, 7, 17), + ), + ] + ..measuresByExercise = { + 'shoot': [measure], + } + ..seriesByExerciseMeasure = { + 'shoot:${measure.measure.name}': ProgressionExerciseSeriesData( + exerciseKey: 'shoot', + exerciseNameSnapshot: 'Shoot ligne', + measure: measure, + points: [ + ProgressionPointData( + workoutHistoryId: 'history-1', + workoutNameSnapshot: 'Prépa match', + startedAt: DateTime.utc(2026, 7, 5), + value: values[0], + rawValue: rawValues[0], + ), + ProgressionPointData( + workoutHistoryId: 'history-2', + workoutNameSnapshot: 'Prépa match', + startedAt: DateTime.utc(2026, 7, 12), + value: values[1], + rawValue: rawValues[1], + ), + ], + hasAnyAllTimeData: true, + hasAnyCompletedExerciseResult: true, + ), + }; +} + +final class _FakeProgressionStatsRepository + implements ProgressionStatsRepository { + ProgressionGlobalStatsData overview = const ProgressionGlobalStatsData( + completedSessionCount: 0, + totalActiveMs: 0, + activeWeekStarts: [], + hasAnyCompletedHistory: false, + ); + List exerciseOptions = const []; + Map> measuresByExercise = const {}; + Map seriesByExerciseMeasure = const {}; + final periods = []; + + @override + Future readGlobalStats( + ProgressionDateRange range, + ) async { + periods.add( + range.startedAt == null + ? ProgressionPeriod.all + : range.startedAt!.isAfter(DateTime.utc(2026, 5)) + ? ProgressionPeriod.fourWeeks + : ProgressionPeriod.threeMonths, + ); + return overview; + } + + @override + Future> listExerciseOptions( + ProgressionDateRange range, + ) async { + return exerciseOptions; + } + + @override + Future> listMeasureOptions({ + required ProgressionDateRange range, + required String exerciseKey, + }) async { + return measuresByExercise[exerciseKey] ?? const []; + } + + @override + Future readExerciseSeries({ + required ProgressionDateRange range, + required String exerciseKey, + required ProgressionMeasure measure, + }) async { + return seriesByExerciseMeasure['$exerciseKey:${measure.name}'] ?? + ProgressionExerciseSeriesData( + exerciseKey: exerciseKey, + exerciseNameSnapshot: exerciseKey, + measure: ProgressionMeasureOptionData( + measure: measure, + label: 'Score', + lowerIsBetter: false, + ), + points: const [], + hasAnyAllTimeData: false, + hasAnyCompletedExerciseResult: false, + ); + } +} + +WorkoutHistory _history({required String id}) { + final start = DateTime.utc(2026, 7, 12, 10); + return WorkoutHistory( + metadata: _metadata(id), + sourceWorkoutTemplateId: 'template-1', + sourceActiveWorkoutSessionId: 'session-1', + nameSnapshot: 'Séance jambes', + startedAt: start, + endedAt: start.add(const Duration(minutes: 30)), + totalActiveMs: 1800000, + completed: true, + historySnapshotJson: jsonEncode({ + 'sessionId': 'session-1', + 'resolvedTemplateSnapshotJson': jsonEncode({ + 'name': 'Séance jambes', + 'programs': [ + { + 'id': 'program-snapshot-1', + 'programNameSnapshot': 'Programme jambes', + 'programSnapshotJson': jsonEncode({ + 'exercises': [ + { + 'id': 'exercise-snapshot-1', + 'exerciseNameSnapshot': 'Squat', + 'setsCount': 1, + 'timeEnabled': true, + 'repsEnabled': true, + 'scoreEnabled': true, + 'targetTimeSeconds': 45, + 'targetReps': 10, + 'targetScore': 80, + 'scoreInputModeSnapshot': ScoreInputMode.manual.name, + 'scoreLabelSnapshot': 'Charge', + 'scoreUnitSnapshot': 'kg', + 'restSecondsOverride': 0, + }, + ], + }), + }, + ], + 'overrides': const [], + }), + 'results': [ + { + 'programSnapshotId': 'program-snapshot-1', + 'exerciseSnapshotId': 'exercise-snapshot-1', + 'programIndex': 0, + 'exerciseIndex': 0, + 'setIndex': 0, + 'actualTimeMs': 45000, + 'actualReps': 10, + 'actualScore': 80, + 'scoreLabelSnapshot': 'Charge', + 'scoreInputModeSnapshot': ScoreInputMode.manual.name, + 'scoreUnitSnapshot': 'kg', + }, + ], + }), + ); +} + +EntityMetadata _metadata(String id) { + return EntityMetadata( + id: id, + createdAt: DateTime.utc(2026, 7, 17), + updatedAt: DateTime.utc(2026, 7, 17), + originDeviceId: 'device-1', + ); +} + +final class _FakeClock implements Clock { + _FakeClock(this.value); + + final DateTime value; + + @override + DateTime now() => value; +} + +final class _FakeIds implements IdGenerator { + var _next = 0; + + @override + String newId() { + _next += 1; + return 'id-$_next'; + } +} + +final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository { + final histories = {}; + final requestedIds = []; + + @override + Future delete(String id, DateTime deletedAt) async {} + + @override + Future findById(String id) async { + requestedIds.add(id); + return histories[id]; + } + + @override + Future> listActive() async => histories.values.toList(); + + @override + Future save(WorkoutHistory history) async {} + + @override + Future saveSetResult(WorkoutHistorySetResult result) async {} + + @override + Future saveStepResult(WorkoutHistoryStepResult result) async {} +} + +final class _FakeWorkoutTemplateRepository + implements WorkoutTemplateRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceComposition( + WorkoutTemplate template, + DateTime deletedAt, + ) async {} + + @override + Future save(WorkoutTemplate template) async {} + + @override + Future saveOverride(WorkoutTemplateExerciseOverride override) async {} + + @override + Future saveProgram(WorkoutTemplateProgram program) async {} +} + +final class _FakeProgramRepository implements ProgramRepository { + @override + Future findById(String id) async => null; + + @override + Future> listActive() async => const []; + + @override + Future replaceExercises(Program program, DateTime deletedAt) async {} + + @override + Future save(Program program) async {} + + @override + Future saveExercise(ProgramExercise exercise) async {} +} + +final class _FakeActiveSessionRepository implements ActiveSessionRepository { + @override + Future deleteScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + required DateTime deletedAt, + }) async {} + + @override + Future findById(String id) async => null; + + @override + Future findExerciseStepProgressState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async => null; + + @override + Future findOpen() async => null; + + @override + Future findRestStateById(String id) async => null; + + @override + Future findScoreStopwatchState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async => null; + + @override + Future findSetTimerState({ + required String sessionId, + required int programIndex, + required int exerciseIndex, + required int setIndex, + }) async => null; + + @override + Future> listExerciseStepProgressStates( + String sessionId, + ) async => const []; + + @override + Future> listExerciseStepResults( + String sessionId, + ) async => const []; + + @override + Future> listRestStates(String sessionId) async { + return const []; + } + + @override + Future> listScoreStopwatchStates( + String sessionId, + ) async => const []; + + @override + Future> listSetResults(String sessionId) async { + return const []; + } + + @override + Future> listSetTimerStates(String sessionId) async { + return const []; + } + + @override + Future save(ActiveWorkoutSession session) async {} + + @override + Future saveExerciseStepProgressState( + ActiveExerciseStepProgressState state, + ) async {} + + @override + Future saveExerciseStepResult(ActiveExerciseStepResult result) async {} + + @override + Future saveRestState(ActiveRestState restState) async {} + + @override + Future saveScoreStopwatchState(ActiveScoreStopwatchState state) async {} + + @override + Future saveSetResult(ActiveSetResult result) async {} + + @override + Future saveSetTimerState(ActiveSetTimerState state) async {} +}