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

@ -71,10 +71,10 @@ void main() {
expect(projection.phase, WatchSessionPhase.running);
expect(projection.dominantTimer?.kind, WatchTimerKind.step);
expect(projection.dominantTimer?.accumulatedMs, 0);
expect(projection.dominantTimer?.accumulatedMs, 5000);
expect(
projection.dominantTimer?.startedAtEpochMs,
now.subtract(const Duration(seconds: 5)).millisecondsSinceEpoch,
now.millisecondsSinceEpoch,
);
expect(projection.secondaryTimers.map((timer) => timer.kind), [
WatchTimerKind.scoreStopwatch,
@ -88,6 +88,103 @@ void main() {
},
);
test('projects manual score state for the current set', () async {
final session = _session(scoreEnabled: true, targetScore: 8);
final repository = _FakeActiveSessionRepository()
..session = session
..manualScoreStates['manual-score'] = ActiveManualScoreState(
metadata: _metadata('manual-score'),
activeWorkoutSessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: 3,
updatedAt: DateTime.utc(2026, 7, 25, 12),
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isTrue);
expect(projection.currentManualScoreValue, 3);
expect(projection.canDecrementScore, isTrue);
expect(projection.manualScoreTargetValue, 8);
expect(projection.manualScoreTargetLabel, 'Cible');
expect(projection.manualScoreScope, WatchManualScoreScope.series);
});
test(
'projects independent current step manual score before series score',
() async {
final session = _session(
scoreEnabled: true,
targetScore: 8,
steps: [
_step(
hasScore: true,
scoreLabel: 'Réussites',
scoreUnit: 'pts',
defaultTargetScore: 5,
),
],
);
final repository = _FakeActiveSessionRepository()
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.waitingManual,
)
..stepResults.add(
ActiveExerciseStepResult(
metadata: _metadata('step-score'),
activeWorkoutSessionId: session.metadata.id,
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
passageIndex: 0,
stepIndex: 0,
stepSnapshotId: 'step-1',
stepNameSnapshot: 'Step 1',
stepTypeSnapshot: ExerciseStepType.time,
targetValueSnapshot: 1,
hasScoreSnapshot: true,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Réussites',
scoreUnitSnapshot: 'pts',
targetScoreSnapshot: 5,
status: SetResultStatus.completed,
actualScore: 2,
),
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isTrue);
expect(projection.currentManualScoreValue, 2);
expect(projection.manualScoreTargetValue, 5);
expect(projection.manualScoreScope, WatchManualScoreScope.step);
},
);
test('does not project manual score controls for stopwatch score', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
);
final projector = _projector(repository, _clock());
final projection = await projector.project(revision: 1);
expect(projection.hasManualScore, isFalse);
expect(projection.currentManualScoreValue, isNull);
expect(projection.canDecrementScore, isFalse);
expect(projection.manualScoreTargetValue, isNull);
});
test('projects paused after a running session is paused', () async {
final now = DateTime.utc(2026, 7, 25, 12);
final session = _session(
@ -282,14 +379,22 @@ void main() {
);
test(
'emits projections through stream and publisher with incremented revision',
'keeps revision stable when refresh only changes volatile timing fields',
() async {
final now = DateTime.utc(2026, 7, 25, 12);
final clock = _clock(now);
final session = _session(steps: [_step(defaultTargetValue: 30)]);
final repository = _FakeActiveSessionRepository()
..session = _session(steps: [_step()]);
..session = session
..stepProgressStates['step-state'] = _stepState(
sessionId: session.metadata.id,
status: ActiveExerciseStepProgressStatus.runningTimer,
startedAt: now,
);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
@ -298,21 +403,54 @@ void main() {
final subscription = useCases.projections.listen(emitted.add);
final first = await useCases.emitCurrentProjection();
clock.value = now.add(const Duration(seconds: 2));
final second = await useCases.emitCurrentProjection();
await Future<void>.delayed(Duration.zero);
expect(first.revision, 1);
expect(second.revision, 2);
expect(emitted.map((projection) => projection.revision), [1, 2]);
expect(second.revision, 1);
expect(second.projectedAtEpochMs, greaterThan(first.projectedAtEpochMs));
expect(
second.dominantTimer?.accumulatedMs,
greaterThan(first.dominantTimer?.accumulatedMs ?? 0),
);
expect(emitted.map((projection) => projection.revision), [1, 1]);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
1,
]);
await subscription.cancel();
await useCases.dispose();
},
);
test('increments revision when session command state changes', () async {
final repository = _FakeActiveSessionRepository()
..session = _session(setsCount: 2);
final publisher = _FakeWatchProjectionPublisher();
final useCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: _clock(),
ids: _FakeIds(),
originDeviceId: 'device-1',
publisher: publisher,
);
final first = await useCases.emitCurrentProjection();
repository.session = repository.session!.copyWith(currentSetIndex: 1);
final second = await useCases.emitCurrentProjection();
expect(first.revision, 1);
expect(second.revision, 2);
expect(second.seriesIndex, 2);
expect(publisher.published.map((projection) => projection.revision), [
1,
2,
]);
await useCases.dispose();
});
}
WatchSessionProjectionProjector _projector(
@ -341,6 +479,7 @@ ActiveWorkoutSession _session({
bool repsEnabled = true,
bool scoreEnabled = false,
int? targetTimeSeconds,
double? targetScore,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
bool? autoStartNextTimedStepSnapshot = true,
List<ExerciseStep> steps = const [],
@ -355,6 +494,7 @@ ActiveWorkoutSession _session({
'scoreEnabled': scoreEnabled,
'targetTimeSeconds': targetTimeSeconds,
'targetReps': repsEnabled ? setsCount : null,
'targetScore': targetScore,
'scoreInputModeSnapshot': scoreInputMode.name,
'exerciseStepsSnapshot': steps
.map((step) => step.toSnapshotJson())
@ -403,6 +543,10 @@ ExerciseStep _step({
String id = 'step-1',
int position = 0,
int defaultTargetValue = 1,
bool hasScore = false,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
}) {
return ExerciseStep(
id: id,
@ -410,6 +554,10 @@ ExerciseStep _step({
name: 'Step ${position + 1}',
type: ExerciseStepType.time,
defaultTargetValue: defaultTargetValue,
hasScore: hasScore,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
);
}
@ -514,6 +662,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
final restStates = <String, ActiveRestState>{};
final setTimerStates = <String, ActiveSetTimerState>{};
final scoreStopwatchStates = <String, ActiveScoreStopwatchState>{};
final manualScoreStates = <String, ActiveManualScoreState>{};
final stepProgressStates = <String, ActiveExerciseStepProgressState>{};
final stepResults = <ActiveExerciseStepResult>[];
@ -528,6 +677,17 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.clear();
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
manualScoreStates.clear();
}
@override
Future<ActiveWorkoutSession?> findById(String id) async {
return session?.metadata.id == id ? session : null;
@ -571,6 +731,21 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
}).firstOrNull;
}
@override
Future<ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
return manualScoreStates.values.where((state) {
return state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex;
}).firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -620,6 +795,15 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where((state) => state.activeWorkoutSessionId == sessionId)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -661,6 +845,11 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates[state.metadata.id] = state;
}
@override
Future<void> saveManualScoreState(ActiveManualScoreState state) async {
manualScoreStates[state.metadata.id] = state;
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.add(result);