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 {
|
abstract interface class WorkoutTelemetryRepository {
|
||||||
Future<bool> saveSample(WorkoutTelemetrySample sample);
|
Future<bool> saveSample(WorkoutTelemetrySample sample);
|
||||||
Future<List<WorkoutTelemetrySample>> listSamples(String sessionId);
|
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({
|
Future<void> replaceAggregatesForSession({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
required List<WorkoutTelemetryAggregate> aggregates,
|
required List<WorkoutTelemetryAggregate> aggregates,
|
||||||
|
|||||||
@ -1148,6 +1148,7 @@ final class ExerciseUseCases {
|
|||||||
double? defaultTargetScore,
|
double? defaultTargetScore,
|
||||||
int? defaultTargetScoreTimeMs,
|
int? defaultTargetScoreTimeMs,
|
||||||
bool autoStartNextTimedStep = true,
|
bool autoStartNextTimedStep = true,
|
||||||
|
List<BusinessExerciseType> businessTypes = const [],
|
||||||
List<String> tags = const [],
|
List<String> tags = const [],
|
||||||
List<ExerciseStep> steps = const [],
|
List<ExerciseStep> steps = const [],
|
||||||
}) async {
|
}) async {
|
||||||
@ -1181,6 +1182,7 @@ final class ExerciseUseCases {
|
|||||||
defaultTargetScore: defaultTargetScore,
|
defaultTargetScore: defaultTargetScore,
|
||||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||||
autoStartNextTimedStep: autoStartNextTimedStep,
|
autoStartNextTimedStep: autoStartNextTimedStep,
|
||||||
|
businessTypes: businessTypes,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
steps: steps,
|
steps: steps,
|
||||||
);
|
);
|
||||||
@ -1215,6 +1217,7 @@ final class ExerciseUseCases {
|
|||||||
Object? defaultTargetScore = _useCaseUnchanged,
|
Object? defaultTargetScore = _useCaseUnchanged,
|
||||||
Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
|
Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
|
||||||
bool? autoStartNextTimedStep,
|
bool? autoStartNextTimedStep,
|
||||||
|
Object? businessTypes = _useCaseUnchanged,
|
||||||
List<String> tags = const [],
|
List<String> tags = const [],
|
||||||
Object? steps = _useCaseUnchanged,
|
Object? steps = _useCaseUnchanged,
|
||||||
}) async {
|
}) async {
|
||||||
@ -1277,6 +1280,9 @@ final class ExerciseUseCases {
|
|||||||
defaultTargetScore: resolvedDefaultTargetScore,
|
defaultTargetScore: resolvedDefaultTargetScore,
|
||||||
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
|
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
|
||||||
autoStartNextTimedStep: autoStartNextTimedStep,
|
autoStartNextTimedStep: autoStartNextTimedStep,
|
||||||
|
businessTypes: businessTypes == _useCaseUnchanged
|
||||||
|
? exercise.businessTypes
|
||||||
|
: businessTypes as List<BusinessExerciseType>,
|
||||||
tags: tags,
|
tags: tags,
|
||||||
steps: resolvedSteps,
|
steps: resolvedSteps,
|
||||||
);
|
);
|
||||||
@ -5492,6 +5498,9 @@ final class CloseWorkoutSessionUseCase {
|
|||||||
sessionId: session.metadata.id,
|
sessionId: session.metadata.id,
|
||||||
scope: WorkoutTelemetryAggregateScope.session,
|
scope: WorkoutTelemetryAggregateScope.session,
|
||||||
);
|
);
|
||||||
|
final telemetrySamples =
|
||||||
|
await telemetryRepository?.listSamples(session.metadata.id) ??
|
||||||
|
const <WorkoutTelemetrySample>[];
|
||||||
final history = WorkoutHistory(
|
final history = WorkoutHistory(
|
||||||
metadata: EntityMetadata(
|
metadata: EntityMetadata(
|
||||||
id: historyId,
|
id: historyId,
|
||||||
@ -5561,6 +5570,9 @@ final class CloseWorkoutSessionUseCase {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.toList(),
|
.toList(),
|
||||||
|
'telemetrySamples': telemetrySamples
|
||||||
|
.map(_workoutTelemetrySampleSnapshotJson)
|
||||||
|
.toList(),
|
||||||
}),
|
}),
|
||||||
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
|
minHeartRateBpm: telemetryAggregate?.minHeartRateBpm,
|
||||||
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
|
averageHeartRateBpm: telemetryAggregate?.averageHeartRateBpm,
|
||||||
@ -5582,10 +5594,73 @@ final class WorkoutTelemetryUseCases {
|
|||||||
required this.ids,
|
required this.ids,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
static const persistedSampleInterval = Duration(seconds: 15);
|
||||||
|
|
||||||
final WorkoutTelemetryRepository repository;
|
final WorkoutTelemetryRepository repository;
|
||||||
final Clock clock;
|
final Clock clock;
|
||||||
final IdGenerator ids;
|
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(
|
Future<List<WorkoutTelemetryAggregate>> recordTelemetrySample(
|
||||||
WatchTelemetrySample sample,
|
WatchTelemetrySample sample,
|
||||||
) async {
|
) async {
|
||||||
@ -5626,7 +5701,7 @@ final class WorkoutTelemetryUseCases {
|
|||||||
)
|
)
|
||||||
: clock.now();
|
: clock.now();
|
||||||
return WorkoutTelemetrySample(
|
return WorkoutTelemetrySample(
|
||||||
id: _telemetrySampleId(sample),
|
id: _telemetrySampleId(sample, sessionId, capturedAt),
|
||||||
sessionId: sessionId,
|
sessionId: sessionId,
|
||||||
capturedAt: capturedAt,
|
capturedAt: capturedAt,
|
||||||
programIndex: sample.programIndex,
|
programIndex: sample.programIndex,
|
||||||
@ -5643,12 +5718,200 @@ final class WorkoutTelemetryUseCases {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _telemetrySampleId(WatchTelemetrySample sample) {
|
String _telemetrySampleId(
|
||||||
final stableId = sample.sampleId?.trim();
|
WatchTelemetrySample sample,
|
||||||
return stableId == null || stableId.isEmpty ? ids.newId() : stableId;
|
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<WorkoutTelemetryAggregate> _telemetryAggregatesFromSamples(
|
||||||
List<WorkoutTelemetrySample> samples,
|
List<WorkoutTelemetrySample> samples,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -21,6 +21,18 @@ enum ExerciseCategory {
|
|||||||
uncategorized,
|
uncategorized,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum BusinessExerciseType {
|
||||||
|
shoot,
|
||||||
|
freeThrows,
|
||||||
|
dribble,
|
||||||
|
finishing,
|
||||||
|
conditioning,
|
||||||
|
defense,
|
||||||
|
mobility,
|
||||||
|
highIntensity,
|
||||||
|
recovery,
|
||||||
|
}
|
||||||
|
|
||||||
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
|
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
|
||||||
|
|
||||||
enum SetResultStatus { completed, skipped }
|
enum SetResultStatus { completed, skipped }
|
||||||
@ -333,6 +345,7 @@ final class Exercise {
|
|||||||
this.defaultTargetScoreTimeMs,
|
this.defaultTargetScoreTimeMs,
|
||||||
this.autoStartNextTimedStep = true,
|
this.autoStartNextTimedStep = true,
|
||||||
this.category = ExerciseCategory.uncategorized,
|
this.category = ExerciseCategory.uncategorized,
|
||||||
|
List<BusinessExerciseType> businessTypes = const [],
|
||||||
this.isExample = false,
|
this.isExample = false,
|
||||||
List<String> tags = const [],
|
List<String> tags = const [],
|
||||||
List<ExerciseStep> steps = const [],
|
List<ExerciseStep> steps = const [],
|
||||||
@ -347,6 +360,7 @@ final class Exercise {
|
|||||||
iconMediaId,
|
iconMediaId,
|
||||||
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
|
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
|
||||||
),
|
),
|
||||||
|
businessTypes = _validatedBusinessExerciseTypes(businessTypes),
|
||||||
steps = _validatedExerciseSteps(steps) {
|
steps = _validatedExerciseSteps(steps) {
|
||||||
_requireAtLeastOneMeasure(
|
_requireAtLeastOneMeasure(
|
||||||
hasTime: hasTimeMeasure,
|
hasTime: hasTimeMeasure,
|
||||||
@ -406,6 +420,7 @@ final class Exercise {
|
|||||||
final int? defaultTargetScoreTimeMs;
|
final int? defaultTargetScoreTimeMs;
|
||||||
final bool autoStartNextTimedStep;
|
final bool autoStartNextTimedStep;
|
||||||
final ExerciseCategory category;
|
final ExerciseCategory category;
|
||||||
|
final List<BusinessExerciseType> businessTypes;
|
||||||
final bool isExample;
|
final bool isExample;
|
||||||
final List<String> tags;
|
final List<String> tags;
|
||||||
final List<ExerciseStep> steps;
|
final List<ExerciseStep> steps;
|
||||||
@ -420,6 +435,10 @@ final class Exercise {
|
|||||||
String? get imageMediaId =>
|
String? get imageMediaId =>
|
||||||
imageMediaIds.isEmpty ? null : imageMediaIds.first;
|
imageMediaIds.isEmpty ? null : imageMediaIds.first;
|
||||||
|
|
||||||
|
List<BusinessExerciseType> get effectiveBusinessTypes => businessTypes.isEmpty
|
||||||
|
? _businessTypesFromCategory(category)
|
||||||
|
: businessTypes;
|
||||||
|
|
||||||
Exercise archive(DateTime now) {
|
Exercise archive(DateTime now) {
|
||||||
return copyWith(archivedAt: now, metadata: metadata.touch(now));
|
return copyWith(archivedAt: now, metadata: metadata.touch(now));
|
||||||
}
|
}
|
||||||
@ -444,6 +463,7 @@ final class Exercise {
|
|||||||
Object? defaultTargetScoreTimeMs = _unchanged,
|
Object? defaultTargetScoreTimeMs = _unchanged,
|
||||||
bool? autoStartNextTimedStep,
|
bool? autoStartNextTimedStep,
|
||||||
ExerciseCategory? category,
|
ExerciseCategory? category,
|
||||||
|
Object? businessTypes = _unchanged,
|
||||||
bool? isExample,
|
bool? isExample,
|
||||||
Object? tags = _unchanged,
|
Object? tags = _unchanged,
|
||||||
Object? steps = _unchanged,
|
Object? steps = _unchanged,
|
||||||
@ -496,6 +516,9 @@ final class Exercise {
|
|||||||
autoStartNextTimedStep:
|
autoStartNextTimedStep:
|
||||||
autoStartNextTimedStep ?? this.autoStartNextTimedStep,
|
autoStartNextTimedStep ?? this.autoStartNextTimedStep,
|
||||||
category: category ?? this.category,
|
category: category ?? this.category,
|
||||||
|
businessTypes: businessTypes == _unchanged
|
||||||
|
? this.businessTypes
|
||||||
|
: businessTypes as List<BusinessExerciseType>,
|
||||||
isExample: isExample ?? this.isExample,
|
isExample: isExample ?? this.isExample,
|
||||||
tags: tags == _unchanged ? this.tags : tags as List<String>,
|
tags: tags == _unchanged ? this.tags : tags as List<String>,
|
||||||
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
|
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
|
||||||
@ -1675,6 +1698,68 @@ final class WorkoutTelemetryAggregate {
|
|||||||
final double? totalCaloriesKcal;
|
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 {
|
final class WorkoutHistorySetResult {
|
||||||
WorkoutHistorySetResult({
|
WorkoutHistorySetResult({
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
@ -1924,6 +2009,33 @@ List<ExerciseStep> _validatedExerciseSteps(List<ExerciseStep> steps) {
|
|||||||
return List.unmodifiable(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({
|
void _requireAtLeastOneMeasure({
|
||||||
required bool hasTime,
|
required bool hasTime,
|
||||||
required bool hasReps,
|
required bool hasReps,
|
||||||
|
|||||||
@ -51,7 +51,7 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 24;
|
int get schemaVersion => 25;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
MigrationStrategy get migration {
|
MigrationStrategy get migration {
|
||||||
@ -60,6 +60,7 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
await migrator.createAll();
|
await migrator.createAll();
|
||||||
await _migrateToSchema15();
|
await _migrateToSchema15();
|
||||||
await _migrateToSchema16(migrator);
|
await _migrateToSchema16(migrator);
|
||||||
|
await _migrateToSchema25();
|
||||||
await _createIndexes();
|
await _createIndexes();
|
||||||
},
|
},
|
||||||
onUpgrade: (migrator, from, to) async {
|
onUpgrade: (migrator, from, to) async {
|
||||||
@ -137,6 +138,9 @@ final class AppDatabase extends _$AppDatabase {
|
|||||||
if (from < 24) {
|
if (from < 24) {
|
||||||
await _migrateToSchema24(migrator);
|
await _migrateToSchema24(migrator);
|
||||||
}
|
}
|
||||||
|
if (from < 25) {
|
||||||
|
await _migrateToSchema25();
|
||||||
|
}
|
||||||
await _createIndexes();
|
await _createIndexes();
|
||||||
},
|
},
|
||||||
beforeOpen: (details) async {
|
beforeOpen: (details) async {
|
||||||
@ -956,6 +960,16 @@ FROM pending_share_actions
|
|||||||
await migrator.createTable(workoutTelemetryAggregates);
|
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 {
|
Future<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
|
||||||
await customStatement(r'''
|
await customStatement(r'''
|
||||||
UPDATE workout_history_set_results AS result
|
UPDATE workout_history_set_results AS result
|
||||||
|
|||||||
@ -112,7 +112,8 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
|||||||
) async {
|
) async {
|
||||||
final row = await database
|
final row = await database
|
||||||
.customSelect(
|
.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)],
|
variables: [Variable<String>(exerciseId)],
|
||||||
)
|
)
|
||||||
.getSingleOrNull();
|
.getSingleOrNull();
|
||||||
@ -123,6 +124,9 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
|||||||
category: _exerciseCategoryFromDb(
|
category: _exerciseCategoryFromDb(
|
||||||
row.readNullable<String>('category') ?? 'uncategorized',
|
row.readNullable<String>('category') ?? 'uncategorized',
|
||||||
),
|
),
|
||||||
|
businessTypes: _decodeBusinessExerciseTypesJson(
|
||||||
|
row.readNullable<String>('business_types_json'),
|
||||||
|
),
|
||||||
isExample: (row.readNullable<int>('is_example') ?? 0) == 1,
|
isExample: (row.readNullable<int>('is_example') ?? 0) == 1,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1484,17 +1488,39 @@ final class DriftActiveSessionRepository implements ActiveSessionRepository {
|
|||||||
Future<void> saveExerciseStepProgressState(
|
Future<void> saveExerciseStepProgressState(
|
||||||
domain.ActiveExerciseStepProgressState state,
|
domain.ActiveExerciseStepProgressState state,
|
||||||
) async {
|
) async {
|
||||||
await _upsertWithChangeLog(
|
await database.transaction(() async {
|
||||||
database: database,
|
final existing = await _findActiveExerciseStepProgressStateRowByPosition(
|
||||||
tableName: 'active_exercise_step_progress_states',
|
database,
|
||||||
entityType: 'ActiveExerciseStepProgressState',
|
state.activeWorkoutSessionId,
|
||||||
metadata: state.metadata,
|
state.programIndex,
|
||||||
write: () => database
|
state.exerciseIndex,
|
||||||
.into(database.activeExerciseStepProgressStates)
|
state.setIndex,
|
||||||
.insertOnConflictUpdate(
|
);
|
||||||
_activeExerciseStepProgressStateCompanion(state),
|
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
|
@override
|
||||||
@ -2049,16 +2075,13 @@ final class DriftWorkoutTelemetryRepository
|
|||||||
final existing = await (database.select(
|
final existing = await (database.select(
|
||||||
database.workoutTelemetrySamples,
|
database.workoutTelemetrySamples,
|
||||||
)..where((table) => table.id.equals(sample.id))).getSingleOrNull();
|
)..where((table) => table.id.equals(sample.id))).getSingleOrNull();
|
||||||
if (existing != null) {
|
if (existing != null && !sample.capturedAt.isAfter(existing.capturedAt)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final inserted = await database
|
await database
|
||||||
.into(database.workoutTelemetrySamples)
|
.into(database.workoutTelemetrySamples)
|
||||||
.insert(
|
.insertOnConflictUpdate(_workoutTelemetrySampleCompanion(sample));
|
||||||
_workoutTelemetrySampleCompanion(sample),
|
return true;
|
||||||
mode: InsertMode.insertOrIgnore,
|
|
||||||
);
|
|
||||||
return inserted > 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -2073,6 +2096,37 @@ final class DriftWorkoutTelemetryRepository
|
|||||||
return rows.map(_workoutTelemetrySampleFromRow).toList();
|
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
|
@override
|
||||||
Future<void> replaceAggregatesForSession({
|
Future<void> replaceAggregatesForSession({
|
||||||
required String sessionId,
|
required String sessionId,
|
||||||
@ -3382,10 +3436,12 @@ String _exerciseImageId(String exerciseId, String mediaId) {
|
|||||||
final class _ExerciseStarterMetadata {
|
final class _ExerciseStarterMetadata {
|
||||||
const _ExerciseStarterMetadata({
|
const _ExerciseStarterMetadata({
|
||||||
this.category = domain.ExerciseCategory.uncategorized,
|
this.category = domain.ExerciseCategory.uncategorized,
|
||||||
|
this.businessTypes = const [],
|
||||||
this.isExample = false,
|
this.isExample = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final domain.ExerciseCategory category;
|
final domain.ExerciseCategory category;
|
||||||
|
final List<domain.BusinessExerciseType> businessTypes;
|
||||||
final bool isExample;
|
final bool isExample;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -3394,10 +3450,12 @@ Future<void> _writeExerciseStarterMetadata(
|
|||||||
domain.Exercise exercise,
|
domain.Exercise exercise,
|
||||||
) async {
|
) async {
|
||||||
await database.customStatement(
|
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),
|
_exerciseCategoryToDb(exercise.category),
|
||||||
exercise.isExample ? 1 : 0,
|
exercise.isExample ? 1 : 0,
|
||||||
|
_encodeBusinessExerciseTypes(exercise.businessTypes),
|
||||||
exercise.metadata.id,
|
exercise.metadata.id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -3764,6 +3822,7 @@ domain.Exercise _exerciseFromRow(
|
|||||||
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
|
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
|
||||||
autoStartNextTimedStep: row.autoStartNextTimedStep,
|
autoStartNextTimedStep: row.autoStartNextTimedStep,
|
||||||
category: starterMetadata.category,
|
category: starterMetadata.category,
|
||||||
|
businessTypes: starterMetadata.businessTypes,
|
||||||
isExample: starterMetadata.isExample,
|
isExample: starterMetadata.isExample,
|
||||||
tags: _decodeTags(row.tagsJson),
|
tags: _decodeTags(row.tagsJson),
|
||||||
steps: steps,
|
steps: steps,
|
||||||
@ -4374,6 +4433,24 @@ Future<db.ActiveManualScoreState?> _findActiveManualScoreStateRowByPosition(
|
|||||||
.getSingleOrNull();
|
.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 _metadataWithStoredIdentity(
|
||||||
domain.EntityMetadata metadata,
|
domain.EntityMetadata metadata,
|
||||||
domain.EntityMetadata stored,
|
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(
|
Future<void> _upsertActiveSetTimerState(
|
||||||
db.AppDatabase database,
|
db.AppDatabase database,
|
||||||
domain.ActiveSetTimerState state,
|
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(
|
domain.ActiveExerciseStepProgressState _activeExerciseStepProgressStateFromRow(
|
||||||
db.ActiveExerciseStepProgressState row,
|
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(
|
domain.WorkoutTelemetryAggregate _workoutTelemetryAggregateFromRow(
|
||||||
db.WorkoutTelemetryAggregate row,
|
db.WorkoutTelemetryAggregate row,
|
||||||
) {
|
) {
|
||||||
@ -5270,6 +5408,12 @@ Map<String, Object?> _exercisePayload(domain.Exercise exercise) => {
|
|||||||
'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs,
|
'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs,
|
||||||
'autoStartNextTimedStep': exercise.autoStartNextTimedStep,
|
'autoStartNextTimedStep': exercise.autoStartNextTimedStep,
|
||||||
'category': exercise.category.name,
|
'category': exercise.category.name,
|
||||||
|
'businessTypes': exercise.businessTypes
|
||||||
|
.map(_businessExerciseTypeToDb)
|
||||||
|
.toList(),
|
||||||
|
'effectiveBusinessTypes': exercise.effectiveBusinessTypes
|
||||||
|
.map(_businessExerciseTypeToDb)
|
||||||
|
.toList(),
|
||||||
'isExample': exercise.isExample,
|
'isExample': exercise.isExample,
|
||||||
'tags': exercise.tags,
|
'tags': exercise.tags,
|
||||||
'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(),
|
'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(),
|
||||||
@ -5549,6 +5693,7 @@ domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
|
|||||||
category: _exerciseCategoryFromDb(
|
category: _exerciseCategoryFromDb(
|
||||||
payload['category'] as String? ?? 'uncategorized',
|
payload['category'] as String? ?? 'uncategorized',
|
||||||
),
|
),
|
||||||
|
businessTypes: _businessExerciseTypesFromPayload(payload['businessTypes']),
|
||||||
isExample: payload['isExample'] as bool? ?? false,
|
isExample: payload['isExample'] as bool? ?? false,
|
||||||
tags: _stringListFromPayload(payload['tags']),
|
tags: _stringListFromPayload(payload['tags']),
|
||||||
steps: _stepsFromPayload(payload['steps']),
|
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) {
|
List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
|
||||||
if (value is! List) {
|
if (value is! List) {
|
||||||
return const [];
|
return const [];
|
||||||
@ -6158,6 +6337,33 @@ domain.ExerciseCategory _exerciseCategoryFromDb(String value) =>
|
|||||||
_ => throw domain.DomainException('Unknown exercise category: $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) {
|
String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) {
|
||||||
domain.SetResultStatus.completed => 'completed',
|
domain.SetResultStatus.completed => 'completed',
|
||||||
domain.SetResultStatus.skipped => 'skipped',
|
domain.SetResultStatus.skipped => 'skipped',
|
||||||
|
|||||||
@ -3324,22 +3324,6 @@ void main() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
final duplicate = await useCase.recordTelemetrySample(
|
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(
|
WatchTelemetrySample(
|
||||||
sampleId: 'sample-2',
|
sampleId: 'sample-2',
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
@ -3348,7 +3332,42 @@ void main() {
|
|||||||
7,
|
7,
|
||||||
28,
|
28,
|
||||||
10,
|
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,
|
).millisecondsSinceEpoch,
|
||||||
programIndex: 0,
|
programIndex: 0,
|
||||||
exerciseIndex: 0,
|
exerciseIndex: 0,
|
||||||
@ -3367,17 +3386,19 @@ void main() {
|
|||||||
WorkoutTelemetryAggregateScope.set,
|
WorkoutTelemetryAggregateScope.set,
|
||||||
WorkoutTelemetryAggregateScope.step,
|
WorkoutTelemetryAggregateScope.step,
|
||||||
]);
|
]);
|
||||||
expect(duplicate, isEmpty);
|
expect(duplicate, isNotEmpty);
|
||||||
|
expect(olderInSameBucket, isEmpty);
|
||||||
expect(repository.samples, hasLength(2));
|
expect(repository.samples, hasLength(2));
|
||||||
|
expect(repository.samples.first.heartRateBpm, 160);
|
||||||
|
|
||||||
final sessionAggregate = second.singleWhere(
|
final sessionAggregate = second.singleWhere(
|
||||||
(aggregate) =>
|
(aggregate) =>
|
||||||
aggregate.scope == WorkoutTelemetryAggregateScope.session,
|
aggregate.scope == WorkoutTelemetryAggregateScope.session,
|
||||||
);
|
);
|
||||||
expect(sessionAggregate.sampleCount, 2);
|
expect(sessionAggregate.sampleCount, 2);
|
||||||
expect(sessionAggregate.minHeartRateBpm, 120);
|
expect(sessionAggregate.minHeartRateBpm, 150);
|
||||||
expect(sessionAggregate.averageHeartRateBpm, 135);
|
expect(sessionAggregate.averageHeartRateBpm, 155);
|
||||||
expect(sessionAggregate.maxHeartRateBpm, 150);
|
expect(sessionAggregate.maxHeartRateBpm, 160);
|
||||||
expect(sessionAggregate.totalDistanceMeters, 620);
|
expect(sessionAggregate.totalDistanceMeters, 620);
|
||||||
expect(sessionAggregate.totalCaloriesKcal, 48);
|
expect(sessionAggregate.totalCaloriesKcal, 48);
|
||||||
expect(
|
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(
|
test(
|
||||||
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
|
'WorkoutHistoryUseCases ignores insufficient heart rate summary',
|
||||||
() async {
|
() async {
|
||||||
@ -5309,10 +5403,15 @@ final class _FakeWorkoutTelemetryRepository
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> saveSample(WorkoutTelemetrySample sample) async {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
samples.add(sample);
|
if (index == -1) {
|
||||||
|
samples.add(sample);
|
||||||
|
} else {
|
||||||
|
samples[index] = sample;
|
||||||
|
}
|
||||||
samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
|
samples.sort((left, right) => left.capturedAt.compareTo(right.capturedAt));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -5324,6 +5423,33 @@ final class _FakeWorkoutTelemetryRepository
|
|||||||
.toList(growable: false);
|
.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
|
@override
|
||||||
Future<void> replaceAggregatesForSession({
|
Future<void> replaceAggregatesForSession({
|
||||||
required String sessionId,
|
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(
|
ActiveWorkoutSessionUseCases _activeUseCase(
|
||||||
_FakeActiveSessionRepository repository,
|
_FakeActiveSessionRepository repository,
|
||||||
_FakeClock clock,
|
_FakeClock clock,
|
||||||
|
|||||||
@ -124,6 +124,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
expect(await columnNames('exercises'), contains('tags_json'));
|
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('programs'), contains('tags_json'));
|
||||||
expect(await columnNames('workout_templates'), contains('tags_json'));
|
expect(await columnNames('workout_templates'), contains('tags_json'));
|
||||||
expect(
|
expect(
|
||||||
@ -143,7 +144,55 @@ void main() {
|
|||||||
await columnNames('workout_telemetry_aggregates'),
|
await columnNames('workout_telemetry_aggregates'),
|
||||||
contains('sample_count'),
|
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(
|
test(
|
||||||
@ -165,17 +214,56 @@ void main() {
|
|||||||
id: 'sample-2',
|
id: 'sample-2',
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
capturedAt: DateTime.utc(2026, 7, 28, 10, 1),
|
capturedAt: DateTime.utc(2026, 7, 28, 10, 1),
|
||||||
|
programIndex: 0,
|
||||||
|
exerciseIndex: 1,
|
||||||
heartRateBpm: 150,
|
heartRateBpm: 150,
|
||||||
distanceMeters: 620,
|
distanceMeters: 620,
|
||||||
caloriesKcal: 48,
|
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), isTrue);
|
||||||
expect(await telemetryRepository.saveSample(first), isFalse);
|
expect(await telemetryRepository.saveSample(first), isFalse);
|
||||||
|
expect(await telemetryRepository.saveSample(replacement), isTrue);
|
||||||
|
expect(await telemetryRepository.saveSample(olderReplacement), isFalse);
|
||||||
expect(await telemetryRepository.saveSample(second), isTrue);
|
expect(await telemetryRepository.saveSample(second), isTrue);
|
||||||
|
|
||||||
final samples = await telemetryRepository.listSamples('session-1');
|
final samples = await telemetryRepository.listSamples('session-1');
|
||||||
expect(samples.map((sample) => sample.id), ['sample-1', 'sample-2']);
|
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(
|
await telemetryRepository.replaceAggregatesForSession(
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
@ -484,6 +572,41 @@ CREATE TABLE pending_share_actions (
|
|||||||
expect(payloadsById['template-sync-tags']!['tags'], ['routine']);
|
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 {
|
test('local sync payload includes full workout history aggregate', () async {
|
||||||
final now = DateTime.utc(2026, 7, 22, 10, 45);
|
final now = DateTime.utc(2026, 7, 22, 10, 45);
|
||||||
await historyRepository.save(
|
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(
|
test(
|
||||||
'closing a session stores autonomous history rows with set snapshots',
|
'closing a session stores autonomous history rows with set snapshots',
|
||||||
() async {
|
() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user