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:
@ -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 =>
|
||||
|
||||
Reference in New Issue
Block a user