chore(wip): consolidation intermédiaire multi-tickets (sprints Statistiques, UI, Bug resolution, Serveur-client)

Regroupe l'état de travail en cours réalisé dans un même worktree sur
plusieurs tickets/sprints (#85, #136, #145, #155-160, #162-164),
mélangeant des tickets QA et inProgress. Ne constitue pas une feature
terminée : commit de sauvegarde avant triage/split par ticket en
branches feature/* dédiées. Exclut les dossiers d'environnement de
build locaux et le heap dump parasite (.gitignore mis à jour).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:48:54 +02:00
parent 58272e354a
commit 917777e18b
279 changed files with 13546 additions and 674 deletions

View File

@ -14,6 +14,7 @@ abstract interface class AppDependencies {
ActiveWorkoutSessionUseCases get activeWorkoutSessionUseCases;
ActiveExerciseStepUseCases get activeExerciseStepUseCases;
ActiveWorkoutSensorUseCases get activeWorkoutSensorUseCases;
WorkoutTelemetryUseCases get workoutTelemetryUseCases;
CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase;
WorkoutHistoryUseCases get workoutHistoryUseCases;
ProgressionStatsUseCase get progressionStatsUseCase;
@ -35,6 +36,7 @@ final class AppBootstrap implements AppDependencies {
required this.activeWorkoutSessionUseCases,
required this.activeExerciseStepUseCases,
required this.activeWorkoutSensorUseCases,
required this.workoutTelemetryUseCases,
required this.watchCompanionProjectionUseCases,
required this.watchCompanionCommandHandler,
required this.watchWearDataLayerAdapter,
@ -67,6 +69,8 @@ final class AppBootstrap implements AppDependencies {
final ActiveExerciseStepUseCases activeExerciseStepUseCases;
@override
final ActiveWorkoutSensorUseCases activeWorkoutSensorUseCases;
@override
final WorkoutTelemetryUseCases workoutTelemetryUseCases;
final WatchCompanionProjectionUseCases watchCompanionProjectionUseCases;
final WatchCompanionCommandHandler watchCompanionCommandHandler;
final WatchWearDataLayerAdapter watchWearDataLayerAdapter;
@ -107,6 +111,7 @@ final class AppBootstrap implements AppDependencies {
final templateRepository = DriftWorkoutTemplateRepository(database);
final activeSessionRepository = DriftActiveSessionRepository(database);
final historyRepository = DriftWorkoutHistoryRepository(database);
final telemetryRepository = DriftWorkoutTelemetryRepository(database);
final progressionStatsRepository = DriftProgressionStatsRepository(
database,
);
@ -134,6 +139,11 @@ final class AppBootstrap implements AppDependencies {
final activeWorkoutSensorUseCases = ActiveWorkoutSensorUseCases(
clock: clock,
);
final workoutTelemetryUseCases = WorkoutTelemetryUseCases(
repository: telemetryRepository,
clock: clock,
ids: ids,
);
final watchCompanionProjectionUseCases = WatchCompanionProjectionUseCases(
sessionRepository: activeSessionRepository,
clock: clock,
@ -156,6 +166,7 @@ final class AppBootstrap implements AppDependencies {
projectionSource: watchCompanionProjectionUseCases,
workoutHistoryUseCases: workoutHistoryUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
workoutTelemetryUseCases: workoutTelemetryUseCases,
);
final sessionNotificationCoordinator = SessionNotificationCoordinator(
projections: watchCompanionProjectionUseCases.projections,
@ -221,6 +232,7 @@ final class AppBootstrap implements AppDependencies {
activeWorkoutSessionUseCases: activeWorkoutSessionUseCases,
activeExerciseStepUseCases: activeExerciseStepUseCases,
activeWorkoutSensorUseCases: activeWorkoutSensorUseCases,
workoutTelemetryUseCases: workoutTelemetryUseCases,
watchCompanionProjectionUseCases: watchCompanionProjectionUseCases,
watchCompanionCommandHandler: watchCompanionCommandHandler,
watchWearDataLayerAdapter: watchWearDataLayerAdapter,
@ -228,6 +240,7 @@ final class AppBootstrap implements AppDependencies {
closeWorkoutSessionUseCase: CloseWorkoutSessionUseCase(
sessionRepository: activeSessionRepository,
historyRepository: historyRepository,
telemetryRepository: telemetryRepository,
clock: clock,
ids: ids,
originDeviceId: originDeviceId,

View File

@ -845,6 +845,7 @@ abstract interface class WorkoutTemplateRepository {
Future<WorkoutTemplate?> findById(String id);
Future<List<WorkoutTemplate>> listActive();
Future<void> save(WorkoutTemplate template);
Future<void> saveAll(List<WorkoutTemplate> templates);
Future<void> saveProgram(WorkoutTemplateProgram program);
Future<void> saveOverride(WorkoutTemplateExerciseOverride override);
Future<void> replaceComposition(WorkoutTemplate template, DateTime deletedAt);
@ -923,8 +924,11 @@ abstract interface class WorkoutHistoryRepository {
Future<void> save(WorkoutHistory history);
Future<void> patchHeartRateSummary({
required String historyId,
int? minHeartRateBpm,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
double? totalDistanceMeters,
double? totalCaloriesKcal,
required DateTime patchedAt,
});
Future<void> saveSetResult(WorkoutHistorySetResult result);
@ -932,6 +936,25 @@ abstract interface class WorkoutHistoryRepository {
Future<void> delete(String id, DateTime deletedAt);
}
abstract interface class WorkoutTelemetryRepository {
Future<bool> saveSample(WorkoutTelemetrySample sample);
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId);
Future<void> replaceAggregatesForSession({
required String sessionId,
required List<WorkoutTelemetryAggregate> aggregates,
});
Future<List<WorkoutTelemetryAggregate>> listAggregates(String sessionId);
Future<WorkoutTelemetryAggregate?> findAggregate({
required String sessionId,
required WorkoutTelemetryAggregateScope scope,
int? programIndex,
int? exerciseIndex,
int? setIndex,
int? passageIndex,
int? stepIndex,
});
}
final class ActivePerformanceMeasures {
const ActivePerformanceMeasures({
required this.timeEnabled,

View File

@ -558,6 +558,8 @@ final class ShareUseCases {
required ShareResourceType resourceType,
required String localResourceId,
required List<String> recipientEmails,
String? packName,
List<String> localResourceIds = const [],
}) async {
final emails = recipientEmails
.map((email) => email.trim())
@ -566,7 +568,12 @@ final class ShareUseCases {
if (emails.isEmpty) {
throw const DomainException('At least one recipient email is required.');
}
final payload = await _sharePayload(resourceType, localResourceId);
final payload = await _sharePayload(
resourceType,
localResourceId,
packName: packName,
localResourceIds: localResourceIds,
);
final token = await tokenStore.readToken();
if (token == null) {
return const ShareSendResult(status: ShareSendStatus.notConnected);
@ -613,6 +620,10 @@ final class ShareUseCases {
}
}
Future<List<WorkoutTemplate>> listShareableWorkoutTemplates() {
return templateRepository.listActive();
}
Future<void> acceptShare(String shareId) async {
final cachedItem = await inboxRepository.findByShareId(shareId);
final token = await tokenStore.readToken();
@ -621,8 +632,16 @@ final class ShareUseCases {
return;
}
try {
final createdResource = await remoteShareApi.acceptShare(shareId, token);
await localChanges.applyRemoteItem(createdResource);
if (cachedItem?.resourceType == ShareResourceType.pack) {
await remoteShareApi.acceptShare(shareId, token);
await _importSharedPayload(cachedItem!);
} else {
final createdResource = await remoteShareApi.acceptShare(
shareId,
token,
);
await localChanges.applyRemoteItem(createdResource);
}
await inboxRepository.markStatus(
shareId,
ShareInboxStatus.accepted,
@ -696,8 +715,10 @@ final class ShareUseCases {
Future<Map<String, Object?>> _sharePayload(
ShareResourceType resourceType,
String localResourceId,
) async {
String localResourceId, {
String? packName,
List<String> localResourceIds = const [],
}) async {
switch (resourceType) {
case ShareResourceType.program:
final program = await programRepository.findById(localResourceId);
@ -711,6 +732,27 @@ final class ShareUseCases {
throw const DomainException('Workout template not found.');
}
return _workoutTemplateSharePayload(template);
case ShareResourceType.pack:
final name = packName?.trim();
if (name == null || name.isEmpty) {
throw const DomainException('Pack name is required.');
}
final ids = localResourceIds
.map((id) => id.trim())
.where((id) => id.isNotEmpty)
.toList(growable: false);
if (ids.isEmpty) {
throw const DomainException('At least one workout is required.');
}
final templates = <WorkoutTemplate>[];
for (final id in ids) {
final template = await templateRepository.findById(id);
if (template == null) {
throw const DomainException('Workout template not found.');
}
templates.add(template);
}
return _packSharePayload(name, templates);
}
}
@ -748,11 +790,16 @@ final class ShareUseCases {
);
return;
case PendingShareActionType.accept:
final created = await remoteShareApi.acceptShare(
action.shareId!,
token,
);
await localChanges.applyRemoteItem(created);
final item = await inboxRepository.findByShareId(action.shareId!);
if (item?.resourceType == ShareResourceType.pack) {
await remoteShareApi.acceptShare(action.shareId!, token);
} else {
final created = await remoteShareApi.acceptShare(
action.shareId!,
token,
);
await localChanges.applyRemoteItem(created);
}
await inboxRepository.markStatus(
action.shareId!,
ShareInboxStatus.accepted,
@ -812,9 +859,39 @@ final class ShareUseCases {
_workoutTemplateCopyFromSharePayload(payload, now),
);
return;
case ShareResourceType.pack:
final templates = _workoutTemplateCopiesFromPackSharePayload(
payload,
now,
);
await templateRepository.saveAll(templates);
return;
}
}
List<WorkoutTemplate> _workoutTemplateCopiesFromPackSharePayload(
Map<String, Object?> payload,
DateTime now,
) {
final rawItems = payload['workouts'];
if (rawItems is! List || rawItems.isEmpty) {
throw const DomainException('Pack payload is empty.');
}
final templates = <WorkoutTemplate>[];
for (final raw in rawItems) {
if (raw is! Map) {
throw const DomainException('Invalid pack payload.');
}
templates.add(
_workoutTemplateCopyFromSharePayload(
Map<String, Object?>.from(raw),
now,
),
);
}
return templates;
}
Program _programCopyFromSharePayload(
Map<String, Object?> payload,
DateTime now,
@ -4030,28 +4107,27 @@ final class WatchSessionProjectionProjector {
final setTimerProjection = setTimer == null
? null
: _setTimerProjection(setTimer, now);
final hideScoreStopwatchTimer = _shouldHideScoreStopwatchForSetTimer(
snapshot: snapshot,
setTimerProjection: setTimerProjection,
);
final timers = <WatchTimerProjection>[
final allTimers = <WatchTimerProjection>[
if (activeRest != null) _restTimerProjection(activeRest, now),
if (currentStep != null && currentStep.type == ExerciseStepType.time)
_stepTimerProjection(stepState, currentStep, now),
?setTimerProjection,
if (!hideScoreStopwatchTimer) ?scoreStopwatchTimer,
?scoreStopwatchTimer,
];
final dominantTimer = timers.isEmpty ? null : timers.first;
final displayTimers = allTimers
.where((timer) => timer.kind != WatchTimerKind.setTimer)
.toList(growable: false);
final dominantTimer = displayTimers.isEmpty ? null : displayTimers.first;
final secondaryTimers = dominantTimer == null
? const <WatchTimerProjection>[]
: timers.skip(1).toList(growable: false);
: displayTimers.skip(1).toList(growable: false);
final phase = _phase(
session: session,
snapshot: snapshot,
activeRest: activeRest,
stepState: stepState,
currentStep: currentStep,
timers: timers,
timers: allTimers,
);
return WatchSessionProjection(
@ -4414,16 +4490,6 @@ WatchTimerProjection? _setTimerProjection(
);
}
bool _shouldHideScoreStopwatchForSetTimer({
required _ResolvedExerciseSnapshot snapshot,
required WatchTimerProjection? setTimerProjection,
}) {
return snapshot.timeEnabled &&
snapshot.scoreEnabled &&
snapshot.scoreInputModeSnapshot == ScoreInputMode.stopwatch &&
setTimerProjection != null;
}
ExerciseStep? _initialStep(_ResolvedExerciseSnapshot snapshot) {
return snapshot.steps.isEmpty ? null : snapshot.steps.first;
}
@ -5335,6 +5401,7 @@ final class CloseWorkoutSessionUseCase {
const CloseWorkoutSessionUseCase({
required this.sessionRepository,
required this.historyRepository,
this.telemetryRepository,
required this.clock,
required this.ids,
required this.originDeviceId,
@ -5342,6 +5409,7 @@ final class CloseWorkoutSessionUseCase {
final ActiveSessionRepository sessionRepository;
final WorkoutHistoryRepository historyRepository;
final WorkoutTelemetryRepository? telemetryRepository;
final Clock clock;
final IdGenerator ids;
final String originDeviceId;
@ -5380,6 +5448,10 @@ final class CloseWorkoutSessionUseCase {
ids: ids,
originDeviceId: originDeviceId,
);
final telemetryAggregate = await telemetryRepository?.findAggregate(
sessionId: session.metadata.id,
scope: WorkoutTelemetryAggregateScope.session,
);
final history = WorkoutHistory(
metadata: EntityMetadata(
id: historyId,
@ -5450,6 +5522,11 @@ final class CloseWorkoutSessionUseCase {
)
.toList(),
}),
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
maxHeartRateBpm: telemetryAggregate?.maxHeartRateBpm,
totalDistanceMeters: telemetryAggregate?.totalDistanceMeters,
totalCaloriesKcal: telemetryAggregate?.totalCaloriesKcal,
results: historyResults,
stepResults: historyStepResults,
);
@ -5458,6 +5535,247 @@ final class CloseWorkoutSessionUseCase {
}
}
final class WorkoutTelemetryUseCases {
const WorkoutTelemetryUseCases({
required this.repository,
required this.clock,
required this.ids,
});
final WorkoutTelemetryRepository repository;
final Clock clock;
final IdGenerator ids;
Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
WatchTelemetrySample sample,
) async {
final domainSample = _telemetrySampleFromWatch(sample);
if (domainSample == null) {
return const [];
}
final inserted = await repository.saveSample(domainSample);
if (!inserted) {
return const [];
}
final samples = await repository.listSamples(domainSample.sessionId);
final aggregates = _telemetryAggregatesFromSamples(samples);
await repository.replaceAggregatesForSession(
sessionId: domainSample.sessionId,
aggregates: aggregates,
);
return aggregates;
}
WorkoutTelemetrySample? _telemetrySampleFromWatch(
WatchTelemetrySample sample,
) {
final sessionId = sample.sessionId.trim();
final hasHeartRate =
sample.heartRateBpm != null && sample.heartRateBpm! > 0;
final hasDistance =
sample.distanceMeters != null && sample.distanceMeters! >= 0;
final hasCalories =
sample.caloriesKcal != null && sample.caloriesKcal! >= 0;
if (sessionId.isEmpty || (!hasHeartRate && !hasDistance && !hasCalories)) {
return null;
}
final capturedAt = sample.capturedAtEpochMs > 0
? DateTime.fromMillisecondsSinceEpoch(
sample.capturedAtEpochMs,
isUtc: true,
)
: clock.now();
return WorkoutTelemetrySample(
id: _telemetrySampleId(sample),
sessionId: sessionId,
capturedAt: capturedAt,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
setIndex: sample.setIndex,
passageIndex: sample.passageIndex,
stepIndex: sample.stepIndex,
programSnapshotId: sample.programSnapshotId,
exerciseSnapshotId: sample.exerciseSnapshotId,
stepSnapshotId: sample.stepSnapshotId,
heartRateBpm: hasHeartRate ? sample.heartRateBpm : null,
distanceMeters: hasDistance ? sample.distanceMeters : null,
caloriesKcal: hasCalories ? sample.caloriesKcal : null,
);
}
String _telemetrySampleId(WatchTelemetrySample sample) {
final stableId = sample.sampleId?.trim();
return stableId == null || stableId.isEmpty ? ids.newId() : stableId;
}
}
List<WorkoutTelemetryAggregate> _telemetryAggregatesFromSamples(
List<WorkoutTelemetrySample> samples,
) {
final builders = <_TelemetryScopeKey, _TelemetryAggregateBuilder>{};
for (final sample in samples) {
for (final key in _telemetryScopeKeys(sample)) {
builders
.putIfAbsent(key, () => _TelemetryAggregateBuilder(key))
.add(sample);
}
}
return [for (final builder in builders.values) builder.build()];
}
List<_TelemetryScopeKey> _telemetryScopeKeys(WorkoutTelemetrySample sample) {
final keys = [
_TelemetryScopeKey(
sessionId: sample.sessionId,
scope: WorkoutTelemetryAggregateScope.session,
),
];
if (sample.programIndex != null && sample.exerciseIndex != null) {
keys.add(
_TelemetryScopeKey(
sessionId: sample.sessionId,
scope: WorkoutTelemetryAggregateScope.exercise,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
),
);
}
if (sample.programIndex != null &&
sample.exerciseIndex != null &&
sample.setIndex != null) {
keys.add(
_TelemetryScopeKey(
sessionId: sample.sessionId,
scope: WorkoutTelemetryAggregateScope.set,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
setIndex: sample.setIndex,
),
);
}
if (sample.programIndex != null &&
sample.exerciseIndex != null &&
sample.setIndex != null &&
sample.stepIndex != null) {
keys.add(
_TelemetryScopeKey(
sessionId: sample.sessionId,
scope: WorkoutTelemetryAggregateScope.step,
programIndex: sample.programIndex,
exerciseIndex: sample.exerciseIndex,
setIndex: sample.setIndex,
passageIndex: sample.passageIndex,
stepIndex: sample.stepIndex,
),
);
}
return keys;
}
final class _TelemetryScopeKey {
const _TelemetryScopeKey({
required this.sessionId,
required this.scope,
this.programIndex,
this.exerciseIndex,
this.setIndex,
this.passageIndex,
this.stepIndex,
});
final String sessionId;
final WorkoutTelemetryAggregateScope scope;
final int? programIndex;
final int? exerciseIndex;
final int? setIndex;
final int? passageIndex;
final int? stepIndex;
@override
bool operator ==(Object other) {
return identical(this, other) ||
other is _TelemetryScopeKey &&
sessionId == other.sessionId &&
scope == other.scope &&
programIndex == other.programIndex &&
exerciseIndex == other.exerciseIndex &&
setIndex == other.setIndex &&
passageIndex == other.passageIndex &&
stepIndex == other.stepIndex;
}
@override
int get hashCode => Object.hash(
sessionId,
scope,
programIndex,
exerciseIndex,
setIndex,
passageIndex,
stepIndex,
);
}
final class _TelemetryAggregateBuilder {
_TelemetryAggregateBuilder(this.key);
final _TelemetryScopeKey key;
var sampleCount = 0;
var heartRateCount = 0;
var heartRateSum = 0.0;
int? minHeartRateBpm;
int? maxHeartRateBpm;
double? maxDistanceMeters;
double? maxCaloriesKcal;
void add(WorkoutTelemetrySample sample) {
sampleCount += 1;
final heartRate = sample.heartRateBpm;
if (heartRate != null) {
heartRateCount += 1;
heartRateSum += heartRate;
minHeartRateBpm = minHeartRateBpm == null
? heartRate
: (heartRate < minHeartRateBpm! ? heartRate : minHeartRateBpm);
maxHeartRateBpm = maxHeartRateBpm == null
? heartRate
: (heartRate > maxHeartRateBpm! ? heartRate : maxHeartRateBpm);
}
final distance = sample.distanceMeters;
if (distance != null) {
maxDistanceMeters = maxDistanceMeters == null
? distance
: (distance > maxDistanceMeters! ? distance : maxDistanceMeters);
}
final calories = sample.caloriesKcal;
if (calories != null) {
maxCaloriesKcal = maxCaloriesKcal == null
? calories
: (calories > maxCaloriesKcal! ? calories : maxCaloriesKcal);
}
}
WorkoutTelemetryAggregate build() {
return WorkoutTelemetryAggregate(
sessionId: key.sessionId,
scope: key.scope,
programIndex: key.programIndex,
exerciseIndex: key.exerciseIndex,
setIndex: key.setIndex,
passageIndex: key.passageIndex,
stepIndex: key.stepIndex,
sampleCount: sampleCount,
minHeartRateBpm: minHeartRateBpm,
averageHeartRateBpm: heartRateCount == 0
? null
: heartRateSum / heartRateCount,
maxHeartRateBpm: maxHeartRateBpm,
totalDistanceMeters: maxDistanceMeters,
totalCaloriesKcal: maxCaloriesKcal,
);
}
}
final class ActiveWorkoutSensorState {
const ActiveWorkoutSensorState({
required this.sessionId,
@ -5707,13 +6025,18 @@ final class WorkoutHistoryUseCases {
final history = _findHistoryForSummary(histories, summary);
if (history == null ||
history.averageHeartRateBpm != null ||
history.maxHeartRateBpm != null) {
history.maxHeartRateBpm != null ||
history.totalDistanceMeters != null ||
history.totalCaloriesKcal != null) {
return false;
}
await repository.patchHeartRateSummary(
historyId: history.metadata.id,
minHeartRateBpm: summary.minHeartRateBpm,
averageHeartRateBpm: summary.averageHeartRateBpm!,
maxHeartRateBpm: summary.maxHeartRateBpm!,
totalDistanceMeters: summary.totalDistanceMeters,
totalCaloriesKcal: summary.totalCaloriesKcal,
patchedAt: clock.now(),
);
return true;
@ -5746,7 +6069,11 @@ final class WorkoutHistoryUseCases {
summary.averageHeartRateBpm != null &&
summary.averageHeartRateBpm! > 0 &&
summary.maxHeartRateBpm != null &&
summary.maxHeartRateBpm! > 0;
summary.maxHeartRateBpm! > 0 &&
(summary.minHeartRateBpm == null || summary.minHeartRateBpm! > 0) &&
(summary.totalDistanceMeters == null ||
summary.totalDistanceMeters! >= 0) &&
(summary.totalCaloriesKcal == null || summary.totalCaloriesKcal! >= 0);
}
}
@ -6355,6 +6682,14 @@ Map<String, Object?> _workoutTemplateSharePayload(WorkoutTemplate template) => {
.toList(),
};
Map<String, Object?> _packSharePayload(
String name,
List<WorkoutTemplate> templates,
) => {
'name': name,
'workouts': templates.map(_workoutTemplateSharePayload).toList(),
};
Map<String, Object?> _metadataSharePayload(EntityMetadata metadata) => {
'id': metadata.id,
'createdAt': metadata.createdAt.toUtc().toIso8601String(),