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,
|
||||
) {
|
||||
|
||||
@ -21,6 +21,18 @@ enum ExerciseCategory {
|
||||
uncategorized,
|
||||
}
|
||||
|
||||
enum BusinessExerciseType {
|
||||
shoot,
|
||||
freeThrows,
|
||||
dribble,
|
||||
finishing,
|
||||
conditioning,
|
||||
defense,
|
||||
mobility,
|
||||
highIntensity,
|
||||
recovery,
|
||||
}
|
||||
|
||||
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
|
||||
|
||||
enum SetResultStatus { completed, skipped }
|
||||
@ -333,6 +345,7 @@ final class Exercise {
|
||||
this.defaultTargetScoreTimeMs,
|
||||
this.autoStartNextTimedStep = true,
|
||||
this.category = ExerciseCategory.uncategorized,
|
||||
List<BusinessExerciseType> businessTypes = const [],
|
||||
this.isExample = false,
|
||||
List<String> tags = const [],
|
||||
List<ExerciseStep> steps = const [],
|
||||
@ -347,6 +360,7 @@ final class Exercise {
|
||||
iconMediaId,
|
||||
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
|
||||
),
|
||||
businessTypes = _validatedBusinessExerciseTypes(businessTypes),
|
||||
steps = _validatedExerciseSteps(steps) {
|
||||
_requireAtLeastOneMeasure(
|
||||
hasTime: hasTimeMeasure,
|
||||
@ -406,6 +420,7 @@ final class Exercise {
|
||||
final int? defaultTargetScoreTimeMs;
|
||||
final bool autoStartNextTimedStep;
|
||||
final ExerciseCategory category;
|
||||
final List<BusinessExerciseType> businessTypes;
|
||||
final bool isExample;
|
||||
final List<String> tags;
|
||||
final List<ExerciseStep> steps;
|
||||
@ -420,6 +435,10 @@ final class Exercise {
|
||||
String? get imageMediaId =>
|
||||
imageMediaIds.isEmpty ? null : imageMediaIds.first;
|
||||
|
||||
List<BusinessExerciseType> get effectiveBusinessTypes => businessTypes.isEmpty
|
||||
? _businessTypesFromCategory(category)
|
||||
: businessTypes;
|
||||
|
||||
Exercise archive(DateTime now) {
|
||||
return copyWith(archivedAt: now, metadata: metadata.touch(now));
|
||||
}
|
||||
@ -444,6 +463,7 @@ final class Exercise {
|
||||
Object? defaultTargetScoreTimeMs = _unchanged,
|
||||
bool? autoStartNextTimedStep,
|
||||
ExerciseCategory? category,
|
||||
Object? businessTypes = _unchanged,
|
||||
bool? isExample,
|
||||
Object? tags = _unchanged,
|
||||
Object? steps = _unchanged,
|
||||
@ -496,6 +516,9 @@ final class Exercise {
|
||||
autoStartNextTimedStep:
|
||||
autoStartNextTimedStep ?? this.autoStartNextTimedStep,
|
||||
category: category ?? this.category,
|
||||
businessTypes: businessTypes == _unchanged
|
||||
? this.businessTypes
|
||||
: businessTypes as List<BusinessExerciseType>,
|
||||
isExample: isExample ?? this.isExample,
|
||||
tags: tags == _unchanged ? this.tags : tags as List<String>,
|
||||
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
|
||||
@ -1675,6 +1698,68 @@ final class WorkoutTelemetryAggregate {
|
||||
final double? totalCaloriesKcal;
|
||||
}
|
||||
|
||||
final class WorkoutTelemetryGraphPoint {
|
||||
WorkoutTelemetryGraphPoint({
|
||||
required this.capturedAt,
|
||||
required this.elapsedMs,
|
||||
this.heartRateBpm,
|
||||
this.distanceMeters,
|
||||
this.caloriesKcal,
|
||||
required this.startsAfterGap,
|
||||
}) {
|
||||
_requireNonNegative(elapsedMs, 'Telemetry graph elapsed ms');
|
||||
_requireNullablePositive(heartRateBpm, 'Heart rate bpm');
|
||||
_requireNullableNonNegativeDouble(distanceMeters, 'Distance meters');
|
||||
_requireNullableNonNegativeDouble(caloriesKcal, 'Calories kcal');
|
||||
if (heartRateBpm == null &&
|
||||
distanceMeters == null &&
|
||||
caloriesKcal == null) {
|
||||
throw const DomainException(
|
||||
'Telemetry graph point must contain at least one metric.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final DateTime capturedAt;
|
||||
final int elapsedMs;
|
||||
final int? heartRateBpm;
|
||||
final double? distanceMeters;
|
||||
final double? caloriesKcal;
|
||||
final bool startsAfterGap;
|
||||
}
|
||||
|
||||
final class WorkoutTelemetryGraphSeries {
|
||||
WorkoutTelemetryGraphSeries({
|
||||
required this.scope,
|
||||
this.programIndex,
|
||||
this.exerciseIndex,
|
||||
this.setIndex,
|
||||
this.passageIndex,
|
||||
this.stepIndex,
|
||||
required this.points,
|
||||
this.minHeartRateBpm,
|
||||
this.maxHeartRateBpm,
|
||||
}) {
|
||||
_requireNullableNonNegative(programIndex, 'Program index');
|
||||
_requireNullableNonNegative(exerciseIndex, 'Exercise index');
|
||||
_requireNullableNonNegative(setIndex, 'Set index');
|
||||
_requireNullableNonNegative(passageIndex, 'Passage index');
|
||||
_requireNullableNonNegative(stepIndex, 'Step index');
|
||||
_requireNullablePositive(minHeartRateBpm, 'Min heart rate bpm');
|
||||
_requireNullablePositive(maxHeartRateBpm, 'Max heart rate bpm');
|
||||
}
|
||||
|
||||
final WorkoutTelemetryAggregateScope scope;
|
||||
final int? programIndex;
|
||||
final int? exerciseIndex;
|
||||
final int? setIndex;
|
||||
final int? passageIndex;
|
||||
final int? stepIndex;
|
||||
final List<WorkoutTelemetryGraphPoint> points;
|
||||
final int? minHeartRateBpm;
|
||||
final int? maxHeartRateBpm;
|
||||
}
|
||||
|
||||
final class WorkoutHistorySetResult {
|
||||
WorkoutHistorySetResult({
|
||||
required this.metadata,
|
||||
@ -1924,6 +2009,33 @@ List<ExerciseStep> _validatedExerciseSteps(List<ExerciseStep> steps) {
|
||||
return List.unmodifiable(steps);
|
||||
}
|
||||
|
||||
List<BusinessExerciseType> _validatedBusinessExerciseTypes(
|
||||
List<BusinessExerciseType> types,
|
||||
) {
|
||||
final unique = <BusinessExerciseType>[];
|
||||
for (final type in types) {
|
||||
if (!unique.contains(type)) {
|
||||
unique.add(type);
|
||||
}
|
||||
}
|
||||
return List.unmodifiable(unique);
|
||||
}
|
||||
|
||||
List<BusinessExerciseType> _businessTypesFromCategory(
|
||||
ExerciseCategory category,
|
||||
) {
|
||||
return switch (category) {
|
||||
ExerciseCategory.shoot => const [BusinessExerciseType.shoot],
|
||||
ExerciseCategory.freeThrows => const [BusinessExerciseType.freeThrows],
|
||||
ExerciseCategory.dribble => const [BusinessExerciseType.dribble],
|
||||
ExerciseCategory.finishing => const [BusinessExerciseType.finishing],
|
||||
ExerciseCategory.conditioning => const [BusinessExerciseType.conditioning],
|
||||
ExerciseCategory.defense => const [BusinessExerciseType.defense],
|
||||
ExerciseCategory.mobility => const [BusinessExerciseType.mobility],
|
||||
ExerciseCategory.uncategorized => const [],
|
||||
};
|
||||
}
|
||||
|
||||
void _requireAtLeastOneMeasure({
|
||||
required bool hasTime,
|
||||
required bool hasReps,
|
||||
|
||||
@ -51,7 +51,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 24;
|
||||
int get schemaVersion => 25;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@ -60,6 +60,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
await migrator.createAll();
|
||||
await _migrateToSchema15();
|
||||
await _migrateToSchema16(migrator);
|
||||
await _migrateToSchema25();
|
||||
await _createIndexes();
|
||||
},
|
||||
onUpgrade: (migrator, from, to) async {
|
||||
@ -137,6 +138,9 @@ final class AppDatabase extends _$AppDatabase {
|
||||
if (from < 24) {
|
||||
await _migrateToSchema24(migrator);
|
||||
}
|
||||
if (from < 25) {
|
||||
await _migrateToSchema25();
|
||||
}
|
||||
await _createIndexes();
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
@ -956,6 +960,16 @@ FROM pending_share_actions
|
||||
await migrator.createTable(workoutTelemetryAggregates);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema25() async {
|
||||
await _addColumnIfMissing(
|
||||
tableName: 'exercises',
|
||||
columnName: 'business_types_json',
|
||||
definition:
|
||||
"business_types_json TEXT NOT NULL DEFAULT '[]' "
|
||||
'CHECK (json_valid(business_types_json))',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
|
||||
await customStatement(r'''
|
||||
UPDATE workout_history_set_results AS result
|
||||
|
||||
@ -112,7 +112,8 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
||||
) async {
|
||||
final row = await database
|
||||
.customSelect(
|
||||
'SELECT category, is_example FROM exercises WHERE id = ? LIMIT 1',
|
||||
'SELECT category, is_example, business_types_json '
|
||||
'FROM exercises WHERE id = ? LIMIT 1',
|
||||
variables: [Variable<String>(exerciseId)],
|
||||
)
|
||||
.getSingleOrNull();
|
||||
@ -123,6 +124,9 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
||||
category: _exerciseCategoryFromDb(
|
||||
row.readNullable<String>('category') ?? 'uncategorized',
|
||||
),
|
||||
businessTypes: _decodeBusinessExerciseTypesJson(
|
||||
row.readNullable<String>('business_types_json'),
|
||||
),
|
||||
isExample: (row.readNullable<int>('is_example') ?? 0) == 1,
|
||||
);
|
||||
}
|
||||
@ -1484,17 +1488,39 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
||||
Future<void> saveExerciseStepProgressState(
|
||||
domain.ActiveExerciseStepProgressState state,
|
||||
) async {
|
||||
await _upsertWithChangeLog(
|
||||
database: database,
|
||||
tableName: 'active_exercise_step_progress_states',
|
||||
entityType: 'ActiveExerciseStepProgressState',
|
||||
metadata: state.metadata,
|
||||
write: () => database
|
||||
.into(database.activeExerciseStepProgressStates)
|
||||
.insertOnConflictUpdate(
|
||||
_activeExerciseStepProgressStateCompanion(state),
|
||||
await database.transaction(() async {
|
||||
final existing = await _findActiveExerciseStepProgressStateRowByPosition(
|
||||
database,
|
||||
state.activeWorkoutSessionId,
|
||||
state.programIndex,
|
||||
state.exerciseIndex,
|
||||
state.setIndex,
|
||||
);
|
||||
final effectiveState = existing == null
|
||||
? state
|
||||
: state.copyWith(
|
||||
metadata: _metadataWithStoredIdentity(
|
||||
state.metadata,
|
||||
_metadataFromRow(existing),
|
||||
),
|
||||
);
|
||||
final operation = existing == null
|
||||
? 'insert'
|
||||
: _operationForExistingMutation(
|
||||
existingDeletedAt: existing.deletedAt,
|
||||
deletedAt: effectiveState.metadata.deletedAt,
|
||||
);
|
||||
await _upsertActiveExerciseStepProgressState(database, effectiveState);
|
||||
await _writeChangeLog(
|
||||
database: database,
|
||||
entityType: 'ActiveExerciseStepProgressState',
|
||||
entityId: effectiveState.metadata.id,
|
||||
operation: operation,
|
||||
localRevision: effectiveState.metadata.localRevision,
|
||||
originDeviceId: effectiveState.metadata.originDeviceId,
|
||||
createdAt: effectiveState.metadata.updatedAt,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@ -2049,16 +2075,13 @@ final class DriftWorkoutTelemetryRepository
|
||||
final existing = await (database.select(
|
||||
database.workoutTelemetrySamples,
|
||||
)..where((table) => table.id.equals(sample.id))).getSingleOrNull();
|
||||
if (existing != null) {
|
||||
if (existing != null && !sample.capturedAt.isAfter(existing.capturedAt)) {
|
||||
return false;
|
||||
}
|
||||
final inserted = await database
|
||||
await database
|
||||
.into(database.workoutTelemetrySamples)
|
||||
.insert(
|
||||
_workoutTelemetrySampleCompanion(sample),
|
||||
mode: InsertMode.insertOrIgnore,
|
||||
);
|
||||
return inserted > 0;
|
||||
.insertOnConflictUpdate(_workoutTelemetrySampleCompanion(sample));
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -2073,6 +2096,37 @@ final class DriftWorkoutTelemetryRepository
|
||||
return rows.map(_workoutTelemetrySampleFromRow).toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<domain.WorkoutTelemetrySample>> listSamplesForScope({
|
||||
required String sessionId,
|
||||
required domain.WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) async {
|
||||
final rows =
|
||||
await (database.select(database.workoutTelemetrySamples)
|
||||
..where((table) => table.sessionId.equals(sessionId))
|
||||
..orderBy([(table) => OrderingTerm.asc(table.capturedAt)]))
|
||||
.get();
|
||||
return rows
|
||||
.map(_workoutTelemetrySampleFromRow)
|
||||
.where(
|
||||
(sample) => _telemetrySampleMatchesScope(
|
||||
sample,
|
||||
scope: scope,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
passageIndex: passageIndex,
|
||||
stepIndex: stepIndex,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAggregatesForSession({
|
||||
required String sessionId,
|
||||
@ -3382,10 +3436,12 @@ String _exerciseImageId(String exerciseId, String mediaId) {
|
||||
final class _ExerciseStarterMetadata {
|
||||
const _ExerciseStarterMetadata({
|
||||
this.category = domain.ExerciseCategory.uncategorized,
|
||||
this.businessTypes = const [],
|
||||
this.isExample = false,
|
||||
});
|
||||
|
||||
final domain.ExerciseCategory category;
|
||||
final List<domain.BusinessExerciseType> businessTypes;
|
||||
final bool isExample;
|
||||
}
|
||||
|
||||
@ -3394,10 +3450,12 @@ Future<void> _writeExerciseStarterMetadata(
|
||||
domain.Exercise exercise,
|
||||
) async {
|
||||
await database.customStatement(
|
||||
'UPDATE exercises SET category = ?, is_example = ? WHERE id = ?',
|
||||
'UPDATE exercises SET category = ?, is_example = ?, '
|
||||
'business_types_json = ? WHERE id = ?',
|
||||
[
|
||||
_exerciseCategoryToDb(exercise.category),
|
||||
exercise.isExample ? 1 : 0,
|
||||
_encodeBusinessExerciseTypes(exercise.businessTypes),
|
||||
exercise.metadata.id,
|
||||
],
|
||||
);
|
||||
@ -3764,6 +3822,7 @@ domain.Exercise _exerciseFromRow(
|
||||
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
|
||||
autoStartNextTimedStep: row.autoStartNextTimedStep,
|
||||
category: starterMetadata.category,
|
||||
businessTypes: starterMetadata.businessTypes,
|
||||
isExample: starterMetadata.isExample,
|
||||
tags: _decodeTags(row.tagsJson),
|
||||
steps: steps,
|
||||
@ -4374,6 +4433,24 @@ Future<db.ActiveManualScoreState?> _findActiveManualScoreStateRowByPosition(
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
Future<db.ActiveExerciseStepProgressState?>
|
||||
_findActiveExerciseStepProgressStateRowByPosition(
|
||||
db.AppDatabase database,
|
||||
String sessionId,
|
||||
int programIndex,
|
||||
int exerciseIndex,
|
||||
int setIndex,
|
||||
) {
|
||||
return (database.select(database.activeExerciseStepProgressStates)..where(
|
||||
(table) =>
|
||||
table.activeWorkoutSessionId.equals(sessionId) &
|
||||
table.programIndex.equals(programIndex) &
|
||||
table.exerciseIndex.equals(exerciseIndex) &
|
||||
table.setIndex.equals(setIndex),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
}
|
||||
|
||||
domain.EntityMetadata _metadataWithStoredIdentity(
|
||||
domain.EntityMetadata metadata,
|
||||
domain.EntityMetadata stored,
|
||||
@ -4517,6 +4594,71 @@ Future<void> _upsertActiveManualScoreState(
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _upsertActiveExerciseStepProgressState(
|
||||
db.AppDatabase database,
|
||||
domain.ActiveExerciseStepProgressState state,
|
||||
) async {
|
||||
final metadata = state.metadata;
|
||||
await database.customInsert(
|
||||
'INSERT INTO active_exercise_step_progress_states ('
|
||||
'id, created_at, updated_at, deleted_at, schema_version, sync_state, '
|
||||
'local_revision, origin_device_id, future_owner_profile_id, '
|
||||
'last_synced_at, remote_revision, active_workout_session_id, '
|
||||
'program_index, exercise_index, set_index, current_passage_index, '
|
||||
'current_step_index, current_step_snapshot_id, status, started_at, '
|
||||
'accumulated_ms, last_transition_at'
|
||||
') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) '
|
||||
'ON CONFLICT(active_workout_session_id, program_index, exercise_index, '
|
||||
'set_index) DO UPDATE SET '
|
||||
'id = active_exercise_step_progress_states.id, '
|
||||
'created_at = active_exercise_step_progress_states.created_at, '
|
||||
'updated_at = excluded.updated_at, '
|
||||
'deleted_at = excluded.deleted_at, '
|
||||
'schema_version = excluded.schema_version, '
|
||||
'sync_state = excluded.sync_state, '
|
||||
'local_revision = excluded.local_revision, '
|
||||
'origin_device_id = excluded.origin_device_id, '
|
||||
'future_owner_profile_id = excluded.future_owner_profile_id, '
|
||||
'last_synced_at = excluded.last_synced_at, '
|
||||
'remote_revision = excluded.remote_revision, '
|
||||
'active_workout_session_id = excluded.active_workout_session_id, '
|
||||
'program_index = excluded.program_index, '
|
||||
'exercise_index = excluded.exercise_index, '
|
||||
'set_index = excluded.set_index, '
|
||||
'current_passage_index = excluded.current_passage_index, '
|
||||
'current_step_index = excluded.current_step_index, '
|
||||
'current_step_snapshot_id = excluded.current_step_snapshot_id, '
|
||||
'status = excluded.status, '
|
||||
'started_at = excluded.started_at, '
|
||||
'accumulated_ms = excluded.accumulated_ms, '
|
||||
'last_transition_at = excluded.last_transition_at',
|
||||
variables: [
|
||||
Variable<String>(metadata.id),
|
||||
Variable<DateTime>(metadata.createdAt.toUtc()),
|
||||
Variable<DateTime>(metadata.updatedAt.toUtc()),
|
||||
Variable<DateTime>(_utcOrNull(metadata.deletedAt)),
|
||||
Variable<int>(metadata.schemaVersion),
|
||||
Variable<String>(_syncStateToDb(metadata.syncState)),
|
||||
Variable<int>(metadata.localRevision),
|
||||
Variable<String>(metadata.originDeviceId),
|
||||
Variable<String>(metadata.futureOwnerProfileId),
|
||||
Variable<DateTime>(_utcOrNull(metadata.lastSyncedAt)),
|
||||
Variable<String>(metadata.remoteRevision),
|
||||
Variable<String>(state.activeWorkoutSessionId),
|
||||
Variable<int>(state.programIndex),
|
||||
Variable<int>(state.exerciseIndex),
|
||||
Variable<int>(state.setIndex),
|
||||
Variable<int>(state.currentPassageIndex),
|
||||
Variable<int>(state.currentStepIndex),
|
||||
Variable<String>(state.currentStepSnapshotId),
|
||||
Variable<String>(_exerciseStepProgressStatusToDb(state.status)),
|
||||
Variable<DateTime>(_utcOrNull(state.startedAt)),
|
||||
Variable<int>(state.accumulatedMs),
|
||||
Variable<DateTime>(state.lastTransitionAt.toUtc()),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _upsertActiveSetTimerState(
|
||||
db.AppDatabase database,
|
||||
domain.ActiveSetTimerState state,
|
||||
@ -4729,37 +4871,6 @@ domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
|
||||
);
|
||||
}
|
||||
|
||||
db.ActiveExerciseStepProgressStatesCompanion
|
||||
_activeExerciseStepProgressStateCompanion(
|
||||
domain.ActiveExerciseStepProgressState state,
|
||||
) {
|
||||
final values = _metadataValues(state.metadata);
|
||||
return db.ActiveExerciseStepProgressStatesCompanion(
|
||||
id: values[0] as Value<String>,
|
||||
createdAt: values[1] as Value<DateTime>,
|
||||
updatedAt: values[2] as Value<DateTime>,
|
||||
deletedAt: values[3] as Value<DateTime?>,
|
||||
schemaVersion: values[4] as Value<int>,
|
||||
syncState: values[5] as Value<String>,
|
||||
localRevision: values[6] as Value<int>,
|
||||
originDeviceId: values[7] as Value<String>,
|
||||
futureOwnerProfileId: values[8] as Value<String?>,
|
||||
lastSyncedAt: values[9] as Value<DateTime?>,
|
||||
remoteRevision: values[10] as Value<String?>,
|
||||
activeWorkoutSessionId: Value(state.activeWorkoutSessionId),
|
||||
programIndex: Value(state.programIndex),
|
||||
exerciseIndex: Value(state.exerciseIndex),
|
||||
setIndex: Value(state.setIndex),
|
||||
currentPassageIndex: Value(state.currentPassageIndex),
|
||||
currentStepIndex: Value(state.currentStepIndex),
|
||||
currentStepSnapshotId: Value(state.currentStepSnapshotId),
|
||||
status: Value(_exerciseStepProgressStatusToDb(state.status)),
|
||||
startedAt: Value<DateTime?>(_utcOrNull(state.startedAt)),
|
||||
accumulatedMs: Value(state.accumulatedMs),
|
||||
lastTransitionAt: Value(state.lastTransitionAt.toUtc()),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ActiveExerciseStepProgressState _activeExerciseStepProgressStateFromRow(
|
||||
db.ActiveExerciseStepProgressState row,
|
||||
) {
|
||||
@ -5080,6 +5191,33 @@ domain.WorkoutTelemetrySample _workoutTelemetrySampleFromRow(
|
||||
);
|
||||
}
|
||||
|
||||
bool _telemetrySampleMatchesScope(
|
||||
domain.WorkoutTelemetrySample sample, {
|
||||
required domain.WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) {
|
||||
return switch (scope) {
|
||||
domain.WorkoutTelemetryAggregateScope.session => true,
|
||||
domain.WorkoutTelemetryAggregateScope.exercise =>
|
||||
sample.programIndex == programIndex &&
|
||||
sample.exerciseIndex == exerciseIndex,
|
||||
domain.WorkoutTelemetryAggregateScope.set =>
|
||||
sample.programIndex == programIndex &&
|
||||
sample.exerciseIndex == exerciseIndex &&
|
||||
sample.setIndex == setIndex,
|
||||
domain.WorkoutTelemetryAggregateScope.step =>
|
||||
sample.programIndex == programIndex &&
|
||||
sample.exerciseIndex == exerciseIndex &&
|
||||
sample.setIndex == setIndex &&
|
||||
(passageIndex == null || sample.passageIndex == passageIndex) &&
|
||||
sample.stepIndex == stepIndex,
|
||||
};
|
||||
}
|
||||
|
||||
domain.WorkoutTelemetryAggregate _workoutTelemetryAggregateFromRow(
|
||||
db.WorkoutTelemetryAggregate row,
|
||||
) {
|
||||
@ -5270,6 +5408,12 @@ Map<String, Object?> _exercisePayload(domain.Exercise exercise) => {
|
||||
'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs,
|
||||
'autoStartNextTimedStep': exercise.autoStartNextTimedStep,
|
||||
'category': exercise.category.name,
|
||||
'businessTypes': exercise.businessTypes
|
||||
.map(_businessExerciseTypeToDb)
|
||||
.toList(),
|
||||
'effectiveBusinessTypes': exercise.effectiveBusinessTypes
|
||||
.map(_businessExerciseTypeToDb)
|
||||
.toList(),
|
||||
'isExample': exercise.isExample,
|
||||
'tags': exercise.tags,
|
||||
'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(),
|
||||
@ -5549,6 +5693,7 @@ domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
|
||||
category: _exerciseCategoryFromDb(
|
||||
payload['category'] as String? ?? 'uncategorized',
|
||||
),
|
||||
businessTypes: _businessExerciseTypesFromPayload(payload['businessTypes']),
|
||||
isExample: payload['isExample'] as bool? ?? false,
|
||||
tags: _stringListFromPayload(payload['tags']),
|
||||
steps: _stepsFromPayload(payload['steps']),
|
||||
@ -5920,6 +6065,40 @@ List<String> _decodeTags(String? encoded) {
|
||||
}
|
||||
}
|
||||
|
||||
String _encodeBusinessExerciseTypes(List<domain.BusinessExerciseType> types) {
|
||||
return jsonEncode(types.map(_businessExerciseTypeToDb).toList());
|
||||
}
|
||||
|
||||
List<domain.BusinessExerciseType> _decodeBusinessExerciseTypesJson(
|
||||
String? encoded,
|
||||
) {
|
||||
if (encoded == null || encoded.trim().isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
try {
|
||||
final decoded = jsonDecode(encoded);
|
||||
return _businessExerciseTypesFromPayload(decoded);
|
||||
} on Object {
|
||||
return const [];
|
||||
}
|
||||
}
|
||||
|
||||
List<domain.BusinessExerciseType> _businessExerciseTypesFromPayload(
|
||||
Object? value,
|
||||
) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
}
|
||||
final output = <domain.BusinessExerciseType>[];
|
||||
for (final raw in value.whereType<String>()) {
|
||||
final type = _businessExerciseTypeFromDbOrNull(raw);
|
||||
if (type != null && !output.contains(type)) {
|
||||
output.add(type);
|
||||
}
|
||||
}
|
||||
return List.unmodifiable(output);
|
||||
}
|
||||
|
||||
List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
@ -6158,6 +6337,33 @@ domain.ExerciseCategory _exerciseCategoryFromDb(String value) =>
|
||||
_ => throw domain.DomainException('Unknown exercise category: $value'),
|
||||
};
|
||||
|
||||
String _businessExerciseTypeToDb(domain.BusinessExerciseType type) =>
|
||||
switch (type) {
|
||||
domain.BusinessExerciseType.shoot => 'shoot',
|
||||
domain.BusinessExerciseType.freeThrows => 'freeThrows',
|
||||
domain.BusinessExerciseType.dribble => 'dribble',
|
||||
domain.BusinessExerciseType.finishing => 'finishing',
|
||||
domain.BusinessExerciseType.conditioning => 'conditioning',
|
||||
domain.BusinessExerciseType.defense => 'defense',
|
||||
domain.BusinessExerciseType.mobility => 'mobility',
|
||||
domain.BusinessExerciseType.highIntensity => 'highIntensity',
|
||||
domain.BusinessExerciseType.recovery => 'recovery',
|
||||
};
|
||||
|
||||
domain.BusinessExerciseType? _businessExerciseTypeFromDbOrNull(String value) =>
|
||||
switch (value) {
|
||||
'shoot' => domain.BusinessExerciseType.shoot,
|
||||
'freeThrows' => domain.BusinessExerciseType.freeThrows,
|
||||
'dribble' => domain.BusinessExerciseType.dribble,
|
||||
'finishing' => domain.BusinessExerciseType.finishing,
|
||||
'conditioning' => domain.BusinessExerciseType.conditioning,
|
||||
'defense' => domain.BusinessExerciseType.defense,
|
||||
'mobility' => domain.BusinessExerciseType.mobility,
|
||||
'highIntensity' => domain.BusinessExerciseType.highIntensity,
|
||||
'recovery' => domain.BusinessExerciseType.recovery,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) {
|
||||
domain.SetResultStatus.completed => 'completed',
|
||||
domain.SetResultStatus.skipped => 'skipped',
|
||||
|
||||
@ -3324,22 +3324,6 @@ void main() {
|
||||
),
|
||||
);
|
||||
final duplicate = await useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
28,
|
||||
10,
|
||||
1,
|
||||
).millisecondsSinceEpoch,
|
||||
heartRateBpm: 160,
|
||||
distanceMeters: 300,
|
||||
caloriesKcal: 10,
|
||||
),
|
||||
);
|
||||
final second = await useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
@ -3348,7 +3332,42 @@ void main() {
|
||||
7,
|
||||
28,
|
||||
10,
|
||||
2,
|
||||
0,
|
||||
10,
|
||||
).millisecondsSinceEpoch,
|
||||
heartRateBpm: 160,
|
||||
distanceMeters: 530,
|
||||
caloriesKcal: 45,
|
||||
),
|
||||
);
|
||||
final olderInSameBucket = await useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-older',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
28,
|
||||
10,
|
||||
0,
|
||||
5,
|
||||
).millisecondsSinceEpoch,
|
||||
heartRateBpm: 130,
|
||||
distanceMeters: 510,
|
||||
caloriesKcal: 43,
|
||||
),
|
||||
);
|
||||
final second = await useCase.recordTelemetrySample(
|
||||
WatchTelemetrySample(
|
||||
sampleId: 'sample-3',
|
||||
sessionId: 'session-1',
|
||||
capturedAtEpochMs: DateTime.utc(
|
||||
2026,
|
||||
7,
|
||||
28,
|
||||
10,
|
||||
0,
|
||||
20,
|
||||
).millisecondsSinceEpoch,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
@ -3367,17 +3386,19 @@ void main() {
|
||||
WorkoutTelemetryAggregateScope.set,
|
||||
WorkoutTelemetryAggregateScope.step,
|
||||
]);
|
||||
expect(duplicate, isEmpty);
|
||||
expect(duplicate, isNotEmpty);
|
||||
expect(olderInSameBucket, isEmpty);
|
||||
expect(repository.samples, hasLength(2));
|
||||
expect(repository.samples.first.heartRateBpm, 160);
|
||||
|
||||
final sessionAggregate = second.singleWhere(
|
||||
(aggregate) =>
|
||||
aggregate.scope == WorkoutTelemetryAggregateScope.session,
|
||||
);
|
||||
expect(sessionAggregate.sampleCount, 2);
|
||||
expect(sessionAggregate.minHeartRateBpm, 120);
|
||||
expect(sessionAggregate.averageHeartRateBpm, 135);
|
||||
expect(sessionAggregate.maxHeartRateBpm, 150);
|
||||
expect(sessionAggregate.minHeartRateBpm, 150);
|
||||
expect(sessionAggregate.averageHeartRateBpm, 155);
|
||||
expect(sessionAggregate.maxHeartRateBpm, 160);
|
||||
expect(sessionAggregate.totalDistanceMeters, 620);
|
||||
expect(sessionAggregate.totalCaloriesKcal, 48);
|
||||
expect(
|
||||
@ -3394,6 +3415,79 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'WorkoutTelemetryUseCases reads graph samples by scope with relative cumulative metrics',
|
||||
() async {
|
||||
final repository = _FakeWorkoutTelemetryRepository();
|
||||
final useCase = WorkoutTelemetryUseCases(
|
||||
repository: repository,
|
||||
clock: _FakeClock(DateTime.utc(2026, 7, 28, 10)),
|
||||
ids: _FakeIds(),
|
||||
);
|
||||
final history = WorkoutHistory(
|
||||
metadata: _metadata('history-telemetry'),
|
||||
nameSnapshot: 'Séance',
|
||||
startedAt: DateTime.utc(2026, 7, 28, 10),
|
||||
endedAt: DateTime.utc(2026, 7, 28, 11),
|
||||
totalActiveMs: 3600000,
|
||||
completed: true,
|
||||
historySnapshotJson: jsonEncode({
|
||||
'telemetrySamples': [
|
||||
{
|
||||
'id': 'sample-1',
|
||||
'sessionId': 'session-1',
|
||||
'capturedAt': DateTime.utc(2026, 7, 28, 10).toIso8601String(),
|
||||
'programIndex': 0,
|
||||
'exerciseIndex': 0,
|
||||
'setIndex': 0,
|
||||
'stepIndex': 0,
|
||||
'heartRateBpm': 120,
|
||||
'distanceMeters': 500,
|
||||
'caloriesKcal': 42,
|
||||
},
|
||||
{
|
||||
'id': 'sample-2',
|
||||
'sessionId': 'session-1',
|
||||
'capturedAt': DateTime.utc(2026, 7, 28, 10, 3).toIso8601String(),
|
||||
'programIndex': 0,
|
||||
'exerciseIndex': 0,
|
||||
'setIndex': 0,
|
||||
'stepIndex': 0,
|
||||
'heartRateBpm': 150,
|
||||
'distanceMeters': 620,
|
||||
'caloriesKcal': 48,
|
||||
},
|
||||
{
|
||||
'id': 'sample-other',
|
||||
'sessionId': 'session-1',
|
||||
'capturedAt': DateTime.utc(2026, 7, 28, 10, 4).toIso8601String(),
|
||||
'programIndex': 0,
|
||||
'exerciseIndex': 1,
|
||||
'heartRateBpm': 90,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
final series = await useCase.readGraphSeriesForHistory(
|
||||
history: history,
|
||||
scope: WorkoutTelemetryAggregateScope.step,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
stepIndex: 0,
|
||||
);
|
||||
|
||||
expect(series.points, hasLength(2));
|
||||
expect(series.minHeartRateBpm, 120);
|
||||
expect(series.maxHeartRateBpm, 150);
|
||||
expect(series.points.first.distanceMeters, 0);
|
||||
expect(series.points.last.distanceMeters, 120);
|
||||
expect(series.points.last.caloriesKcal, 6);
|
||||
expect(series.points.last.startsAfterGap, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
|
||||
() async {
|
||||
@ -5309,10 +5403,15 @@ final class _FakeWorkoutTelemetryRepository
|
||||
|
||||
@override
|
||||
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
||||
if (samples.any((existing) => existing.id == sample.id)) {
|
||||
final index = samples.indexWhere((existing) => existing.id == sample.id);
|
||||
if (index != -1 && !sample.capturedAt.isAfter(samples[index].capturedAt)) {
|
||||
return false;
|
||||
}
|
||||
if (index == -1) {
|
||||
samples.add(sample);
|
||||
} else {
|
||||
samples[index] = sample;
|
||||
}
|
||||
samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
|
||||
return true;
|
||||
}
|
||||
@ -5324,6 +5423,33 @@ final class _FakeWorkoutTelemetryRepository
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<WorkoutTelemetrySample>> listSamplesForScope({
|
||||
required String sessionId,
|
||||
required WorkoutTelemetryAggregateScope scope,
|
||||
int? programIndex,
|
||||
int? exerciseIndex,
|
||||
int? setIndex,
|
||||
int? passageIndex,
|
||||
int? stepIndex,
|
||||
}) async {
|
||||
return samples
|
||||
.where(
|
||||
(sample) =>
|
||||
sample.sessionId == sessionId &&
|
||||
_fakeTelemetrySampleMatchesScope(
|
||||
sample,
|
||||
scope: scope,
|
||||
programIndex: programIndex,
|
||||
exerciseIndex: exerciseIndex,
|
||||
setIndex: setIndex,
|
||||
passageIndex: passageIndex,
|
||||
stepIndex: stepIndex,
|
||||
),
|
||||
)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> replaceAggregatesForSession({
|
||||
required String sessionId,
|
||||
@ -5369,6 +5495,33 @@ final class _FakeWorkoutTelemetryRepository
|
||||
}
|
||||
}
|
||||
|
||||
bool _fakeTelemetrySampleMatchesScope(
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
ActiveWorkoutSessionUseCases _activeUseCase(
|
||||
_FakeActiveSessionRepository repository,
|
||||
_FakeClock clock,
|
||||
|
||||
@ -124,6 +124,7 @@ void main() {
|
||||
}
|
||||
|
||||
expect(await columnNames('exercises'), contains('tags_json'));
|
||||
expect(await columnNames('exercises'), contains('business_types_json'));
|
||||
expect(await columnNames('programs'), contains('tags_json'));
|
||||
expect(await columnNames('workout_templates'), contains('tags_json'));
|
||||
expect(
|
||||
@ -143,7 +144,55 @@ void main() {
|
||||
await columnNames('workout_telemetry_aggregates'),
|
||||
contains('sample_count'),
|
||||
);
|
||||
expect(database.schemaVersion, 24);
|
||||
expect(database.schemaVersion, 25);
|
||||
});
|
||||
|
||||
test('exercise business types persist with category fallback', () async {
|
||||
final now = DateTime.utc(2026, 7, 30, 12);
|
||||
final exercise = Exercise(
|
||||
metadata: _metadata('exercise-business-types', now),
|
||||
name: 'Dribble intense',
|
||||
hasTimeMeasure: true,
|
||||
hasRepsMeasure: false,
|
||||
hasScoreMeasure: false,
|
||||
category: ExerciseCategory.shoot,
|
||||
businessTypes: const [
|
||||
BusinessExerciseType.dribble,
|
||||
BusinessExerciseType.highIntensity,
|
||||
BusinessExerciseType.dribble,
|
||||
],
|
||||
);
|
||||
await exerciseRepository.save(exercise);
|
||||
|
||||
final restoredExercise = await exerciseRepository.findById(
|
||||
exercise.metadata.id,
|
||||
);
|
||||
expect(restoredExercise?.businessTypes, [
|
||||
BusinessExerciseType.dribble,
|
||||
BusinessExerciseType.highIntensity,
|
||||
]);
|
||||
expect(restoredExercise?.effectiveBusinessTypes, [
|
||||
BusinessExerciseType.dribble,
|
||||
BusinessExerciseType.highIntensity,
|
||||
]);
|
||||
|
||||
final legacyExercise = Exercise(
|
||||
metadata: _metadata('exercise-business-types-legacy', now),
|
||||
name: 'Shoot',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
category: ExerciseCategory.shoot,
|
||||
);
|
||||
await exerciseRepository.save(legacyExercise);
|
||||
|
||||
final restoredLegacy = await exerciseRepository.findById(
|
||||
legacyExercise.metadata.id,
|
||||
);
|
||||
expect(restoredLegacy?.businessTypes, isEmpty);
|
||||
expect(restoredLegacy?.effectiveBusinessTypes, [
|
||||
BusinessExerciseType.shoot,
|
||||
]);
|
||||
});
|
||||
|
||||
test(
|
||||
@ -165,17 +214,56 @@ void main() {
|
||||
id: 'sample-2',
|
||||
sessionId: 'session-1',
|
||||
capturedAt: DateTime.utc(2026, 7, 28, 10, 1),
|
||||
programIndex: 0,
|
||||
exerciseIndex: 1,
|
||||
heartRateBpm: 150,
|
||||
distanceMeters: 620,
|
||||
caloriesKcal: 48,
|
||||
);
|
||||
final replacement = WorkoutTelemetrySample(
|
||||
id: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 10),
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
stepIndex: 0,
|
||||
heartRateBpm: 130,
|
||||
distanceMeters: 530,
|
||||
caloriesKcal: 45,
|
||||
);
|
||||
final olderReplacement = WorkoutTelemetrySample(
|
||||
id: 'sample-1',
|
||||
sessionId: 'session-1',
|
||||
capturedAt: DateTime.utc(2026, 7, 28, 10, 0, 5),
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
stepIndex: 0,
|
||||
heartRateBpm: 125,
|
||||
distanceMeters: 510,
|
||||
caloriesKcal: 43,
|
||||
);
|
||||
|
||||
expect(await telemetryRepository.saveSample(first), isTrue);
|
||||
expect(await telemetryRepository.saveSample(first), isFalse);
|
||||
expect(await telemetryRepository.saveSample(replacement), isTrue);
|
||||
expect(await telemetryRepository.saveSample(olderReplacement), isFalse);
|
||||
expect(await telemetryRepository.saveSample(second), isTrue);
|
||||
|
||||
final samples = await telemetryRepository.listSamples('session-1');
|
||||
expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']);
|
||||
expect(samples.first.heartRateBpm, 130);
|
||||
final stepSamples = await telemetryRepository.listSamplesForScope(
|
||||
sessionId: 'session-1',
|
||||
scope: WorkoutTelemetryAggregateScope.step,
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
stepIndex: 0,
|
||||
);
|
||||
expect(stepSamples, hasLength(1));
|
||||
expect(stepSamples.single.distanceMeters, 530);
|
||||
|
||||
await telemetryRepository.replaceAggregatesForSession(
|
||||
sessionId: 'session-1',
|
||||
@ -484,6 +572,41 @@ CREATE TABLE pending_share_actions (
|
||||
expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
|
||||
});
|
||||
|
||||
test('local sync payload includes exercise business types', () async {
|
||||
final now = DateTime.utc(2026, 7, 30, 13);
|
||||
final exercise = Exercise(
|
||||
metadata: _metadata('exercise-sync-business-types', now),
|
||||
name: 'Drive',
|
||||
hasTimeMeasure: true,
|
||||
hasRepsMeasure: false,
|
||||
hasScoreMeasure: false,
|
||||
);
|
||||
await exerciseRepository.save(exercise);
|
||||
|
||||
final updatedExercise = exercise.copyWith(
|
||||
metadata: _metadata(
|
||||
'exercise-sync-business-types',
|
||||
now.add(const Duration(seconds: 1)),
|
||||
1,
|
||||
),
|
||||
businessTypes: const [
|
||||
BusinessExerciseType.dribble,
|
||||
BusinessExerciseType.finishing,
|
||||
],
|
||||
);
|
||||
await exerciseRepository.save(updatedExercise);
|
||||
|
||||
final changes = await syncChangeRepository.listPendingChanges();
|
||||
final payloadsById = {
|
||||
for (final change in changes) change.item.clientId: change.item.payload,
|
||||
};
|
||||
|
||||
expect(payloadsById['exercise-sync-business-types']!['businessTypes'], [
|
||||
'dribble',
|
||||
'finishing',
|
||||
]);
|
||||
});
|
||||
|
||||
test('local sync payload includes full workout history aggregate', () async {
|
||||
final now = DateTime.utc(2026, 7, 22, 10, 45);
|
||||
await historyRepository.save(
|
||||
@ -2249,6 +2372,82 @@ CREATE TABLE pending_share_actions (
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'active exercise step progress keeps stored identity when upserted by position',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 7, 17, 12);
|
||||
await activeRepository.save(
|
||||
ActiveWorkoutSession(
|
||||
metadata: _metadata('session-step-progress-upsert', now),
|
||||
status: ActiveWorkoutStatus.running,
|
||||
startedAt: now,
|
||||
lastPersistedAt: now,
|
||||
elapsedActiveMs: 0,
|
||||
currentProgramIndex: 0,
|
||||
currentExerciseIndex: 0,
|
||||
currentSetIndex: 0,
|
||||
resolvedTemplateSnapshotJson: _resolvedSnapshot(),
|
||||
),
|
||||
);
|
||||
await activeRepository.saveExerciseStepProgressState(
|
||||
ActiveExerciseStepProgressState(
|
||||
metadata: _metadata('step-progress-original', now),
|
||||
activeWorkoutSessionId: 'session-step-progress-upsert',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
currentPassageIndex: 0,
|
||||
currentStepIndex: 0,
|
||||
currentStepSnapshotId: 'step-snapshot-1',
|
||||
status: ActiveExerciseStepProgressStatus.runningTimer,
|
||||
startedAt: now,
|
||||
accumulatedMs: 0,
|
||||
lastTransitionAt: now,
|
||||
),
|
||||
);
|
||||
|
||||
final updatedAt = now.add(const Duration(minutes: 1));
|
||||
await activeRepository.saveExerciseStepProgressState(
|
||||
ActiveExerciseStepProgressState(
|
||||
metadata: _metadata('step-progress-new-id', updatedAt, 1),
|
||||
activeWorkoutSessionId: 'session-step-progress-upsert',
|
||||
programIndex: 0,
|
||||
exerciseIndex: 0,
|
||||
setIndex: 0,
|
||||
currentPassageIndex: 0,
|
||||
currentStepIndex: 1,
|
||||
currentStepSnapshotId: 'step-snapshot-2',
|
||||
status: ActiveExerciseStepProgressStatus.stoppedTimer,
|
||||
accumulatedMs: 30000,
|
||||
lastTransitionAt: updatedAt,
|
||||
),
|
||||
);
|
||||
|
||||
final rows = await database
|
||||
.select(database.activeExerciseStepProgressStates)
|
||||
.get();
|
||||
final row =
|
||||
await (database.select(database.activeExerciseStepProgressStates)
|
||||
..where((table) => table.id.equals('step-progress-original')))
|
||||
.getSingle();
|
||||
final changes =
|
||||
await (database.select(database.changeLogEntries)..where(
|
||||
(table) =>
|
||||
table.entityId.equals('step-progress-original') &
|
||||
table.operation.equals('update'),
|
||||
))
|
||||
.get();
|
||||
|
||||
expect(rows, hasLength(1));
|
||||
expect(row.createdAt.toUtc(), now);
|
||||
expect(row.currentStepIndex, 1);
|
||||
expect(row.currentStepSnapshotId, 'step-snapshot-2');
|
||||
expect(row.status, 'stoppedTimer');
|
||||
expect(row.accumulatedMs, 30000);
|
||||
expect(changes.map((change) => change.localRevision), [1]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'closing a session stores autonomous history rows with set snapshots',
|
||||
() async {
|
||||
|
||||
Reference in New Issue
Block a user