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

@ -334,9 +334,11 @@ final class Exercise {
this.autoStartNextTimedStep = true,
this.category = ExerciseCategory.uncategorized,
this.isExample = false,
List<String> tags = const [],
List<ExerciseStep> steps = const [],
this.archivedAt,
}) : name = _nonBlank(name, 'Exercise name'),
tags = normalizeTags(tags),
imageMediaIds = _resolvedExerciseImageMediaIds(
imageMediaId,
imageMediaIds,
@ -394,6 +396,7 @@ final class Exercise {
final bool autoStartNextTimedStep;
final ExerciseCategory category;
final bool isExample;
final List<String> tags;
final List<ExerciseStep> steps;
final DateTime? archivedAt;
@ -431,6 +434,7 @@ final class Exercise {
bool? autoStartNextTimedStep,
ExerciseCategory? category,
bool? isExample,
Object? tags = _unchanged,
Object? steps = _unchanged,
Object? archivedAt = _unchanged,
}) {
@ -482,6 +486,7 @@ final class Exercise {
autoStartNextTimedStep ?? this.autoStartNextTimedStep,
category: category ?? this.category,
isExample: isExample ?? this.isExample,
tags: tags == _unchanged ? this.tags : tags as List<String>,
steps: steps == _unchanged ? this.steps : steps as List<ExerciseStep>,
archivedAt: archivedAt == _unchanged
? this.archivedAt
@ -552,8 +557,10 @@ final class Program {
required String name,
required this.defaultRestSeconds,
this.isExample = false,
List<String> tags = const [],
this.exercises = const [],
}) : name = _nonBlank(name, 'Program name') {
}) : name = _nonBlank(name, 'Program name'),
tags = normalizeTags(tags) {
_requireNonNegative(defaultRestSeconds, 'Default rest seconds');
}
@ -561,6 +568,7 @@ final class Program {
final String name;
final int defaultRestSeconds;
final bool isExample;
final List<String> tags;
final List<ProgramExercise> exercises;
Program copyWith({
@ -569,12 +577,14 @@ final class Program {
String? name,
int? defaultRestSeconds,
bool? isExample,
Object? tags = _unchanged,
}) {
return Program(
metadata: metadata ?? this.metadata,
name: name ?? this.name,
defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds,
isExample: isExample ?? this.isExample,
tags: tags == _unchanged ? this.tags : tags as List<String>,
exercises: exercises ?? this.exercises,
);
}
@ -769,14 +779,17 @@ final class WorkoutTemplate {
required String name,
this.lastStartedAt,
this.isExample = false,
List<String> tags = const [],
this.programs = const [],
this.overrides = const [],
}) : name = _nonBlank(name, 'Workout template name');
}) : name = _nonBlank(name, 'Workout template name'),
tags = normalizeTags(tags);
final EntityMetadata metadata;
final String name;
final DateTime? lastStartedAt;
final bool isExample;
final List<String> tags;
final List<WorkoutTemplateProgram> programs;
final List<WorkoutTemplateExerciseOverride> overrides;
}
@ -1591,6 +1604,28 @@ String _nonBlank(String? value, String label) {
return trimmed;
}
List<String> normalizeTags(Iterable<String> tags) {
final normalized = <String>[];
final seen = <String>{};
for (final tag in tags) {
final value = tag.trim().replaceAll(RegExp(r'\s+'), ' ').toLowerCase();
if (value.isEmpty) {
throw const DomainException('Tag must not be blank.');
}
if (value.length > 24) {
throw const DomainException('Tag must be 24 characters or less.');
}
if (!seen.add(value)) {
throw const DomainException('Tags must be unique.');
}
normalized.add(value);
}
if (normalized.length > 8) {
throw const DomainException('An item cannot have more than 8 tags.');
}
return List.unmodifiable(normalized);
}
List<String> _validatedImageMediaIds(List<String> ids) {
if (ids.length > 5) {
throw const DomainException('An exercise cannot have more than 5 images.');