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:
2026-07-19 19:32:24 +02:00
parent 73d01371d4
commit b1182db32e
8 changed files with 2958 additions and 6 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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

View File

@ -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,

View File

@ -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 =>

View File

@ -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,

View File

@ -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 {