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>
This commit is contained in:
2026-07-22 08:58:16 +02:00
parent a8baa3169c
commit 3b7d904953
7 changed files with 1745 additions and 1 deletions

View File

@ -16,6 +16,7 @@ final class HistoryListScreen extends StatefulWidget {
this.mediaUseCases, this.mediaUseCases,
this.stepUseCases, this.stepUseCases,
this.performanceReferenceUseCase, this.performanceReferenceUseCase,
this.onOpenProgression,
super.key, super.key,
}); });
@ -26,6 +27,7 @@ final class HistoryListScreen extends StatefulWidget {
final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase; final ExercisePerformanceReferenceUseCase? performanceReferenceUseCase;
final CloseWorkoutSessionUseCase closeUseCase; final CloseWorkoutSessionUseCase closeUseCase;
final MediaUseCases? mediaUseCases; final MediaUseCases? mediaUseCases;
final VoidCallback? onOpenProgression;
@override @override
State<HistoryListScreen> createState() => _HistoryListScreenState(); State<HistoryListScreen> createState() => _HistoryListScreenState();
@ -43,7 +45,16 @@ final class _HistoryListScreenState extends State<HistoryListScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( 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<List<WorkoutHistory>>( body: FutureBuilder<List<WorkoutHistory>>(
future: _histories, future: _histories,
builder: (context, snapshot) { builder: (context, snapshot) {

View File

@ -5,6 +5,7 @@ import '../domain/domain.dart';
import 'exercise_library_screen.dart'; import 'exercise_library_screen.dart';
import 'history_screen.dart'; import 'history_screen.dart';
import 'profile_screen.dart'; import 'profile_screen.dart';
import 'progression_screen.dart';
import 'program_screen.dart'; import 'program_screen.dart';
import 'theme.dart'; import 'theme.dart';
import 'workout_execution_screen.dart'; import 'workout_execution_screen.dart';
@ -185,10 +186,20 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
widget.bootstrap.exercisePerformanceReferenceUseCase, widget.bootstrap.exercisePerformanceReferenceUseCase,
closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase, closeUseCase: widget.bootstrap.closeWorkoutSessionUseCase,
mediaUseCases: widget.bootstrap.mediaUseCases, 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( ListTile(
leading: FutureBuilder<UserAccountSession?>( leading: FutureBuilder<UserAccountSession?>(
future: widget.bootstrap.authUseCases.currentSession(), future: widget.bootstrap.authUseCases.currentSession(),
@ -225,6 +236,24 @@ final class _HomeScreenState extends State<HomeScreen> with RouteAware {
); );
} }
Future<void> _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<void> _resume(ActiveWorkoutSession session) async { Future<void> _resume(ActiveWorkoutSession session) async {
await Navigator.of(context).push( await Navigator.of(context).push(
MaterialPageRoute( MaterialPageRoute(

View File

@ -5,6 +5,7 @@ export 'exercise_library_screen.dart';
export 'history_screen.dart'; export 'history_screen.dart';
export 'home_screen.dart'; export 'home_screen.dart';
export 'profile_screen.dart'; export 'profile_screen.dart';
export 'progression_screen.dart';
export 'program_screen.dart'; export 'program_screen.dart';
export 'share_inbox_screen.dart'; export 'share_inbox_screen.dart';
export 'share_screen.dart'; export 'share_screen.dart';

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,27 @@ import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/presentation.dart'; import 'package:gametime/presentation/presentation.dart';
void main() { 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 { testWidgets('la liste groupe les séances par période', (tester) async {
final groups = groupHistoriesByPeriod([ final groups = groupHistoriesByPeriod([
_history(id: 'today', startedAt: DateTime.utc(2026, 7, 17, 9)), _history(id: 'today', startedAt: DateTime.utc(2026, 7, 17, 9)),

View File

@ -9,6 +9,31 @@ import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/home_screen.dart'; import 'package:gametime/presentation/home_screen.dart';
void main() { void main() {
testWidgets('lentré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('lentrée Profil est présente sur laccueil', (tester) async { testWidgets('lentrée Profil est présente sur laccueil', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(

View File

@ -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<Object> rawValues,
required List<double> 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<ProgressionExerciseOptionData> exerciseOptions = const [];
Map<String, List<ProgressionMeasureOptionData>> measuresByExercise = const {};
Map<String, ProgressionExerciseSeriesData> seriesByExerciseMeasure = const {};
final periods = <ProgressionPeriod>[];
@override
Future<ProgressionGlobalStatsData> 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<List<ProgressionExerciseOptionData>> listExerciseOptions(
ProgressionDateRange range,
) async {
return exerciseOptions;
}
@override
Future<List<ProgressionMeasureOptionData>> listMeasureOptions({
required ProgressionDateRange range,
required String exerciseKey,
}) async {
return measuresByExercise[exerciseKey] ?? const [];
}
@override
Future<ProgressionExerciseSeriesData> 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 = <String, WorkoutHistory>{};
final requestedIds = <String>[];
@override
Future<void> delete(String id, DateTime deletedAt) async {}
@override
Future<WorkoutHistory?> findById(String id) async {
requestedIds.add(id);
return histories[id];
}
@override
Future<List<WorkoutHistory>> listActive() async => histories.values.toList();
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
}
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
@override
Future<void> replaceComposition(
WorkoutTemplate template,
DateTime deletedAt,
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@override
Future<void> saveProgram(WorkoutTemplateProgram program) async {}
}
final class _FakeProgramRepository implements ProgramRepository {
@override
Future<Program?> findById(String id) async => null;
@override
Future<List<Program>> listActive() async => const [];
@override
Future<void> replaceExercises(Program program, DateTime deletedAt) async {}
@override
Future<void> save(Program program) async {}
@override
Future<void> saveExercise(ProgramExercise exercise) async {}
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> deleteScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<ActiveWorkoutSession?> findById(String id) async => null;
@override
Future<ActiveExerciseStepProgressState?> findExerciseStepProgressState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveWorkoutSession?> findOpen() async => null;
@override
Future<ActiveRestState?> findRestStateById(String id) async => null;
@override
Future<ActiveScoreStopwatchState?> findScoreStopwatchState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<List<ActiveExerciseStepProgressState>> listExerciseStepProgressStates(
String sessionId,
) async => const [];
@override
Future<List<ActiveExerciseStepResult>> listExerciseStepResults(
String sessionId,
) async => const [];
@override
Future<List<ActiveRestState>> listRestStates(String sessionId) async {
return const [];
}
@override
Future<List<ActiveScoreStopwatchState>> listScoreStopwatchStates(
String sessionId,
) async => const [];
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
}
@override
Future<void> save(ActiveWorkoutSession session) async {}
@override
Future<void> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
) async {}
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {}
@override
Future<void> saveRestState(ActiveRestState restState) async {}
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
}