merge(develop): clôture lot #91 Wear OS - fréquence cardiaque, notifications, finitions montre

Intègre le commit de clôture du lot #91 resté non fusionné. Conflits
attendus sur entities.dart, workout_execution_screen.dart et
watch_companion_projection_test.dart : arbitrage Architect - garder la
base feature/ticket91-wear-os-watch-sync sur ces 3 fichiers puis réinjecter
uniquement les invariants chronos utiles (Durée / Temps réalisé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	lib/domain/entities.dart
#	lib/presentation/workout_execution_screen.dart
#	test/application/watch_companion_projection_test.dart
This commit is contained in:
2026-07-28 11:35:22 +02:00
80 changed files with 12296 additions and 891 deletions

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: (_) {},
),
),
@ -870,6 +1125,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 {
@ -1248,11 +1560,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 {
@ -1527,7 +1903,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(
@ -1588,7 +1964,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);
@ -1909,6 +2285,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,
@ -2022,6 +2412,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>[];
@ -2063,6 +2454,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,
@ -2117,6 +2527,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;
@ -2176,6 +2595,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(
@ -2245,6 +2676,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(
@ -2299,6 +2747,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 {}