From add5025dd25598cda6db702bad63dc0910ef6eff Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 22 Jul 2026 10:01:31 +0200 Subject: [PATCH] feat(bibliotheque): tags et duplication profonde MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/application/ports.dart | 7 + lib/application/use_cases.dart | 205 ++++++++++++- lib/domain/entities.dart | 39 ++- lib/infrastructure/local/app_database.dart | 26 +- lib/infrastructure/local/app_database.g.dart | 203 ++++++++++++- .../local/drift_repositories.dart | 29 ++ lib/infrastructure/local/tables.dart | 12 +- test/application/use_cases_test.dart | 215 +++++++++++++ .../drift_repositories_test.dart | 286 ++++++++++++++++++ 9 files changed, 1012 insertions(+), 10 deletions(-) diff --git a/lib/application/ports.dart b/lib/application/ports.dart index f01ee29..c04e75d 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -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 } diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index d7427cc..74cb851 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -7,6 +7,48 @@ import 'starter_content/starter_content.dart'; const Object _useCaseUnchanged = Object(); +List filterByRequiredTags( + List items, + Set requiredTags, + List 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 tagSuggestionsFor( + List items, + List Function(T item) tagsOf, +) { + final counts = {}; + 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 tags = const [], List 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 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 create({ required String name, required int defaultRestSeconds, + List 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 tags = const [], required List 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 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 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 create({required String name}) async { + Future create({ + required String name, + List 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 saveConfigured({ String? id, required String name, + List tags = const [], required List programs, required List 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 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 = {}; + 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 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 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; + } +} diff --git a/lib/domain/entities.dart b/lib/domain/entities.dart index 105b2e3..bc0fdb4 100644 --- a/lib/domain/entities.dart +++ b/lib/domain/entities.dart @@ -334,9 +334,11 @@ final class Exercise { this.autoStartNextTimedStep = true, this.category = ExerciseCategory.uncategorized, this.isExample = false, + List tags = const [], List 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 tags; final List 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, steps: steps == _unchanged ? this.steps : steps as List, archivedAt: archivedAt == _unchanged ? this.archivedAt @@ -552,8 +557,10 @@ final class Program { required String name, required this.defaultRestSeconds, this.isExample = false, + List 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 tags; final List 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, exercises: exercises ?? this.exercises, ); } @@ -769,14 +779,17 @@ final class WorkoutTemplate { required String name, this.lastStartedAt, this.isExample = false, + List 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 tags; final List programs; final List overrides; } @@ -1591,6 +1604,28 @@ String _nonBlank(String? value, String label) { return trimmed; } +List normalizeTags(Iterable tags) { + final normalized = []; + final seen = {}; + 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 _validatedImageMediaIds(List ids) { if (ids.length > 5) { throw const DomainException('An exercise cannot have more than 5 images.'); diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index f173f73..dfe5d70 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -48,7 +48,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 18; + int get schemaVersion => 19; @override MigrationStrategy get migration { @@ -116,6 +116,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 17) { await _migrateToSchema17(); } + if (from < 19) { + await _migrateToSchema19(); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -746,6 +749,27 @@ CREATE TABLE IF NOT EXISTS active_set_timer_states ( await _backfillWorkoutHistoryStepSourceExerciseIds(); } + Future _migrateToSchema19() async { + await _addColumnIfMissing( + tableName: 'exercises', + columnName: 'tags_json', + definition: + "tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))", + ); + await _addColumnIfMissing( + tableName: 'programs', + columnName: 'tags_json', + definition: + "tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))", + ); + await _addColumnIfMissing( + tableName: 'workout_templates', + columnName: 'tags_json', + definition: + "tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json))", + ); + } + Future _backfillWorkoutHistorySetSourceExerciseIds() async { await customStatement(r''' UPDATE workout_history_set_results AS result diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 4fe48c6..f79f92e 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -170,6 +170,18 @@ class $WorkoutTemplatesTable extends WorkoutTemplates ), defaultValue: const Constant(false), ); + static const VerificationMeta _tagsJsonMeta = const VerificationMeta( + 'tagsJson', + ); + @override + late final GeneratedColumn tagsJson = GeneratedColumn( + 'tags_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('[]'), + ); @override List get $columns => [ id, @@ -186,6 +198,7 @@ class $WorkoutTemplatesTable extends WorkoutTemplates name, lastStartedAt, isExample, + tagsJson, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -315,6 +328,12 @@ class $WorkoutTemplatesTable extends WorkoutTemplates isExample.isAcceptableOrUnknown(data['is_example']!, _isExampleMeta), ); } + if (data.containsKey('tags_json')) { + context.handle( + _tagsJsonMeta, + tagsJson.isAcceptableOrUnknown(data['tags_json']!, _tagsJsonMeta), + ); + } return context; } @@ -380,6 +399,10 @@ class $WorkoutTemplatesTable extends WorkoutTemplates DriftSqlType.bool, data['${effectivePrefix}is_example'], )!, + tagsJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags_json'], + )!, ); } @@ -404,6 +427,7 @@ class WorkoutTemplate extends DataClass implements Insertable { final String name; final DateTime? lastStartedAt; final bool isExample; + final String tagsJson; const WorkoutTemplate({ required this.id, required this.createdAt, @@ -419,6 +443,7 @@ class WorkoutTemplate extends DataClass implements Insertable { required this.name, this.lastStartedAt, required this.isExample, + required this.tagsJson, }); @override Map toColumns(bool nullToAbsent) { @@ -447,6 +472,7 @@ class WorkoutTemplate extends DataClass implements Insertable { map['last_started_at'] = Variable(lastStartedAt); } map['is_example'] = Variable(isExample); + map['tags_json'] = Variable(tagsJson); return map; } @@ -476,6 +502,7 @@ class WorkoutTemplate extends DataClass implements Insertable { ? const Value.absent() : Value(lastStartedAt), isExample: Value(isExample), + tagsJson: Value(tagsJson), ); } @@ -501,6 +528,7 @@ class WorkoutTemplate extends DataClass implements Insertable { name: serializer.fromJson(json['name']), lastStartedAt: serializer.fromJson(json['lastStartedAt']), isExample: serializer.fromJson(json['isExample']), + tagsJson: serializer.fromJson(json['tagsJson']), ); } @override @@ -521,6 +549,7 @@ class WorkoutTemplate extends DataClass implements Insertable { 'name': serializer.toJson(name), 'lastStartedAt': serializer.toJson(lastStartedAt), 'isExample': serializer.toJson(isExample), + 'tagsJson': serializer.toJson(tagsJson), }; } @@ -539,6 +568,7 @@ class WorkoutTemplate extends DataClass implements Insertable { String? name, Value lastStartedAt = const Value.absent(), bool? isExample, + String? tagsJson, }) => WorkoutTemplate( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -560,6 +590,7 @@ class WorkoutTemplate extends DataClass implements Insertable { ? lastStartedAt.value : this.lastStartedAt, isExample: isExample ?? this.isExample, + tagsJson: tagsJson ?? this.tagsJson, ); WorkoutTemplate copyWithCompanion(WorkoutTemplatesCompanion data) { return WorkoutTemplate( @@ -591,6 +622,7 @@ class WorkoutTemplate extends DataClass implements Insertable { ? data.lastStartedAt.value : this.lastStartedAt, isExample: data.isExample.present ? data.isExample.value : this.isExample, + tagsJson: data.tagsJson.present ? data.tagsJson.value : this.tagsJson, ); } @@ -610,7 +642,8 @@ class WorkoutTemplate extends DataClass implements Insertable { ..write('remoteRevision: $remoteRevision, ') ..write('name: $name, ') ..write('lastStartedAt: $lastStartedAt, ') - ..write('isExample: $isExample') + ..write('isExample: $isExample, ') + ..write('tagsJson: $tagsJson') ..write(')')) .toString(); } @@ -631,6 +664,7 @@ class WorkoutTemplate extends DataClass implements Insertable { name, lastStartedAt, isExample, + tagsJson, ); @override bool operator ==(Object other) => @@ -649,7 +683,8 @@ class WorkoutTemplate extends DataClass implements Insertable { other.remoteRevision == this.remoteRevision && other.name == this.name && other.lastStartedAt == this.lastStartedAt && - other.isExample == this.isExample); + other.isExample == this.isExample && + other.tagsJson == this.tagsJson); } class WorkoutTemplatesCompanion extends UpdateCompanion { @@ -667,6 +702,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { final Value name; final Value lastStartedAt; final Value isExample; + final Value tagsJson; final Value rowid; const WorkoutTemplatesCompanion({ this.id = const Value.absent(), @@ -683,6 +719,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { this.name = const Value.absent(), this.lastStartedAt = const Value.absent(), this.isExample = const Value.absent(), + this.tagsJson = const Value.absent(), this.rowid = const Value.absent(), }); WorkoutTemplatesCompanion.insert({ @@ -700,6 +737,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { required String name, this.lastStartedAt = const Value.absent(), this.isExample = const Value.absent(), + this.tagsJson = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -723,6 +761,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { Expression? name, Expression? lastStartedAt, Expression? isExample, + Expression? tagsJson, Expression? rowid, }) { return RawValuesInsertable({ @@ -741,6 +780,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { if (name != null) 'name': name, if (lastStartedAt != null) 'last_started_at': lastStartedAt, if (isExample != null) 'is_example': isExample, + if (tagsJson != null) 'tags_json': tagsJson, if (rowid != null) 'rowid': rowid, }); } @@ -760,6 +800,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { Value? name, Value? lastStartedAt, Value? isExample, + Value? tagsJson, Value? rowid, }) { return WorkoutTemplatesCompanion( @@ -777,6 +818,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { name: name ?? this.name, lastStartedAt: lastStartedAt ?? this.lastStartedAt, isExample: isExample ?? this.isExample, + tagsJson: tagsJson ?? this.tagsJson, rowid: rowid ?? this.rowid, ); } @@ -828,6 +870,9 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { if (isExample.present) { map['is_example'] = Variable(isExample.value); } + if (tagsJson.present) { + map['tags_json'] = Variable(tagsJson.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -851,6 +896,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion { ..write('name: $name, ') ..write('lastStartedAt: $lastStartedAt, ') ..write('isExample: $isExample, ') + ..write('tagsJson: $tagsJson, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -12903,6 +12949,18 @@ class $ExercisesTable extends Exercises ), defaultValue: const Constant(false), ); + static const VerificationMeta _tagsJsonMeta = const VerificationMeta( + 'tagsJson', + ); + @override + late final GeneratedColumn tagsJson = GeneratedColumn( + 'tags_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('[]'), + ); static const VerificationMeta _archivedAtMeta = const VerificationMeta( 'archivedAt', ); @@ -12944,6 +13002,7 @@ class $ExercisesTable extends Exercises autoStartNextTimedStep, category, isExample, + tagsJson, archivedAt, ]; @override @@ -13197,6 +13256,12 @@ class $ExercisesTable extends Exercises isExample.isAcceptableOrUnknown(data['is_example']!, _isExampleMeta), ); } + if (data.containsKey('tags_json')) { + context.handle( + _tagsJsonMeta, + tagsJson.isAcceptableOrUnknown(data['tags_json']!, _tagsJsonMeta), + ); + } if (data.containsKey('archived_at')) { context.handle( _archivedAtMeta, @@ -13324,6 +13389,10 @@ class $ExercisesTable extends Exercises DriftSqlType.bool, data['${effectivePrefix}is_example'], )!, + tagsJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags_json'], + )!, archivedAt: attachedDatabase.typeMapping.read( DriftSqlType.dateTime, data['${effectivePrefix}archived_at'], @@ -13366,6 +13435,7 @@ class Exercise extends DataClass implements Insertable { final bool autoStartNextTimedStep; final String category; final bool isExample; + final String tagsJson; final DateTime? archivedAt; const Exercise({ required this.id, @@ -13396,6 +13466,7 @@ class Exercise extends DataClass implements Insertable { required this.autoStartNextTimedStep, required this.category, required this.isExample, + required this.tagsJson, this.archivedAt, }); @override @@ -13459,6 +13530,7 @@ class Exercise extends DataClass implements Insertable { map['auto_start_next_timed_step'] = Variable(autoStartNextTimedStep); map['category'] = Variable(category); map['is_example'] = Variable(isExample); + map['tags_json'] = Variable(tagsJson); if (!nullToAbsent || archivedAt != null) { map['archived_at'] = Variable(archivedAt); } @@ -13521,6 +13593,7 @@ class Exercise extends DataClass implements Insertable { autoStartNextTimedStep: Value(autoStartNextTimedStep), category: Value(category), isExample: Value(isExample), + tagsJson: Value(tagsJson), archivedAt: archivedAt == null && nullToAbsent ? const Value.absent() : Value(archivedAt), @@ -13571,6 +13644,7 @@ class Exercise extends DataClass implements Insertable { ), category: serializer.fromJson(json['category']), isExample: serializer.fromJson(json['isExample']), + tagsJson: serializer.fromJson(json['tagsJson']), archivedAt: serializer.fromJson(json['archivedAt']), ); } @@ -13610,6 +13684,7 @@ class Exercise extends DataClass implements Insertable { 'autoStartNextTimedStep': serializer.toJson(autoStartNextTimedStep), 'category': serializer.toJson(category), 'isExample': serializer.toJson(isExample), + 'tagsJson': serializer.toJson(tagsJson), 'archivedAt': serializer.toJson(archivedAt), }; } @@ -13643,6 +13718,7 @@ class Exercise extends DataClass implements Insertable { bool? autoStartNextTimedStep, String? category, bool? isExample, + String? tagsJson, Value archivedAt = const Value.absent(), }) => Exercise( id: id ?? this.id, @@ -13686,6 +13762,7 @@ class Exercise extends DataClass implements Insertable { autoStartNextTimedStep ?? this.autoStartNextTimedStep, category: category ?? this.category, isExample: isExample ?? this.isExample, + tagsJson: tagsJson ?? this.tagsJson, archivedAt: archivedAt.present ? archivedAt.value : this.archivedAt, ); Exercise copyWithCompanion(ExercisesCompanion data) { @@ -13756,6 +13833,7 @@ class Exercise extends DataClass implements Insertable { : this.autoStartNextTimedStep, category: data.category.present ? data.category.value : this.category, isExample: data.isExample.present ? data.isExample.value : this.isExample, + tagsJson: data.tagsJson.present ? data.tagsJson.value : this.tagsJson, archivedAt: data.archivedAt.present ? data.archivedAt.value : this.archivedAt, @@ -13793,6 +13871,7 @@ class Exercise extends DataClass implements Insertable { ..write('autoStartNextTimedStep: $autoStartNextTimedStep, ') ..write('category: $category, ') ..write('isExample: $isExample, ') + ..write('tagsJson: $tagsJson, ') ..write('archivedAt: $archivedAt') ..write(')')) .toString(); @@ -13828,6 +13907,7 @@ class Exercise extends DataClass implements Insertable { autoStartNextTimedStep, category, isExample, + tagsJson, archivedAt, ]); @override @@ -13862,6 +13942,7 @@ class Exercise extends DataClass implements Insertable { other.autoStartNextTimedStep == this.autoStartNextTimedStep && other.category == this.category && other.isExample == this.isExample && + other.tagsJson == this.tagsJson && other.archivedAt == this.archivedAt); } @@ -13894,6 +13975,7 @@ class ExercisesCompanion extends UpdateCompanion { final Value autoStartNextTimedStep; final Value category; final Value isExample; + final Value tagsJson; final Value archivedAt; final Value rowid; const ExercisesCompanion({ @@ -13925,6 +14007,7 @@ class ExercisesCompanion extends UpdateCompanion { this.autoStartNextTimedStep = const Value.absent(), this.category = const Value.absent(), this.isExample = const Value.absent(), + this.tagsJson = const Value.absent(), this.archivedAt = const Value.absent(), this.rowid = const Value.absent(), }); @@ -13957,6 +14040,7 @@ class ExercisesCompanion extends UpdateCompanion { this.autoStartNextTimedStep = const Value.absent(), this.category = const Value.absent(), this.isExample = const Value.absent(), + this.tagsJson = const Value.absent(), this.archivedAt = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), @@ -13998,6 +14082,7 @@ class ExercisesCompanion extends UpdateCompanion { Expression? autoStartNextTimedStep, Expression? category, Expression? isExample, + Expression? tagsJson, Expression? archivedAt, Expression? rowid, }) { @@ -14035,6 +14120,7 @@ class ExercisesCompanion extends UpdateCompanion { 'auto_start_next_timed_step': autoStartNextTimedStep, if (category != null) 'category': category, if (isExample != null) 'is_example': isExample, + if (tagsJson != null) 'tags_json': tagsJson, if (archivedAt != null) 'archived_at': archivedAt, if (rowid != null) 'rowid': rowid, }); @@ -14069,6 +14155,7 @@ class ExercisesCompanion extends UpdateCompanion { Value? autoStartNextTimedStep, Value? category, Value? isExample, + Value? tagsJson, Value? archivedAt, Value? rowid, }) { @@ -14104,6 +14191,7 @@ class ExercisesCompanion extends UpdateCompanion { autoStartNextTimedStep ?? this.autoStartNextTimedStep, category: category ?? this.category, isExample: isExample ?? this.isExample, + tagsJson: tagsJson ?? this.tagsJson, archivedAt: archivedAt ?? this.archivedAt, rowid: rowid ?? this.rowid, ); @@ -14204,6 +14292,9 @@ class ExercisesCompanion extends UpdateCompanion { if (isExample.present) { map['is_example'] = Variable(isExample.value); } + if (tagsJson.present) { + map['tags_json'] = Variable(tagsJson.value); + } if (archivedAt.present) { map['archived_at'] = Variable(archivedAt.value); } @@ -14244,6 +14335,7 @@ class ExercisesCompanion extends UpdateCompanion { ..write('autoStartNextTimedStep: $autoStartNextTimedStep, ') ..write('category: $category, ') ..write('isExample: $isExample, ') + ..write('tagsJson: $tagsJson, ') ..write('archivedAt: $archivedAt, ') ..write('rowid: $rowid') ..write(')')) @@ -18019,6 +18111,18 @@ class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { ), defaultValue: const Constant(false), ); + static const VerificationMeta _tagsJsonMeta = const VerificationMeta( + 'tagsJson', + ); + @override + late final GeneratedColumn tagsJson = GeneratedColumn( + 'tags_json', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('[]'), + ); @override List get $columns => [ id, @@ -18035,6 +18139,7 @@ class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { name, defaultRestSeconds, isExample, + tagsJson, ]; @override String get aliasedName => _alias ?? actualTableName; @@ -18166,6 +18271,12 @@ class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { isExample.isAcceptableOrUnknown(data['is_example']!, _isExampleMeta), ); } + if (data.containsKey('tags_json')) { + context.handle( + _tagsJsonMeta, + tagsJson.isAcceptableOrUnknown(data['tags_json']!, _tagsJsonMeta), + ); + } return context; } @@ -18231,6 +18342,10 @@ class $ProgramsTable extends Programs with TableInfo<$ProgramsTable, Program> { DriftSqlType.bool, data['${effectivePrefix}is_example'], )!, + tagsJson: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}tags_json'], + )!, ); } @@ -18255,6 +18370,7 @@ class Program extends DataClass implements Insertable { final String name; final int defaultRestSeconds; final bool isExample; + final String tagsJson; const Program({ required this.id, required this.createdAt, @@ -18270,6 +18386,7 @@ class Program extends DataClass implements Insertable { required this.name, required this.defaultRestSeconds, required this.isExample, + required this.tagsJson, }); @override Map toColumns(bool nullToAbsent) { @@ -18296,6 +18413,7 @@ class Program extends DataClass implements Insertable { map['name'] = Variable(name); map['default_rest_seconds'] = Variable(defaultRestSeconds); map['is_example'] = Variable(isExample); + map['tags_json'] = Variable(tagsJson); return map; } @@ -18323,6 +18441,7 @@ class Program extends DataClass implements Insertable { name: Value(name), defaultRestSeconds: Value(defaultRestSeconds), isExample: Value(isExample), + tagsJson: Value(tagsJson), ); } @@ -18348,6 +18467,7 @@ class Program extends DataClass implements Insertable { name: serializer.fromJson(json['name']), defaultRestSeconds: serializer.fromJson(json['defaultRestSeconds']), isExample: serializer.fromJson(json['isExample']), + tagsJson: serializer.fromJson(json['tagsJson']), ); } @override @@ -18368,6 +18488,7 @@ class Program extends DataClass implements Insertable { 'name': serializer.toJson(name), 'defaultRestSeconds': serializer.toJson(defaultRestSeconds), 'isExample': serializer.toJson(isExample), + 'tagsJson': serializer.toJson(tagsJson), }; } @@ -18386,6 +18507,7 @@ class Program extends DataClass implements Insertable { String? name, int? defaultRestSeconds, bool? isExample, + String? tagsJson, }) => Program( id: id ?? this.id, createdAt: createdAt ?? this.createdAt, @@ -18405,6 +18527,7 @@ class Program extends DataClass implements Insertable { name: name ?? this.name, defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds, isExample: isExample ?? this.isExample, + tagsJson: tagsJson ?? this.tagsJson, ); Program copyWithCompanion(ProgramsCompanion data) { return Program( @@ -18436,6 +18559,7 @@ class Program extends DataClass implements Insertable { ? data.defaultRestSeconds.value : this.defaultRestSeconds, isExample: data.isExample.present ? data.isExample.value : this.isExample, + tagsJson: data.tagsJson.present ? data.tagsJson.value : this.tagsJson, ); } @@ -18455,7 +18579,8 @@ class Program extends DataClass implements Insertable { ..write('remoteRevision: $remoteRevision, ') ..write('name: $name, ') ..write('defaultRestSeconds: $defaultRestSeconds, ') - ..write('isExample: $isExample') + ..write('isExample: $isExample, ') + ..write('tagsJson: $tagsJson') ..write(')')) .toString(); } @@ -18476,6 +18601,7 @@ class Program extends DataClass implements Insertable { name, defaultRestSeconds, isExample, + tagsJson, ); @override bool operator ==(Object other) => @@ -18494,7 +18620,8 @@ class Program extends DataClass implements Insertable { other.remoteRevision == this.remoteRevision && other.name == this.name && other.defaultRestSeconds == this.defaultRestSeconds && - other.isExample == this.isExample); + other.isExample == this.isExample && + other.tagsJson == this.tagsJson); } class ProgramsCompanion extends UpdateCompanion { @@ -18512,6 +18639,7 @@ class ProgramsCompanion extends UpdateCompanion { final Value name; final Value defaultRestSeconds; final Value isExample; + final Value tagsJson; final Value rowid; const ProgramsCompanion({ this.id = const Value.absent(), @@ -18528,6 +18656,7 @@ class ProgramsCompanion extends UpdateCompanion { this.name = const Value.absent(), this.defaultRestSeconds = const Value.absent(), this.isExample = const Value.absent(), + this.tagsJson = const Value.absent(), this.rowid = const Value.absent(), }); ProgramsCompanion.insert({ @@ -18545,6 +18674,7 @@ class ProgramsCompanion extends UpdateCompanion { required String name, required int defaultRestSeconds, this.isExample = const Value.absent(), + this.tagsJson = const Value.absent(), this.rowid = const Value.absent(), }) : id = Value(id), createdAt = Value(createdAt), @@ -18569,6 +18699,7 @@ class ProgramsCompanion extends UpdateCompanion { Expression? name, Expression? defaultRestSeconds, Expression? isExample, + Expression? tagsJson, Expression? rowid, }) { return RawValuesInsertable({ @@ -18588,6 +18719,7 @@ class ProgramsCompanion extends UpdateCompanion { if (defaultRestSeconds != null) 'default_rest_seconds': defaultRestSeconds, if (isExample != null) 'is_example': isExample, + if (tagsJson != null) 'tags_json': tagsJson, if (rowid != null) 'rowid': rowid, }); } @@ -18607,6 +18739,7 @@ class ProgramsCompanion extends UpdateCompanion { Value? name, Value? defaultRestSeconds, Value? isExample, + Value? tagsJson, Value? rowid, }) { return ProgramsCompanion( @@ -18624,6 +18757,7 @@ class ProgramsCompanion extends UpdateCompanion { name: name ?? this.name, defaultRestSeconds: defaultRestSeconds ?? this.defaultRestSeconds, isExample: isExample ?? this.isExample, + tagsJson: tagsJson ?? this.tagsJson, rowid: rowid ?? this.rowid, ); } @@ -18675,6 +18809,9 @@ class ProgramsCompanion extends UpdateCompanion { if (isExample.present) { map['is_example'] = Variable(isExample.value); } + if (tagsJson.present) { + map['tags_json'] = Variable(tagsJson.value); + } if (rowid.present) { map['rowid'] = Variable(rowid.value); } @@ -18698,6 +18835,7 @@ class ProgramsCompanion extends UpdateCompanion { ..write('name: $name, ') ..write('defaultRestSeconds: $defaultRestSeconds, ') ..write('isExample: $isExample, ') + ..write('tagsJson: $tagsJson, ') ..write('rowid: $rowid') ..write(')')) .toString(); @@ -30394,6 +30532,7 @@ typedef $$WorkoutTemplatesTableCreateCompanionBuilder = required String name, Value lastStartedAt, Value isExample, + Value tagsJson, Value rowid, }); typedef $$WorkoutTemplatesTableUpdateCompanionBuilder = @@ -30412,6 +30551,7 @@ typedef $$WorkoutTemplatesTableUpdateCompanionBuilder = Value name, Value lastStartedAt, Value isExample, + Value tagsJson, Value rowid, }); @@ -30589,6 +30729,11 @@ class $$WorkoutTemplatesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnFilters(column), + ); + Expression activeWorkoutSessionsRefs( Expression Function($$ActiveWorkoutSessionsTableFilterComposer f) f, ) { @@ -30745,6 +30890,11 @@ class $$WorkoutTemplatesTableOrderingComposer column: $table.isExample, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnOrderings(column), + ); } class $$WorkoutTemplatesTableAnnotationComposer @@ -30812,6 +30962,9 @@ class $$WorkoutTemplatesTableAnnotationComposer GeneratedColumn get isExample => $composableBuilder(column: $table.isExample, builder: (column) => column); + GeneratedColumn get tagsJson => + $composableBuilder(column: $table.tagsJson, builder: (column) => column); + Expression activeWorkoutSessionsRefs( Expression Function($$ActiveWorkoutSessionsTableAnnotationComposer a) f, ) { @@ -30939,6 +31092,7 @@ class $$WorkoutTemplatesTableTableManager Value name = const Value.absent(), Value lastStartedAt = const Value.absent(), Value isExample = const Value.absent(), + Value tagsJson = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutTemplatesCompanion( id: id, @@ -30955,6 +31109,7 @@ class $$WorkoutTemplatesTableTableManager name: name, lastStartedAt: lastStartedAt, isExample: isExample, + tagsJson: tagsJson, rowid: rowid, ), createCompanionCallback: @@ -30973,6 +31128,7 @@ class $$WorkoutTemplatesTableTableManager required String name, Value lastStartedAt = const Value.absent(), Value isExample = const Value.absent(), + Value tagsJson = const Value.absent(), Value rowid = const Value.absent(), }) => WorkoutTemplatesCompanion.insert( id: id, @@ -30989,6 +31145,7 @@ class $$WorkoutTemplatesTableTableManager name: name, lastStartedAt: lastStartedAt, isExample: isExample, + tagsJson: tagsJson, rowid: rowid, ), withReferenceMapper: (p0) => p0 @@ -38397,6 +38554,7 @@ typedef $$ExercisesTableCreateCompanionBuilder = Value autoStartNextTimedStep, Value category, Value isExample, + Value tagsJson, Value archivedAt, Value rowid, }); @@ -38430,6 +38588,7 @@ typedef $$ExercisesTableUpdateCompanionBuilder = Value autoStartNextTimedStep, Value category, Value isExample, + Value tagsJson, Value archivedAt, Value rowid, }); @@ -38668,6 +38827,11 @@ class $$ExercisesTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnFilters(column), + ); + ColumnFilters get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => ColumnFilters(column), @@ -38934,6 +39098,11 @@ class $$ExercisesTableOrderingComposer builder: (column) => ColumnOrderings(column), ); + ColumnOrderings get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnOrderings(column), + ); + ColumnOrderings get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => ColumnOrderings(column), @@ -39107,6 +39276,9 @@ class $$ExercisesTableAnnotationComposer GeneratedColumn get isExample => $composableBuilder(column: $table.isExample, builder: (column) => column); + GeneratedColumn get tagsJson => + $composableBuilder(column: $table.tagsJson, builder: (column) => column); + GeneratedColumn get archivedAt => $composableBuilder( column: $table.archivedAt, builder: (column) => column, @@ -39296,6 +39468,7 @@ class $$ExercisesTableTableManager Value autoStartNextTimedStep = const Value.absent(), Value category = const Value.absent(), Value isExample = const Value.absent(), + Value tagsJson = const Value.absent(), Value archivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ExercisesCompanion( @@ -39327,6 +39500,7 @@ class $$ExercisesTableTableManager autoStartNextTimedStep: autoStartNextTimedStep, category: category, isExample: isExample, + tagsJson: tagsJson, archivedAt: archivedAt, rowid: rowid, ), @@ -39360,6 +39534,7 @@ class $$ExercisesTableTableManager Value autoStartNextTimedStep = const Value.absent(), Value category = const Value.absent(), Value isExample = const Value.absent(), + Value tagsJson = const Value.absent(), Value archivedAt = const Value.absent(), Value rowid = const Value.absent(), }) => ExercisesCompanion.insert( @@ -39391,6 +39566,7 @@ class $$ExercisesTableTableManager autoStartNextTimedStep: autoStartNextTimedStep, category: category, isExample: isExample, + tagsJson: tagsJson, archivedAt: archivedAt, rowid: rowid, ), @@ -41614,6 +41790,7 @@ typedef $$ProgramsTableCreateCompanionBuilder = required String name, required int defaultRestSeconds, Value isExample, + Value tagsJson, Value rowid, }); typedef $$ProgramsTableUpdateCompanionBuilder = @@ -41632,6 +41809,7 @@ typedef $$ProgramsTableUpdateCompanionBuilder = Value name, Value defaultRestSeconds, Value isExample, + Value tagsJson, Value rowid, }); @@ -41767,6 +41945,11 @@ class $$ProgramsTableFilterComposer builder: (column) => ColumnFilters(column), ); + ColumnFilters get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnFilters(column), + ); + Expression programExercisesRefs( Expression Function($$ProgramExercisesTableFilterComposer f) f, ) { @@ -41897,6 +42080,11 @@ class $$ProgramsTableOrderingComposer column: $table.isExample, builder: (column) => ColumnOrderings(column), ); + + ColumnOrderings get tagsJson => $composableBuilder( + column: $table.tagsJson, + builder: (column) => ColumnOrderings(column), + ); } class $$ProgramsTableAnnotationComposer @@ -41964,6 +42152,9 @@ class $$ProgramsTableAnnotationComposer GeneratedColumn get isExample => $composableBuilder(column: $table.isExample, builder: (column) => column); + GeneratedColumn get tagsJson => + $composableBuilder(column: $table.tagsJson, builder: (column) => column); + Expression programExercisesRefs( Expression Function($$ProgramExercisesTableAnnotationComposer a) f, ) { @@ -42062,6 +42253,7 @@ class $$ProgramsTableTableManager Value name = const Value.absent(), Value defaultRestSeconds = const Value.absent(), Value isExample = const Value.absent(), + Value tagsJson = const Value.absent(), Value rowid = const Value.absent(), }) => ProgramsCompanion( id: id, @@ -42078,6 +42270,7 @@ class $$ProgramsTableTableManager name: name, defaultRestSeconds: defaultRestSeconds, isExample: isExample, + tagsJson: tagsJson, rowid: rowid, ), createCompanionCallback: @@ -42096,6 +42289,7 @@ class $$ProgramsTableTableManager required String name, required int defaultRestSeconds, Value isExample = const Value.absent(), + Value tagsJson = const Value.absent(), Value rowid = const Value.absent(), }) => ProgramsCompanion.insert( id: id, @@ -42112,6 +42306,7 @@ class $$ProgramsTableTableManager name: name, defaultRestSeconds: defaultRestSeconds, isExample: isExample, + tagsJson: tagsJson, rowid: rowid, ), withReferenceMapper: (p0) => p0 diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 37b6811..aeaf96a 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -2816,6 +2816,7 @@ db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) { defaultTargetScore: Value(exercise.defaultTargetScore), defaultTargetScoreTimeMs: Value(exercise.defaultTargetScoreTimeMs), autoStartNextTimedStep: Value(exercise.autoStartNextTimedStep), + tagsJson: Value(_encodeTags(exercise.tags)), archivedAt: Value(_utcOrNull(exercise.archivedAt)), ); } @@ -2846,6 +2847,7 @@ domain.Exercise _exerciseFromRow( autoStartNextTimedStep: row.autoStartNextTimedStep, category: starterMetadata.category, isExample: starterMetadata.isExample, + tags: _decodeTags(row.tagsJson), steps: steps, archivedAt: _utcOrNull(row.archivedAt), ); @@ -3028,6 +3030,7 @@ db.ProgramsCompanion _programCompanion(domain.Program program) { remoteRevision: values[10] as Value, name: Value(program.name), defaultRestSeconds: Value(program.defaultRestSeconds), + tagsJson: Value(_encodeTags(program.tags)), ); } @@ -3041,6 +3044,7 @@ domain.Program _programFromRow( name: row.name, defaultRestSeconds: row.defaultRestSeconds, isExample: isExample, + tags: _decodeTags(row.tagsJson), exercises: exercises, ); } @@ -3157,6 +3161,7 @@ db.WorkoutTemplatesCompanion _workoutTemplateCompanion( remoteRevision: values[10] as Value, name: Value(template.name), lastStartedAt: Value(_utcOrNull(template.lastStartedAt)), + tagsJson: Value(_encodeTags(template.tags)), ); } @@ -3171,6 +3176,7 @@ domain.WorkoutTemplate _workoutTemplateFromRow( name: row.name, lastStartedAt: _utcOrNull(row.lastStartedAt), isExample: isExample, + tags: _decodeTags(row.tagsJson), programs: programs, overrides: overrides, ); @@ -4077,6 +4083,7 @@ Map _exercisePayload(domain.Exercise exercise) => { 'autoStartNextTimedStep': exercise.autoStartNextTimedStep, 'category': exercise.category.name, 'isExample': exercise.isExample, + 'tags': exercise.tags, 'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(), 'archivedAt': exercise.archivedAt?.toUtc().toIso8601String(), }; @@ -4102,6 +4109,7 @@ Map _programPayload(domain.Program program) => { 'name': program.name, 'defaultRestSeconds': program.defaultRestSeconds, 'isExample': program.isExample, + 'tags': program.tags, 'exercises': program.exercises .map((exercise) => exercise.toSnapshotJson()) .toList(), @@ -4114,6 +4122,7 @@ Map _workoutTemplatePayload(domain.WorkoutTemplate template) => 'name': template.name, 'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(), 'isExample': template.isExample, + 'tags': template.tags, 'programs': template.programs .map( (program) => { @@ -4183,6 +4192,7 @@ domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) { payload['category'] as String? ?? 'uncategorized', ), isExample: payload['isExample'] as bool? ?? false, + tags: _stringListFromPayload(payload['tags']), steps: _stepsFromPayload(payload['steps']), archivedAt: _dateTimeFromPayload(payload['archivedAt']), ); @@ -4213,6 +4223,7 @@ domain.Program _programFromPayload(RemoteSyncedItem item) { name: _stringFromPayload(payload, 'name', item.clientId), defaultRestSeconds: payload['defaultRestSeconds'] as int? ?? 0, isExample: payload['isExample'] as bool? ?? false, + tags: _stringListFromPayload(payload['tags']), exercises: _programExercisesFromPayload(payload['exercises'], metadata), ); } @@ -4225,6 +4236,7 @@ domain.WorkoutTemplate _workoutTemplateFromPayload(RemoteSyncedItem item) { name: _stringFromPayload(payload, 'name', item.clientId), lastStartedAt: _dateTimeFromPayload(payload['lastStartedAt']), isExample: payload['isExample'] as bool? ?? false, + tags: _stringListFromPayload(payload['tags']), programs: _workoutTemplateProgramsFromPayload( payload['programs'], metadata, @@ -4422,6 +4434,23 @@ List _stringListFromPayload(Object? value) { return value.whereType().toList(growable: false); } +String _encodeTags(List tags) => jsonEncode(domain.normalizeTags(tags)); + +List _decodeTags(String? encoded) { + if (encoded == null || encoded.trim().isEmpty) { + return const []; + } + try { + final decoded = jsonDecode(encoded); + if (decoded is! List) { + return const []; + } + return domain.normalizeTags(decoded.whereType()); + } on Object { + return const []; + } +} + List _stepsFromPayload(Object? value) { if (value is! List) { return const []; diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index 7297fc1..dfde9e3 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -196,6 +196,7 @@ class Exercises extends SyncableTable { TextColumn get category => text().withDefault(const Constant('uncategorized'))(); BoolColumn get isExample => boolean().withDefault(const Constant(false))(); + TextColumn get tagsJson => text().withDefault(const Constant('[]'))(); DateTimeColumn get archivedAt => dateTime().nullable()(); @override @@ -214,6 +215,7 @@ class Exercises extends SyncableTable { 'default_target_score_time_ms > 0)', "CHECK (category IN ('shoot', 'freeThrows', 'dribble', 'finishing', " "'conditioning', 'defense', 'mobility', 'uncategorized'))", + 'CHECK (json_valid(tags_json))', ]; } @@ -284,9 +286,13 @@ class Programs extends SyncableTable { TextColumn get name => text().withLength(min: 1)(); IntColumn get defaultRestSeconds => integer()(); BoolColumn get isExample => boolean().withDefault(const Constant(false))(); + TextColumn get tagsJson => text().withDefault(const Constant('[]'))(); @override - List get customConstraints => ['CHECK (default_rest_seconds >= 0)']; + List get customConstraints => [ + 'CHECK (default_rest_seconds >= 0)', + 'CHECK (json_valid(tags_json))', + ]; } class ProgramExercises extends SyncableTable { @@ -367,6 +373,10 @@ class WorkoutTemplates extends SyncableTable { TextColumn get name => text().withLength(min: 1)(); DateTimeColumn get lastStartedAt => dateTime().nullable()(); BoolColumn get isExample => boolean().withDefault(const Constant(false))(); + TextColumn get tagsJson => text().withDefault(const Constant('[]'))(); + + @override + List get customConstraints => ['CHECK (json_valid(tags_json))']; } class LocalSeedMetadata extends Table { diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index 96db4b9..9a5ab05 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -2256,6 +2256,221 @@ void main() { ); }); + test('Tags are normalized and validated on taggable entities', () { + final exercise = Exercise( + metadata: _metadata('exercise-tags'), + name: 'Shoot', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + tags: const [' Match Prep ', 'INTENSE'], + ); + + expect(exercise.tags, ['match prep', 'intense']); + expect( + () => Program( + metadata: _metadata('program-tags'), + name: 'Program', + defaultRestSeconds: 30, + tags: const ['match', 'MATCH'], + ), + throwsA(isA()), + ); + expect( + () => WorkoutTemplate( + metadata: _metadata('template-tags'), + name: 'Template', + tags: List.generate(9, (index) => 'tag$index'), + ), + throwsA(isA()), + ); + expect( + () => Exercise( + metadata: _metadata('exercise-long-tag'), + name: 'Shoot', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + tags: const ['tag beaucoup trop long pour le mvp'], + ), + throwsA(isA()), + ); + }); + + test('Tag helpers filter by all selected tags and sort suggestions', () { + final programs = [ + Program( + metadata: _metadata('program-1'), + name: 'A', + defaultRestSeconds: 30, + tags: const ['match', 'intense'], + ), + Program( + metadata: _metadata('program-2'), + name: 'B', + defaultRestSeconds: 30, + tags: const ['match', 'extérieur'], + ), + Program( + metadata: _metadata('program-3'), + name: 'C', + defaultRestSeconds: 30, + tags: const ['intense'], + ), + ]; + + final filtered = filterByRequiredTags(programs, { + ' MATCH ', + 'intense', + }, (program) => program.tags); + final suggestions = tagSuggestionsFor(programs, (program) => program.tags); + + expect(filtered.map((program) => program.metadata.id), ['program-1']); + expect(suggestions.map((usage) => '${usage.tag}:${usage.count}'), [ + 'intense:2', + 'match:2', + 'extérieur:1', + ]); + }); + + test('Program use case duplicates deeply with copy name conflicts', () async { + final programRepository = _FakeProgramRepository(); + final templateRepository = _FakeWorkoutTemplateRepository(); + final source = Program( + metadata: _metadata('program-source'), + name: 'Programme tirs', + defaultRestSeconds: 45, + isExample: true, + tags: const ['match', 'extérieur'], + exercises: [ + _programExercise( + id: 'program-exercise-source', + programId: 'program-source', + sourceExerciseId: 'exercise-source', + position: 0, + ), + ], + ); + programRepository.programs.addAll([ + source, + Program( + metadata: _metadata('program-copy-1'), + name: 'Copie de Programme tirs', + defaultRestSeconds: 45, + ), + ]); + final useCase = ProgramUseCases( + programRepository: programRepository, + exerciseRepository: _FakeExerciseRepository(), + templateRepository: templateRepository, + clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final copy = await useCase.duplicate('program-source'); + + expect(copy.metadata.id, isNot('program-source')); + expect(copy.name, 'Copie 2 de Programme tirs'); + expect(copy.isExample, isFalse); + expect(copy.tags, source.tags); + expect(copy.exercises.single.metadata.id, isNot('program-exercise-source')); + expect(copy.exercises.single.programId, copy.metadata.id); + expect(copy.exercises.single.sourceExerciseId, 'exercise-source'); + expect(copy.exercises.single.targetReps, 10); + expect( + programRepository.programs + .singleWhere((program) => program.metadata.id == 'program-source') + .exercises + .single + .metadata + .id, + 'program-exercise-source', + ); + }); + + test( + 'Workout template use case duplicates deeply and remaps overrides', + () async { + final templateRepository = _FakeWorkoutTemplateRepository(); + final source = WorkoutTemplate( + metadata: _metadata('template-source'), + name: 'Prépa match', + lastStartedAt: DateTime.utc(2026, 7, 20, 12), + isExample: true, + tags: const ['match', 'intense'], + programs: [ + _templateProgram( + id: 'template-program-source', + workoutTemplateId: 'template-source', + sourceProgramId: 'program-source', + position: 0, + ), + ], + overrides: [ + WorkoutTemplateExerciseOverride( + metadata: _metadata('override-source'), + workoutTemplateProgramId: 'template-program-source', + snapshotProgramExerciseId: 'program-exercise-source', + setsCountOverride: 4, + targetRepsOverride: 12, + autoStartNextTimedStepOverride: false, + ), + ], + ); + templateRepository.templates.addAll([ + source, + WorkoutTemplate( + metadata: _metadata('template-copy-1'), + name: 'Copie de Prépa match', + ), + ]); + final useCase = WorkoutTemplateUseCases( + templateRepository: templateRepository, + programRepository: _FakeProgramRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 22, 12)), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final copy = await useCase.duplicate('template-source'); + + expect(copy.metadata.id, isNot('template-source')); + expect(copy.name, 'Copie 2 de Prépa match'); + expect(copy.lastStartedAt, isNull); + expect(copy.isExample, isFalse); + expect(copy.tags, source.tags); + expect( + copy.programs.single.metadata.id, + isNot('template-program-source'), + ); + expect(copy.programs.single.workoutTemplateId, copy.metadata.id); + expect(copy.overrides.single.metadata.id, isNot('override-source')); + expect( + copy.overrides.single.workoutTemplateProgramId, + copy.programs.single.metadata.id, + ); + expect( + templateRepository.templates + .singleWhere( + (template) => template.metadata.id == 'template-source', + ) + .programs + .single + .metadata + .id, + 'template-program-source', + ); + expect( + copy.overrides.single.snapshotProgramExerciseId, + 'program-exercise-source', + ); + expect(copy.overrides.single.setsCountOverride, 4); + expect(copy.overrides.single.targetRepsOverride, 12); + expect(copy.overrides.single.autoStartNextTimedStepOverride, isFalse); + }, + ); + test('Progression stats use case resolves four week overview', () async { final repository = _FakeProgressionStatsRepository(); final now = DateTime.utc(2026, 7, 22, 12); diff --git a/test/infrastructure/drift_repositories_test.dart b/test/infrastructure/drift_repositories_test.dart index ea20e6f..24bf6bc 100644 --- a/test/infrastructure/drift_repositories_test.dart +++ b/test/infrastructure/drift_repositories_test.dart @@ -15,6 +15,7 @@ void main() { late local.DriftWorkoutTemplateRepository templateRepository; late local.DriftWorkoutHistoryRepository historyRepository; late local.DriftProgressionStatsRepository progressionStatsRepository; + late local.DriftLocalSyncChangeRepository syncChangeRepository; late local.DriftExercisePerformanceReferenceRepository performanceReferenceRepository; @@ -28,6 +29,7 @@ void main() { progressionStatsRepository = local.DriftProgressionStatsRepository( database, ); + syncChangeRepository = local.DriftLocalSyncChangeRepository(database); performanceReferenceRepository = local.DriftExercisePerformanceReferenceRepository(database); }); @@ -106,6 +108,290 @@ void main() { expect(restored!.defaultTargetScore, 0); }); + test('taggable tables expose tags json columns on fresh schema', () async { + Future> columnNames(String tableName) async { + final rows = await database + .customSelect('PRAGMA table_info($tableName)') + .get(); + return rows.map((row) => row.data['name'] as String).toList(); + } + + expect(await columnNames('exercises'), contains('tags_json')); + expect(await columnNames('programs'), contains('tags_json')); + expect(await columnNames('workout_templates'), contains('tags_json')); + expect(database.schemaVersion, 19); + }); + + test('repositories save and load normalized tags', () async { + final now = DateTime.utc(2026, 7, 22, 10); + await exerciseRepository.save( + Exercise( + metadata: _metadata('exercise-tags', now), + name: 'Shoot', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + tags: const [' Match ', 'Extérieur'], + ), + ); + await programRepository.save( + Program( + metadata: _metadata('program-tags', now), + name: 'Program', + defaultRestSeconds: 45, + tags: const ['Intense'], + ), + ); + await templateRepository.save( + WorkoutTemplate( + metadata: _metadata('template-tags', now), + name: 'Template', + tags: const ['Routine'], + ), + ); + + final exercise = await exerciseRepository.findById('exercise-tags'); + final program = await programRepository.findById('program-tags'); + final template = await templateRepository.findById('template-tags'); + + expect(exercise!.tags, ['match', 'extérieur']); + expect(program!.tags, ['intense']); + expect(template!.tags, ['routine']); + }); + + test('local sync payload includes tags for taggable resources', () async { + final now = DateTime.utc(2026, 7, 22, 10, 30); + await exerciseRepository.save( + Exercise( + metadata: _metadata('exercise-sync-tags', now), + name: 'Shoot', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + tags: const ['match'], + ), + ); + await programRepository.save( + Program( + metadata: _metadata('program-sync-tags', now), + name: 'Program', + defaultRestSeconds: 45, + tags: const ['intense'], + ), + ); + await templateRepository.save( + WorkoutTemplate( + metadata: _metadata('template-sync-tags', now), + name: 'Template', + tags: const ['routine'], + ), + ); + + final changes = await syncChangeRepository.listPendingChanges(); + final payloadsById = { + for (final change in changes) change.item.clientId: change.item.payload, + }; + + expect(payloadsById['exercise-sync-tags']!['tags'], ['match']); + expect(payloadsById['program-sync-tags']!['tags'], ['intense']); + expect(payloadsById['template-sync-tags']!['tags'], ['routine']); + }); + + test('local sync pull defaults missing tags to empty lists', () async { + final now = DateTime.utc(2026, 7, 22, 11); + await syncChangeRepository.applyRemoteItem( + RemoteSyncedItem( + resourceType: SyncResourceType.exercise, + clientId: 'remote-exercise-no-tags', + serverId: 'server-exercise-no-tags', + schemaVersion: 1, + clientUpdatedAt: now, + serverUpdatedAt: now, + deletedAt: null, + payload: const { + 'id': 'remote-exercise-no-tags', + 'name': 'Remote exercise', + 'hasTimeMeasure': false, + 'hasRepsMeasure': true, + 'hasScoreMeasure': false, + }, + ), + ); + await syncChangeRepository.applyRemoteItem( + RemoteSyncedItem( + resourceType: SyncResourceType.program, + clientId: 'remote-program-no-tags', + serverId: 'server-program-no-tags', + schemaVersion: 1, + clientUpdatedAt: now, + serverUpdatedAt: now, + deletedAt: null, + payload: const { + 'id': 'remote-program-no-tags', + 'name': 'Remote program', + 'defaultRestSeconds': 30, + }, + ), + ); + await syncChangeRepository.applyRemoteItem( + RemoteSyncedItem( + resourceType: SyncResourceType.workoutTemplate, + clientId: 'remote-template-no-tags', + serverId: 'server-template-no-tags', + schemaVersion: 1, + clientUpdatedAt: now, + serverUpdatedAt: now, + deletedAt: null, + payload: const { + 'id': 'remote-template-no-tags', + 'name': 'Remote template', + }, + ), + ); + + final exercise = await exerciseRepository.findById( + 'remote-exercise-no-tags', + ); + final program = await programRepository.findById('remote-program-no-tags'); + final template = await templateRepository.findById( + 'remote-template-no-tags', + ); + + expect(exercise!.tags, isEmpty); + expect(program!.tags, isEmpty); + expect(template!.tags, isEmpty); + }); + + test('program duplication persists copied children through Drift', () async { + final now = DateTime.utc(2026, 7, 22, 11, 15); + await exerciseRepository.save( + Exercise( + metadata: _metadata('exercise-dup-source', now), + name: 'Shoot', + hasTimeMeasure: false, + hasRepsMeasure: true, + hasScoreMeasure: false, + defaultTargetReps: 10, + ), + ); + await programRepository.replaceExercises( + Program( + metadata: _metadata('program-dup-source', now), + name: 'Programme tirs', + defaultRestSeconds: 30, + isExample: true, + tags: const ['match'], + exercises: [ + _programExercise( + 'program-exercise-dup-source', + now, + programId: 'program-dup-source', + position: 0, + ), + ], + ), + now, + ); + final useCase = ProgramUseCases( + programRepository: programRepository, + exerciseRepository: exerciseRepository, + templateRepository: templateRepository, + clock: _FakeClock(now.add(const Duration(minutes: 1))), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final copy = await useCase.duplicate('program-dup-source'); + final restored = await programRepository.findById(copy.metadata.id); + + expect(restored, isNotNull); + expect(restored!.name, 'Copie de Programme tirs'); + expect(restored.isExample, isFalse); + expect(restored.tags, ['match']); + expect( + restored.exercises.single.metadata.id, + isNot('program-exercise-dup-source'), + ); + expect(restored.exercises.single.programId, restored.metadata.id); + expect(restored.exercises.single.exerciseNameSnapshot, 'Exercise 0'); + }); + + test( + 'workout template duplication persists remapped overrides through Drift', + () async { + final now = DateTime.utc(2026, 7, 22, 11, 30); + await programRepository.save( + Program( + metadata: _metadata('program-template-source', now), + name: 'Program source', + defaultRestSeconds: 30, + ), + ); + await templateRepository.replaceComposition( + WorkoutTemplate( + metadata: _metadata('template-dup-source', now), + name: 'Prépa match', + lastStartedAt: now, + isExample: true, + tags: const ['intense'], + programs: [ + WorkoutTemplateProgram( + metadata: _metadata('template-program-dup-source', now), + workoutTemplateId: 'template-dup-source', + sourceProgramId: 'program-template-source', + position: 0, + programNameSnapshot: 'Program source', + defaultRestSecondsSnapshot: 30, + programSnapshotJson: '{"exercises":[]}', + ), + ], + overrides: [ + WorkoutTemplateExerciseOverride( + metadata: _metadata('template-override-dup-source', now), + workoutTemplateProgramId: 'template-program-dup-source', + snapshotProgramExerciseId: 'snapshot-exercise', + setsCountOverride: 4, + ), + ], + ), + now, + ); + final useCase = WorkoutTemplateUseCases( + templateRepository: templateRepository, + programRepository: programRepository, + clock: _FakeClock(now.add(const Duration(minutes: 1))), + ids: _FakeIds(), + originDeviceId: 'device-1', + ); + + final copy = await useCase.duplicate('template-dup-source'); + final restored = await templateRepository.findById(copy.metadata.id); + + expect(restored, isNotNull); + expect(restored!.name, 'Copie de Prépa match'); + expect(restored.lastStartedAt, isNull); + expect(restored.isExample, isFalse); + expect(restored.tags, ['intense']); + expect( + restored.programs.single.metadata.id, + isNot('template-program-dup-source'), + ); + expect(restored.programs.single.workoutTemplateId, restored.metadata.id); + expect( + restored.overrides.single.metadata.id, + isNot('template-override-dup-source'), + ); + expect( + restored.overrides.single.workoutTemplateProgramId, + restored.programs.single.metadata.id, + ); + expect( + restored.overrides.single.snapshotProgramExerciseId, + 'snapshot-exercise', + ); + }, + ); + test('starter seed populates a fresh database once', () async { final seedRepository = local.DriftStarterSeedRepository(database); final result = await SeedStarterContentUseCase(