feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre

Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 05:56:12 +02:00
parent c65a5a76a9
commit 65d43b9768
80 changed files with 12292 additions and 892 deletions

View File

@ -405,6 +405,66 @@ void main() {
expect(find.text('Mode de score'), findsOneWidget);
});
testWidgets('une étape peut utiliser le score de la série', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 4200));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Tirs');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Score à saisir').first,
'Paniers',
);
await tester.enterText(find.widgetWithText(TextFormField, 'Unité'), 'pts');
await tester.enterText(
find.widgetWithText(TextFormField, 'Score par défaut'),
'0',
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Rythmer cet exercice avec des étapes',
),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.tap(_stepTypeRadio('Répétitions').last);
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Tirs main droite',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Répétitions par défaut').last,
'10',
);
await tester.tap(find.widgetWithText(SwitchListTile, 'Score détape'));
await tester.pump();
await tester.tap(
find.widgetWithText(SwitchListTile, 'Utiliser le score de la série'),
);
await tester.pump();
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
final step = exerciseRepository.saved.single.steps.single;
expect(step.linkedToSeriesScore, isTrue);
expect(step.scoreLabel, 'Paniers');
expect(step.scoreUnit, 'pts');
expect(step.defaultTargetScore, isNull);
});
testWidgets('réordonner les étapes via Monter Descendre', (tester) async {
final exerciseRepository = _FakeExerciseRepository();
@ -543,6 +603,82 @@ void main() {
expect(saved.steps.single.defaultTargetValue, 12);
});
testWidgets('une étape invalide affiche une erreur inline sans sauvegarder', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository();
await tester.binding.setSurfaceSize(const Size(400, 3600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.tap(
find.widgetWithText(
SwitchListTile,
'Rythmer cet exercice avec des étapes',
),
);
await tester.pump();
await tester.tap(find.text('Ajouter une étape'));
await tester.pump();
await tester.enterText(
find.widgetWithText(TextFormField, 'Nom de létape').last,
'Dribble main droite',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Durée par défaut (s)').last,
'abc',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(find.text('Saisis une durée supérieure à 0.'), findsOneWidget);
expect(exerciseRepository.saved, isEmpty);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('un échec inattendu de sauvegarde libère le bouton', (
tester,
) async {
final exerciseRepository = _FakeExerciseRepository()..throwOnSave = true;
await tester.binding.setSurfaceSize(const Size(400, 1600));
addTearDown(() => tester.binding.setSurfaceSize(null));
await _pumpExerciseForm(tester, exerciseRepository);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Combo');
await tester.enterText(
find.widgetWithText(TextFormField, 'Temps par défaut (s)'),
'30',
);
await tester.ensureVisible(find.text('Enregistrer'));
await tester.tap(find.text('Enregistrer'));
await tester.pumpAndSettle();
expect(
find.text(
'Impossible denregistrer lexercice. Vérifie les champs puis réessaie.',
),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNotNull,
);
});
testWidgets(
'le réglage denchaînement automatique des étapes est sauvegardé',
(tester) async {
@ -839,6 +975,7 @@ final class _FakeExerciseRepository implements ExerciseRepository {
final exercises = <Exercise>[];
final saved = <Exercise>[];
var referenced = false;
var throwOnSave = false;
@override
Future<Exercise?> findById(String id) async {
@ -862,6 +999,9 @@ final class _FakeExerciseRepository implements ExerciseRepository {
@override
Future<void> save(Exercise exercise) async {
if (throwOnSave) {
throw Exception('save failed');
}
saved.add(exercise);
exercises.removeWhere((item) => item.metadata.id == exercise.metadata.id);
exercises.add(exercise);

View File

@ -71,6 +71,32 @@ void main() {
);
});
testWidgets('le détail affiche la fréquence cardiaque quand elle existe', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(
id: 'history-1',
averageHeartRateBpm: 126.4,
maxHeartRateBpm: 171,
),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(),
closeUseCase: _closeUseCase(),
),
),
);
expect(find.text('Fréquence cardiaque'), findsOneWidget);
expect(find.text('Moyenne'), findsOneWidget);
expect(find.text('Max'), findsOneWidget);
expect(find.text('126 bpm', findRichText: true), findsOneWidget);
expect(find.text('171 bpm', findRichText: true), findsOneWidget);
});
testWidgets('le détail affiche le score chrono comme temps réalisé', (
tester,
) async {
@ -156,6 +182,72 @@ void main() {
expect(repository.deletedIds, ['history-1']);
});
testWidgets('relancer un historique vide affiche une erreur sans navigation', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository();
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-empty', emptySnapshot: true),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(activeRepository),
closeUseCase: _closeUseCase(),
),
),
);
await tester.tap(find.text('Relancer cette séance'));
await tester.pump();
expect(
find.text(
'Cette séance ne peut pas être relancée car elle ne contient aucun exercice.',
),
findsOneWidget,
);
expect(find.byType(WorkoutExecutionScreen), findsNothing);
});
testWidgets('une session legacy invalide ne bloque pas une relance valide', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('legacy-session'),
sourceWorkoutTemplateId: 'template-legacy',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 9),
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptyResolvedSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
home: HistoryDetailScreen(
history: _history(id: 'history-1'),
historyUseCases: _historyUseCases(_FakeWorkoutHistoryRepository()),
workoutTemplateUseCases: _workoutTemplateUseCases(),
activeUseCases: _activeUseCases(activeRepository),
closeUseCase: _closeUseCase(),
),
),
);
await tester.tap(find.text('Relancer cette séance'));
await tester.pumpAndSettle();
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
expect(activeRepository.session?.sourceWorkoutTemplateId, isNull);
});
}
WorkoutHistory _history({
@ -163,6 +255,9 @@ WorkoutHistory _history({
DateTime? startedAt,
bool stopwatchScore = false,
bool withStepResults = false,
bool emptySnapshot = false,
double? averageHeartRateBpm,
int? maxHeartRateBpm,
}) {
final start = startedAt ?? DateTime.utc(2026, 7, 17, 10);
return WorkoutHistory(
@ -173,13 +268,17 @@ WorkoutHistory _history({
startedAt: start,
endedAt: start.add(const Duration(minutes: 30)),
totalActiveMs: 1800000,
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
completed: true,
historySnapshotJson: jsonEncode({
'sessionId': 'session-1',
'resolvedTemplateSnapshotJson': _resolvedSnapshot(
stopwatchScore: stopwatchScore,
withSteps: withStepResults,
),
'resolvedTemplateSnapshotJson': emptySnapshot
? _emptyResolvedSnapshot()
: _resolvedSnapshot(
stopwatchScore: stopwatchScore,
withSteps: withStepResults,
),
'results': [
{
'programSnapshotId': 'program-snapshot-1',
@ -308,6 +407,20 @@ String _resolvedSnapshot({
});
}
String _emptyResolvedSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'program-snapshot-empty',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
@ -336,9 +449,11 @@ WorkoutTemplateUseCases _workoutTemplateUseCases() {
);
}
ActiveWorkoutSessionUseCases _activeUseCases() {
ActiveWorkoutSessionUseCases _activeUseCases([
_FakeActiveSessionRepository? repository,
]) {
return ActiveWorkoutSessionUseCases(
sessionRepository: _FakeActiveSessionRepository(),
sessionRepository: repository ?? _FakeActiveSessionRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17)),
ids: _FakeIds(),
@ -392,6 +507,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@ -441,11 +564,15 @@ final class _FakeProgramRepository implements ProgramRepository {
}
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<ActiveWorkoutSession?> findById(String id) async => null;
ActiveWorkoutSession? session;
@override
Future<ActiveWorkoutSession?> findOpen() async => null;
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
}
@override
Future<ActiveWorkoutSession?> findOpen() async => session;
@override
Future<ActiveRestState?> findRestStateById(String id) async => null;
@ -458,6 +585,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required int setIndex,
}) async => null;
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -486,6 +621,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const [];
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
@ -511,7 +653,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
@override
Future<void> save(ActiveWorkoutSession session) async {}
Future<void> save(ActiveWorkoutSession session) async {
this.session = session;
}
@override
Future<void> saveRestState(ActiveRestState restState) async {}
@ -519,6 +663,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@ -539,6 +686,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}

View File

@ -102,6 +102,37 @@ void main() {
);
});
testWidgets('le bandeau de reprise masque une séance invalide', (
tester,
) async {
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 12),
pausedAt: DateTime.utc(2026, 7, 17, 12, 5),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12, 5),
elapsedActiveMs: 300000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptySessionSnapshot(),
);
await tester.pumpWidget(
MaterialApp(
navigatorObservers: [homeRouteObserver],
home: HomeScreen(bootstrap: _FakeBootstrap(activeRepository)),
),
);
await tester.pump();
await tester.pump();
expect(find.textContaining('Séance en cours'), findsNothing);
expect(tester.takeException(), isNull);
});
testWidgets('le bandeau de reprise disparaît au retour après abandon', (
tester,
) async {
@ -196,6 +227,9 @@ final class _FakeBootstrap implements AppDependencies {
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
),
closeWorkoutSessionUseCase = CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: _FakeWorkoutHistoryRepository(),
@ -282,6 +316,9 @@ final class _FakeBootstrap implements AppDependencies {
@override
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
@override
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
@override
final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase;
@ -327,6 +364,20 @@ String _sessionSnapshot() {
});
}
String _emptySessionSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
EntityMetadata _metadata(String id) {
return EntityMetadata(
id: id,
@ -553,6 +604,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -585,6 +646,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return scoreStopwatchStates;
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return setTimerStates;
@ -624,6 +692,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.add(state);
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
setTimerStates.add(state);
@ -646,6 +717,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);
@ -808,6 +888,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}

View File

@ -27,7 +27,7 @@ void main() {
);
await tester.pump();
expect(find.text('Compte optionnel'), findsOneWidget);
expect(find.text('Compte GameTime'), findsOneWidget);
expect(find.text('Créer un compte'), findsOneWidget);
expect(find.text('Se connecter'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
@ -52,6 +52,43 @@ void main() {
);
await tester.pump();
await tester.tap(find.text('Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Tu peux revenir à GameTime.'), findsNothing);
await tester.enterText(
find.widgetWithText(TextFormField, 'Email'),
'a@b.fr',
);
await tester.enterText(
find.widgetWithText(TextFormField, 'Mot de passe'),
'password1',
);
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('une erreur serveur affiche le message dédié', (tester) async {
final harness = _AuthHarness()
..remote.loginFailure = RemoteAuthFailure.server;
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
),
),
);
await tester.pump();
await tester.tap(find.text('Se connecter'));
await tester.pumpAndSettle();
await tester.enterText(
@ -65,7 +102,10 @@ void main() {
await tester.tap(find.widgetWithText(FilledButton, 'Se connecter'));
await tester.pumpAndSettle();
expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget);
expect(
find.text('Serveur indisponible pour le moment. Réessaie plus tard.'),
findsOneWidget,
);
expect(find.byType(AlertDialog), findsNothing);
});
@ -142,7 +182,7 @@ void main() {
expect(harness.tokenStore.token, isNull);
expect(harness.accountRepository.session?.isLoggedIn, isFalse);
expect(find.text('Compte optionnel'), findsOneWidget);
expect(find.text('Compte GameTime'), findsOneWidget);
});
testWidgets(
@ -295,36 +335,33 @@ void main() {
},
);
testWidgets(
'export réussi affiche le message de sauvegarde exportée',
(tester) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter(
status: ShareResultStatus.success,
);
testWidgets('export réussi affiche le message de sauvegarde exportée', (
tester,
) async {
final harness = _AuthHarness();
final exporter = _FakeBackupFileExporter(status: ShareResultStatus.success);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFileExporter: exporter,
),
);
await tester.pump();
),
);
await tester.pump();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Exporter mes données'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
expect(exporter.exportedFileName, isNotNull);
expect(exporter.exportedFileName, endsWith('.gametime'));
},
);
expect(find.text('Sauvegarde exportée.'), findsOneWidget);
expect(exporter.exportedFileName, isNotNull);
expect(exporter.exportedFileName, endsWith('.gametime'));
});
testWidgets('un échec dexport affiche un message neutre', (tester) async {
final harness = _AuthHarness();
@ -404,74 +441,70 @@ void main() {
]);
});
testWidgets(
'remplacer tout exige une seconde confirmation destructive',
(tester) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
testWidgets('remplacer tout exige une seconde confirmation destructive', (
tester,
) async {
final harness = _AuthHarness()..backupRepository.hasData = true;
final bytes = const LocalBackupCodec().encode(
LocalDataExportSnapshot(
exportedAt: DateTime.utc(2026, 7, 22, 10, 15),
appSchemaVersion: 19,
originDeviceId: 'device-1',
mediaAssets: const [],
exercises: const [],
programs: const [],
workoutTemplates: const [],
workoutHistories: const [],
mediaFiles: const [],
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
);
final picker = _FakeBackupFilePicker(
file: LocalBackupPickedFile(
fileName: 'gametime-sauvegarde-2026-07-22.gametime',
bytes: bytes,
),
);
),
);
await tester.pump();
await tester.pumpWidget(
MaterialApp(
home: ProfileScreen(
authUseCases: harness.useCases,
syncUseCases: harness.syncUseCases,
shareUseCases: harness.shareUseCases,
dataExportUseCase: harness.dataExportUseCase,
dataImportUseCase: harness.dataImportUseCase,
backupFilePicker: picker,
),
),
);
await tester.pump();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
expect(find.text('Remplacer toutes les données locales ?'), findsOneWidget);
expect(harness.backupRepository.appliedModes, isEmpty);
expect(
find.text('Remplacer toutes les données locales ?'),
findsOneWidget,
);
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Annuler'));
await tester.pumpAndSettle();
expect(harness.backupRepository.appliedModes, isEmpty);
expect(harness.backupRepository.appliedModes, isEmpty);
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.text('Importer des données'));
await tester.pumpAndSettle();
await tester.tap(find.text('Remplacer tout'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, 'Remplacer tout'));
await tester.pumpAndSettle();
expect(find.text('Sauvegarde restaurée.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.replaceAll,
]);
},
);
expect(find.text('Sauvegarde restaurée.'), findsOneWidget);
expect(harness.backupRepository.appliedModes, [
LocalBackupImportMode.replaceAll,
]);
});
testWidgets('un fichier invalide affiche le message dédié', (tester) async {
final harness = _AuthHarness();

View File

@ -15,6 +15,22 @@ void main() {
name: 'Fondations basket - 45 min',
defaultRestSeconds: 45,
isExample: true,
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository();
@ -65,6 +81,44 @@ void main() {
);
});
testWidgets('un programme sans exercice ne peut pas être enregistré', (
tester,
) async {
final programRepository = _FakeProgramRepository();
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
home: ProgramFormScreen(
programUseCases: _programUseCases(
programRepository,
exerciseRepository,
),
exerciseUseCases: _exerciseUseCases(exerciseRepository),
),
),
);
expect(
find.text('Ajoute au moins un exercice pour enregistrer ce programme.'),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNull,
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Vide');
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(programRepository.saved, isEmpty);
});
testWidgets('filtre les programmes par tag et peut effacer les filtres', (
tester,
) async {
@ -125,9 +179,36 @@ void main() {
name: 'Jambes',
defaultRestSeconds: 60,
tags: const ['match'],
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
sourceExerciseId: 'exercise-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository()
..exercises.add(
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tirs',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
),
);
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(
@ -167,9 +248,36 @@ void main() {
name: 'Fondations basket - 45 min',
defaultRestSeconds: 45,
isExample: true,
exercises: [
ProgramExercise(
metadata: _metadata('program-exercise-1'),
programId: 'program-1',
sourceExerciseId: 'exercise-1',
position: 0,
exerciseNameSnapshot: 'Tirs',
availableTimeSnapshot: false,
availableRepsSnapshot: true,
availableScoreSnapshot: false,
setsCount: 3,
timeEnabled: false,
repsEnabled: true,
scoreEnabled: false,
targetReps: 10,
),
],
),
);
final exerciseRepository = _FakeExerciseRepository()
..exercises.add(
Exercise(
metadata: _metadata('exercise-1'),
name: 'Tirs',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 10,
),
);
final exerciseRepository = _FakeExerciseRepository();
await tester.pumpWidget(
MaterialApp(

View File

@ -456,6 +456,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@ -539,6 +547,14 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required int setIndex,
}) async => null;
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async => null;
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -567,6 +583,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
String sessionId,
) async => const [];
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async => const [];
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return const [];
@ -594,9 +615,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
}

View File

@ -6,6 +6,7 @@ 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';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
testWidgets('le temps affiché est recalculé depuis les horodatages', (
@ -45,6 +46,48 @@ void main() {
expect(find.widgetWithText(FilledButton, 'Démarrer'), findsOneWidget);
});
testWidgets('une séance invalide est abandonnée avec un message clair', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12, 0, 15));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 11, 59),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 30000,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _emptySessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(find.text('Séance indisponible'), findsOneWidget);
expect(
find.text(
'Cette séance ne peut plus être reprise. Elle a été abandonnée automatiquement.',
),
findsOneWidget,
);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
});
testWidgets('les répétitions sont initialisées avec la cible', (
tester,
) async {
@ -81,6 +124,214 @@ void main() {
expect(find.text('8'), findsOneWidget);
});
testWidgets('affiche les statistiques montre live disponibles', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
),
)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 124,
distanceMeters: 840,
caloriesKcal: 186,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.text('840 m'), findsOneWidget);
expect(find.text('186 kcal'), findsOneWidget);
});
testWidgets('naffiche aucun indicateur capteur sans donnée FC', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.textContaining('bpm'), findsNothing);
expect(find.textContaining('kcal'), findsNothing);
expect(find.byIcon(Icons.favorite), findsNothing);
expect(find.byIcon(Icons.local_fire_department), findsNothing);
});
testWidgets('ne fabrique pas de calories sans mesure montre native', (
tester,
) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(hours: 1))
.millisecondsSinceEpoch,
heartRateBpm: 124,
),
)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now.millisecondsSinceEpoch,
heartRateBpm: 124,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 124 bpm'), findsOneWidget);
expect(find.textContaining('kcal'), findsNothing);
});
testWidgets('atténue la fréquence cardiaque périmée', (tester) async {
final now = DateTime.now().toUtc();
final clock = _FakeClock(now);
final repository = _FakeActiveSessionRepository();
final sensorUseCases = ActiveWorkoutSensorUseCases(clock: clock)
..recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: now
.subtract(const Duration(seconds: 30))
.millisecondsSinceEpoch,
heartRateBpm: 118,
),
);
addTearDown(sensorUseCases.dispose);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: now,
lastPersistedAt: now,
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
sensorUseCases: sensorUseCases,
),
),
);
expect(find.text('FC 118 bpm'), findsOneWidget);
expect(
find.ancestor(
of: find.text('FC 118 bpm'),
matching: find.byWidgetPredicate(
(widget) => widget is Opacity && widget.opacity == 0.45,
),
),
findsOneWidget,
);
});
testWidgets('les références de temps sont formatées sans valeurs brutes', (
tester,
) async {
@ -152,7 +403,9 @@ void main() {
tester,
) async {
final scoreController = TextEditingController();
final scoreFocusNode = FocusNode();
addTearDown(scoreController.dispose);
addTearDown(scoreFocusNode.dispose);
await tester.pumpWidget(
MaterialApp(
@ -170,6 +423,8 @@ void main() {
),
reps: 0,
scoreController: scoreController,
scoreFocusNode: scoreFocusNode,
onScoreSubmitted: (_) {},
onRepsChanged: (_) {},
),
),
@ -808,6 +1063,63 @@ void main() {
},
);
testWidgets(
'la saisie du score téléphone alimente le score vivant de la montre',
(tester) async {
await tester.binding.setSurfaceSize(const Size(400, 1400));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final activeUseCases = _activeUseCases(repository, clock);
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
restSeconds: 0,
scoreUnit: 'points',
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: activeUseCases,
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.enterText(
find.widgetWithText(TextField, 'Score (points)'),
'7.5',
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(repository.manualScoreStates.single.value, 7.5);
final incremented = await activeUseCases.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(incremented.state.value, 8.5);
},
);
testWidgets('la flèche de retour met la séance active en pause', (
tester,
) async {
@ -1186,11 +1498,75 @@ void main() {
expect(find.byType(ListView), findsNothing);
expect(find.text('SÉQUENCE'), findsOneWidget);
expect(find.text('Passage 1 / 10'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Dribble main droite'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
});
testWidgets('un exercice dense avec étapes et scores reste scrollable', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 700));
addTearDown(() => tester.binding.setSurfaceSize(null));
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository();
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
sourceWorkoutTemplateId: 'template-1',
status: ActiveWorkoutStatus.running,
startedAt: DateTime.utc(2026, 7, 17, 12),
lastPersistedAt: DateTime.utc(2026, 7, 17, 12),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: _sessionSnapshot(
setsCount: 3,
targetReps: 20,
scoreEnabled: true,
scoreUnit: 'paniers',
exerciseSteps: [
_step(
'step-1',
0,
'Tirs après dribble',
ExerciseStepType.time,
10,
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'paniers',
defaultTargetScore: 8,
),
_step('step-2', 1, 'Replacement défensif', ExerciseStepType.reps, 6),
],
),
);
repository.session = session;
await tester.pumpWidget(
MaterialApp(
home: WorkoutExecutionScreen(
initialSession: session,
activeUseCases: _activeUseCases(repository, clock),
stepUseCases: _stepUseCases(repository, clock),
stepAudioCuePlayer: _FakeStepAudioCuePlayer(),
closeUseCase: _closeUseCase(repository, clock),
historyUseCases: _historyUseCases(clock),
workoutTemplateUseCases: _workoutTemplateUseCases(),
),
),
);
await tester.pump();
expect(tester.takeException(), isNull);
expect(find.text('ÉTAPE 1 / 2'), findsNothing);
expect(find.text('Tirs après dribble'), findsOneWidget);
expect(find.text('00:10'), findsOneWidget);
expect(find.text('Passer létape'), findsOneWidget);
expect(find.byType(SingleChildScrollView), findsWidgets);
});
testWidgets('lécran actif avec séquence longue reste sans scroll', (
tester,
) async {
@ -1465,7 +1841,7 @@ void main() {
expect(repository.stepResults.single.stepIndex, 0);
expect(repository.stepResults.single.status, SetResultStatus.completed);
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(
@ -1526,7 +1902,7 @@ void main() {
await tester.pump();
expect(repository.stepResults.single.stepIndex, 0);
expect(find.text('ÉTAPE 2 / 2'), findsOneWidget);
expect(find.text('ÉTAPE 2 / 2'), findsNothing);
expect(find.text('Droite'), findsOneWidget);
expect(find.text('00:02'), findsOneWidget);
expect(find.text('Chrono prêt'), findsOneWidget);
@ -1847,6 +2223,20 @@ String _sessionSnapshot({
});
}
String _emptySessionSnapshot() {
return jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-1',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
});
}
Map<String, Object?> _step(
String id,
int position,
@ -1960,6 +2350,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final results = <ActiveSetResult>[];
final restStates = <ActiveRestState>[];
final scoreStopwatchStates = <ActiveScoreStopwatchState>[];
final manualScoreStates = <ActiveManualScoreState>[];
final setTimerStates = <ActiveSetTimerState>[];
final stepProgressStates = <ActiveExerciseStepProgressState>[];
final stepResults = <ActiveExerciseStepResult>[];
@ -2001,6 +2392,25 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
for (final state in manualScoreStates) {
if (state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null) {
return state;
}
}
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -2055,6 +2465,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results;
@ -2114,6 +2533,18 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
final index = manualScoreStates.indexWhere(
(saved) => saved.metadata.id == state.metadata.id,
);
if (index == -1) {
manualScoreStates.add(state);
} else {
manualScoreStates[index] = state;
}
}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {
final index = setTimerStates.indexWhere(
@ -2183,6 +2614,23 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.removeWhere(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex,
);
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
final index = results.indexWhere(
@ -2237,6 +2685,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}

View File

@ -126,6 +126,134 @@ void main() {
);
});
testWidgets('une séance sans programme ne peut pas être enregistrée', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository(const []);
final programRepository = _FakeProgramRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateFormScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
),
),
);
expect(
find.text('Ajoute au moins un programme pour enregistrer cette séance.'),
findsOneWidget,
);
expect(
tester
.widget<FilledButton>(
find.widgetWithText(FilledButton, 'Enregistrer'),
)
.onPressed,
isNull,
);
await tester.enterText(find.widgetWithText(TextFormField, 'Nom'), 'Vide');
await tester.tap(find.text('Enregistrer'));
await tester.pump();
expect(templateRepository.saved, isEmpty);
});
testWidgets('le lancement est bloqué si aucun exercice ne peut être joué', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
WorkoutTemplate(
metadata: _metadata('template-empty'),
name: 'Séance vide',
programs: [
WorkoutTemplateProgram(
metadata: _metadata('template-program-empty'),
workoutTemplateId: 'template-empty',
position: 0,
programNameSnapshot: 'Programme vide',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({'exercises': const []}),
),
],
),
]);
final activeRepository = _FakeActiveSessionRepository();
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('Lancer'));
await tester.pump();
expect(
find.text(
'Cette séance ne contient aucun exercice. Ajoute un programme avec '
'au moins un exercice avant de la lancer.',
),
findsOneWidget,
);
expect(activeRepository.session, isNull);
});
testWidgets('filtre les séances par tag et peut effacer les filtres', (
tester,
) async {
@ -517,6 +645,89 @@ void main() {
},
);
testWidgets('une session legacy invalide ne bloque pas le lancement', (
tester,
) async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository([
_workoutTemplate(),
]);
final activeRepository = _FakeActiveSessionRepository()
..session = ActiveWorkoutSession(
metadata: _metadata('legacy-session'),
sourceWorkoutTemplateId: 'template-legacy',
status: ActiveWorkoutStatus.paused,
startedAt: DateTime.utc(2026, 7, 17, 9),
lastPersistedAt: DateTime.utc(2026, 7, 17, 9),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({
'name': 'Séance vide',
'programs': [
{
'id': 'template-program-empty',
'programNameSnapshot': 'Programme vide',
'programSnapshotJson': jsonEncode({'exercises': const []}),
},
],
'overrides': const [],
}),
);
final programRepository = _FakeProgramRepository();
final historyRepository = _FakeWorkoutHistoryRepository();
await tester.pumpWidget(
MaterialApp(
home: WorkoutTemplateListScreen(
workoutTemplateUseCases: WorkoutTemplateUseCases(
templateRepository: templateRepository,
programRepository: programRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
programUseCases: ProgramUseCases(
programRepository: programRepository,
exerciseRepository: _FakeExerciseRepository(),
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
activeUseCases: ActiveWorkoutSessionUseCases(
sessionRepository: activeRepository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
closeUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeRepository,
historyRepository: historyRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
),
historyUseCases: WorkoutHistoryUseCases(
repository: historyRepository,
clock: clock,
),
),
),
);
await tester.pump();
await tester.pump();
await tester.tap(find.text('Lancer'));
await tester.pumpAndSettle();
expect(find.textContaining('Une séance est déjà en cours.'), findsNothing);
expect(find.byType(WorkoutExecutionScreen), findsOneWidget);
expect(activeRepository.session?.sourceWorkoutTemplateId, 'template-1');
});
testWidgets(
'supprimer une séance demande confirmation puis la retire de la liste',
(tester) async {
@ -828,6 +1039,16 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return null;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return null;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -860,6 +1081,13 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
return const [];
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return const [];
}
@override
Future<List<ActiveSetTimerState>> listSetTimerStates(String sessionId) async {
return const [];
@ -896,6 +1124,9 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveScoreStopwatchState(ActiveScoreStopwatchState state) async {}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {}
@override
Future<void> saveSetTimerState(ActiveSetTimerState state) async {}
@ -916,6 +1147,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
required DateTime deletedAt,
}) async {}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {}
@override
Future<void> saveSetResult(ActiveSetResult result) async {}
}
@ -964,6 +1204,14 @@ final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
@override
Future<void> save(WorkoutHistory history) async {}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}