feat(bibliotheque): tags et duplication profonde

Ticket #83 B1-B3 : tags JSON dénormalisés, migration Drift v19, mappings sync, helpers de filtre/suggestions, duplication profonde Program/WorkoutTemplate, tests associés.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 10:01:31 +02:00
parent 10fba8d2ec
commit add5025dd2
9 changed files with 1012 additions and 10 deletions

View File

@ -27,6 +27,13 @@ final class StarterContent {
final WorkoutTemplate workoutTemplate;
}
final class TagUsage {
const TagUsage({required this.tag, required this.count});
final String tag;
final int count;
}
enum ProgressionPeriod { fourWeeks, threeMonths, all }
enum ProgressionMeasure { manualScore, stopwatchScore, reps, time }

View File

@ -7,6 +7,48 @@ import 'starter_content/starter_content.dart';
const Object _useCaseUnchanged = Object();
List<T> filterByRequiredTags<T>(
List<T> items,
Set<String> requiredTags,
List<String> Function(T item) tagsOf,
) {
final normalizedRequiredTags = normalizeTags(requiredTags).toSet();
if (normalizedRequiredTags.isEmpty) {
return items;
}
return items
.where(
(item) => normalizeTags(
tagsOf(item),
).toSet().containsAll(normalizedRequiredTags),
)
.toList();
}
List<TagUsage> tagSuggestionsFor<T>(
List<T> items,
List<String> Function(T item) tagsOf,
) {
final counts = <String, int>{};
for (final item in items) {
for (final tag in normalizeTags(tagsOf(item))) {
counts[tag] = (counts[tag] ?? 0) + 1;
}
}
final suggestions = [
for (final entry in counts.entries)
TagUsage(tag: entry.key, count: entry.value),
];
suggestions.sort((left, right) {
final countComparison = right.count.compareTo(left.count);
if (countComparison != 0) {
return countComparison;
}
return left.tag.compareTo(right.tag);
});
return suggestions;
}
enum StarterSeedStatus { inserted, skippedAlreadyApplied, skippedNotEmpty }
final class StarterSeedResult {
@ -846,6 +888,7 @@ final class ExerciseUseCases {
double? defaultTargetScore,
int? defaultTargetScoreTimeMs,
bool autoStartNextTimedStep = true,
List<String> tags = const [],
List<ExerciseStep> steps = const [],
}) async {
_validateExerciseSteps(steps);
@ -878,6 +921,7 @@ final class ExerciseUseCases {
defaultTargetScore: defaultTargetScore,
defaultTargetScoreTimeMs: defaultTargetScoreTimeMs,
autoStartNextTimedStep: autoStartNextTimedStep,
tags: tags,
steps: steps,
);
await repository.save(exercise);
@ -911,6 +955,7 @@ final class ExerciseUseCases {
Object? defaultTargetScore = _useCaseUnchanged,
Object? defaultTargetScoreTimeMs = _useCaseUnchanged,
bool? autoStartNextTimedStep,
List<String> tags = const [],
Object? steps = _useCaseUnchanged,
}) async {
final exercise = await repository.findById(id);
@ -972,6 +1017,7 @@ final class ExerciseUseCases {
defaultTargetScore: resolvedDefaultTargetScore,
defaultTargetScoreTimeMs: resolvedDefaultTargetScoreTimeMs,
autoStartNextTimedStep: autoStartNextTimedStep,
tags: tags,
steps: resolvedSteps,
);
await repository.save(updated);
@ -1232,12 +1278,14 @@ final class ProgramUseCases {
Future<Program> create({
required String name,
required int defaultRestSeconds,
List<String> tags = const [],
}) async {
final now = clock.now();
final program = Program(
metadata: _newMetadata(ids, originDeviceId, now),
name: name,
defaultRestSeconds: defaultRestSeconds,
tags: tags,
);
await programRepository.save(program);
return program;
@ -1247,6 +1295,7 @@ final class ProgramUseCases {
String? id,
required String name,
required int defaultRestSeconds,
List<String> tags = const [],
required List<ProgramExerciseConfig> exercises,
}) async {
final now = clock.now();
@ -1307,12 +1356,71 @@ final class ProgramUseCases {
name: name,
defaultRestSeconds: defaultRestSeconds,
isExample: existing?.isExample ?? false,
tags: tags,
exercises: programExercises,
);
await programRepository.replaceExercises(program, now);
return program;
}
Future<Program> duplicate(String id) async {
final source = await programRepository.findById(id);
if (source == null) {
throw const DomainException('Program not found.');
}
final activePrograms = await programRepository.listActive();
final now = clock.now();
final copyId = ids.newId();
final copyExercises = [
for (final exercise in source.exercises)
ProgramExercise(
metadata: _newMetadata(ids, originDeviceId, now),
programId: copyId,
sourceExerciseId: exercise.sourceExerciseId,
position: exercise.position,
exerciseNameSnapshot: exercise.exerciseNameSnapshot,
exerciseDescriptionSnapshot: exercise.exerciseDescriptionSnapshot,
exerciseImageMediaIdSnapshot: exercise.exerciseImageMediaIdSnapshot,
exerciseImageMediaIdsSnapshot: exercise.exerciseImageMediaIdsSnapshot,
exerciseVideoMediaIdSnapshot: exercise.exerciseVideoMediaIdSnapshot,
exerciseStepsSnapshot: exercise.exerciseStepsSnapshot,
autoStartNextTimedStepSnapshot:
exercise.autoStartNextTimedStepSnapshot,
autoStartNextTimedStepOverride:
exercise.autoStartNextTimedStepOverride,
exerciseArchivedSnapshot: exercise.exerciseArchivedSnapshot,
availableTimeSnapshot: exercise.availableTimeSnapshot,
availableRepsSnapshot: exercise.availableRepsSnapshot,
availableScoreSnapshot: exercise.availableScoreSnapshot,
scoreInputModeSnapshot: exercise.scoreInputModeSnapshot,
scoreLabelSnapshot: exercise.scoreLabelSnapshot,
scoreUnitSnapshot: exercise.scoreUnitSnapshot,
setsCount: exercise.setsCount,
timeEnabled: exercise.timeEnabled,
repsEnabled: exercise.repsEnabled,
scoreEnabled: exercise.scoreEnabled,
targetTimeSeconds: exercise.targetTimeSeconds,
targetReps: exercise.targetReps,
targetScore: exercise.targetScore,
targetScoreTimeMs: exercise.targetScoreTimeMs,
restSecondsOverride: exercise.restSecondsOverride,
),
];
final copy = Program(
metadata: _newMetadataWithId(copyId, originDeviceId, now),
name: _copyName(
source.name,
activePrograms.map((program) => program.name),
),
defaultRestSeconds: source.defaultRestSeconds,
isExample: false,
tags: source.tags,
exercises: copyExercises,
);
await programRepository.replaceExercises(copy, now);
return copy;
}
Future<ProgramExercise> addExercise({
required String programId,
required String exerciseId,
@ -1376,6 +1484,8 @@ final class ProgramUseCases {
metadata: template.metadata.touch(now),
name: template.name,
lastStartedAt: template.lastStartedAt,
isExample: template.isExample,
tags: template.tags,
programs: repositionedPrograms,
overrides: template.overrides
.where(
@ -1469,11 +1579,15 @@ final class WorkoutTemplateUseCases {
return templateRepository.listActive();
}
Future<WorkoutTemplate> create({required String name}) async {
Future<WorkoutTemplate> create({
required String name,
List<String> tags = const [],
}) async {
final now = clock.now();
final template = WorkoutTemplate(
metadata: _newMetadata(ids, originDeviceId, now),
name: name,
tags: tags,
);
await templateRepository.save(template);
return template;
@ -1482,6 +1596,7 @@ final class WorkoutTemplateUseCases {
Future<WorkoutTemplate> saveConfigured({
String? id,
required String name,
List<String> tags = const [],
required List<WorkoutTemplateProgramConfig> programs,
required List<WorkoutTemplateExerciseOverrideConfig> overrides,
}) async {
@ -1553,6 +1668,7 @@ final class WorkoutTemplateUseCases {
name: name,
lastStartedAt: existing?.lastStartedAt,
isExample: existing?.isExample ?? false,
tags: tags,
programs: templatePrograms,
overrides: templateOverrides,
);
@ -1560,6 +1676,64 @@ final class WorkoutTemplateUseCases {
return template;
}
Future<WorkoutTemplate> duplicate(String id) async {
final source = await templateRepository.findById(id);
if (source == null) {
throw const DomainException('Workout template not found.');
}
final activeTemplates = await templateRepository.listActive();
final now = clock.now();
final copyId = ids.newId();
final templateProgramIdsBySourceId = <String, String>{};
final copyPrograms = [
for (final program in source.programs)
() {
final copyProgramId = ids.newId();
templateProgramIdsBySourceId[program.metadata.id] = copyProgramId;
return WorkoutTemplateProgram(
metadata: _newMetadataWithId(copyProgramId, originDeviceId, now),
workoutTemplateId: copyId,
sourceProgramId: program.sourceProgramId,
position: program.position,
programNameSnapshot: program.programNameSnapshot,
defaultRestSecondsSnapshot: program.defaultRestSecondsSnapshot,
programSnapshotJson: program.programSnapshotJson,
);
}(),
];
final copyOverrides = [
for (final override in source.overrides)
if (templateProgramIdsBySourceId[override.workoutTemplateProgramId]
case final copyProgramId?)
WorkoutTemplateExerciseOverride(
metadata: _newMetadata(ids, originDeviceId, now),
workoutTemplateProgramId: copyProgramId,
snapshotProgramExerciseId: override.snapshotProgramExerciseId,
setsCountOverride: override.setsCountOverride,
targetTimeSecondsOverride: override.targetTimeSecondsOverride,
targetRepsOverride: override.targetRepsOverride,
targetScoreOverride: override.targetScoreOverride,
targetScoreTimeMsOverride: override.targetScoreTimeMsOverride,
autoStartNextTimedStepOverride:
override.autoStartNextTimedStepOverride,
),
];
final copy = WorkoutTemplate(
metadata: _newMetadataWithId(copyId, originDeviceId, now),
name: _copyName(
source.name,
activeTemplates.map((template) => template.name),
),
lastStartedAt: null,
isExample: false,
tags: source.tags,
programs: copyPrograms,
overrides: copyOverrides,
);
await templateRepository.replaceComposition(copy, now);
return copy;
}
Future<WorkoutTemplateProgram> addProgramSnapshot({
required String workoutTemplateId,
required String programId,
@ -1622,6 +1796,7 @@ final class WorkoutTemplateUseCases {
metadata: template.metadata.markDeleted(now),
name: template.name,
lastStartedAt: template.lastStartedAt,
tags: template.tags,
);
await templateRepository.save(deleted);
return deleted;
@ -4505,11 +4680,37 @@ EntityMetadata _newMetadata(
IdGenerator ids,
String originDeviceId,
DateTime now,
) {
return _newMetadataWithId(ids.newId(), originDeviceId, now);
}
EntityMetadata _newMetadataWithId(
String id,
String originDeviceId,
DateTime now,
) {
return EntityMetadata(
id: ids.newId(),
id: id,
createdAt: now,
updatedAt: now,
originDeviceId: originDeviceId,
);
}
String _copyName(String sourceName, Iterable<String> existingNames) {
final normalizedNames = existingNames
.map((name) => name.trim().toLowerCase())
.toSet();
final firstCandidate = 'Copie de $sourceName';
if (!normalizedNames.contains(firstCandidate.trim().toLowerCase())) {
return firstCandidate;
}
var copyNumber = 2;
while (true) {
final candidate = 'Copie $copyNumber de $sourceName';
if (!normalizedNames.contains(candidate.trim().toLowerCase())) {
return candidate;
}
copyNumber += 1;
}
}