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

@ -4,6 +4,7 @@ import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:gametime/application/application.dart';
import 'package:gametime/domain/domain.dart';
import 'package:watch_bridge_contract/watch_bridge_contract.dart';
void main() {
test('Exercise requires at least one active measure', () {
@ -285,6 +286,40 @@ void main() {
);
});
test('linked step score requires manual series score', () async {
final linkedStep = _step(
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'pts',
linkedToSeriesScore: true,
);
final useCase = _exerciseUseCase(_FakeExerciseRepository());
await expectLater(
useCase.create(
name: 'Tirs',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: false,
defaultTargetReps: 1,
steps: [linkedStep],
),
throwsA(isA<DomainException>()),
);
await expectLater(
useCase.create(
name: 'Tirs chrono',
hasTimeMeasure: false,
hasRepsMeasure: true,
hasScoreMeasure: true,
scoreInputMode: ScoreInputMode.stopwatch,
defaultTargetReps: 1,
steps: [linkedStep],
),
throwsA(isA<DomainException>()),
);
});
test('Exercise image gallery rejects a sixth image', () async {
final repository = _FakeExerciseRepository()
..exercise = Exercise(
@ -560,6 +595,26 @@ void main() {
expect(program.exercises.single.autoStartNextTimedStepOverride, isFalse);
});
test('program saveConfigured rejects empty exercise list', () async {
final useCase = ProgramUseCases(
programRepository: _FakeProgramRepository(),
exerciseRepository: _FakeExerciseRepository(),
templateRepository: _FakeWorkoutTemplateRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.saveConfigured(
name: 'Programme',
defaultRestSeconds: 60,
exercises: const [],
),
throwsA(isA<DomainException>()),
);
});
test('workout template override preserves step chaining override', () async {
final templateRepository = _FakeWorkoutTemplateRepository();
final useCase = WorkoutTemplateUseCases(
@ -603,6 +658,54 @@ void main() {
expect(template.overrides.single.autoStartNextTimedStepOverride, isFalse);
});
test('workout template saveConfigured rejects empty program list', () async {
final useCase = WorkoutTemplateUseCases(
templateRepository: _FakeWorkoutTemplateRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.saveConfigured(
name: 'Séance',
programs: const [],
overrides: const [],
),
throwsA(isA<DomainException>()),
);
});
test(
'workout template saveConfigured rejects program snapshot without sets',
() async {
final useCase = WorkoutTemplateUseCases(
templateRepository: _FakeWorkoutTemplateRepository(),
programRepository: _FakeProgramRepository(),
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.saveConfigured(
name: 'Séance',
programs: [
WorkoutTemplateProgramConfig(
clientKey: 'program-1',
programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({'exercises': const []}),
),
],
overrides: const [],
),
throwsA(isA<DomainException>()),
);
},
);
test('delete exercise keeps existing program snapshots unchanged', () async {
final exerciseRepository = _FakeExerciseRepository()
..exercise = Exercise(
@ -854,6 +957,152 @@ void main() {
expect(resumed.elapsedActiveMillisecondsAt(clock.now()), 45000);
});
test(
'startFromTemplate rejects template without playable exercise',
() async {
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.add(
WorkoutTemplate(
metadata: _metadata('template-1'),
name: 'Séance vide',
),
);
final repository = _FakeActiveSessionRepository();
final useCase = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: templateRepository,
clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)),
ids: _FakeIds(),
originDeviceId: 'device-1',
);
await expectLater(
useCase.startFromTemplate('template-1'),
throwsA(isA<DomainException>()),
);
expect(repository.session, isNull);
},
);
test('startFromTemplate stamps the source template start time', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.add(_playableTemplate(id: 'template-1', name: 'A'));
final repository = _FakeActiveSessionRepository();
final useCase = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final session = await useCase.startFromTemplate('template-1');
expect(session.sourceWorkoutTemplateId, 'template-1');
expect(repository.session, session);
expect(templateRepository.templates.single.lastStartedAt, clock.now());
});
test(
'startFromLastTemplate starts most recently used playable template',
() async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final templateRepository = _FakeWorkoutTemplateRepository()
..templates.addAll([
_playableTemplate(
id: 'older',
name: 'B',
lastStartedAt: DateTime.utc(2026, 7, 10, 12),
),
_playableTemplate(
id: 'latest',
name: 'A',
lastStartedAt: DateTime.utc(2026, 7, 12, 12),
),
WorkoutTemplate(metadata: _metadata('empty'), name: 'Empty'),
]);
final repository = _FakeActiveSessionRepository();
final useCase = ActiveWorkoutSessionUseCases(
sessionRepository: repository,
templateRepository: templateRepository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
);
final session = await useCase.startFromLastTemplate();
expect(session.sourceWorkoutTemplateId, 'latest');
expect(repository.session, session);
},
);
test('resume abandons invalid session snapshot before throwing', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.paused,
startedAt: clock.now(),
pausedAt: clock.now(),
lastPersistedAt: clock.now(),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
await expectLater(
useCase.resume(session.metadata.id),
throwsA(isA<DomainException>()),
);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
expect(repository.session?.endedAt, clock.now());
});
test('findOpen silently abandons invalid legacy session', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = ActiveWorkoutSession(
metadata: _metadata('session-1'),
status: ActiveWorkoutStatus.running,
startedAt: clock.now(),
lastPersistedAt: clock.now(),
elapsedActiveMs: 0,
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
resolvedTemplateSnapshotJson: jsonEncode({'programs': const []}),
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
final open = await useCase.findOpen();
expect(open, isNull);
expect(repository.session?.status, ActiveWorkoutStatus.abandoned);
expect(repository.session?.endedAt, clock.now());
});
test('findOpen returns playable open session unchanged', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
);
final repository = _FakeActiveSessionRepository()..session = session;
final useCase = _activeUseCase(repository, clock);
final open = await useCase.findOpen();
expect(open, session);
expect(repository.session?.status, ActiveWorkoutStatus.running);
expect(repository.session?.endedAt, isNull);
});
test('adjustRestSeconds persists adjusted rest duration', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final repository = _FakeActiveSessionRepository()
@ -1420,6 +1669,78 @@ void main() {
},
);
test('linked step score controls current series manual score', () async {
final clock = _FakeClock(DateTime.utc(2026, 7, 17, 12));
final session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
setsCount: 1,
targetReps: 1,
scoreEnabled: true,
exerciseSteps: [
_step(
id: 'step-1',
position: 0,
name: 'Tirs main droite',
type: ExerciseStepType.reps,
defaultTargetValue: 10,
hasScore: true,
scoreLabel: 'Paniers',
scoreUnit: 'pts',
linkedToSeriesScore: true,
),
],
);
final repository = _FakeActiveSessionRepository()..session = session;
final activeUseCase = _activeUseCase(repository, clock);
final stepUseCase = ActiveExerciseStepUseCases(
sessionRepository: repository,
clock: clock,
ids: _FakeIds(),
originDeviceId: 'device-1',
activeSessionUseCases: activeUseCase,
);
await stepUseCase.startOrResumeProgress(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await stepUseCase.incrementCurrentStepScore(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await stepUseCase.incrementCurrentStepScore(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await stepUseCase.decrementCurrentStepScore(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(repository.manualScoreStates.values.single.value, 1);
final result = await stepUseCase.completeCurrentStep(
sessionId: session.metadata.id,
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 3,
);
expect(result.actualScore, 3);
expect(repository.manualScoreStates.values.single.value, 4);
});
test(
'step chaining resolution uses template override before program override',
() async {
@ -2257,6 +2578,365 @@ void main() {
);
});
test(
'recordCurrentSetResult uses live manual score unless overridden',
() async {
final repository = _FakeActiveSessionRepository()
..session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
scoreEnabled: true,
);
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
final useCase = _activeUseCase(repository, clock);
await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final fromLiveState = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
expect(fromLiveState.actualScore, 2);
expect(repository.manualScoreStates, isEmpty);
await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
final overridden = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 5,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
expect(overridden.actualScore, 5);
},
);
test('recordCurrentSetResult upserts by logical set key', () async {
final repository = _FakeActiveSessionRepository()
..session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
scoreEnabled: true,
);
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
final useCase = _activeUseCase(repository, clock);
final first = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 2,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
clock.value = DateTime.utc(2026, 7, 25, 12, 1);
final second = await useCase.recordCurrentSetResult(
sessionId: 'session-1',
programSnapshotId: 'program-snapshot-1',
exerciseSnapshotId: 'exercise-snapshot-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
actualScore: 5,
scoreInputModeSnapshot: ScoreInputMode.manual,
scoreLabelSnapshot: 'Points',
scoreUnitSnapshot: 'pts',
);
expect(second.metadata.id, first.metadata.id);
expect(second.metadata.localRevision, first.metadata.localRevision + 1);
expect(repository.results, hasLength(1));
expect(repository.results.single.actualScore, 5);
});
test(
'setManualScore pushes phone corrections into live manual score state',
() async {
final repository = _FakeActiveSessionRepository()
..session = _sessionWithSnapshot(
currentProgramIndex: 0,
currentExerciseIndex: 0,
currentSetIndex: 0,
scoreEnabled: true,
);
final clock = _FakeClock(DateTime.utc(2026, 7, 25, 12));
final useCase = _activeUseCase(repository, clock);
final corrected = await useCase.setManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: 7.5,
);
expect(corrected.state.value, 7.5);
expect(corrected.changed, isTrue);
final incremented = await useCase.incrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(incremented.state.value, 8.5);
final clamped = await useCase.setManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
value: -4,
);
expect(clamped.state.value, 0);
final decremented = await useCase.decrementManualScore(
sessionId: 'session-1',
programIndex: 0,
exerciseIndex: 0,
setIndex: 0,
);
expect(decremented.state.value, 0);
expect(decremented.changed, isFalse);
},
);
test('WorkoutHistoryUseCases applies heart rate summary once', () async {
final repository = _FakeWorkoutHistoryRepository()
..histories.add(
WorkoutHistory(
metadata: _metadata('history-1'),
sourceActiveWorkoutSessionId: 'session-1',
nameSnapshot: 'Seance',
startedAt: DateTime.utc(2026, 7, 25, 11),
endedAt: DateTime.utc(2026, 7, 25, 12),
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: '{"programs":[]}',
),
);
final useCase = WorkoutHistoryUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
);
await useCase.updateHeartRateSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 12,
averageHeartRateBpm: 126.5,
maxHeartRateBpm: 171,
),
);
await useCase.updateHeartRateSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 14,
averageHeartRateBpm: 130,
maxHeartRateBpm: 180,
),
);
expect(repository.histories.single.averageHeartRateBpm, 126.5);
expect(repository.histories.single.maxHeartRateBpm, 171);
});
test(
'ActiveWorkoutSensorUseCases tracks live heart rate and calories',
() async {
final useCase = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
);
final updates = <ActiveWorkoutSensorState>[];
final subscription = useCase.updates.listen(updates.add);
expect(
useCase.recordHeartRateSample(
const WatchSensorSample(
sessionId: '',
recordedAtEpochMs: 0,
heartRateBpm: 120,
),
),
isNull,
);
useCase.recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
).millisecondsSinceEpoch,
heartRateBpm: 120,
),
);
final state = useCase.recordHeartRateSample(
WatchSensorSample(
sessionId: 'session-1',
recordedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
30,
).millisecondsSinceEpoch,
heartRateBpm: 150,
),
);
await Future<void>.delayed(Duration.zero);
expect(state?.latestHeartRateBpm, 150);
expect(state?.sampleCount, 2);
expect(state?.averageHeartRateBpm, 135);
expect(state?.maxHeartRateBpm, 150);
expect(state?.estimatedCaloriesKcal, 187.5);
expect(updates, hasLength(2));
useCase.clear('session-1');
expect(useCase.current('session-1'), isNull);
await subscription.cancel();
await useCase.dispose();
},
);
test(
'ActiveWorkoutSensorUseCases tracks telemetry and ignores duplicates',
() async {
final useCase = ActiveWorkoutSensorUseCases(
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12)),
);
final first = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
).millisecondsSinceEpoch,
heartRateBpm: 120,
distanceMeters: 500,
caloriesKcal: 42,
),
);
final duplicate = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-1',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
1,
).millisecondsSinceEpoch,
heartRateBpm: 160,
distanceMeters: 300,
caloriesKcal: 10,
),
);
final distanceOnly = useCase.recordTelemetrySample(
WatchTelemetrySample(
sampleId: 'sample-2',
sessionId: 'session-1',
capturedAtEpochMs: DateTime.utc(
2026,
7,
25,
12,
2,
).millisecondsSinceEpoch,
distanceMeters: 620,
caloriesKcal: 48,
),
);
expect(first?.latestHeartRateBpm, 120);
expect(duplicate, isNull);
expect(distanceOnly?.sampleCount, 2);
expect(distanceOnly?.latestHeartRateBpm, 120);
expect(distanceOnly?.minHeartRateBpm, 120);
expect(distanceOnly?.averageHeartRateBpm, 120);
expect(distanceOnly?.maxHeartRateBpm, 120);
expect(distanceOnly?.latestDistanceMeters, 620);
expect(distanceOnly?.latestCaloriesKcal, 48);
await useCase.dispose();
},
);
test(
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
() async {
final repository = _FakeWorkoutHistoryRepository()
..histories.add(
WorkoutHistory(
metadata: _metadata('history-1'),
sourceActiveWorkoutSessionId: 'session-1',
nameSnapshot: 'Seance',
startedAt: DateTime.utc(2026, 7, 25, 11),
endedAt: DateTime.utc(2026, 7, 25, 12),
totalActiveMs: 3600000,
completed: true,
historySnapshotJson: '{"programs":[]}',
),
);
final useCase = WorkoutHistoryUseCases(
repository: repository,
clock: _FakeClock(DateTime.utc(2026, 7, 25, 12, 1)),
);
await useCase.updateHeartRateSummary(
const WatchSensorSummary(
sessionId: 'session-1',
sampleCount: 2,
averageHeartRateBpm: 126.5,
maxHeartRateBpm: 171,
),
);
expect(repository.histories.single.averageHeartRateBpm, isNull);
expect(repository.histories.single.maxHeartRateBpm, isNull);
},
);
test('Tags are normalized and validated on taggable entities', () {
final exercise = Exercise(
metadata: _metadata('exercise-tags'),
@ -3046,6 +3726,7 @@ ExerciseStep _step({
String? scoreUnit,
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
bool linkedToSeriesScore = false,
}) {
return ExerciseStep(
id: id,
@ -3059,6 +3740,7 @@ ExerciseStep _step({
scoreUnit: scoreUnit,
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
linkedToSeriesScore: linkedToSeriesScore,
);
}
@ -3720,6 +4402,43 @@ WorkoutTemplateProgram _templateProgram({
);
}
WorkoutTemplate _playableTemplate({
required String id,
required String name,
DateTime? lastStartedAt,
}) {
return WorkoutTemplate(
metadata: _metadata(id),
name: name,
lastStartedAt: lastStartedAt,
programs: [
WorkoutTemplateProgram(
metadata: _metadata('$id-program'),
workoutTemplateId: id,
sourceProgramId: null,
position: 0,
programNameSnapshot: 'Programme',
defaultRestSecondsSnapshot: 60,
programSnapshotJson: jsonEncode({
'exercises': [
{
'id': '$id-exercise',
'exerciseNameSnapshot': 'Squat',
'setsCount': 2,
'timeEnabled': false,
'repsEnabled': true,
'scoreEnabled': false,
'targetReps': 10,
'exerciseStepsSnapshot': const [],
'autoStartNextTimedStepSnapshot': false,
},
],
}),
),
],
);
}
final class _FakeExerciseRepository implements ExerciseRepository {
Exercise? exercise;
final saved = <Exercise>[];
@ -3970,6 +4689,72 @@ final class _FakeProgramRepository implements ProgramRepository {
}
}
final class _FakeWorkoutHistoryRepository implements WorkoutHistoryRepository {
final histories = <WorkoutHistory>[];
@override
Future<WorkoutHistory?> findById(String id) async {
return histories.where((history) => history.metadata.id == id).firstOrNull;
}
@override
Future<List<WorkoutHistory>> listActive() async {
return histories
.where((history) => history.metadata.deletedAt == null)
.toList();
}
@override
Future<void> save(WorkoutHistory history) async {
histories.removeWhere(
(existing) => existing.metadata.id == history.metadata.id,
);
histories.add(history);
}
@override
Future<void> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {
final index = histories.indexWhere(
(history) =>
history.metadata.id == historyId &&
history.metadata.deletedAt == null &&
history.averageHeartRateBpm == null &&
history.maxHeartRateBpm == null,
);
if (index == -1) {
return;
}
final history = histories[index];
histories[index] = history.copyWith(
metadata: history.metadata.touch(patchedAt),
averageHeartRateBpm: averageHeartRateBpm,
maxHeartRateBpm: maxHeartRateBpm,
);
}
@override
Future<void> saveSetResult(WorkoutHistorySetResult result) async {}
@override
Future<void> saveStepResult(WorkoutHistoryStepResult result) async {}
@override
Future<void> delete(String id, DateTime deletedAt) async {
final index = histories.indexWhere((history) => history.metadata.id == id);
if (index == -1) {
return;
}
histories[index] = histories[index].copyWith(
metadata: histories[index].metadata.markDeleted(deletedAt),
);
}
}
ActiveWorkoutSessionUseCases _activeUseCase(
_FakeActiveSessionRepository repository,
_FakeClock clock,
@ -3989,6 +4774,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>[];
@ -4022,6 +4808,25 @@ 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) =>
state.activeWorkoutSessionId == sessionId &&
state.programIndex == programIndex &&
state.exerciseIndex == exerciseIndex &&
state.setIndex == setIndex &&
state.metadata.deletedAt == null,
)
.firstOrNull;
}
@override
Future<ActiveSetTimerState?> findSetTimerState({
required String sessionId,
@ -4080,6 +4885,19 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
.toList();
}
@override
Future<List<ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
return manualScoreStates.values
.where(
(state) =>
state.activeWorkoutSessionId == sessionId &&
state.metadata.deletedAt == null,
)
.toList();
}
@override
Future<List<ActiveSetResult>> listSetResults(String sessionId) async {
return results
@ -4136,6 +4954,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> saveExerciseStepProgressState(
ActiveExerciseStepProgressState state,
@ -4177,6 +5000,26 @@ final class _FakeActiveSessionRepository implements ActiveSessionRepository {
scoreStopwatchStates.remove(state.metadata.id);
}
@override
Future<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
final state = await findManualScoreState(
sessionId: sessionId,
programIndex: programIndex,
exerciseIndex: exerciseIndex,
setIndex: setIndex,
);
if (state == null) {
return;
}
manualScoreStates.remove(state.metadata.id);
}
@override
Future<void> saveSetResult(ActiveSetResult result) async {
results.removeWhere(