feat(core): consolide modele donnees stats par scope, types metier d'exercice et correctif contrainte active_exercise_step_progress_states
Couche domaine/application/infra locale partagee par #179 (stockage stats par etape/serie/exercice/seance), #182 (correctif contrainte UNIQUE active_exercise_step_progress_states) et #183 (modele/persistance types metier d'exercice). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -939,6 +939,15 @@ abstract interface class WorkoutHistoryRepository {
|
||||
abstract interface class WorkoutTelemetryRepository {
|
||||
Future<bool> saveSample(WorkoutTelemetrySample sample);
|
||||
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId);
|
||||
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
|
||||
required String sessionId,
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
});
|
||||
Future<void> replaceAggregatesForSession({
|
||||
required String sessionId,
|
||||
required List<WorkoutTelemetryAggregate> aggregates,
|
||||
|
||||
@ -1148,6 +1148,7 @@ final class ExerciseUseCases {
|
||||
double? defaultTargetScore,
|
||||
int? defaultTargetScoreTimeMs,
|
||||
bool autoStartNextTimedStep = true,
|
||||
List<BusinessExerciseType> businessTypes = const [],
|
||||
List<String> tags = const [],
|
||||
List<ExerciseStep> steps = const [],
|
||||
}) async {
|
||||
@ -1181,6 +1182,7 @@ final class ExerciseUseCases {
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
autoStartNextTimedStep: autoStartNextTimedStep,
|
||||
businessTypes: businessTypes,
|
||||
tags: tags,
|
||||
steps: steps,
|
||||
);
|
||||
@ -1215,6 +1217,7 @@ final class ExerciseUseCases {
|
||||
Object? defaultTargetScore = _useCaseUnchanged,
|
||||
Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
|
||||
bool? autoStartNextTimedStep,
|
||||
Object? businessTypes = _useCaseUnchanged,
|
||||
List<String> tags = const [],
|
||||
Object? steps = _useCaseUnchanged,
|
||||
}) async {
|
||||
@ -1277,6 +1280,9 @@ final class ExerciseUseCases {
|
||||
defaultTargetScore: resolvedDefaultTargetScore,
|
||||
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
|
||||
autoStartNextTimedStep: autoStartNextTimedStep,
|
||||
businessTypes: businessTypes == _useCaseUnchanged
|
||||
? exercise.businessTypes
|
||||
: businessTypes as List<BusinessExerciseType>,
|
||||
tags: tags,
|
||||
steps: resolvedSteps,
|
||||
);
|
||||
@ -5492,6 +5498,9 @@ final class CloseWorkoutSessionUseCase {
|
||||
sessionId: session.metadata.id,
|
||||
scope: WorkoutTelemetryAggregateScope.session,
|
||||
);
|
||||
final telemetrySamples =
|
||||
await telemetryRepository?.listSamples(session.metadata.id) ??
|
||||
const <WorkoutTelemetrySample>[];
|
||||
final history = WorkoutHistory(
|
||||
metadata: EntityMetadata(
|
||||
id: historyId,
|
||||
@ -5561,6 +5570,9 @@ final class CloseWorkoutSessionUseCase {
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
'telemetrySamples': telemetrySamples
|
||||
.map(_workoutTelemetrySampleSnapshotJson)
|
||||
.toList(),
|
||||
}),
|
||||
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
|
||||
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
|
||||
@ -5582,10 +5594,73 @@ final class WorkoutTelemetryUseCases {
|
||||
required this.ids,
|
||||
});
|
||||
|
||||
static const persistedSampleInterval = Duration(seconds: 15);
|
||||
|
||||
final WorkoutTelemetryRepository repository;
|
||||
final Clock clock;
|
||||
final IdGenerator ids;
|
||||
|
||||
Future<List<WorkoutTelemetrySample>> listSamplesForHistory(
|
||||
WorkoutHistory history,
|
||||
) async {
|
||||
final sourceSessionId = history.sourceActiveWorkoutSessionId?.trim();
|
||||
if (sourceSessionId != null && sourceSessionId.isNotEmpty) {
|
||||
final samples = await repository.listSamples(sourceSessionId);
|
||||
if (samples.isNotEmpty) {
|
||||
return samples;
|
||||
}
|
||||
}
|
||||
return _telemetrySamplesFromHistorySnapshot(history);
|
||||
}
|
||||
|
||||
Future<WorkoutTelemetryGraphSeries> readGraphSeriesForHistory({
|
||||
required WorkoutHistory history,
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) async {
|
||||
final sourceSessionId = history.sourceActiveWorkoutSessionId?.trim();
|
||||
var samples = const <WorkoutTelemetrySample>[];
|
||||
if (sourceSessionId != null && sourceSessionId.isNotEmpty) {
|
||||
samples = await repository.listSamplesForScope(
|
||||
sessionId: sourceSessionId,
|
||||
scope: scope,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
passageIndex: passageIndex,
|
||||
stepIndex: stepIndex,
|
||||
);
|
||||
}
|
||||
if (samples.isEmpty) {
|
||||
samples = _telemetrySamplesFromHistorySnapshot(history)
|
||||
.where(
|
||||
(sample) => _matchesTelemetryScope(
|
||||
sample,
|
||||
scope: scope,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
passageIndex: passageIndex,
|
||||
stepIndex: stepIndex,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
return _telemetryGraphSeriesFromSamples(
|
||||
samples,
|
||||
scope: scope,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
passageIndex: passageIndex,
|
||||
stepIndex: stepIndex,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
|
||||
WatchTelemetrySample sample,
|
||||
) async {
|
||||
@ -5626,7 +5701,7 @@ final class WorkoutTelemetryUseCases {
|
||||
)
|
||||
: clock.now();
|
||||
return WorkoutTelemetrySample(
|
||||
id: _telemetrySampleId(sample),
|
||||
id: _telemetrySampleId(sample, sessionId, capturedAt),
|
||||
sessionId: sessionId,
|
||||
capturedAt: capturedAt,
|
||||
programIndex: sample.programIndex,
|
||||
@ -5643,12 +5718,200 @@ final class WorkoutTelemetryUseCases {
|
||||
);
|
||||
}
|
||||
|
||||
String _telemetrySampleId(WatchTelemetrySample sample) {
|
||||
final stableId = sample.sampleId?.trim();
|
||||
return stableId == null || stableId.isEmpty ? ids.newId() : stableId;
|
||||
String _telemetrySampleId(
|
||||
WatchTelemetrySample sample,
|
||||
String sessionId,
|
||||
DateTime capturedAt,
|
||||
) {
|
||||
final bucketMs =
|
||||
capturedAt.millisecondsSinceEpoch ~/
|
||||
persistedSampleInterval.inMilliseconds *
|
||||
persistedSampleInterval.inMilliseconds;
|
||||
return 'telemetry:$sessionId:$bucketMs';
|
||||
}
|
||||
}
|
||||
|
||||
WorkoutTelemetryGraphSeries _telemetryGraphSeriesFromSamples(
|
||||
List<WorkoutTelemetrySample> samples, {
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) {
|
||||
final ordered = samples.toList()
|
||||
..sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
|
||||
if (ordered.isEmpty) {
|
||||
return WorkoutTelemetryGraphSeries(
|
||||
scope: scope,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
passageIndex: passageIndex,
|
||||
stepIndex: stepIndex,
|
||||
points: const [],
|
||||
);
|
||||
}
|
||||
|
||||
final firstCapturedAt = ordered.first.capturedAt;
|
||||
DateTime? previousCapturedAt;
|
||||
double? distanceBaseline;
|
||||
double? caloriesBaseline;
|
||||
int? minHeartRateBpm;
|
||||
int? maxHeartRateBpm;
|
||||
final points = <WorkoutTelemetryGraphPoint>[];
|
||||
|
||||
for (final sample in ordered) {
|
||||
final heartRate = sample.heartRateBpm;
|
||||
if (heartRate != null) {
|
||||
minHeartRateBpm = minHeartRateBpm == null
|
||||
? heartRate
|
||||
: (heartRate < minHeartRateBpm ? heartRate : minHeartRateBpm);
|
||||
maxHeartRateBpm = maxHeartRateBpm == null
|
||||
? heartRate
|
||||
: (heartRate > maxHeartRateBpm ? heartRate : maxHeartRateBpm);
|
||||
}
|
||||
|
||||
final distance = sample.distanceMeters;
|
||||
if (distance != null) {
|
||||
distanceBaseline ??= distance;
|
||||
}
|
||||
final calories = sample.caloriesKcal;
|
||||
if (calories != null) {
|
||||
caloriesBaseline ??= calories;
|
||||
}
|
||||
|
||||
points.add(
|
||||
WorkoutTelemetryGraphPoint(
|
||||
capturedAt: sample.capturedAt,
|
||||
elapsedMs: sample.capturedAt.difference(firstCapturedAt).inMilliseconds,
|
||||
heartRateBpm: heartRate,
|
||||
distanceMeters: distance == null
|
||||
? null
|
||||
: (distance - distanceBaseline!)
|
||||
.clamp(0, double.infinity)
|
||||
.toDouble(),
|
||||
caloriesKcal: calories == null
|
||||
? null
|
||||
: (calories - caloriesBaseline!)
|
||||
.clamp(0, double.infinity)
|
||||
.toDouble(),
|
||||
startsAfterGap:
|
||||
previousCapturedAt != null &&
|
||||
sample.capturedAt.difference(previousCapturedAt) >
|
||||
const Duration(minutes: 2),
|
||||
),
|
||||
);
|
||||
previousCapturedAt = sample.capturedAt;
|
||||
}
|
||||
|
||||
return WorkoutTelemetryGraphSeries(
|
||||
scope: scope,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
passageIndex: passageIndex,
|
||||
stepIndex: stepIndex,
|
||||
points: points,
|
||||
minHeartRateBpm: minHeartRateBpm,
|
||||
maxHeartRateBpm: maxHeartRateBpm,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _workoutTelemetrySampleSnapshotJson(
|
||||
WorkoutTelemetrySample sample,
|
||||
) {
|
||||
return {
|
||||
'id': sample.id,
|
||||
'sessionId': sample.sessionId,
|
||||
'capturedAt': sample.capturedAt.toUtc().toIso8601String(),
|
||||
'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': sample.heartRateBpm,
|
||||
'distanceMeters': sample.distanceMeters,
|
||||
'caloriesKcal': sample.caloriesKcal,
|
||||
};
|
||||
}
|
||||
|
||||
List<WorkoutTelemetrySample> _telemetrySamplesFromHistorySnapshot(
|
||||
WorkoutHistory history,
|
||||
) {
|
||||
final decoded = jsonDecode(history.historySnapshotJson);
|
||||
if (decoded is! Map) {
|
||||
return const [];
|
||||
}
|
||||
final rawSamples = decoded['telemetrySamples'];
|
||||
if (rawSamples is! List) {
|
||||
return const [];
|
||||
}
|
||||
final output = <WorkoutTelemetrySample>[];
|
||||
for (final rawSample in rawSamples) {
|
||||
if (rawSample is! Map) {
|
||||
continue;
|
||||
}
|
||||
final json = Map<String, Object?>.from(rawSample);
|
||||
final id = json['id'] as String?;
|
||||
final sessionId = json['sessionId'] as String?;
|
||||
final capturedAt = DateTime.tryParse(json['capturedAt'] as String? ?? '');
|
||||
if (id == null || sessionId == null || capturedAt == null) {
|
||||
continue;
|
||||
}
|
||||
output.add(
|
||||
WorkoutTelemetrySample(
|
||||
id: id,
|
||||
sessionId: sessionId,
|
||||
capturedAt: capturedAt,
|
||||
programIndex: json['programIndex'] as int?,
|
||||
exerciseIndex: json['exerciseIndex'] as int?,
|
||||
setIndex: json['setIndex'] as int?,
|
||||
passageIndex: json['passageIndex'] as int?,
|
||||
stepIndex: json['stepIndex'] as int?,
|
||||
programSnapshotId: json['programSnapshotId'] as String?,
|
||||
exerciseSnapshotId: json['exerciseSnapshotId'] as String?,
|
||||
stepSnapshotId: json['stepSnapshotId'] as String?,
|
||||
heartRateBpm: json['heartRateBpm'] as int?,
|
||||
distanceMeters: (json['distanceMeters'] as num?)?.toDouble(),
|
||||
caloriesKcal: (json['caloriesKcal'] as num?)?.toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
bool _matchesTelemetryScope(
|
||||
WorkoutTelemetrySample sample, {
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) {
|
||||
return switch (scope) {
|
||||
WorkoutTelemetryAggregateScope.session => true,
|
||||
WorkoutTelemetryAggregateScope.exercise =>
|
||||
sample.programIndex == programIndex &&
|
||||
sample.exerciseIndex == exerciseIndex,
|
||||
WorkoutTelemetryAggregateScope.set =>
|
||||
sample.programIndex == programIndex &&
|
||||
sample.exerciseIndex == exerciseIndex &&
|
||||
sample.setIndex == setIndex,
|
||||
WorkoutTelemetryAggregateScope.step =>
|
||||
sample.programIndex == programIndex &&
|
||||
sample.exerciseIndex == exerciseIndex &&
|
||||
sample.setIndex == setIndex &&
|
||||
(passageIndex == null || sample.passageIndex == passageIndex) &&
|
||||
sample.stepIndex == stepIndex,
|
||||
};
|
||||
}
|
||||
|
||||
List<WorkoutTelemetryAggregate> _telemetryAggregatesFromSamples(
|
||||
List<WorkoutTelemetrySample> samples,
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user