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;
}
}

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.');

View File

@ -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<void> _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<void> _backfillWorkoutHistorySetSourceExerciseIds() async {
await customStatement(r'''
UPDATE workout_history_set_results AS result

View File

@ -170,6 +170,18 @@ class $WorkoutTemplatesTable extends WorkoutTemplates
),
defaultValue: const Constant(false),
);
static const VerificationMeta _tagsJsonMeta = const VerificationMeta(
'tagsJson',
);
@override
late final GeneratedColumn<String> tagsJson = GeneratedColumn<String>(
'tags_json',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('[]'),
);
@override
List<GeneratedColumn> 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<WorkoutTemplate> {
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<WorkoutTemplate> {
required this.name,
this.lastStartedAt,
required this.isExample,
required this.tagsJson,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@ -447,6 +472,7 @@ class WorkoutTemplate extends DataClass implements Insertable<WorkoutTemplate> {
map['last_started_at'] = Variable<DateTime>(lastStartedAt);
}
map['is_example'] = Variable<bool>(isExample);
map['tags_json'] = Variable<String>(tagsJson);
return map;
}
@ -476,6 +502,7 @@ class WorkoutTemplate extends DataClass implements Insertable<WorkoutTemplate> {
? const Value.absent()
: Value(lastStartedAt),
isExample: Value(isExample),
tagsJson: Value(tagsJson),
);
}
@ -501,6 +528,7 @@ class WorkoutTemplate extends DataClass implements Insertable<WorkoutTemplate> {
name: serializer.fromJson<String>(json['name']),
lastStartedAt: serializer.fromJson<DateTime?>(json['lastStartedAt']),
isExample: serializer.fromJson<bool>(json['isExample']),
tagsJson: serializer.fromJson<String>(json['tagsJson']),
);
}
@override
@ -521,6 +549,7 @@ class WorkoutTemplate extends DataClass implements Insertable<WorkoutTemplate> {
'name': serializer.toJson<String>(name),
'lastStartedAt': serializer.toJson<DateTime?>(lastStartedAt),
'isExample': serializer.toJson<bool>(isExample),
'tagsJson': serializer.toJson<String>(tagsJson),
};
}
@ -539,6 +568,7 @@ class WorkoutTemplate extends DataClass implements Insertable<WorkoutTemplate> {
String? name,
Value<DateTime?> 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<WorkoutTemplate> {
? 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<WorkoutTemplate> {
? 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<WorkoutTemplate> {
..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<WorkoutTemplate> {
name,
lastStartedAt,
isExample,
tagsJson,
);
@override
bool operator ==(Object other) =>
@ -649,7 +683,8 @@ class WorkoutTemplate extends DataClass implements Insertable<WorkoutTemplate> {
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<WorkoutTemplate> {
@ -667,6 +702,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion<WorkoutTemplate> {
final Value<String> name;
final Value<DateTime?> lastStartedAt;
final Value<bool> isExample;
final Value<String> tagsJson;
final Value<int> rowid;
const WorkoutTemplatesCompanion({
this.id = const Value.absent(),
@ -683,6 +719,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion<WorkoutTemplate> {
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<WorkoutTemplate> {
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<WorkoutTemplate> {
Expression<String>? name,
Expression<DateTime>? lastStartedAt,
Expression<bool>? isExample,
Expression<String>? tagsJson,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
@ -741,6 +780,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion<WorkoutTemplate> {
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<WorkoutTemplate> {
Value<String>? name,
Value<DateTime?>? lastStartedAt,
Value<bool>? isExample,
Value<String>? tagsJson,
Value<int>? rowid,
}) {
return WorkoutTemplatesCompanion(
@ -777,6 +818,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion<WorkoutTemplate> {
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<WorkoutTemplate> {
if (isExample.present) {
map['is_example'] = Variable<bool>(isExample.value);
}
if (tagsJson.present) {
map['tags_json'] = Variable<String>(tagsJson.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@ -851,6 +896,7 @@ class WorkoutTemplatesCompanion extends UpdateCompanion<WorkoutTemplate> {
..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<String> tagsJson = GeneratedColumn<String>(
'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<Exercise> {
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<Exercise> {
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<Exercise> {
map['auto_start_next_timed_step'] = Variable<bool>(autoStartNextTimedStep);
map['category'] = Variable<String>(category);
map['is_example'] = Variable<bool>(isExample);
map['tags_json'] = Variable<String>(tagsJson);
if (!nullToAbsent || archivedAt != null) {
map['archived_at'] = Variable<DateTime>(archivedAt);
}
@ -13521,6 +13593,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
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<Exercise> {
),
category: serializer.fromJson<String>(json['category']),
isExample: serializer.fromJson<bool>(json['isExample']),
tagsJson: serializer.fromJson<String>(json['tagsJson']),
archivedAt: serializer.fromJson<DateTime?>(json['archivedAt']),
);
}
@ -13610,6 +13684,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
'autoStartNextTimedStep': serializer.toJson<bool>(autoStartNextTimedStep),
'category': serializer.toJson<String>(category),
'isExample': serializer.toJson<bool>(isExample),
'tagsJson': serializer.toJson<String>(tagsJson),
'archivedAt': serializer.toJson<DateTime?>(archivedAt),
};
}
@ -13643,6 +13718,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
bool? autoStartNextTimedStep,
String? category,
bool? isExample,
String? tagsJson,
Value<DateTime?> archivedAt = const Value.absent(),
}) => Exercise(
id: id ?? this.id,
@ -13686,6 +13762,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
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<Exercise> {
: 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<Exercise> {
..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<Exercise> {
autoStartNextTimedStep,
category,
isExample,
tagsJson,
archivedAt,
]);
@override
@ -13862,6 +13942,7 @@ class Exercise extends DataClass implements Insertable<Exercise> {
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<Exercise> {
final Value<bool> autoStartNextTimedStep;
final Value<String> category;
final Value<bool> isExample;
final Value<String> tagsJson;
final Value<DateTime?> archivedAt;
final Value<int> rowid;
const ExercisesCompanion({
@ -13925,6 +14007,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
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<Exercise> {
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<Exercise> {
Expression<bool>? autoStartNextTimedStep,
Expression<String>? category,
Expression<bool>? isExample,
Expression<String>? tagsJson,
Expression<DateTime>? archivedAt,
Expression<int>? rowid,
}) {
@ -14035,6 +14120,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
'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<Exercise> {
Value<bool>? autoStartNextTimedStep,
Value<String>? category,
Value<bool>? isExample,
Value<String>? tagsJson,
Value<DateTime?>? archivedAt,
Value<int>? rowid,
}) {
@ -14104,6 +14191,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
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<Exercise> {
if (isExample.present) {
map['is_example'] = Variable<bool>(isExample.value);
}
if (tagsJson.present) {
map['tags_json'] = Variable<String>(tagsJson.value);
}
if (archivedAt.present) {
map['archived_at'] = Variable<DateTime>(archivedAt.value);
}
@ -14244,6 +14335,7 @@ class ExercisesCompanion extends UpdateCompanion<Exercise> {
..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<String> tagsJson = GeneratedColumn<String>(
'tags_json',
aliasedName,
false,
type: DriftSqlType.string,
requiredDuringInsert: false,
defaultValue: const Constant('[]'),
);
@override
List<GeneratedColumn> 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<Program> {
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<Program> {
required this.name,
required this.defaultRestSeconds,
required this.isExample,
required this.tagsJson,
});
@override
Map<String, Expression> toColumns(bool nullToAbsent) {
@ -18296,6 +18413,7 @@ class Program extends DataClass implements Insertable<Program> {
map['name'] = Variable<String>(name);
map['default_rest_seconds'] = Variable<int>(defaultRestSeconds);
map['is_example'] = Variable<bool>(isExample);
map['tags_json'] = Variable<String>(tagsJson);
return map;
}
@ -18323,6 +18441,7 @@ class Program extends DataClass implements Insertable<Program> {
name: Value(name),
defaultRestSeconds: Value(defaultRestSeconds),
isExample: Value(isExample),
tagsJson: Value(tagsJson),
);
}
@ -18348,6 +18467,7 @@ class Program extends DataClass implements Insertable<Program> {
name: serializer.fromJson<String>(json['name']),
defaultRestSeconds: serializer.fromJson<int>(json['defaultRestSeconds']),
isExample: serializer.fromJson<bool>(json['isExample']),
tagsJson: serializer.fromJson<String>(json['tagsJson']),
);
}
@override
@ -18368,6 +18488,7 @@ class Program extends DataClass implements Insertable<Program> {
'name': serializer.toJson<String>(name),
'defaultRestSeconds': serializer.toJson<int>(defaultRestSeconds),
'isExample': serializer.toJson<bool>(isExample),
'tagsJson': serializer.toJson<String>(tagsJson),
};
}
@ -18386,6 +18507,7 @@ class Program extends DataClass implements Insertable<Program> {
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<Program> {
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<Program> {
? 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<Program> {
..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<Program> {
name,
defaultRestSeconds,
isExample,
tagsJson,
);
@override
bool operator ==(Object other) =>
@ -18494,7 +18620,8 @@ class Program extends DataClass implements Insertable<Program> {
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<Program> {
@ -18512,6 +18639,7 @@ class ProgramsCompanion extends UpdateCompanion<Program> {
final Value<String> name;
final Value<int> defaultRestSeconds;
final Value<bool> isExample;
final Value<String> tagsJson;
final Value<int> rowid;
const ProgramsCompanion({
this.id = const Value.absent(),
@ -18528,6 +18656,7 @@ class ProgramsCompanion extends UpdateCompanion<Program> {
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<Program> {
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<Program> {
Expression<String>? name,
Expression<int>? defaultRestSeconds,
Expression<bool>? isExample,
Expression<String>? tagsJson,
Expression<int>? rowid,
}) {
return RawValuesInsertable({
@ -18588,6 +18719,7 @@ class ProgramsCompanion extends UpdateCompanion<Program> {
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<Program> {
Value<String>? name,
Value<int>? defaultRestSeconds,
Value<bool>? isExample,
Value<String>? tagsJson,
Value<int>? rowid,
}) {
return ProgramsCompanion(
@ -18624,6 +18757,7 @@ class ProgramsCompanion extends UpdateCompanion<Program> {
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<Program> {
if (isExample.present) {
map['is_example'] = Variable<bool>(isExample.value);
}
if (tagsJson.present) {
map['tags_json'] = Variable<String>(tagsJson.value);
}
if (rowid.present) {
map['rowid'] = Variable<int>(rowid.value);
}
@ -18698,6 +18835,7 @@ class ProgramsCompanion extends UpdateCompanion<Program> {
..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<DateTime?> lastStartedAt,
Value<bool> isExample,
Value<String> tagsJson,
Value<int> rowid,
});
typedef $$WorkoutTemplatesTableUpdateCompanionBuilder =
@ -30412,6 +30551,7 @@ typedef $$WorkoutTemplatesTableUpdateCompanionBuilder =
Value<String> name,
Value<DateTime?> lastStartedAt,
Value<bool> isExample,
Value<String> tagsJson,
Value<int> rowid,
});
@ -30589,6 +30729,11 @@ class $$WorkoutTemplatesTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get tagsJson => $composableBuilder(
column: $table.tagsJson,
builder: (column) => ColumnFilters(column),
);
Expression<bool> activeWorkoutSessionsRefs(
Expression<bool> Function($$ActiveWorkoutSessionsTableFilterComposer f) f,
) {
@ -30745,6 +30890,11 @@ class $$WorkoutTemplatesTableOrderingComposer
column: $table.isExample,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get tagsJson => $composableBuilder(
column: $table.tagsJson,
builder: (column) => ColumnOrderings(column),
);
}
class $$WorkoutTemplatesTableAnnotationComposer
@ -30812,6 +30962,9 @@ class $$WorkoutTemplatesTableAnnotationComposer
GeneratedColumn<bool> get isExample =>
$composableBuilder(column: $table.isExample, builder: (column) => column);
GeneratedColumn<String> get tagsJson =>
$composableBuilder(column: $table.tagsJson, builder: (column) => column);
Expression<T> activeWorkoutSessionsRefs<T extends Object>(
Expression<T> Function($$ActiveWorkoutSessionsTableAnnotationComposer a) f,
) {
@ -30939,6 +31092,7 @@ class $$WorkoutTemplatesTableTableManager
Value<String> name = const Value.absent(),
Value<DateTime?> lastStartedAt = const Value.absent(),
Value<bool> isExample = const Value.absent(),
Value<String> tagsJson = const Value.absent(),
Value<int> 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<DateTime?> lastStartedAt = const Value.absent(),
Value<bool> isExample = const Value.absent(),
Value<String> tagsJson = const Value.absent(),
Value<int> 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<bool> autoStartNextTimedStep,
Value<String> category,
Value<bool> isExample,
Value<String> tagsJson,
Value<DateTime?> archivedAt,
Value<int> rowid,
});
@ -38430,6 +38588,7 @@ typedef $$ExercisesTableUpdateCompanionBuilder =
Value<bool> autoStartNextTimedStep,
Value<String> category,
Value<bool> isExample,
Value<String> tagsJson,
Value<DateTime?> archivedAt,
Value<int> rowid,
});
@ -38668,6 +38827,11 @@ class $$ExercisesTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get tagsJson => $composableBuilder(
column: $table.tagsJson,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get archivedAt => $composableBuilder(
column: $table.archivedAt,
builder: (column) => ColumnFilters(column),
@ -38934,6 +39098,11 @@ class $$ExercisesTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get tagsJson => $composableBuilder(
column: $table.tagsJson,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get archivedAt => $composableBuilder(
column: $table.archivedAt,
builder: (column) => ColumnOrderings(column),
@ -39107,6 +39276,9 @@ class $$ExercisesTableAnnotationComposer
GeneratedColumn<bool> get isExample =>
$composableBuilder(column: $table.isExample, builder: (column) => column);
GeneratedColumn<String> get tagsJson =>
$composableBuilder(column: $table.tagsJson, builder: (column) => column);
GeneratedColumn<DateTime> get archivedAt => $composableBuilder(
column: $table.archivedAt,
builder: (column) => column,
@ -39296,6 +39468,7 @@ class $$ExercisesTableTableManager
Value<bool> autoStartNextTimedStep = const Value.absent(),
Value<String> category = const Value.absent(),
Value<bool> isExample = const Value.absent(),
Value<String> tagsJson = const Value.absent(),
Value<DateTime?> archivedAt = const Value.absent(),
Value<int> 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<bool> autoStartNextTimedStep = const Value.absent(),
Value<String> category = const Value.absent(),
Value<bool> isExample = const Value.absent(),
Value<String> tagsJson = const Value.absent(),
Value<DateTime?> archivedAt = const Value.absent(),
Value<int> 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<bool> isExample,
Value<String> tagsJson,
Value<int> rowid,
});
typedef $$ProgramsTableUpdateCompanionBuilder =
@ -41632,6 +41809,7 @@ typedef $$ProgramsTableUpdateCompanionBuilder =
Value<String> name,
Value<int> defaultRestSeconds,
Value<bool> isExample,
Value<String> tagsJson,
Value<int> rowid,
});
@ -41767,6 +41945,11 @@ class $$ProgramsTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<String> get tagsJson => $composableBuilder(
column: $table.tagsJson,
builder: (column) => ColumnFilters(column),
);
Expression<bool> programExercisesRefs(
Expression<bool> Function($$ProgramExercisesTableFilterComposer f) f,
) {
@ -41897,6 +42080,11 @@ class $$ProgramsTableOrderingComposer
column: $table.isExample,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<String> get tagsJson => $composableBuilder(
column: $table.tagsJson,
builder: (column) => ColumnOrderings(column),
);
}
class $$ProgramsTableAnnotationComposer
@ -41964,6 +42152,9 @@ class $$ProgramsTableAnnotationComposer
GeneratedColumn<bool> get isExample =>
$composableBuilder(column: $table.isExample, builder: (column) => column);
GeneratedColumn<String> get tagsJson =>
$composableBuilder(column: $table.tagsJson, builder: (column) => column);
Expression<T> programExercisesRefs<T extends Object>(
Expression<T> Function($$ProgramExercisesTableAnnotationComposer a) f,
) {
@ -42062,6 +42253,7 @@ class $$ProgramsTableTableManager
Value<String> name = const Value.absent(),
Value<int> defaultRestSeconds = const Value.absent(),
Value<bool> isExample = const Value.absent(),
Value<String> tagsJson = const Value.absent(),
Value<int> 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<bool> isExample = const Value.absent(),
Value<String> tagsJson = const Value.absent(),
Value<int> 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

View File

@ -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<String?>,
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<String?>,
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<String, Object?> _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<String, Object?> _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<String, Object?> _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<String> _stringListFromPayload(Object? value) {
return value.whereType<String>().toList(growable: false);
}
String _encodeTags(List<String> tags) => jsonEncode(domain.normalizeTags(tags));
List<String> _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<String>());
} on Object {
return const [];
}
}
List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
if (value is! List) {
return const [];

View File

@ -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<String> get customConstraints => ['CHECK (default_rest_seconds >= 0)'];
List<String> 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<String> get customConstraints => ['CHECK (json_valid(tags_json))'];
}
class LocalSeedMetadata extends Table {

View File

@ -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<DomainException>()),
);
expect(
() => WorkoutTemplate(
metadata: _metadata('template-tags'),
name: 'Template',
tags: List.generate(9, (index) => 'tag$index'),
),
throwsA(isA<DomainException>()),
);
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<DomainException>()),
);
});
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);

View File

@ -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<List<String>> 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(