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:
@ -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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user