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

@ -213,6 +213,189 @@ void main() {
expect(env.repository.session?.currentSetIndex, 1);
});
test(
'accepts skipCurrentRest after a refresh that keeps the same revision',
() async {
final session = _session(setsCount: 2);
final repository = _FakeActiveSessionRepository()..session = session;
repository.restStates['rest'] = ActiveRestState(
metadata: _metadata('rest'),
activeWorkoutSessionId: session.metadata.id,
afterProgramIndex: 0,
afterExerciseIndex: 0,
afterSetIndex: 0,
plannedRestSeconds: 60,
adjustedRestSeconds: 60,
startedAt: _now,
);
final clock = _FakeClock(_now);
final ids = _FakeIds();
final activeUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
clock: clock,
ids: ids,
originDeviceId: 'device-1',
);
final projectionUseCases = WatchCompanionProjectionUseCases(
sessionRepository: repository,
clock: clock,
ids: ids,
originDeviceId: 'device-1',
);
final handler = WatchCompanionCommandHandler(
sessionRepository: repository,
activeSessionUseCases: activeUseCases,
stepUseCases: ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: ids,
originDeviceId: 'device-1',
activeSessionUseCases: activeUseCases,
),
projectionSource: projectionUseCases,
);
final firstProjection = await projectionUseCases.emitCurrentProjection();
final refreshedProjection = await projectionUseCases
.emitCurrentProjection();
final ack = await handler.dispatch(
_command(
WatchCommandType.skipCurrentRest,
expectedRevision: firstProjection.revision,
),
);
expect(refreshedProjection.revision, firstProjection.revision);
expect(ack, WatchCommandAck.accepted);
expect(repository.restStates['rest']?.skippedAt, isNotNull);
expect(repository.session?.currentSetIndex, 1);
await projectionUseCases.dispose();
},
);
test(
'routes incrementScore and decrementScore to manual score use cases',
() async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(hasManualScore: true),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 1);
expect(
await env.dispatch(
WatchCommandType.decrementScore,
commandId: 'command-2',
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 0);
},
);
test('routes score commands to independent current step score', () async {
final session = _session(
scoreEnabled: true,
steps: [
_step(
hasScore: true,
scoreLabel: 'Réussites',
scoreUnit: 'pts',
defaultTargetScore: 5,
),
],
);
final env = _env(
session: session,
projection: _projection(
hasManualScore: true,
manualScoreScope: WatchManualScoreScope.step,
),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates, isEmpty);
expect(env.repository.stepResults.single.actualScore, 1);
expect(
await env.dispatch(
WatchCommandType.decrementScore,
commandId: 'command-2',
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates, isEmpty);
expect(env.repository.stepResults.last.actualScore, 0);
});
test('decrementScore at zero is accepted no-op', () async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(hasManualScore: true),
);
final ack = await env.dispatch(WatchCommandType.decrementScore);
expect(ack, WatchCommandAck.acceptedNoOp);
expect(env.repository.manualScoreStates.values.single.value, 0);
expect(env.projections.emitCount, 0);
});
test('rejects score commands outside manual score mode', () async {
final env = _env(
session: _session(
scoreEnabled: true,
scoreInputMode: ScoreInputMode.stopwatch,
),
projection: _projection(hasManualScore: false),
);
expect(
await env.dispatch(WatchCommandType.incrementScore),
WatchCommandAck.rejectedNotApplicable,
);
expect(env.repository.manualScoreStates, isEmpty);
});
test(
'accepts repeated score commands with stale expected revisions',
() async {
final env = _env(
session: _session(scoreEnabled: true),
projection: _projection(revision: 3, hasManualScore: true),
);
expect(
await env.dispatch(
WatchCommandType.incrementScore,
commandId: 'command-1',
expectedRevision: 1,
),
WatchCommandAck.accepted,
);
expect(
await env.dispatch(
WatchCommandType.incrementScore,
commandId: 'command-2',
expectedRevision: 1,
),
WatchCommandAck.accepted,
);
expect(env.repository.manualScoreStates.values.single.value, 2);
},
);
test(
'rejects stale revision, non applicable, missing and mismatch',
() async {
@ -256,6 +439,22 @@ void main() {
);
},
);
test('rejects commands when no session is active', () async {
final env = _env(
projection: _projection(
phase: WatchSessionPhase.noActiveSession,
deviceSessionId: '',
primaryAction: WatchPrimaryAction.none,
),
);
expect(
await env.dispatch(WatchCommandType.startCurrentExercise),
WatchCommandAck.rejectedNoActiveSession,
);
expect(env.repository.session, isNull);
});
}
final _now = DateTime.utc(2026, 7, 25, 12);
@ -263,13 +462,14 @@ final _now = DateTime.utc(2026, 7, 25, 12);
_Harness _env({
ActiveWorkoutSession? session,
required WatchSessionProjection projection,
_FakeWorkoutTemplateRepository? templateRepository,
}) {
final repository = _FakeActiveSessionRepository()..session = session;
final clock = _FakeClock(_now);
final ids = _FakeIds();
final activeUseCases = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: _FakeWorkoutTemplateRepository(),
templateRepository: templateRepository ?? _FakeWorkoutTemplateRepository(),
clock: clock,
ids: ids,
originDeviceId: 'device-1',
@ -304,20 +504,27 @@ final class _Harness {
final _FakeProjectionSource projections;
final WatchCompanionCommandHandler handler;
Future<WatchCommandAck> dispatch(WatchCommandType type) {
return handler.dispatch(_command(type));
Future<WatchCommandAck> dispatch(
WatchCommandType type, {
String commandId = 'command-1',
int expectedRevision = 1,
}) {
return handler.dispatch(
_command(type, commandId: commandId, expectedRevision: expectedRevision),
);
}
}
WatchCommandEnvelope _command(
WatchCommandType type, {
String commandId = 'command-1',
int expectedRevision = 1,
}) {
return WatchCommandEnvelope(
commandId: commandId,
type: type,
sessionId: 'session-1',
expectedRevision: 1,
expectedRevision: expectedRevision,
sentAtEpochMs: _now.millisecondsSinceEpoch,
);
}
@ -331,6 +538,10 @@ WatchSessionProjection _projection({
WatchSecondaryAction.finishCurrentSet,
WatchSecondaryAction.skipCurrentSet,
],
bool hasManualScore = false,
double? currentManualScoreValue,
bool canDecrementScore = false,
WatchManualScoreScope? manualScoreScope,
}) {
return WatchSessionProjection(
deviceSessionId: deviceSessionId,
@ -343,6 +554,12 @@ WatchSessionProjection _projection({
exerciseName: 'Squat',
primaryAction: primaryAction,
secondaryActions: secondaryActions,
hasManualScore: hasManualScore,
currentManualScoreValue: currentManualScoreValue,
canDecrementScore: canDecrementScore,
manualScoreScope:
manualScoreScope ??
(hasManualScore ? WatchManualScoreScope.series : null),
);
}
@ -354,6 +571,8 @@ ActiveWorkoutSession _session({
bool timeEnabled = false,
int? targetReps = 10,
int restSeconds = 0,
bool scoreEnabled = false,
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
List<ExerciseStep> steps = const [],
}) {
final exerciseSnapshot = {
@ -362,9 +581,9 @@ ActiveWorkoutSession _session({
'setsCount': setsCount,
'timeEnabled': timeEnabled,
'repsEnabled': true,
'scoreEnabled': false,
'scoreEnabled': scoreEnabled,
'targetReps': targetReps,
'scoreInputModeSnapshot': ScoreInputMode.manual.name,
'scoreInputModeSnapshot': scoreInputMode.name,
'restSecondsOverride': restSeconds,
'exerciseStepsSnapshot': steps
.map((step) => step.toSnapshotJson())
@ -395,13 +614,24 @@ ActiveWorkoutSession _session({
);
}
ExerciseStep _step({String id = 'step-1', int position = 0}) {
ExerciseStep _step({
String id = 'step-1',
int position = 0,
bool hasScore = false,
String? scoreLabel,
String? scoreUnit,
double? defaultTargetScore,
}) {
return ExerciseStep(
id: id,
position: position,
name: 'Step ${position + 1}',
type: ExerciseStepType.time,
defaultTargetValue: 1,
hasScore: hasScore,
scoreLabel: scoreLabel,
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
);
}
@ -473,6 +703,10 @@ final class _FakeProjectionSource implements WatchProjectionSource {
exerciseName: projection.exerciseName,
primaryAction: projection.primaryAction,
secondaryActions: projection.secondaryActions,
hasManualScore: projection.hasManualScore,
currentManualScoreValue: projection.currentManualScoreValue,
canDecrementScore: projection.canDecrementScore,
manualScoreScope: projection.manualScoreScope,
);
return projection;
}
@ -499,11 +733,24 @@ final class _FakeIds implements IdGenerator {
final class _FakeWorkoutTemplateRepository
implements WorkoutTemplateRepository {
@override
Future<WorkoutTemplate?> findById(String id) async => null;
final templates = <WorkoutTemplate>[];
@override
Future<List<WorkoutTemplate>> listActive() async => const [];
Future<WorkoutTemplate?> findById(String id) async {
for (final template in templates) {
if (template.metadata.id == id && template.metadata.deletedAt == null) {
return template;
}
}
return null;
}
@override
Future<List<WorkoutTemplate>> listActive() async {
return templates
.where((template) => template.metadata.deletedAt == null)
.toList();
}
@override
Future<void> replaceComposition(
@ -512,7 +759,10 @@ final class _FakeWorkoutTemplateRepository
) async {}
@override
Future<void> save(WorkoutTemplate template) async {}
Future<void> save(WorkoutTemplate template) async {
templates.removeWhere((saved) => saved.metadata.id == template.metadata.id);
templates.add(template);
}
@override
Future<void> saveOverride(WorkoutTemplateExerciseOverride override) async {}
@ -527,6 +777,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>[];
@ -541,6 +792,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;
@ -584,6 +846,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,
@ -633,6 +910,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 +947,7 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
@override
Future<void> saveExerciseStepResult(ActiveExerciseStepResult result) async {
stepResults.removeWhere((item) => item.metadata.id == result.metadata.id);
stepResults.add(result);
}
@ -674,6 +961,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);