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