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