feat(exercice): modèle domain + Drift pour exercices à étapes (ticket #56)
Étend le modèle Drift (tables.dart, app_database.dart/.g.dart, migration schemaVersion 7→8) et la couche application (entités, use cases, repositories) pour supporter des exercices composés de plusieurs étapes. flutter pub get OK, build_runner OK, flutter analyze propre (mêmes 8 infos préexistantes), 87/87 tests verts, build APK debug validé. Premier ticket du chantier "Exercice à plusieurs étapes" (#54). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -37,7 +37,9 @@ final class ExerciseUseCases {
|
||||
int? defaultTargetReps,
|
||||
double? defaultTargetScore,
|
||||
int? defaultTargetScoreTimeMs,
|
||||
List<ExerciseStep> steps = const [],
|
||||
}) async {
|
||||
_validateExerciseSteps(steps);
|
||||
_validateExerciseDefaultTargets(
|
||||
hasTimeMeasure: hasTimeMeasure,
|
||||
hasRepsMeasure: hasRepsMeasure,
|
||||
@ -97,6 +99,7 @@ final class ExerciseUseCases {
|
||||
Object? defaultTargetReps = _useCaseUnchanged,
|
||||
Object? defaultTargetScore = _useCaseUnchanged,
|
||||
Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
|
||||
Object? steps = _useCaseUnchanged,
|
||||
}) async {
|
||||
final exercise = await repository.findById(id);
|
||||
if (exercise == null) {
|
||||
@ -126,6 +129,10 @@ final class ExerciseUseCases {
|
||||
defaultTargetScore: resolvedDefaultTargetScore,
|
||||
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
|
||||
);
|
||||
final resolvedSteps = steps == _useCaseUnchanged
|
||||
? exercise.steps
|
||||
: steps as List<ExerciseStep>;
|
||||
_validateExerciseSteps(resolvedSteps);
|
||||
final resolvedImageMediaIds = imageMediaIds == _useCaseUnchanged
|
||||
? _resolveExerciseImageIds(imageMediaId, exercise.imageMediaIds)
|
||||
: imageMediaIds as List<String>;
|
||||
@ -152,6 +159,7 @@ final class ExerciseUseCases {
|
||||
defaultTargetReps: resolvedDefaultTargetReps,
|
||||
defaultTargetScore: resolvedDefaultTargetScore,
|
||||
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
|
||||
steps: resolvedSteps,
|
||||
);
|
||||
await repository.save(updated);
|
||||
return updated;
|
||||
@ -475,6 +483,7 @@ final class ProgramUseCases {
|
||||
exerciseImageMediaIdSnapshot: input.exerciseImageMediaIdSnapshot,
|
||||
exerciseImageMediaIdsSnapshot: input.exerciseImageMediaIdsSnapshot,
|
||||
exerciseVideoMediaIdSnapshot: input.exerciseVideoMediaIdSnapshot,
|
||||
exerciseStepsSnapshot: input.exerciseStepsSnapshot,
|
||||
exerciseArchivedSnapshot: input.exerciseArchivedSnapshot,
|
||||
availableTimeSnapshot: input.availableTimeSnapshot,
|
||||
availableRepsSnapshot: input.availableRepsSnapshot,
|
||||
@ -592,6 +601,7 @@ final class ProgramExerciseConfig {
|
||||
this.exerciseImageMediaIdSnapshot,
|
||||
this.exerciseImageMediaIdsSnapshot = const [],
|
||||
this.exerciseVideoMediaIdSnapshot,
|
||||
this.exerciseStepsSnapshot = const [],
|
||||
this.exerciseArchivedSnapshot = false,
|
||||
required this.availableTimeSnapshot,
|
||||
required this.availableRepsSnapshot,
|
||||
@ -615,6 +625,7 @@ final class ProgramExerciseConfig {
|
||||
final String? exerciseImageMediaIdSnapshot;
|
||||
final List<String> exerciseImageMediaIdsSnapshot;
|
||||
final String? exerciseVideoMediaIdSnapshot;
|
||||
final List<ExerciseStep> exerciseStepsSnapshot;
|
||||
final bool exerciseArchivedSnapshot;
|
||||
final bool availableTimeSnapshot;
|
||||
final bool availableRepsSnapshot;
|
||||
@ -1560,6 +1571,7 @@ List<ProgramExercise> _repositionProgramExercises(
|
||||
exercises[index].exerciseImageMediaIdsSnapshot,
|
||||
exerciseVideoMediaIdSnapshot:
|
||||
exercises[index].exerciseVideoMediaIdSnapshot,
|
||||
exerciseStepsSnapshot: exercises[index].exerciseStepsSnapshot,
|
||||
exerciseArchivedSnapshot: exercises[index].exerciseArchivedSnapshot,
|
||||
availableTimeSnapshot: exercises[index].availableTimeSnapshot,
|
||||
availableRepsSnapshot: exercises[index].availableRepsSnapshot,
|
||||
@ -1625,6 +1637,82 @@ bool _sameImageSet(List<String> left, List<String> right) {
|
||||
return left.toSet().containsAll(right) && right.toSet().containsAll(left);
|
||||
}
|
||||
|
||||
void _validateExerciseSteps(List<ExerciseStep> steps) {
|
||||
if (steps.length > 8) {
|
||||
throw const DomainException('An exercise cannot have more than 8 steps.');
|
||||
}
|
||||
for (var index = 0; index < steps.length; index++) {
|
||||
final step = steps[index];
|
||||
if (step.position != index) {
|
||||
throw const DomainException(
|
||||
'Exercise step positions must be contiguous and ordered.',
|
||||
);
|
||||
}
|
||||
if (step.name.trim().isEmpty) {
|
||||
throw const DomainException('Exercise step name must not be blank.');
|
||||
}
|
||||
if (step.defaultTargetValue <= 0) {
|
||||
throw const DomainException(
|
||||
'Exercise step default target value must be positive.',
|
||||
);
|
||||
}
|
||||
if (!step.hasScore) {
|
||||
if (step.scoreLabel != null ||
|
||||
step.scoreUnit != null ||
|
||||
step.defaultTargetScore != null ||
|
||||
step.defaultTargetScoreTimeMs != null) {
|
||||
throw const DomainException(
|
||||
'Disabled exercise step score must not define score values.',
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (step.defaultTargetScore != null &&
|
||||
step.defaultTargetScoreTimeMs != null) {
|
||||
throw const DomainException(
|
||||
'Manual and stopwatch step score targets cannot both be set.',
|
||||
);
|
||||
}
|
||||
switch (step.scoreInputMode) {
|
||||
case ScoreInputMode.manual:
|
||||
if (step.scoreLabel == null || step.scoreLabel!.trim().isEmpty) {
|
||||
throw const DomainException(
|
||||
'Exercise step score label must not be blank.',
|
||||
);
|
||||
}
|
||||
if (step.scoreUnit == null || step.scoreUnit!.trim().isEmpty) {
|
||||
throw const DomainException(
|
||||
'Exercise step score unit must not be blank.',
|
||||
);
|
||||
}
|
||||
if (step.defaultTargetScore != null && step.defaultTargetScore! < 0) {
|
||||
throw const DomainException(
|
||||
'Exercise step default target score must not be negative.',
|
||||
);
|
||||
}
|
||||
if (step.defaultTargetScoreTimeMs != null) {
|
||||
throw const DomainException(
|
||||
'Manual exercise step score cannot define stopwatch target.',
|
||||
);
|
||||
}
|
||||
case ScoreInputMode.stopwatch:
|
||||
if (step.scoreLabel != null ||
|
||||
step.scoreUnit != null ||
|
||||
step.defaultTargetScore != null) {
|
||||
throw const DomainException(
|
||||
'Stopwatch exercise step score cannot define manual score values.',
|
||||
);
|
||||
}
|
||||
if (step.defaultTargetScoreTimeMs != null &&
|
||||
step.defaultTargetScoreTimeMs! <= 0) {
|
||||
throw const DomainException(
|
||||
'Exercise step default target score time ms must be positive.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _validateExerciseDefaultTargets({
|
||||
required bool hasTimeMeasure,
|
||||
required bool hasRepsMeasure,
|
||||
|
||||
@ -8,6 +8,8 @@ enum WorkoutMeasure { time, reps, score }
|
||||
|
||||
enum ScoreInputMode { manual, stopwatch }
|
||||
|
||||
enum ExerciseStepType { time, reps }
|
||||
|
||||
enum ActiveWorkoutStatus { running, paused, savedExit, completed, abandoned }
|
||||
|
||||
enum SetResultStatus { completed, skipped }
|
||||
@ -138,6 +140,7 @@ final class Exercise {
|
||||
this.defaultTargetReps,
|
||||
this.defaultTargetScore,
|
||||
this.defaultTargetScoreTimeMs,
|
||||
List<ExerciseStep> steps = const [],
|
||||
this.archivedAt,
|
||||
}) : name = _nonBlank(name, 'Exercise name'),
|
||||
imageMediaIds = _resolvedExerciseImageMediaIds(
|
||||
@ -147,7 +150,8 @@ final class Exercise {
|
||||
iconMediaId = _validatedIconMediaId(
|
||||
iconMediaId,
|
||||
_resolvedExerciseImageMediaIds(imageMediaId, imageMediaIds),
|
||||
) {
|
||||
),
|
||||
steps = _validatedExerciseSteps(steps) {
|
||||
_requireAtLeastOneMeasure(
|
||||
hasTime: hasTimeMeasure,
|
||||
hasReps: hasRepsMeasure,
|
||||
@ -193,6 +197,7 @@ final class Exercise {
|
||||
final int? defaultTargetReps;
|
||||
final double? defaultTargetScore;
|
||||
final int? defaultTargetScoreTimeMs;
|
||||
final List<ExerciseStep> steps;
|
||||
final DateTime? archivedAt;
|
||||
|
||||
Set<WorkoutMeasure> get availableMeasures => {
|
||||
@ -226,6 +231,7 @@ final class Exercise {
|
||||
Object? defaultTargetReps = _unchanged,
|
||||
Object? defaultTargetScore = _unchanged,
|
||||
Object? defaultTargetScoreTimeMs = _unchanged,
|
||||
Object? steps = _unchanged,
|
||||
Object? archivedAt = _unchanged,
|
||||
}) {
|
||||
final resolvedImageMediaIds = imageMediaIds == _unchanged
|
||||
@ -272,6 +278,7 @@ final class Exercise {
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs == _unchanged
|
||||
? this.defaultTargetScoreTimeMs
|
||||
: defaultTargetScoreTimeMs as int?,
|
||||
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
|
||||
archivedAt: archivedAt == _unchanged
|
||||
? this.archivedAt
|
||||
: archivedAt as DateTime?,
|
||||
@ -279,6 +286,60 @@ final class Exercise {
|
||||
}
|
||||
}
|
||||
|
||||
final class ExerciseStep {
|
||||
ExerciseStep({
|
||||
required String id,
|
||||
required this.position,
|
||||
required String name,
|
||||
required this.type,
|
||||
required this.defaultTargetValue,
|
||||
this.hasScore = false,
|
||||
this.scoreInputMode = ScoreInputMode.manual,
|
||||
this.scoreLabel,
|
||||
this.scoreUnit,
|
||||
this.defaultTargetScore,
|
||||
this.defaultTargetScoreTimeMs,
|
||||
}) : id = _nonBlank(id, 'Exercise step id'),
|
||||
name = _nonBlank(name, 'Exercise step name') {
|
||||
_requireNonNegative(position, 'Exercise step position');
|
||||
_requirePositive(defaultTargetValue, 'Exercise step default target value');
|
||||
_validateExerciseStepScoreShape(
|
||||
hasScore: hasScore,
|
||||
scoreInputMode: scoreInputMode,
|
||||
scoreLabel: scoreLabel,
|
||||
scoreUnit: scoreUnit,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
final String id;
|
||||
final int position;
|
||||
final String name;
|
||||
final ExerciseStepType type;
|
||||
final int defaultTargetValue;
|
||||
final bool hasScore;
|
||||
final ScoreInputMode scoreInputMode;
|
||||
final String? scoreLabel;
|
||||
final String? scoreUnit;
|
||||
final double? defaultTargetScore;
|
||||
final int? defaultTargetScoreTimeMs;
|
||||
|
||||
Map<String, Object?> toSnapshotJson() => {
|
||||
'id': id,
|
||||
'position': position,
|
||||
'name': name,
|
||||
'type': type.name,
|
||||
'defaultTargetValue': defaultTargetValue,
|
||||
'hasScore': hasScore,
|
||||
'scoreInputMode': scoreInputMode.name,
|
||||
'scoreLabel': scoreLabel,
|
||||
'scoreUnit': scoreUnit,
|
||||
'defaultTargetScore': defaultTargetScore,
|
||||
'defaultTargetScoreTimeMs': defaultTargetScoreTimeMs,
|
||||
};
|
||||
}
|
||||
|
||||
const Object _unchanged = Object();
|
||||
|
||||
final class Program {
|
||||
@ -322,6 +383,7 @@ final class ProgramExercise {
|
||||
this.exerciseImageMediaIdSnapshot,
|
||||
List<String> exerciseImageMediaIdsSnapshot = const [],
|
||||
this.exerciseVideoMediaIdSnapshot,
|
||||
List<ExerciseStep> exerciseStepsSnapshot = const [],
|
||||
this.exerciseArchivedSnapshot = false,
|
||||
required this.availableTimeSnapshot,
|
||||
required this.availableRepsSnapshot,
|
||||
@ -347,7 +409,8 @@ final class ProgramExercise {
|
||||
exerciseImageMediaIdSnapshot != null
|
||||
? [exerciseImageMediaIdSnapshot]
|
||||
: exerciseImageMediaIdsSnapshot,
|
||||
) {
|
||||
),
|
||||
exerciseStepsSnapshot = _validatedExerciseSteps(exerciseStepsSnapshot) {
|
||||
_requireNonNegative(position, 'Position');
|
||||
_requirePositive(setsCount, 'Sets count');
|
||||
_requireAtLeastOneMeasure(
|
||||
@ -386,6 +449,7 @@ final class ProgramExercise {
|
||||
final String? exerciseImageMediaIdSnapshot;
|
||||
final List<String> exerciseImageMediaIdsSnapshot;
|
||||
final String? exerciseVideoMediaIdSnapshot;
|
||||
final List<ExerciseStep> exerciseStepsSnapshot;
|
||||
final bool exerciseArchivedSnapshot;
|
||||
final bool availableTimeSnapshot;
|
||||
final bool availableRepsSnapshot;
|
||||
@ -432,6 +496,7 @@ final class ProgramExercise {
|
||||
exerciseImageMediaIdSnapshot: exercise.imageMediaId,
|
||||
exerciseImageMediaIdsSnapshot: exercise.imageMediaIds,
|
||||
exerciseVideoMediaIdSnapshot: exercise.videoMediaId,
|
||||
exerciseStepsSnapshot: exercise.steps,
|
||||
exerciseArchivedSnapshot: exercise.archivedAt != null,
|
||||
availableTimeSnapshot: exercise.hasTimeMeasure,
|
||||
availableRepsSnapshot: exercise.hasRepsMeasure,
|
||||
@ -461,6 +526,9 @@ final class ProgramExercise {
|
||||
'exerciseImageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
|
||||
'imageMediaIdsSnapshot': exerciseImageMediaIdsSnapshot,
|
||||
'exerciseVideoMediaIdSnapshot': exerciseVideoMediaIdSnapshot,
|
||||
'exerciseStepsSnapshot': exerciseStepsSnapshot
|
||||
.map((step) => step.toSnapshotJson())
|
||||
.toList(),
|
||||
'exerciseArchivedSnapshot': exerciseArchivedSnapshot,
|
||||
'availableTimeSnapshot': availableTimeSnapshot,
|
||||
'availableRepsSnapshot': availableRepsSnapshot,
|
||||
@ -998,6 +1066,24 @@ String? _validatedIconMediaId(String? iconMediaId, List<String> imageMediaIds) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
List<ExerciseStep> _validatedExerciseSteps(List<ExerciseStep> steps) {
|
||||
if (steps.length > 8) {
|
||||
throw const DomainException('An exercise cannot have more than 8 steps.');
|
||||
}
|
||||
for (var index = 0; index < steps.length; index++) {
|
||||
if (steps[index].position != index) {
|
||||
throw const DomainException(
|
||||
'Exercise step positions must be contiguous and ordered.',
|
||||
);
|
||||
}
|
||||
}
|
||||
final ids = steps.map((step) => _nonBlank(step.id, 'Exercise step id'));
|
||||
if (ids.toSet().length != steps.length) {
|
||||
throw const DomainException('Exercise step ids must be unique.');
|
||||
}
|
||||
return List.unmodifiable(steps);
|
||||
}
|
||||
|
||||
void _requireAtLeastOneMeasure({
|
||||
required bool hasTime,
|
||||
required bool hasReps,
|
||||
@ -1063,6 +1149,58 @@ void _requireScoreTargetShape({
|
||||
}
|
||||
}
|
||||
|
||||
void _validateExerciseStepScoreShape({
|
||||
required bool hasScore,
|
||||
required ScoreInputMode scoreInputMode,
|
||||
required String? scoreLabel,
|
||||
required String? scoreUnit,
|
||||
required double? defaultTargetScore,
|
||||
required int? defaultTargetScoreTimeMs,
|
||||
}) {
|
||||
if (!hasScore) {
|
||||
if (scoreLabel != null ||
|
||||
scoreUnit != null ||
|
||||
defaultTargetScore != null ||
|
||||
defaultTargetScoreTimeMs != null) {
|
||||
throw const DomainException(
|
||||
'Disabled exercise step score must not define score values.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (defaultTargetScore != null && defaultTargetScoreTimeMs != null) {
|
||||
throw const DomainException(
|
||||
'Manual and stopwatch step score targets cannot both be set.',
|
||||
);
|
||||
}
|
||||
switch (scoreInputMode) {
|
||||
case ScoreInputMode.manual:
|
||||
_nonBlank(scoreLabel, 'Exercise step score label');
|
||||
_nonBlank(scoreUnit, 'Exercise step score unit');
|
||||
_requireNullableNonNegativeDouble(
|
||||
defaultTargetScore,
|
||||
'Exercise step default target score',
|
||||
);
|
||||
if (defaultTargetScoreTimeMs != null) {
|
||||
throw const DomainException(
|
||||
'Manual exercise step score cannot define stopwatch target.',
|
||||
);
|
||||
}
|
||||
case ScoreInputMode.stopwatch:
|
||||
if (scoreLabel != null ||
|
||||
scoreUnit != null ||
|
||||
defaultTargetScore != null) {
|
||||
throw const DomainException(
|
||||
'Stopwatch exercise step score cannot define manual score values.',
|
||||
);
|
||||
}
|
||||
_requireNullablePositive(
|
||||
defaultTargetScoreTimeMs,
|
||||
'Exercise step default target score time ms',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _requireScoreResultShape({
|
||||
required ScoreInputMode scoreInputMode,
|
||||
required double? actualScore,
|
||||
|
||||
@ -14,6 +14,7 @@ part 'app_database.g.dart';
|
||||
ChangeLogEntries,
|
||||
Exercises,
|
||||
ExerciseImages,
|
||||
ExerciseSteps,
|
||||
MediaAssets,
|
||||
ProgramExercises,
|
||||
Programs,
|
||||
@ -37,7 +38,7 @@ final class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
@override
|
||||
int get schemaVersion => 7;
|
||||
int get schemaVersion => 8;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@ -73,6 +74,9 @@ final class AppDatabase extends _$AppDatabase {
|
||||
if (from < 7) {
|
||||
await _migrateToSchema7();
|
||||
}
|
||||
if (from < 8) {
|
||||
await _migrateToSchema8(migrator);
|
||||
}
|
||||
await _createIndexes();
|
||||
},
|
||||
beforeOpen: (details) async {
|
||||
@ -109,6 +113,10 @@ final class AppDatabase extends _$AppDatabase {
|
||||
'CREATE INDEX IF NOT EXISTS idx_exercise_images_exercise_id '
|
||||
'ON exercise_images (exercise_id)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_exercise_steps_exercise_id_position '
|
||||
'ON exercise_steps (exercise_id, position)',
|
||||
);
|
||||
await customStatement(
|
||||
'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id '
|
||||
'ON workout_template_programs (workout_template_id)',
|
||||
@ -162,6 +170,7 @@ const _syncableTableNames = [
|
||||
'active_workout_sessions',
|
||||
'exercises',
|
||||
'exercise_images',
|
||||
'exercise_steps',
|
||||
'media_assets',
|
||||
'program_exercises',
|
||||
'programs',
|
||||
@ -270,4 +279,12 @@ extension on AppDatabase {
|
||||
'REFERENCES media_assets(id)',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema8(Migrator migrator) async {
|
||||
await migrator.createTable(exerciseSteps);
|
||||
await customStatement(
|
||||
'ALTER TABLE program_exercises ADD COLUMN '
|
||||
'exercise_steps_snapshot_json TEXT',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -18,7 +18,11 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
||||
)..where((table) => table.id.equals(id))).getSingleOrNull();
|
||||
return row == null
|
||||
? null
|
||||
: _exerciseFromRow(row, await _imageMediaIdsForExercise(id));
|
||||
: _exerciseFromRow(
|
||||
row,
|
||||
await _imageMediaIdsForExercise(id),
|
||||
await _stepsForExercise(id),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
@ -33,7 +37,11 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
||||
final exercises = <domain.Exercise>[];
|
||||
for (final row in rows) {
|
||||
exercises.add(
|
||||
_exerciseFromRow(row, await _imageMediaIdsForExercise(row.id)),
|
||||
_exerciseFromRow(
|
||||
row,
|
||||
await _imageMediaIdsForExercise(row.id),
|
||||
await _stepsForExercise(row.id),
|
||||
),
|
||||
);
|
||||
}
|
||||
return exercises;
|
||||
@ -66,6 +74,7 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
||||
.insertOnConflictUpdate(_exerciseCompanion(exercise)),
|
||||
);
|
||||
await _replaceExerciseImages(database, exercise);
|
||||
await _replaceExerciseSteps(database, exercise);
|
||||
});
|
||||
}
|
||||
|
||||
@ -81,6 +90,19 @@ final class DriftExerciseRepository implements ExerciseRepository {
|
||||
.get();
|
||||
return rows.map((row) => row.mediaAssetId).toList();
|
||||
}
|
||||
|
||||
Future<List<domain.ExerciseStep>> _stepsForExercise(String exerciseId) async {
|
||||
final rows =
|
||||
await (database.select(database.exerciseSteps)
|
||||
..where(
|
||||
(table) =>
|
||||
table.exerciseId.equals(exerciseId) &
|
||||
table.deletedAt.isNull(),
|
||||
)
|
||||
..orderBy([(table) => OrderingTerm.asc(table.position)]))
|
||||
.get();
|
||||
return rows.map(_exerciseStepFromRow).toList();
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftMediaAssetRepository implements MediaAssetRepository {
|
||||
@ -823,6 +845,76 @@ Future<void> _replaceExerciseImages(
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _replaceExerciseSteps(
|
||||
db.AppDatabase database,
|
||||
domain.Exercise exercise,
|
||||
) async {
|
||||
final rows = await (database.select(
|
||||
database.exerciseSteps,
|
||||
)..where((table) => table.exerciseId.equals(exercise.metadata.id))).get();
|
||||
final activeRowsByStepId = {
|
||||
for (final row in rows)
|
||||
if (row.deletedAt == null) row.id: row,
|
||||
};
|
||||
final desiredIds = exercise.steps.map((step) => step.id).toSet();
|
||||
final removedRows = activeRowsByStepId.values
|
||||
.where((row) => !desiredIds.contains(row.id))
|
||||
.toList();
|
||||
await _softDeleteExerciseStepRows(
|
||||
database,
|
||||
removedRows,
|
||||
exercise.metadata.updatedAt,
|
||||
);
|
||||
|
||||
for (final step in exercise.steps) {
|
||||
final existing = activeRowsByStepId[step.id];
|
||||
final metadata = existing == null
|
||||
? domain.EntityMetadata(
|
||||
id: step.id,
|
||||
createdAt: exercise.metadata.updatedAt,
|
||||
updatedAt: exercise.metadata.updatedAt,
|
||||
originDeviceId: exercise.metadata.originDeviceId,
|
||||
)
|
||||
: _metadataFromRow(existing).touch(exercise.metadata.updatedAt);
|
||||
await _upsertWithChangeLog(
|
||||
database: database,
|
||||
tableName: 'exercise_steps',
|
||||
entityType: 'ExerciseStep',
|
||||
metadata: metadata,
|
||||
write: () => database
|
||||
.into(database.exerciseSteps)
|
||||
.insertOnConflictUpdate(
|
||||
db.ExerciseStepsCompanion(
|
||||
id: Value(metadata.id),
|
||||
createdAt: Value(metadata.createdAt.toUtc()),
|
||||
updatedAt: Value(metadata.updatedAt.toUtc()),
|
||||
deletedAt: Value(_utcOrNull(metadata.deletedAt)),
|
||||
schemaVersion: Value(metadata.schemaVersion),
|
||||
syncState: Value(_syncStateToDb(metadata.syncState)),
|
||||
localRevision: Value(metadata.localRevision),
|
||||
originDeviceId: Value(metadata.originDeviceId),
|
||||
futureOwnerProfileId: Value(metadata.futureOwnerProfileId),
|
||||
lastSyncedAt: Value(_utcOrNull(metadata.lastSyncedAt)),
|
||||
remoteRevision: Value(metadata.remoteRevision),
|
||||
exerciseId: Value(exercise.metadata.id),
|
||||
position: Value(step.position),
|
||||
name: Value(step.name),
|
||||
type: Value(_exerciseStepTypeToDb(step.type)),
|
||||
defaultTargetValue: Value(step.defaultTargetValue),
|
||||
hasScore: Value(step.hasScore),
|
||||
scoreInputMode: Value(
|
||||
step.hasScore ? _scoreInputModeToDb(step.scoreInputMode) : null,
|
||||
),
|
||||
scoreLabel: Value(step.scoreLabel),
|
||||
scoreUnit: Value(step.scoreUnit),
|
||||
defaultTargetScore: Value(step.defaultTargetScore),
|
||||
defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _softDeleteExerciseImageRows(
|
||||
db.AppDatabase database,
|
||||
List<db.ExerciseImage> rows,
|
||||
@ -852,6 +944,35 @@ Future<void> _softDeleteExerciseImageRows(
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _softDeleteExerciseStepRows(
|
||||
db.AppDatabase database,
|
||||
List<db.ExerciseStep> rows,
|
||||
DateTime deletedAt,
|
||||
) async {
|
||||
for (final row in rows) {
|
||||
final revision = row.localRevision + 1;
|
||||
await (database.update(
|
||||
database.exerciseSteps,
|
||||
)..where((table) => table.id.equals(row.id))).write(
|
||||
db.ExerciseStepsCompanion(
|
||||
deletedAt: Value(deletedAt.toUtc()),
|
||||
updatedAt: Value(deletedAt.toUtc()),
|
||||
localRevision: Value(revision),
|
||||
syncState: const Value('deleted'),
|
||||
),
|
||||
);
|
||||
await _writeChangeLog(
|
||||
database: database,
|
||||
entityType: 'ExerciseStep',
|
||||
entityId: row.id,
|
||||
operation: 'softDelete',
|
||||
localRevision: revision,
|
||||
originDeviceId: row.originDeviceId,
|
||||
createdAt: deletedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _exerciseImageId(String exerciseId, String mediaId) {
|
||||
return 'exercise-image:$exerciseId:$mediaId';
|
||||
}
|
||||
@ -1094,7 +1215,11 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) {
|
||||
);
|
||||
}
|
||||
|
||||
domain.Exercise _exerciseFromRow(db.Exercise row, List<String> imageMediaIds) {
|
||||
domain.Exercise _exerciseFromRow(
|
||||
db.Exercise row,
|
||||
List<String> imageMediaIds,
|
||||
List<domain.ExerciseStep> steps,
|
||||
) {
|
||||
return domain.Exercise(
|
||||
metadata: _metadataFromRow(row),
|
||||
name: row.name,
|
||||
@ -1112,10 +1237,29 @@ domain.Exercise _exerciseFromRow(db.Exercise row, List<String> imageMediaIds) {
|
||||
defaultTargetReps: row.defaultTargetReps,
|
||||
defaultTargetScore: row.defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
|
||||
steps: steps,
|
||||
archivedAt: _utcOrNull(row.archivedAt),
|
||||
);
|
||||
}
|
||||
|
||||
domain.ExerciseStep _exerciseStepFromRow(db.ExerciseStep row) {
|
||||
return domain.ExerciseStep(
|
||||
id: row.id,
|
||||
position: row.position,
|
||||
name: row.name,
|
||||
type: _exerciseStepTypeFromDb(row.type),
|
||||
defaultTargetValue: row.defaultTargetValue,
|
||||
hasScore: row.hasScore,
|
||||
scoreInputMode: row.scoreInputMode == null
|
||||
? domain.ScoreInputMode.manual
|
||||
: _scoreInputModeFromDb(row.scoreInputMode!),
|
||||
scoreLabel: row.scoreLabel,
|
||||
scoreUnit: row.scoreUnit,
|
||||
defaultTargetScore: row.defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
db.MediaAssetsCompanion _mediaAssetCompanion(domain.MediaAsset asset) {
|
||||
final values = _metadataValues(asset.metadata);
|
||||
return db.MediaAssetsCompanion(
|
||||
@ -1215,6 +1359,9 @@ db.ProgramExercisesCompanion _programExerciseCompanion(
|
||||
exerciseImageMediaIdsSnapshotJson: Value(
|
||||
_encodeImageMediaIdsSnapshot(exercise.exerciseImageMediaIdsSnapshot),
|
||||
),
|
||||
exerciseStepsSnapshotJson: Value(
|
||||
_encodeExerciseStepsSnapshot(exercise.exerciseStepsSnapshot),
|
||||
),
|
||||
exerciseVideoMediaIdSnapshot: Value(exercise.exerciseVideoMediaIdSnapshot),
|
||||
exerciseArchivedSnapshot: Value(exercise.exerciseArchivedSnapshot),
|
||||
availableTimeSnapshot: Value(exercise.availableTimeSnapshot),
|
||||
@ -1250,6 +1397,9 @@ domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) {
|
||||
row.exerciseImageMediaIdsSnapshotJson,
|
||||
row.exerciseImageMediaIdSnapshot,
|
||||
),
|
||||
exerciseStepsSnapshot: _decodeExerciseStepsSnapshot(
|
||||
row.exerciseStepsSnapshotJson,
|
||||
),
|
||||
exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot,
|
||||
exerciseArchivedSnapshot: row.exerciseArchivedSnapshot,
|
||||
availableTimeSnapshot: row.availableTimeSnapshot,
|
||||
@ -1751,6 +1901,18 @@ domain.ScoreInputMode _scoreInputModeFromDb(String value) => switch (value) {
|
||||
_ => throw domain.DomainException('Unknown score input mode: $value'),
|
||||
};
|
||||
|
||||
String _exerciseStepTypeToDb(domain.ExerciseStepType type) => switch (type) {
|
||||
domain.ExerciseStepType.time => 'time',
|
||||
domain.ExerciseStepType.reps => 'reps',
|
||||
};
|
||||
|
||||
domain.ExerciseStepType _exerciseStepTypeFromDb(String value) =>
|
||||
switch (value) {
|
||||
'time' => domain.ExerciseStepType.time,
|
||||
'reps' => domain.ExerciseStepType.reps,
|
||||
_ => throw domain.DomainException('Unknown exercise step type: $value'),
|
||||
};
|
||||
|
||||
String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) {
|
||||
domain.SetResultStatus.completed => 'completed',
|
||||
domain.SetResultStatus.skipped => 'skipped',
|
||||
@ -1800,6 +1962,90 @@ List<String> _decodeImageMediaIdsSnapshot(
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String? _encodeExerciseStepsSnapshot(List<domain.ExerciseStep> steps) {
|
||||
return steps.isEmpty
|
||||
? null
|
||||
: jsonEncode(steps.map((step) => step.toSnapshotJson()).toList());
|
||||
}
|
||||
|
||||
List<domain.ExerciseStep> _decodeExerciseStepsSnapshot(String? encoded) {
|
||||
if (encoded == null || encoded.trim().isEmpty) {
|
||||
return const [];
|
||||
}
|
||||
final decoded = jsonDecode(encoded);
|
||||
if (decoded is! List) {
|
||||
throw const domain.DomainException('Invalid exercise steps snapshot.');
|
||||
}
|
||||
return decoded
|
||||
.map((item) {
|
||||
if (item is! Map) {
|
||||
throw const domain.DomainException('Invalid exercise step snapshot.');
|
||||
}
|
||||
final json = Map<String, Object?>.from(item);
|
||||
return domain.ExerciseStep(
|
||||
id: _requiredString(json, 'id'),
|
||||
position: _requiredInt(json, 'position'),
|
||||
name: _requiredString(json, 'name'),
|
||||
type: _exerciseStepTypeFromDb(_requiredString(json, 'type')),
|
||||
defaultTargetValue: _requiredInt(json, 'defaultTargetValue'),
|
||||
hasScore: _requiredBool(json, 'hasScore'),
|
||||
scoreInputMode: _scoreInputModeFromDb(
|
||||
_optionalString(json, 'scoreInputMode') ?? 'manual',
|
||||
),
|
||||
scoreLabel: _optionalString(json, 'scoreLabel'),
|
||||
scoreUnit: _optionalString(json, 'scoreUnit'),
|
||||
defaultTargetScore: _optionalDouble(json, 'defaultTargetScore'),
|
||||
defaultTargetScoreTimeMs: _optionalInt(
|
||||
json,
|
||||
'defaultTargetScoreTimeMs',
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _requiredString(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value;
|
||||
}
|
||||
throw domain.DomainException('$key must be a non-empty string.');
|
||||
}
|
||||
|
||||
String? _optionalString(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
return value is String ? value : null;
|
||||
}
|
||||
|
||||
int _requiredInt(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is int) {
|
||||
return value;
|
||||
}
|
||||
throw domain.DomainException('$key must be an integer.');
|
||||
}
|
||||
|
||||
int? _optionalInt(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
return value is int ? value : null;
|
||||
}
|
||||
|
||||
double? _optionalDouble(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
return (value as num?)?.toDouble();
|
||||
}
|
||||
|
||||
bool _requiredBool(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is bool) {
|
||||
return value;
|
||||
}
|
||||
throw domain.DomainException('$key must be a boolean.');
|
||||
}
|
||||
|
||||
domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) {
|
||||
'running' => domain.ActiveWorkoutStatus.running,
|
||||
'paused' => domain.ActiveWorkoutStatus.paused,
|
||||
|
||||
@ -107,6 +107,50 @@ class ExerciseImages extends SyncableTable {
|
||||
];
|
||||
}
|
||||
|
||||
class ExerciseSteps extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'exercise_steps';
|
||||
|
||||
TextColumn get exerciseId => text().references(Exercises, #id)();
|
||||
IntColumn get position => integer()();
|
||||
TextColumn get name => text().withLength(min: 1)();
|
||||
TextColumn get type => text()();
|
||||
IntColumn get defaultTargetValue => integer()();
|
||||
BoolColumn get hasScore => boolean()();
|
||||
TextColumn get scoreInputMode => text().nullable()();
|
||||
TextColumn get scoreLabel => text().nullable()();
|
||||
TextColumn get scoreUnit => text().nullable()();
|
||||
RealColumn get defaultTargetScore => real().nullable()();
|
||||
IntColumn get defaultTargetScoreTimeMs => integer().nullable()();
|
||||
|
||||
@override
|
||||
List<String> get customConstraints => [
|
||||
"CHECK (type IN ('time', 'reps'))",
|
||||
'CHECK (position >= 0 AND position < 8)',
|
||||
'CHECK (default_target_value > 0)',
|
||||
"CHECK (score_input_mode IS NULL OR score_input_mode IN ('manual', "
|
||||
"'stopwatch'))",
|
||||
'CHECK (has_score OR (score_input_mode IS NULL '
|
||||
'AND score_label IS NULL AND score_unit IS NULL '
|
||||
'AND default_target_score IS NULL '
|
||||
'AND default_target_score_time_ms IS NULL))',
|
||||
'CHECK (NOT has_score OR score_input_mode IS NOT NULL)',
|
||||
"CHECK (NOT has_score OR score_input_mode != 'manual' OR "
|
||||
'(score_label IS NOT NULL AND length(trim(score_label)) > 0 '
|
||||
'AND score_unit IS NOT NULL AND length(trim(score_unit)) > 0))',
|
||||
'CHECK (default_target_score IS NULL OR default_target_score >= 0)',
|
||||
'CHECK (default_target_score_time_ms IS NULL OR '
|
||||
'default_target_score_time_ms > 0)',
|
||||
"CHECK (score_input_mode != 'manual' OR "
|
||||
'default_target_score_time_ms IS NULL)',
|
||||
"CHECK (score_input_mode != 'stopwatch' OR "
|
||||
'(score_label IS NULL AND score_unit IS NULL '
|
||||
'AND default_target_score IS NULL))',
|
||||
'CHECK (default_target_score IS NULL OR '
|
||||
'default_target_score_time_ms IS NULL)',
|
||||
];
|
||||
}
|
||||
|
||||
class Programs extends SyncableTable {
|
||||
@override
|
||||
String get tableName => 'programs';
|
||||
@ -132,6 +176,7 @@ class ProgramExercises extends SyncableTable {
|
||||
TextColumn get exerciseImageMediaIdSnapshot =>
|
||||
text().nullable().references(MediaAssets, #id)();
|
||||
TextColumn get exerciseImageMediaIdsSnapshotJson => text().nullable()();
|
||||
TextColumn get exerciseStepsSnapshotJson => text().nullable()();
|
||||
|
||||
@ReferenceName('programExerciseVideoSnapshotReferences')
|
||||
TextColumn get exerciseVideoMediaIdSnapshot =>
|
||||
|
||||
@ -177,6 +177,113 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('Exercise use case rejects more than eight steps', () async {
|
||||
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
||||
|
||||
await expectLater(
|
||||
useCase.create(
|
||||
name: 'Complexe',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 1,
|
||||
steps: [
|
||||
for (var index = 0; index < 9; index++)
|
||||
_step(id: 'step-$index', position: index),
|
||||
],
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Exercise use case rejects non-contiguous step positions', () async {
|
||||
final useCase = _exerciseUseCase(_FakeExerciseRepository());
|
||||
|
||||
await expectLater(
|
||||
useCase.create(
|
||||
name: 'Complexe',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 1,
|
||||
steps: [
|
||||
_step(id: 'step-1', position: 1),
|
||||
_step(id: 'step-0', position: 0),
|
||||
],
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Exercise step validation rejects invalid basic fields', () {
|
||||
expect(() => _step(name: ''), throwsA(isA<DomainException>()));
|
||||
expect(() => _step(defaultTargetValue: 0), throwsA(isA<DomainException>()));
|
||||
});
|
||||
|
||||
test('Exercise step validation rejects disabled score values', () {
|
||||
expect(
|
||||
() => _step(hasScore: false, scoreLabel: 'Charge'),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
expect(
|
||||
() => _step(hasScore: false, defaultTargetScore: 0),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'Exercise step manual score validation accepts zero and rejects negative',
|
||||
() {
|
||||
final step = _step(
|
||||
hasScore: true,
|
||||
scoreLabel: 'Charge',
|
||||
scoreUnit: 'kg',
|
||||
defaultTargetScore: 0,
|
||||
);
|
||||
|
||||
expect(step.defaultTargetScore, 0);
|
||||
expect(
|
||||
() => _step(
|
||||
hasScore: true,
|
||||
scoreLabel: 'Charge',
|
||||
scoreUnit: 'kg',
|
||||
defaultTargetScore: -1,
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
expect(
|
||||
() => _step(hasScore: true, scoreLabel: 'Charge'),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test('Exercise step stopwatch score validation rejects manual fields', () {
|
||||
final step = _step(
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
defaultTargetScoreTimeMs: 12000,
|
||||
);
|
||||
|
||||
expect(step.defaultTargetScoreTimeMs, 12000);
|
||||
expect(
|
||||
() => _step(
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
defaultTargetScoreTimeMs: 0,
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
expect(
|
||||
() => _step(
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
scoreLabel: 'Charge',
|
||||
),
|
||||
throwsA(isA<DomainException>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('Exercise image gallery rejects a sixth image', () async {
|
||||
final repository = _FakeExerciseRepository()
|
||||
..exercise = Exercise(
|
||||
@ -959,6 +1066,34 @@ void main() {
|
||||
});
|
||||
}
|
||||
|
||||
ExerciseStep _step({
|
||||
String id = 'step-1',
|
||||
int position = 0,
|
||||
String name = 'Phase',
|
||||
ExerciseStepType type = ExerciseStepType.time,
|
||||
int defaultTargetValue = 30,
|
||||
bool hasScore = false,
|
||||
ScoreInputMode scoreInputMode = ScoreInputMode.manual,
|
||||
String? scoreLabel,
|
||||
String? scoreUnit,
|
||||
double? defaultTargetScore,
|
||||
int? defaultTargetScoreTimeMs,
|
||||
}) {
|
||||
return ExerciseStep(
|
||||
id: id,
|
||||
position: position,
|
||||
name: name,
|
||||
type: type,
|
||||
defaultTargetValue: defaultTargetValue,
|
||||
hasScore: hasScore,
|
||||
scoreInputMode: scoreInputMode,
|
||||
scoreLabel: scoreLabel,
|
||||
scoreUnit: scoreUnit,
|
||||
defaultTargetScore: defaultTargetScore,
|
||||
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
|
||||
);
|
||||
}
|
||||
|
||||
EntityMetadata _metadata(String id) {
|
||||
return EntityMetadata(
|
||||
id: id,
|
||||
|
||||
@ -79,6 +79,158 @@ void main() {
|
||||
expect(changes.every((change) => change.entityType == 'Exercise'), isTrue);
|
||||
});
|
||||
|
||||
test('exercise repository round-trips configured steps', () async {
|
||||
final now = DateTime.utc(2026, 7, 17, 12);
|
||||
final exercise = Exercise(
|
||||
metadata: _metadata('exercise-steps-1', now),
|
||||
name: 'Burpee complexe',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 1,
|
||||
steps: [
|
||||
ExerciseStep(
|
||||
id: 'step-1',
|
||||
position: 0,
|
||||
name: 'Planche',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: 20,
|
||||
),
|
||||
ExerciseStep(
|
||||
id: 'step-2',
|
||||
position: 1,
|
||||
name: 'Sauts',
|
||||
type: ExerciseStepType.reps,
|
||||
defaultTargetValue: 8,
|
||||
hasScore: true,
|
||||
scoreLabel: 'Amplitude',
|
||||
scoreUnit: 'pts',
|
||||
defaultTargetScore: 0,
|
||||
),
|
||||
ExerciseStep(
|
||||
id: 'step-3',
|
||||
position: 2,
|
||||
name: 'Sprint final',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: 10,
|
||||
hasScore: true,
|
||||
scoreInputMode: ScoreInputMode.stopwatch,
|
||||
defaultTargetScoreTimeMs: 12000,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
await exerciseRepository.save(exercise);
|
||||
|
||||
final restored = await exerciseRepository.findById(exercise.metadata.id);
|
||||
|
||||
expect(restored, isNotNull);
|
||||
expect(restored!.steps, hasLength(3));
|
||||
expect(restored.steps.map((step) => step.name), [
|
||||
'Planche',
|
||||
'Sauts',
|
||||
'Sprint final',
|
||||
]);
|
||||
expect(restored.steps[1].defaultTargetScore, 0);
|
||||
expect(restored.steps[2].scoreInputMode, ScoreInputMode.stopwatch);
|
||||
expect(restored.steps[2].defaultTargetScoreTimeMs, 12000);
|
||||
});
|
||||
|
||||
test(
|
||||
'program exercise step snapshot is preserved when source exercise changes',
|
||||
() async {
|
||||
final now = DateTime.utc(2026, 7, 17, 12);
|
||||
final source = Exercise(
|
||||
metadata: _metadata('exercise-source-1', now),
|
||||
name: 'Complexe original',
|
||||
hasTimeMeasure: false,
|
||||
hasRepsMeasure: true,
|
||||
hasScoreMeasure: false,
|
||||
defaultTargetReps: 1,
|
||||
steps: [
|
||||
ExerciseStep(
|
||||
id: 'step-original-1',
|
||||
position: 0,
|
||||
name: 'Phase originale A',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: 30,
|
||||
),
|
||||
ExerciseStep(
|
||||
id: 'step-original-2',
|
||||
position: 1,
|
||||
name: 'Phase originale B',
|
||||
type: ExerciseStepType.reps,
|
||||
defaultTargetValue: 12,
|
||||
),
|
||||
],
|
||||
);
|
||||
await exerciseRepository.save(source);
|
||||
await programRepository.save(
|
||||
Program(
|
||||
metadata: _metadata('program-steps-1', now),
|
||||
name: 'Programme étapes',
|
||||
defaultRestSeconds: 60,
|
||||
),
|
||||
);
|
||||
final useCase = ProgramUseCases(
|
||||
programRepository: programRepository,
|
||||
exerciseRepository: exerciseRepository,
|
||||
templateRepository: templateRepository,
|
||||
clock: _FakeClock(now),
|
||||
ids: _FakeIds(),
|
||||
originDeviceId: 'device-1',
|
||||
);
|
||||
|
||||
final snapshot = await useCase.addExercise(
|
||||
programId: 'program-steps-1',
|
||||
exerciseId: source.metadata.id,
|
||||
position: 0,
|
||||
setsCount: 1,
|
||||
enabledMeasures: const {WorkoutMeasure.reps},
|
||||
);
|
||||
await exerciseRepository.save(
|
||||
source.copyWith(
|
||||
metadata: _metadata(
|
||||
source.metadata.id,
|
||||
now.add(const Duration(minutes: 1)),
|
||||
1,
|
||||
),
|
||||
steps: [
|
||||
ExerciseStep(
|
||||
id: 'step-updated-1',
|
||||
position: 0,
|
||||
name: 'Phase modifiée',
|
||||
type: ExerciseStepType.time,
|
||||
defaultTargetValue: 45,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final restoredProgram = await programRepository.findById(
|
||||
'program-steps-1',
|
||||
);
|
||||
final restoredSnapshot = restoredProgram!.exercises.single;
|
||||
final snapshotJson = snapshot.toSnapshotJson();
|
||||
|
||||
expect(restoredSnapshot.exerciseStepsSnapshot, hasLength(2));
|
||||
expect(
|
||||
restoredSnapshot.exerciseStepsSnapshot.first.name,
|
||||
'Phase originale A',
|
||||
);
|
||||
expect(
|
||||
restoredSnapshot.exerciseStepsSnapshot.last.type,
|
||||
ExerciseStepType.reps,
|
||||
);
|
||||
expect(
|
||||
(snapshotJson['exerciseStepsSnapshot'] as List).map(
|
||||
(step) => (step as Map)['name'],
|
||||
),
|
||||
['Phase originale A', 'Phase originale B'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'removing a program exercise writes a soft delete change log entry',
|
||||
() async {
|
||||
|
||||
Reference in New Issue
Block a user