import 'dart:convert'; import 'package:drift/drift.dart'; import '../../application/application.dart'; import '../../domain/domain.dart' as domain; import 'app_database.dart' as db; final class DriftExerciseRepository implements ExerciseRepository { const DriftExerciseRepository(this.database); final db.AppDatabase database; @override Future findById(String id) async { final row = await (database.select( database.exercises, )..where((table) => table.id.equals(id))).getSingleOrNull(); return row == null ? null : _exerciseFromRow( row, await _imageMediaIdsForExercise(id), await _stepsForExercise(id), await _exerciseStarterMetadata(id), ); } @override Future> listActive() async { final rows = await (database.select(database.exercises) ..where( (table) => table.deletedAt.isNull() & table.archivedAt.isNull(), ) ..orderBy([(table) => OrderingTerm.asc(table.name)])) .get(); final exercises = []; for (final row in rows) { exercises.add( _exerciseFromRow( row, await _imageMediaIdsForExercise(row.id), await _stepsForExercise(row.id), await _exerciseStarterMetadata(row.id), ), ); } return exercises; } @override Future isReferencedByProgram(String id) async { final row = await (database.select(database.programExercises) ..where( (table) => table.deletedAt.isNull() & table.sourceExerciseId.equals(id), ) ..limit(1)) .getSingleOrNull(); return row != null; } @override Future save(domain.Exercise exercise) async { await database.transaction(() async { await _upsertWithChangeLog( database: database, tableName: 'exercises', entityType: 'Exercise', metadata: exercise.metadata, write: () => database .into(database.exercises) .insertOnConflictUpdate(_exerciseCompanion(exercise)), ); await _writeExerciseStarterMetadata(database, exercise); await _replaceExerciseImages(database, exercise); await _replaceExerciseSteps(database, exercise); }); } Future> _imageMediaIdsForExercise(String exerciseId) async { final rows = await (database.select(database.exerciseImages) ..where( (table) => table.exerciseId.equals(exerciseId) & table.deletedAt.isNull(), ) ..orderBy([(table) => OrderingTerm.asc(table.position)])) .get(); return rows.map((row) => row.mediaAssetId).toList(); } Future> _stepsForExercise(String exerciseId) async { final rows = await (database.select(database.exerciseSteps) ..where( (table) => table.exerciseId.equals(exerciseId) & table.deletedAt.isNull(), ) ..orderBy([(table) => OrderingTerm.asc(table.position)])) .get(); return rows.map(_exerciseStepFromRow).toList(); } Future<_ExerciseStarterMetadata> _exerciseStarterMetadata( String exerciseId, ) async { final row = await database .customSelect( 'SELECT category, is_example FROM exercises WHERE id = ? LIMIT 1', variables: [Variable(exerciseId)], ) .getSingleOrNull(); if (row == null) { return const _ExerciseStarterMetadata(); } return _ExerciseStarterMetadata( category: _exerciseCategoryFromDb( row.readNullable('category') ?? 'uncategorized', ), isExample: (row.readNullable('is_example') ?? 0) == 1, ); } } final class DriftStarterSeedRepository implements StarterSeedStateRepository, StarterContentRepository { const DriftStarterSeedRepository(this.database); static const _starterSeedKey = 'starter'; final db.AppDatabase database; @override Future readAppliedStarterSeedVersion() async { final row = await database .customSelect( 'SELECT version FROM local_seed_metadata WHERE key = ? LIMIT 1', variables: [Variable(_starterSeedKey)], ) .getSingleOrNull(); return row?.read('version') ?? 0; } @override Future writeAppliedStarterSeedVersion( int version, DateTime appliedAt, ) async { await database.customStatement( 'INSERT INTO local_seed_metadata (key, version, applied_at) ' 'VALUES (?, ?, ?) ' 'ON CONFLICT(key) DO UPDATE SET ' 'version = excluded.version, applied_at = excluded.applied_at', [_starterSeedKey, version, appliedAt.toUtc().millisecondsSinceEpoch], ); } @override Future isLocalContentEmpty() async { final exerciseCount = await _tableCount(database.exercises.actualTableName); final programCount = await _tableCount(database.programs.actualTableName); final templateCount = await _tableCount( database.workoutTemplates.actualTableName, ); return exerciseCount == 0 && programCount == 0 && templateCount == 0; } @override Future insertStarterContent(StarterContent content) async { await database.transaction(() async { for (final exercise in content.exercises) { await database .into(database.exercises) .insertOnConflictUpdate(_exerciseCompanion(exercise)); await _writeExerciseStarterMetadata(database, exercise); await _replaceExerciseSteps(database, exercise); } await database .into(database.programs) .insertOnConflictUpdate(_programCompanion(content.program)); await _writeProgramStarterMetadata(database, content.program); for (final exercise in content.program.exercises) { await database .into(database.programExercises) .insertOnConflictUpdate(_programExerciseCompanion(exercise)); } await database .into(database.workoutTemplates) .insertOnConflictUpdate( _workoutTemplateCompanion(content.workoutTemplate), ); await _writeWorkoutTemplateStarterMetadata( database, content.workoutTemplate, ); for (final program in content.workoutTemplate.programs) { await database .into(database.workoutTemplatePrograms) .insertOnConflictUpdate(_workoutTemplateProgramCompanion(program)); } }); } Future _tableCount(String tableName) async { final row = await database .customSelect('SELECT COUNT(*) AS count FROM $tableName') .getSingle(); return row.read('count'); } } final class DriftMediaAssetRepository implements MediaAssetRepository { const DriftMediaAssetRepository(this.database); final db.AppDatabase database; @override Future findById(String id) async { final row = await (database.select( database.mediaAssets, )..where((table) => table.id.equals(id))).getSingleOrNull(); return row == null ? null : _mediaAssetFromRow(row); } @override Future> listActive() async { final rows = await (database.select( database.mediaAssets, )..where((table) => table.deletedAt.isNull())).get(); return rows.map(_mediaAssetFromRow).toList(); } @override Future save(domain.MediaAsset mediaAsset) async { await _upsertWithChangeLog( database: database, tableName: 'media_assets', entityType: 'MediaAsset', metadata: mediaAsset.metadata, write: () => database .into(database.mediaAssets) .insertOnConflictUpdate(_mediaAssetCompanion(mediaAsset)), ); } } final class DriftOnlineAccountRepository implements OnlineAccountRepository { const DriftOnlineAccountRepository(this.database); final db.AppDatabase database; @override Future currentSession() async { final row = await (database.select(database.onlineAccountSessions) ..orderBy([(table) => OrderingTerm.desc(table.updatedAt)]) ..limit(1)) .getSingleOrNull(); return row == null ? null : _userAccountSessionFromRow(row); } @override Future saveSession(domain.UserAccountSession session) async { await database .into(database.onlineAccountSessions) .insertOnConflictUpdate(_userAccountSessionCompanion(session)); } @override Future clearSession() async { await database.delete(database.onlineAccountSessions).go(); } } final class DriftSyncMetadataRepository implements SyncMetadataRepository { const DriftSyncMetadataRepository(this.database); static const _id = 'singleton'; final db.AppDatabase database; @override Future read() async { final row = await (database.select( database.syncMetadataEntries, )..where((table) => table.id.equals(_id))).getSingleOrNull(); if (row == null) { return const SyncMetadataSnapshot(); } return SyncMetadataSnapshot( serverCursor: row.serverCursor, lastSuccessfulSyncAt: _utcOrNull(row.lastSuccessfulSyncAt), lastAttemptAt: _utcOrNull(row.lastAttemptAt), lastFailureAt: _utcOrNull(row.lastFailureAt), status: _onlineSyncStatusFromDb(row.status), pendingPushCount: row.pendingPushCount, ); } @override Future save(SyncMetadataSnapshot metadata) async { await database .into(database.syncMetadataEntries) .insertOnConflictUpdate( db.SyncMetadataEntriesCompanion.insert( id: const Value(_id), serverCursor: Value(metadata.serverCursor), lastSuccessfulSyncAt: Value( _utcOrNull(metadata.lastSuccessfulSyncAt), ), lastAttemptAt: Value(_utcOrNull(metadata.lastAttemptAt)), lastFailureAt: Value(_utcOrNull(metadata.lastFailureAt)), status: Value(_onlineSyncStatusToDb(metadata.status)), pendingPushCount: Value(metadata.pendingPushCount), ), ); } } final class DriftRemoteResourceMappingRepository implements RemoteResourceMappingRepository { const DriftRemoteResourceMappingRepository(this.database); final db.AppDatabase database; @override Future find({ required SyncResourceType resourceType, required String clientId, }) async { final row = await (database.select(database.remoteResourceMappings)..where( (table) => table.resourceType.equals( _syncResourceTypeToDb(resourceType), ) & table.clientId.equals(clientId), )) .getSingleOrNull(); return row == null ? null : _remoteResourceMappingFromRow(row); } @override Future save(RemoteResourceMapping mapping) async { await database .into(database.remoteResourceMappings) .insertOnConflictUpdate(_remoteResourceMappingCompanion(mapping)); } } final class DriftLocalSyncChangeRepository implements LocalSyncChangeRepository { const DriftLocalSyncChangeRepository(this.database); final db.AppDatabase database; @override Future> listPendingChanges() async { final rows = await (database.select(database.changeLogEntries) ..where( (table) => table.syncedAt.isNull() & table.entityType.isIn(_syncableEntityTypes), ) ..orderBy([(table) => OrderingTerm.asc(table.createdAt)])) .get(); final grouped = >{}; for (final row in rows) { grouped .putIfAbsent('${row.entityType}:${row.entityId}', () => []) .add(row); } final changes = []; for (final group in grouped.values) { group.sort((left, right) => left.createdAt.compareTo(right.createdAt)); final latest = group.last; final item = await _pushItemForChange(latest); if (item == null) { continue; } changes.add( PendingSyncChange( changeLogIds: group.map((row) => row.id).toList(), item: item, ), ); } return changes; } @override Future markChangesSynced( List changeLogIds, DateTime syncedAt, ) async { if (changeLogIds.isEmpty) { return; } await (database.update( database.changeLogEntries, )..where((table) => table.id.isIn(changeLogIds))).write( db.ChangeLogEntriesCompanion( syncedAt: Value(syncedAt.toUtc()), ), ); } @override Future applyRemoteItem(RemoteSyncedItem item) async { final localUpdatedAt = await _localUpdatedAt( item.resourceType, item.clientId, ); if (localUpdatedAt != null && !item.clientUpdatedAt.isAfter(localUpdatedAt)) { return false; } if (item.deletedAt != null) { return _applyRemoteDelete(item); } return _applyRemotePayload(item); } Future _pushItemForChange( db.ChangeLogEntry change, ) async { final resourceType = _syncResourceTypeFromEntityType(change.entityType); if (resourceType == null) { return null; } final snapshot = await _localSnapshot(resourceType, change.entityId); return RemoteSyncPushItem( resourceType: resourceType, clientId: change.entityId, schemaVersion: snapshot.schemaVersion, clientUpdatedAt: snapshot.updatedAt, deletedAt: snapshot.deletedAt, payload: snapshot.payload, ); } Future<_LocalSyncSnapshot> _localSnapshot( SyncResourceType resourceType, String id, ) async { return switch (resourceType) { SyncResourceType.exercise => _exerciseSnapshot(id), SyncResourceType.program => _programSnapshot(id), SyncResourceType.workoutTemplate => _workoutTemplateSnapshot(id), SyncResourceType.workoutHistory => _workoutHistorySnapshot(id), SyncResourceType.mediaAsset => _mediaAssetSnapshot(id), }; } Future _localUpdatedAt( SyncResourceType resourceType, String id, ) async { final tableName = _tableNameForResourceType(resourceType); final row = await database .customSelect( 'SELECT updated_at FROM $tableName WHERE id = ? LIMIT 1', variables: [Variable(id)], ) .getSingleOrNull(); final value = row?.data['updated_at']; return value is DateTime ? value.toUtc() : null; } Future _applyRemoteDelete(RemoteSyncedItem item) async { final tableName = _tableNameForResourceType(item.resourceType); final updated = await database.customUpdate( 'UPDATE $tableName SET deleted_at = ?, updated_at = ?, ' "sync_state = 'deleted' WHERE id = ?", variables: [ Variable(item.deletedAt!.toUtc()), Variable(item.clientUpdatedAt.toUtc()), Variable(item.clientId), ], ); return updated > 0; } Future _applyRemotePayload(RemoteSyncedItem item) async { switch (item.resourceType) { case SyncResourceType.exercise: final exercise = _exerciseFromPayload(item); await database .into(database.exercises) .insertOnConflictUpdate(_exerciseCompanion(exercise)); await _writeExerciseStarterMetadata(database, exercise); return true; case SyncResourceType.mediaAsset: await database .into(database.mediaAssets) .insertOnConflictUpdate( _mediaAssetCompanion(_mediaAssetFromPayload(item)), ); return true; case SyncResourceType.program: final program = _programFromPayload(item); await database.transaction(() async { await database .into(database.programs) .insertOnConflictUpdate(_programCompanion(program)); await _writeProgramStarterMetadata(database, program); for (final exercise in program.exercises) { await database .into(database.programExercises) .insertOnConflictUpdate(_programExerciseCompanion(exercise)); } }); return true; case SyncResourceType.workoutTemplate: final template = _workoutTemplateFromPayload(item); await database.transaction(() async { await database .into(database.workoutTemplates) .insertOnConflictUpdate(_workoutTemplateCompanion(template)); await _writeWorkoutTemplateStarterMetadata(database, template); for (final program in template.programs) { await database .into(database.workoutTemplatePrograms) .insertOnConflictUpdate( _workoutTemplateProgramCompanion(program), ); } for (final override in template.overrides) { await database .into(database.workoutTemplateExerciseOverrides) .insertOnConflictUpdate( _workoutTemplateExerciseOverrideCompanion(override), ); } }); return true; case SyncResourceType.workoutHistory: return false; } } Future<_LocalSyncSnapshot> _exerciseSnapshot(String id) async { final row = await (database.select( database.exercises, )..where((table) => table.id.equals(id))).getSingle(); final exercise = _exerciseFromRow( row, await DriftExerciseRepository(database)._imageMediaIdsForExercise(id), await DriftExerciseRepository(database)._stepsForExercise(id), await DriftExerciseRepository(database)._exerciseStarterMetadata(id), ); return _LocalSyncSnapshot.fromMetadata( exercise.metadata, _exercisePayload(exercise), ); } Future<_LocalSyncSnapshot> _mediaAssetSnapshot(String id) async { final row = await (database.select( database.mediaAssets, )..where((table) => table.id.equals(id))).getSingle(); final asset = _mediaAssetFromRow(row); return _LocalSyncSnapshot.fromMetadata( asset.metadata, _mediaAssetPayload(asset), ); } Future<_LocalSyncSnapshot> _programSnapshot(String id) async { final program = await DriftProgramRepository(database).findById(id); if (program == null) { return _deletedFallbackSnapshot(SyncResourceType.program, id); } return _LocalSyncSnapshot.fromMetadata( program.metadata, _programPayload(program), ); } Future<_LocalSyncSnapshot> _workoutTemplateSnapshot(String id) async { final template = await DriftWorkoutTemplateRepository( database, ).findById(id); if (template == null) { return _deletedFallbackSnapshot(SyncResourceType.workoutTemplate, id); } return _LocalSyncSnapshot.fromMetadata( template.metadata, _workoutTemplatePayload(template), ); } Future<_LocalSyncSnapshot> _workoutHistorySnapshot(String id) async { final history = await DriftWorkoutHistoryRepository(database).findById(id); if (history == null) { return _deletedFallbackSnapshot(SyncResourceType.workoutHistory, id); } return _LocalSyncSnapshot.fromMetadata( history.metadata, _workoutHistoryPayload(history), ); } Future<_LocalSyncSnapshot> _deletedFallbackSnapshot( SyncResourceType resourceType, String id, ) async { final tableName = _tableNameForResourceType(resourceType); final row = await database .customSelect( 'SELECT updated_at, deleted_at, schema_version FROM $tableName ' 'WHERE id = ? LIMIT 1', variables: [Variable(id)], ) .getSingle(); return _LocalSyncSnapshot( schemaVersion: row.data['schema_version'] as int? ?? 1, updatedAt: (row.data['updated_at'] as DateTime).toUtc(), deletedAt: (row.data['deleted_at'] as DateTime?)?.toUtc(), payload: {'id': id}, ); } } final class DriftShareInboxRepository implements ShareInboxRepository { const DriftShareInboxRepository(this.database); final db.AppDatabase database; @override Future> listAll() async { final rows = await (database.select( database.shareInboxItems, )..orderBy([(table) => OrderingTerm.desc(table.createdAt)])).get(); return rows.map(_shareInboxItemFromRow).toList(growable: false); } @override Future findByShareId(String shareId) async { final row = await (database.select( database.shareInboxItems, )..where((table) => table.shareId.equals(shareId))).getSingleOrNull(); return row == null ? null : _shareInboxItemFromRow(row); } @override Future upsert(domain.ShareInboxItem item) async { await database .into(database.shareInboxItems) .insertOnConflictUpdate(_shareInboxItemCompanion(item)); } @override Future upsertAll(List items) async { await database.batch((batch) { batch.insertAllOnConflictUpdate( database.shareInboxItems, items.map(_shareInboxItemCompanion).toList(), ); }); } @override Future markStatus( String shareId, domain.ShareInboxStatus status, DateTime respondedAt, ) async { await (database.update( database.shareInboxItems, )..where((table) => table.shareId.equals(shareId))).write( db.ShareInboxItemsCompanion( status: Value(_shareInboxStatusToDb(status)), respondedAt: Value(respondedAt.toUtc()), updatedAt: Value(respondedAt.toUtc()), ), ); } } final class DriftPendingShareActionRepository implements PendingShareActionRepository { const DriftPendingShareActionRepository(this.database); final db.AppDatabase database; @override Future> listPending() async { final rows = await (database.select(database.pendingShareActions) ..where( (table) => table.status.isIn([ _pendingShareActionStatusToDb( domain.PendingShareActionStatus.pending, ), _pendingShareActionStatusToDb( domain.PendingShareActionStatus.failed, ), ]), ) ..orderBy([(table) => OrderingTerm.asc(table.createdAt)])) .get(); return rows.map(_pendingShareActionFromRow).toList(growable: false); } @override Future add(domain.PendingShareAction action) async { await database .into(database.pendingShareActions) .insertOnConflictUpdate(_pendingShareActionCompanion(action)); } @override Future markSucceeded(String id, DateTime attemptedAt) async { await _markActionAttempt( id, attemptedAt, domain.PendingShareActionStatus.succeeded, ); } @override Future markFailed(String id, DateTime attemptedAt) async { await _markActionAttempt( id, attemptedAt, domain.PendingShareActionStatus.failed, ); } Future _markActionAttempt( String id, DateTime attemptedAt, domain.PendingShareActionStatus status, ) async { final row = await (database.select( database.pendingShareActions, )..where((table) => table.id.equals(id))).getSingleOrNull(); await (database.update( database.pendingShareActions, )..where((table) => table.id.equals(id))).write( db.PendingShareActionsCompanion( lastAttemptAt: Value(attemptedAt.toUtc()), attemptCount: Value((row?.attemptCount ?? 0) + 1), status: Value(_pendingShareActionStatusToDb(status)), ), ); } } final class DriftProgramRepository implements ProgramRepository { const DriftProgramRepository(this.database); final db.AppDatabase database; @override Future findById(String id) async { final query = database.select(database.programs) ..where((table) => table.id.equals(id) & table.deletedAt.isNull()); final row = await query.getSingleOrNull(); if (row == null) { return null; } final exercises = await (database.select(database.programExercises) ..where( (table) => table.programId.equals(id) & table.deletedAt.isNull(), ) ..orderBy([(table) => OrderingTerm.asc(table.position)])) .get(); return _programFromRow( row, exercises.map(_programExerciseFromRow).toList(), isExample: await _isProgramExample(id), ); } @override Future> listActive() async { final rows = await (database.select(database.programs) ..where((table) => table.deletedAt.isNull()) ..orderBy([(table) => OrderingTerm.asc(table.name)])) .get(); final programs = []; for (final row in rows) { final exercises = await (database.select(database.programExercises) ..where( (table) => table.programId.equals(row.id) & table.deletedAt.isNull(), ) ..orderBy([(table) => OrderingTerm.asc(table.position)])) .get(); programs.add( _programFromRow( row, exercises.map(_programExerciseFromRow).toList(), isExample: await _isProgramExample(row.id), ), ); } return programs; } @override Future save(domain.Program program) async { await database.transaction(() async { await _upsertWithChangeLog( database: database, tableName: 'programs', entityType: 'Program', metadata: program.metadata, write: () => database .into(database.programs) .insertOnConflictUpdate(_programCompanion(program)), ); await _writeProgramStarterMetadata(database, program); for (final exercise in program.exercises) { await saveExercise(exercise); } }); } @override Future saveExercise(domain.ProgramExercise exercise) async { await _upsertWithChangeLog( database: database, tableName: 'program_exercises', entityType: 'ProgramExercise', metadata: exercise.metadata, write: () => database .into(database.programExercises) .insertOnConflictUpdate(_programExerciseCompanion(exercise)), ); } @override Future replaceExercises( domain.Program program, DateTime deletedAt, ) async { await database.transaction(() async { await save(program); final activeIds = program.exercises .map((exercise) => exercise.metadata.id) .toSet(); final staleQuery = database.select(database.programExercises); if (activeIds.isEmpty) { staleQuery.where( (table) => table.programId.equals(program.metadata.id) & table.deletedAt.isNull(), ); } else { staleQuery.where( (table) => table.programId.equals(program.metadata.id) & table.deletedAt.isNull() & table.id.isNotIn(activeIds), ); } await _softDeleteProgramExerciseRows( database, await staleQuery.get(), deletedAt, ); }); } Future _isProgramExample(String id) async { final row = await database .customSelect( 'SELECT is_example FROM programs WHERE id = ? LIMIT 1', variables: [Variable(id)], ) .getSingleOrNull(); return (row?.readNullable('is_example') ?? 0) == 1; } } final class DriftWorkoutTemplateRepository implements WorkoutTemplateRepository { const DriftWorkoutTemplateRepository(this.database); final db.AppDatabase database; @override Future findById(String id) async { final row = await (database.select( database.workoutTemplates, )..where((table) => table.id.equals(id) & table.deletedAt.isNull())) .getSingleOrNull(); if (row == null) { return null; } final programs = await (database.select(database.workoutTemplatePrograms) ..where( (table) => table.workoutTemplateId.equals(id) & table.deletedAt.isNull(), ) ..orderBy([(table) => OrderingTerm.asc(table.position)])) .get(); final programIds = programs.map((program) => program.id).toSet(); final overrides = programIds.isEmpty ? [] : await (database.select(database.workoutTemplateExerciseOverrides) ..where( (table) => table.workoutTemplateProgramId.isIn(programIds) & table.deletedAt.isNull(), )) .get(); return _workoutTemplateFromRow( row, programs.map(_workoutTemplateProgramFromRow).toList(), overrides.map(_workoutTemplateExerciseOverrideFromRow).toList(), isExample: await _isWorkoutTemplateExample(id), ); } @override Future> listActive() async { final rows = await (database.select(database.workoutTemplates) ..where((table) => table.deletedAt.isNull()) ..orderBy([(table) => OrderingTerm.asc(table.name)])) .get(); final templates = []; for (final row in rows) { final template = await findById(row.id); if (template != null) { templates.add(template); } } return templates; } @override Future save(domain.WorkoutTemplate template) async { await database.transaction(() async { await _saveTemplate(template); }); } @override Future saveAll(List templates) async { await database.transaction(() async { for (final template in templates) { await _saveTemplate(template); } }); } Future _saveTemplate(domain.WorkoutTemplate template) async { await _upsertWithChangeLog( database: database, tableName: 'workout_templates', entityType: 'WorkoutTemplate', metadata: template.metadata, write: () => database .into(database.workoutTemplates) .insertOnConflictUpdate(_workoutTemplateCompanion(template)), ); await _writeWorkoutTemplateStarterMetadata(database, template); for (final program in template.programs) { await saveProgram(program); } for (final override in template.overrides) { await saveOverride(override); } } @override Future saveProgram(domain.WorkoutTemplateProgram program) async { await _upsertWithChangeLog( database: database, tableName: 'workout_template_programs', entityType: 'WorkoutTemplateProgram', metadata: program.metadata, write: () => database .into(database.workoutTemplatePrograms) .insertOnConflictUpdate(_workoutTemplateProgramCompanion(program)), ); } @override Future saveOverride( domain.WorkoutTemplateExerciseOverride override, ) async { await _upsertWithChangeLog( database: database, tableName: 'workout_template_exercise_overrides', entityType: 'WorkoutTemplateExerciseOverride', metadata: override.metadata, write: () => database .into(database.workoutTemplateExerciseOverrides) .insertOnConflictUpdate( _workoutTemplateExerciseOverrideCompanion(override), ), ); } @override Future replaceComposition( domain.WorkoutTemplate template, DateTime deletedAt, ) async { await database.transaction(() async { final previousPrograms = await (database.select(database.workoutTemplatePrograms)..where( (table) => table.workoutTemplateId.equals(template.metadata.id) & table.deletedAt.isNull(), )) .get(); final previousProgramIds = previousPrograms .map((program) => program.id) .toSet(); await save(template); final activeProgramIds = template.programs .map((program) => program.metadata.id) .toSet(); final staleProgramsQuery = database.select( database.workoutTemplatePrograms, ); if (activeProgramIds.isEmpty) { staleProgramsQuery.where( (table) => table.workoutTemplateId.equals(template.metadata.id) & table.deletedAt.isNull(), ); } else { staleProgramsQuery.where( (table) => table.workoutTemplateId.equals(template.metadata.id) & table.deletedAt.isNull() & table.id.isNotIn(activeProgramIds), ); } await _softDeleteWorkoutTemplateProgramRows( database, await staleProgramsQuery.get(), deletedAt, ); final activeOverrideIds = template.overrides .map((override) => override.metadata.id) .toSet(); final relevantProgramIds = {...previousProgramIds, ...activeProgramIds}; if (relevantProgramIds.isEmpty) { return; } final staleOverridesQuery = database.select( database.workoutTemplateExerciseOverrides, ); if (activeOverrideIds.isEmpty) { staleOverridesQuery.where( (table) => table.workoutTemplateProgramId.isIn(relevantProgramIds) & table.deletedAt.isNull(), ); } else { staleOverridesQuery.where( (table) => table.workoutTemplateProgramId.isIn(relevantProgramIds) & table.deletedAt.isNull() & table.id.isNotIn(activeOverrideIds), ); } await _softDeleteWorkoutTemplateExerciseOverrideRows( database, await staleOverridesQuery.get(), deletedAt, ); }); } Future _isWorkoutTemplateExample(String id) async { final row = await database .customSelect( 'SELECT is_example FROM workout_templates WHERE id = ? LIMIT 1', variables: [Variable(id)], ) .getSingleOrNull(); return (row?.readNullable('is_example') ?? 0) == 1; } } final class DriftActiveSessionRepository implements ActiveSessionRepository { const DriftActiveSessionRepository(this.database); final db.AppDatabase database; @override Future findById(String id) async { final row = await (database.select( database.activeWorkoutSessions, )..where((table) => table.id.equals(id))).getSingleOrNull(); return row == null ? null : _activeWorkoutSessionFromRow(row); } @override Future findOpen() async { final row = await (database.select(database.activeWorkoutSessions) ..where( (table) => table.deletedAt.isNull() & table.status.isIn(['running', 'paused', 'savedExit']), ) ..orderBy([(table) => OrderingTerm.desc(table.updatedAt)]) ..limit(1)) .getSingleOrNull(); return row == null ? null : _activeWorkoutSessionFromRow(row); } @override Future save(domain.ActiveWorkoutSession session) async { await _upsertWithChangeLog( database: database, tableName: 'active_workout_sessions', entityType: 'ActiveWorkoutSession', metadata: session.metadata, write: () => database .into(database.activeWorkoutSessions) .insertOnConflictUpdate(_activeWorkoutSessionCompanion(session)), ); } @override Future saveSetResult(domain.ActiveSetResult result) async { await _upsertWithChangeLog( database: database, tableName: 'active_set_results', entityType: 'ActiveSetResult', metadata: result.metadata, write: () => database .into(database.activeSetResults) .insertOnConflictUpdate(_activeSetResultCompanion(result)), ); } @override Future saveSetTimerState(domain.ActiveSetTimerState state) async { await _upsertWithChangeLog( database: database, tableName: 'active_set_timer_states', entityType: 'ActiveSetTimerState', metadata: state.metadata, write: () => _upsertActiveSetTimerState(database, state), ); } @override Future saveRestState(domain.ActiveRestState restState) async { await _upsertWithChangeLog( database: database, tableName: 'active_rest_states', entityType: 'ActiveRestState', metadata: restState.metadata, write: () async { await database .into(database.activeRestStates) .insertOnConflictUpdate(_activeRestStateCompanion(restState)); await _updateActiveRestPauseFields(database, restState); }, ); } @override Future findRestStateById(String id) async { final row = await database .customSelect( 'SELECT * FROM active_rest_states WHERE id = ? LIMIT 1', variables: [Variable(id)], ) .getSingleOrNull(); return row == null ? null : _activeRestStateFromCustomRow(row); } @override Future saveScoreStopwatchState( domain.ActiveScoreStopwatchState state, ) async { await _upsertWithChangeLog( database: database, tableName: 'active_score_stopwatch_states', entityType: 'ActiveScoreStopwatchState', metadata: state.metadata, write: () => database .into(database.activeScoreStopwatchStates) .insertOnConflictUpdate(_activeScoreStopwatchStateCompanion(state)), ); } @override Future saveManualScoreState(domain.ActiveManualScoreState state) async { await _upsertWithChangeLog( database: database, tableName: 'active_manual_score_states', entityType: 'ActiveManualScoreState', metadata: state.metadata, write: () => database .into(database.activeManualScoreStates) .insertOnConflictUpdate(_activeManualScoreStateCompanion(state)), ); } @override Future saveExerciseStepProgressState( domain.ActiveExerciseStepProgressState state, ) async { await _upsertWithChangeLog( database: database, tableName: 'active_exercise_step_progress_states', entityType: 'ActiveExerciseStepProgressState', metadata: state.metadata, write: () => database .into(database.activeExerciseStepProgressStates) .insertOnConflictUpdate( _activeExerciseStepProgressStateCompanion(state), ), ); } @override Future saveExerciseStepResult( domain.ActiveExerciseStepResult result, ) async { await _upsertWithChangeLog( database: database, tableName: 'active_exercise_step_results', entityType: 'ActiveExerciseStepResult', metadata: result.metadata, write: () => database .into(database.activeExerciseStepResults) .insertOnConflictUpdate(_activeExerciseStepResultCompanion(result)), ); } @override Future deleteScoreStopwatchState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, required DateTime deletedAt, }) async { final row = await (database.select(database.activeScoreStopwatchStates)..where( (table) => table.activeWorkoutSessionId.equals(sessionId) & table.programIndex.equals(programIndex) & table.exerciseIndex.equals(exerciseIndex) & table.setIndex.equals(setIndex) & table.deletedAt.isNull(), )) .getSingleOrNull(); if (row == null) { return; } final revision = row.localRevision + 1; await (database.delete( database.activeScoreStopwatchStates, )..where((table) => table.id.equals(row.id))).go(); await _writeChangeLog( database: database, entityType: 'ActiveScoreStopwatchState', entityId: row.id, operation: 'delete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, ); } @override Future deleteManualScoreState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, required DateTime deletedAt, }) async { final row = await (database.select(database.activeManualScoreStates)..where( (table) => table.activeWorkoutSessionId.equals(sessionId) & table.programIndex.equals(programIndex) & table.exerciseIndex.equals(exerciseIndex) & table.setIndex.equals(setIndex) & table.deletedAt.isNull(), )) .getSingleOrNull(); if (row == null) { return; } final revision = row.localRevision + 1; await (database.delete( database.activeManualScoreStates, )..where((table) => table.id.equals(row.id))).go(); await _writeChangeLog( database: database, entityType: 'ActiveManualScoreState', entityId: row.id, operation: 'delete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, ); } @override Future findScoreStopwatchState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { final row = await (database.select(database.activeScoreStopwatchStates)..where( (table) => table.activeWorkoutSessionId.equals(sessionId) & table.programIndex.equals(programIndex) & table.exerciseIndex.equals(exerciseIndex) & table.setIndex.equals(setIndex) & table.deletedAt.isNull(), )) .getSingleOrNull(); return row == null ? null : _activeScoreStopwatchStateFromRow(row); } @override Future findManualScoreState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { final row = await (database.select(database.activeManualScoreStates)..where( (table) => table.activeWorkoutSessionId.equals(sessionId) & table.programIndex.equals(programIndex) & table.exerciseIndex.equals(exerciseIndex) & table.setIndex.equals(setIndex) & table.deletedAt.isNull(), )) .getSingleOrNull(); return row == null ? null : _activeManualScoreStateFromRow(row); } @override Future findSetTimerState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { final row = await database .customSelect( 'SELECT * FROM active_set_timer_states ' 'WHERE active_workout_session_id = ? ' 'AND program_index = ? ' 'AND exercise_index = ? ' 'AND set_index = ? ' 'AND deleted_at IS NULL ' 'LIMIT 1', variables: [ Variable(sessionId), Variable(programIndex), Variable(exerciseIndex), Variable(setIndex), ], ) .getSingleOrNull(); return row == null ? null : _activeSetTimerStateFromCustomRow(row); } @override Future findExerciseStepProgressState({ required String sessionId, required int programIndex, required int exerciseIndex, required int setIndex, }) async { final row = await (database.select(database.activeExerciseStepProgressStates) ..where( (table) => table.activeWorkoutSessionId.equals(sessionId) & table.programIndex.equals(programIndex) & table.exerciseIndex.equals(exerciseIndex) & table.setIndex.equals(setIndex) & table.deletedAt.isNull(), )) .getSingleOrNull(); return row == null ? null : _activeExerciseStepProgressStateFromRow(row); } @override Future> listSetResults(String sessionId) async { final rows = await (database.select(database.activeSetResults) ..where((table) => table.activeWorkoutSessionId.equals(sessionId)) ..orderBy([ (table) => OrderingTerm.asc(table.programIndex), (table) => OrderingTerm.asc(table.exerciseIndex), (table) => OrderingTerm.asc(table.setIndex), ])) .get(); return rows.map(_activeSetResultFromRow).toList(); } @override Future> listSetTimerStates( String sessionId, ) async { final rows = await database .customSelect( 'SELECT * FROM active_set_timer_states ' 'WHERE active_workout_session_id = ? ' 'AND deleted_at IS NULL ' 'ORDER BY program_index, exercise_index, set_index', variables: [Variable(sessionId)], ) .get(); return rows.map(_activeSetTimerStateFromCustomRow).toList(); } @override Future> listExerciseStepProgressStates(String sessionId) async { final rows = await (database.select(database.activeExerciseStepProgressStates) ..where( (table) => table.activeWorkoutSessionId.equals(sessionId) & table.deletedAt.isNull(), ) ..orderBy([ (table) => OrderingTerm.asc(table.programIndex), (table) => OrderingTerm.asc(table.exerciseIndex), (table) => OrderingTerm.asc(table.setIndex), ])) .get(); return rows.map(_activeExerciseStepProgressStateFromRow).toList(); } @override Future> listExerciseStepResults( String sessionId, ) async { final rows = await (database.select(database.activeExerciseStepResults) ..where( (table) => table.activeWorkoutSessionId.equals(sessionId) & table.deletedAt.isNull(), ) ..orderBy([ (table) => OrderingTerm.asc(table.programIndex), (table) => OrderingTerm.asc(table.exerciseIndex), (table) => OrderingTerm.asc(table.setIndex), (table) => OrderingTerm.asc(table.passageIndex), (table) => OrderingTerm.asc(table.stepIndex), ])) .get(); return rows.map(_activeExerciseStepResultFromRow).toList(); } @override Future> listRestStates(String sessionId) async { final rows = await database .customSelect( 'SELECT * FROM active_rest_states ' 'WHERE active_workout_session_id = ? ' 'ORDER BY started_at', variables: [Variable(sessionId)], ) .get(); return rows.map(_activeRestStateFromCustomRow).toList(); } @override Future> listScoreStopwatchStates( String sessionId, ) async { final rows = await (database.select(database.activeScoreStopwatchStates) ..where( (table) => table.activeWorkoutSessionId.equals(sessionId) & table.deletedAt.isNull(), ) ..orderBy([ (table) => OrderingTerm.asc(table.programIndex), (table) => OrderingTerm.asc(table.exerciseIndex), (table) => OrderingTerm.asc(table.setIndex), ])) .get(); return rows.map(_activeScoreStopwatchStateFromRow).toList(); } @override Future> listManualScoreStates( String sessionId, ) async { final rows = await (database.select(database.activeManualScoreStates) ..where( (table) => table.activeWorkoutSessionId.equals(sessionId) & table.deletedAt.isNull(), ) ..orderBy([ (table) => OrderingTerm.asc(table.programIndex), (table) => OrderingTerm.asc(table.exerciseIndex), (table) => OrderingTerm.asc(table.setIndex), ])) .get(); return rows.map(_activeManualScoreStateFromRow).toList(); } } final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository { const DriftWorkoutHistoryRepository(this.database); final db.AppDatabase database; @override Future findById(String id) async { final row = await (database.select( database.workoutHistories, )..where((table) => table.id.equals(id) & table.deletedAt.isNull())) .getSingleOrNull(); if (row == null) { return null; } final results = await (database.select(database.workoutHistorySetResults) ..where( (table) => table.workoutHistoryId.equals(id) & table.deletedAt.isNull(), ) ..orderBy([ (table) => OrderingTerm.asc(table.programIndex), (table) => OrderingTerm.asc(table.exerciseIndex), (table) => OrderingTerm.asc(table.setIndex), ])) .get(); final stepResults = await (database.select(database.workoutHistoryStepResults) ..where( (table) => table.workoutHistoryId.equals(id) & table.deletedAt.isNull(), ) ..orderBy([ (table) => OrderingTerm.asc(table.programIndex), (table) => OrderingTerm.asc(table.exerciseIndex), (table) => OrderingTerm.asc(table.setIndex), (table) => OrderingTerm.asc(table.passageIndex), (table) => OrderingTerm.asc(table.stepIndex), ])) .get(); return _workoutHistoryFromRow( row, results.map(_workoutHistorySetResultFromRow).toList(), stepResults.map(_workoutHistoryStepResultFromRow).toList(), ); } @override Future> listActive() async { final rows = await (database.select(database.workoutHistories) ..where((table) => table.deletedAt.isNull()) ..orderBy([(table) => OrderingTerm.desc(table.startedAt)])) .get(); final histories = []; for (final row in rows) { final history = await findById(row.id); if (history != null) { histories.add(history); } } return histories; } @override Future save(domain.WorkoutHistory history) async { await database.transaction(() async { await _upsertWithChangeLog( database: database, tableName: 'workout_history', entityType: 'WorkoutHistory', metadata: history.metadata, write: () => database .into(database.workoutHistories) .insertOnConflictUpdate(_workoutHistoryCompanion(history)), ); for (final result in history.results) { await saveSetResult(result); } for (final result in history.stepResults) { await saveStepResult(result); } }); } @override Future patchHeartRateSummary({ required String historyId, int? minHeartRateBpm, required double averageHeartRateBpm, required int maxHeartRateBpm, double? totalDistanceMeters, double? totalCaloriesKcal, required DateTime patchedAt, }) async { if ((minHeartRateBpm != null && minHeartRateBpm <= 0) || averageHeartRateBpm <= 0 || maxHeartRateBpm <= 0 || (totalDistanceMeters != null && totalDistanceMeters < 0) || (totalCaloriesKcal != null && totalCaloriesKcal < 0)) { return; } final row = await (database.select(database.workoutHistories)..where( (table) => table.id.equals(historyId) & table.deletedAt.isNull() & table.minHeartRateBpm.isNull() & table.averageHeartRateBpm.isNull() & table.maxHeartRateBpm.isNull() & table.totalDistanceMeters.isNull() & table.totalCaloriesKcal.isNull(), )) .getSingleOrNull(); if (row == null) { return; } final revision = row.localRevision + 1; await (database.update( database.workoutHistories, )..where((table) => table.id.equals(historyId))).write( db.WorkoutHistoriesCompanion( updatedAt: Value(patchedAt.toUtc()), syncState: const Value('dirty'), localRevision: Value(revision), minHeartRateBpm: Value(minHeartRateBpm), averageHeartRateBpm: Value(averageHeartRateBpm), maxHeartRateBpm: Value(maxHeartRateBpm), totalDistanceMeters: Value(totalDistanceMeters), totalCaloriesKcal: Value(totalCaloriesKcal), ), ); await _writeChangeLog( database: database, entityType: 'WorkoutHistory', entityId: historyId, operation: 'update', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: patchedAt, ); } @override Future saveSetResult(domain.WorkoutHistorySetResult result) async { await _upsertWithChangeLog( database: database, tableName: 'workout_history_set_results', entityType: 'WorkoutHistorySetResult', metadata: result.metadata, write: () => database .into(database.workoutHistorySetResults) .insertOnConflictUpdate(_workoutHistorySetResultCompanion(result)), ); } @override Future saveStepResult(domain.WorkoutHistoryStepResult result) async { await _upsertWithChangeLog( database: database, tableName: 'workout_history_step_results', entityType: 'WorkoutHistoryStepResult', metadata: result.metadata, write: () => database .into(database.workoutHistoryStepResults) .insertOnConflictUpdate(_workoutHistoryStepResultCompanion(result)), ); } @override Future delete(String id, DateTime deletedAt) async { await database.transaction(() async { final history = await (database.select( database.workoutHistories, )..where((table) => table.id.equals(id))).getSingle(); final revision = history.localRevision + 1; await (database.update( database.workoutHistories, )..where((table) => table.id.equals(id))).write( db.WorkoutHistoriesCompanion( deletedAt: Value(deletedAt.toUtc()), updatedAt: Value(deletedAt.toUtc()), localRevision: Value(revision), syncState: const Value('deleted'), ), ); await _writeChangeLog( database: database, entityType: 'WorkoutHistory', entityId: id, operation: 'softDelete', localRevision: revision, originDeviceId: history.originDeviceId, createdAt: deletedAt, ); final resultRows = await (database.select(database.workoutHistorySetResults)..where( (table) => table.workoutHistoryId.equals(id) & table.deletedAt.isNull(), )) .get(); await _softDeleteWorkoutHistorySetResultRows( database, resultRows, deletedAt, ); final stepResultRows = await (database.select(database.workoutHistoryStepResults)..where( (table) => table.workoutHistoryId.equals(id) & table.deletedAt.isNull(), )) .get(); await _softDeleteWorkoutHistoryStepResultRows( database, stepResultRows, deletedAt, ); }); } } final class DriftWorkoutTelemetryRepository implements WorkoutTelemetryRepository { const DriftWorkoutTelemetryRepository(this.database); final db.AppDatabase database; @override Future saveSample(domain.WorkoutTelemetrySample sample) async { final existing = await (database.select( database.workoutTelemetrySamples, )..where((table) => table.id.equals(sample.id))).getSingleOrNull(); if (existing != null) { return false; } final inserted = await database .into(database.workoutTelemetrySamples) .insert( _workoutTelemetrySampleCompanion(sample), mode: InsertMode.insertOrIgnore, ); return inserted > 0; } @override Future> listSamples( String sessionId, ) async { final rows = await (database.select(database.workoutTelemetrySamples) ..where((table) => table.sessionId.equals(sessionId)) ..orderBy([(table) => OrderingTerm.asc(table.capturedAt)])) .get(); return rows.map(_workoutTelemetrySampleFromRow).toList(); } @override Future replaceAggregatesForSession({ required String sessionId, required List aggregates, }) async { await database.transaction(() async { await (database.delete( database.workoutTelemetryAggregates, )..where((table) => table.sessionId.equals(sessionId))).go(); if (aggregates.isEmpty) { return; } await database.batch((batch) { batch.insertAll( database.workoutTelemetryAggregates, aggregates.map(_workoutTelemetryAggregateCompanion).toList(), ); }); }); } @override Future> listAggregates( String sessionId, ) async { final rows = await (database.select( database.workoutTelemetryAggregates, )..where((table) => table.sessionId.equals(sessionId))).get(); return rows.map(_workoutTelemetryAggregateFromRow).toList(); } @override Future findAggregate({ required String sessionId, required domain.WorkoutTelemetryAggregateScope scope, int? programIndex, int? exerciseIndex, int? setIndex, int? passageIndex, int? stepIndex, }) async { final rows = await (database.select(database.workoutTelemetryAggregates)..where( (table) => table.sessionId.equals(sessionId) & table.scope.equals(scope.wireName), )) .get(); for (final row in rows) { final aggregate = _workoutTelemetryAggregateFromRow(row); if (aggregate.programIndex == programIndex && aggregate.exerciseIndex == exerciseIndex && aggregate.setIndex == setIndex && aggregate.passageIndex == passageIndex && aggregate.stepIndex == stepIndex) { return aggregate; } } return null; } } final class DriftLocalDataBackupRepository implements LocalDataBackupRepository { const DriftLocalDataBackupRepository(this.database); final db.AppDatabase database; @override Future readExportSnapshot( DateTime exportedAt, ) async { final mediaAssets = await DriftMediaAssetRepository(database).listActive(); final exercises = await DriftExerciseRepository(database).listActive(); final programs = await DriftProgramRepository(database).listActive(); final templates = await DriftWorkoutTemplateRepository( database, ).listActive(); final histories = await DriftWorkoutHistoryRepository( database, ).listActive(); return LocalDataExportSnapshot( exportedAt: exportedAt.toUtc(), appSchemaVersion: database.schemaVersion, originDeviceId: 'local-device', mediaAssets: [ for (final asset in mediaAssets) _backupResource(asset.metadata, _mediaAssetPayload(asset)), ], exercises: [ for (final exercise in exercises) _backupResource(exercise.metadata, _exercisePayload(exercise)), ], programs: [ for (final program in programs) _backupResource(program.metadata, _programPayload(program)), ], workoutTemplates: [ for (final template in templates) _backupResource(template.metadata, _workoutTemplatePayload(template)), ], workoutHistories: [ for (final history in histories) _backupResource( history.metadata, _localWorkoutHistoryPayload(history), ), ], ); } @override Future hasOpenActiveWorkoutSession() async { return DriftActiveSessionRepository( database, ).findOpen().then((session) => session != null); } @override Future hasAnyUserData() async { final count = await database.customSelect(''' SELECT (SELECT COUNT(*) FROM media_assets WHERE deleted_at IS NULL) + (SELECT COUNT(*) FROM exercises WHERE deleted_at IS NULL AND archived_at IS NULL) + (SELECT COUNT(*) FROM programs WHERE deleted_at IS NULL) + (SELECT COUNT(*) FROM workout_templates WHERE deleted_at IS NULL) + (SELECT COUNT(*) FROM workout_history WHERE deleted_at IS NULL) AS total ''').getSingle(); return count.read('total') > 0; } @override Future applyImportSnapshot({ required LocalDataExportSnapshot snapshot, required LocalBackupImportMode mode, required DateTime importedAt, }) async { if (await hasOpenActiveWorkoutSession()) { throw const LocalBackupException( LocalBackupValidationError.activeWorkoutInProgress, ); } var inserted = 0; var updated = 0; var ignored = 0; var deleted = 0; await database.transaction(() async { if (mode == LocalBackupImportMode.replaceAll) { deleted += await _softDeleteResourcesAbsentFromBackup( snapshot, importedAt, ); } final force = mode == LocalBackupImportMode.replaceAll; final mediaResult = await _applyBackupResources( resources: snapshot.mediaAssets, resourceType: SyncResourceType.mediaAsset, tableName: 'media_assets', importedAt: importedAt, force: force, ); inserted += mediaResult.insertedCount; updated += mediaResult.updatedCount; ignored += mediaResult.ignoredOlderCount; final exerciseResult = await _applyBackupResources( resources: snapshot.exercises, resourceType: SyncResourceType.exercise, tableName: 'exercises', importedAt: importedAt, force: force, ); inserted += exerciseResult.insertedCount; updated += exerciseResult.updatedCount; ignored += exerciseResult.ignoredOlderCount; final programResult = await _applyBackupResources( resources: snapshot.programs, resourceType: SyncResourceType.program, tableName: 'programs', importedAt: importedAt, force: force, ); inserted += programResult.insertedCount; updated += programResult.updatedCount; ignored += programResult.ignoredOlderCount; final templateResult = await _applyBackupResources( resources: snapshot.workoutTemplates, resourceType: SyncResourceType.workoutTemplate, tableName: 'workout_templates', importedAt: importedAt, force: force, ); inserted += templateResult.insertedCount; updated += templateResult.updatedCount; ignored += templateResult.ignoredOlderCount; final historyResult = await _applyBackupResources( resources: snapshot.workoutHistories, resourceType: SyncResourceType.workoutHistory, tableName: 'workout_history', importedAt: importedAt, force: force, ); inserted += historyResult.insertedCount; updated += historyResult.updatedCount; ignored += historyResult.ignoredOlderCount; }); return LocalBackupImportResult( insertedCount: inserted, updatedCount: updated, ignoredOlderCount: ignored, deletedByReplaceCount: deleted, missingMediaCount: 0, ); } Future _applyBackupResources({ required List resources, required SyncResourceType resourceType, required String tableName, required DateTime importedAt, required bool force, }) async { var inserted = 0; var updated = 0; var ignored = 0; for (final resource in resources) { final local = await _localSyncRow(tableName, resource.id); if (!force && local != null && !resource.updatedAt.isAfter(local.updatedAt)) { ignored += 1; continue; } final revision = (local?.localRevision ?? 0) + 1; final payload = _payloadWithImportedMetadata( resource.payload, importedAt, revision, snapshotOriginDeviceId: resource.payload['originDeviceId'] as String?, ); await _saveBackupResource( resourceType: resourceType, resource: resource.copyWith(payload: payload), importedAt: importedAt, ); if (local == null || local.deletedAt != null) { inserted += 1; } else { updated += 1; } } return LocalBackupImportResult( insertedCount: inserted, updatedCount: updated, ignoredOlderCount: ignored, deletedByReplaceCount: 0, missingMediaCount: 0, ); } Future _saveBackupResource({ required SyncResourceType resourceType, required LocalBackupResource resource, required DateTime importedAt, }) async { final remoteItem = _remoteItemFromBackupResource(resourceType, resource); switch (resourceType) { case SyncResourceType.mediaAsset: await DriftMediaAssetRepository( database, ).save(_mediaAssetFromPayload(remoteItem)); case SyncResourceType.exercise: await DriftExerciseRepository( database, ).save(_exerciseFromPayload(remoteItem)); case SyncResourceType.program: await DriftProgramRepository( database, ).replaceExercises(_programFromPayload(remoteItem), importedAt); case SyncResourceType.workoutTemplate: await DriftWorkoutTemplateRepository(database).replaceComposition( _workoutTemplateFromPayload(remoteItem), importedAt, ); case SyncResourceType.workoutHistory: await _replaceWorkoutHistory( _workoutHistoryFromLocalBackupPayload(remoteItem), importedAt, ); } } Future _replaceWorkoutHistory( domain.WorkoutHistory history, DateTime deletedAt, ) async { await DriftWorkoutHistoryRepository(database).save(history); final activeResultIds = history.results .map((result) => result.metadata.id) .toSet(); final resultRows = await (database.select(database.workoutHistorySetResults)..where( (table) => table.workoutHistoryId.equals(history.metadata.id) & table.deletedAt.isNull() & (activeResultIds.isEmpty ? const Constant(true) : table.id.isNotIn(activeResultIds)), )) .get(); await _softDeleteWorkoutHistorySetResultRows( database, resultRows, deletedAt, ); final activeStepResultIds = history.stepResults .map((result) => result.metadata.id) .toSet(); final stepRows = await (database.select(database.workoutHistoryStepResults)..where( (table) => table.workoutHistoryId.equals(history.metadata.id) & table.deletedAt.isNull() & (activeStepResultIds.isEmpty ? const Constant(true) : table.id.isNotIn(activeStepResultIds)), )) .get(); await _softDeleteWorkoutHistoryStepResultRows( database, stepRows, deletedAt, ); } Future _softDeleteResourcesAbsentFromBackup( LocalDataExportSnapshot snapshot, DateTime deletedAt, ) async { var deleted = 0; deleted += await _softDeleteMainRowsAbsent( tableName: 'media_assets', entityType: 'MediaAsset', keepIds: snapshot.mediaAssets.map((resource) => resource.id).toSet(), deletedAt: deletedAt, ); deleted += await _softDeleteMainRowsAbsent( tableName: 'exercises', entityType: 'Exercise', keepIds: snapshot.exercises.map((resource) => resource.id).toSet(), deletedAt: deletedAt, ); deleted += await _softDeleteMainRowsAbsent( tableName: 'programs', entityType: 'Program', keepIds: snapshot.programs.map((resource) => resource.id).toSet(), deletedAt: deletedAt, ); deleted += await _softDeleteMainRowsAbsent( tableName: 'workout_templates', entityType: 'WorkoutTemplate', keepIds: snapshot.workoutTemplates.map((resource) => resource.id).toSet(), deletedAt: deletedAt, ); deleted += await _softDeleteMainRowsAbsent( tableName: 'workout_history', entityType: 'WorkoutHistory', keepIds: snapshot.workoutHistories.map((resource) => resource.id).toSet(), deletedAt: deletedAt, ); await _softDeleteAllActiveSessionRows(deletedAt); return deleted; } Future _softDeleteMainRowsAbsent({ required String tableName, required String entityType, required Set keepIds, required DateTime deletedAt, }) async { final where = keepIds.isEmpty ? 'deleted_at IS NULL' : 'deleted_at IS NULL AND id NOT IN (${List.filled(keepIds.length, '?').join(', ')})'; final rows = await database .customSelect( 'SELECT id, local_revision, origin_device_id FROM $tableName WHERE $where', variables: [for (final id in keepIds) Variable(id)], ) .get(); for (final row in rows) { final id = row.read('id'); final revision = row.read('local_revision') + 1; await database.customUpdate( 'UPDATE $tableName SET deleted_at = ?, updated_at = ?, ' 'local_revision = ?, sync_state = ? WHERE id = ?', variables: [ Variable(deletedAt.toUtc()), Variable(deletedAt.toUtc()), Variable(revision), const Variable('deleted'), Variable(id), ], ); await _writeChangeLog( database: database, entityType: entityType, entityId: id, operation: 'softDelete', localRevision: revision, originDeviceId: row.read('origin_device_id'), createdAt: deletedAt, ); } return rows.length; } Future _softDeleteAllActiveSessionRows(DateTime deletedAt) async { await database.customUpdate( 'UPDATE active_workout_sessions SET deleted_at = ?, updated_at = ? ' 'WHERE deleted_at IS NULL', variables: [ Variable(deletedAt.toUtc()), Variable(deletedAt.toUtc()), ], updates: {database.activeWorkoutSessions}, ); } Future<_BackupLocalRow?> _localSyncRow(String tableName, String id) async { final row = await database .customSelect( 'SELECT updated_at, deleted_at, local_revision FROM $tableName ' 'WHERE id = ? LIMIT 1', variables: [Variable(id)], ) .getSingleOrNull(); if (row == null) { return null; } return _BackupLocalRow( updatedAt: _dateTimeFromData(row.data, 'updated_at'), deletedAt: _dateTimeOrNullFromData(row.data, 'deleted_at'), localRevision: row.read('local_revision'), ); } } final class DriftExercisePerformanceReferenceRepository implements ExercisePerformanceReferenceRepository { const DriftExercisePerformanceReferenceRepository(this.database); final db.AppDatabase database; @override Future hasAnyCompletedHistoryForExercise(String exerciseId) async { final row = await database .customSelect( 'SELECT 1 FROM workout_history_set_results AS result ' 'INNER JOIN workout_history AS history ' 'ON history.id = result.workout_history_id ' 'WHERE result.deleted_at IS NULL ' 'AND history.deleted_at IS NULL ' 'AND history.completed = 1 ' 'AND result.status = ? ' 'AND result.source_exercise_id_snapshot = ? ' 'LIMIT 1', variables: [ const Variable('completed'), Variable(exerciseId), ], ) .getSingleOrNull(); return row != null; } @override Future findLatestSetPerformance({ required String exerciseId, required ActivePerformanceMeasures activeMeasures, required int currentSetIndex, }) async { final valuePredicate = _activeMeasuresPredicate(activeMeasures); if (valuePredicate == null) { return null; } final row = await database .customSelect( ''' WITH candidates AS ( SELECT result.workout_history_id, history.started_at AS history_started_at, result.set_index, result.exercise_name_snapshot, result.score_input_mode_snapshot, result.actual_time_ms, result.actual_reps, result.actual_score, result.actual_score_time_ms, result.completed_at FROM workout_history_set_results AS result INNER JOIN workout_history AS history ON history.id = result.workout_history_id WHERE result.deleted_at IS NULL AND history.deleted_at IS NULL AND history.completed = 1 AND result.status = ? AND result.source_exercise_id_snapshot = ? AND ($valuePredicate) ), latest_history AS ( SELECT workout_history_id FROM candidates ORDER BY history_started_at DESC LIMIT 1 ) SELECT * FROM candidates WHERE workout_history_id = (SELECT workout_history_id FROM latest_history) ORDER BY CASE WHEN set_index = ? THEN 0 ELSE 1 END, set_index DESC LIMIT 1 ''', variables: [ const Variable('completed'), Variable(exerciseId), Variable(currentSetIndex), ], ) .getSingleOrNull(); return row == null ? null : _setPerformanceFromRow(row); } @override Future findBestMetricPerformance({ required String exerciseId, required PerformanceMetric metric, required domain.ScoreInputMode scoreInputMode, }) async { final metricSql = _metricSql(metric, scoreInputMode); final row = await database .customSelect( ''' SELECT result.workout_history_id, history.started_at AS history_started_at, result.set_index, result.exercise_name_snapshot, result.score_input_mode_snapshot, result.actual_time_ms, result.actual_reps, result.actual_score, result.actual_score_time_ms, result.completed_at FROM workout_history_set_results AS result INNER JOIN workout_history AS history ON history.id = result.workout_history_id WHERE result.deleted_at IS NULL AND history.deleted_at IS NULL AND history.completed = 1 AND result.status = ? AND result.source_exercise_id_snapshot = ? AND ${metricSql.predicate} ORDER BY ${metricSql.ordering}, history.started_at DESC, result.completed_at DESC LIMIT 1 ''', variables: [ const Variable('completed'), Variable(exerciseId), ], ) .getSingleOrNull(); return row == null ? null : _metricPerformanceFromRow(row, metric: metric); } } final class DriftProgressionStatsRepository implements ProgressionStatsRepository { const DriftProgressionStatsRepository(this.database); final db.AppDatabase database; @override Future readGlobalStats( ProgressionDateRange range, ) async { final variables = _rangeVariables(range); final row = await database.customSelect(''' SELECT COUNT(*) AS completed_count, COALESCE(SUM(total_active_ms), 0) AS total_active_ms, MIN(started_at) AS first_started_at FROM workout_history WHERE deleted_at IS NULL AND completed = 1 AND started_at <= ? ${range.startedAt == null ? '' : 'AND started_at >= ?'} ''', variables: variables).getSingle(); final anyRow = await database.customSelect(''' SELECT MIN(started_at) AS first_started_at FROM workout_history WHERE deleted_at IS NULL AND completed = 1 ''').getSingle(); final weekRows = await database.customSelect(''' SELECT started_at FROM workout_history WHERE deleted_at IS NULL AND completed = 1 AND started_at <= ? ${range.startedAt == null ? '' : 'AND started_at >= ?'} ''', variables: variables).get(); final weekStarts = { for (final row in weekRows) _startOfLocalWeekFromUtc(_dateTimeFromData(row.data, 'started_at')), }.toList()..sort(); return ProgressionGlobalStatsData( completedSessionCount: row.data['completed_count'] as int, totalActiveMs: row.data['total_active_ms'] as int, activeWeekStarts: weekStarts, hasAnyCompletedHistory: anyRow.data['first_started_at'] != null, firstCompletedSessionAt: _dateTimeOrNullFromData( anyRow.data, 'first_started_at', ), ); } @override Future> listExerciseOptions( ProgressionDateRange range, ) async { final rows = await database.customSelect(''' WITH result_projection AS ( ${_progressionResultProjection(range)} ), ranked AS ( SELECT exercise_key, exercise_name_snapshot, source_exercise_id_snapshot, started_at, ROW_NUMBER() OVER ( PARTITION BY exercise_key ORDER BY started_at DESC ) AS name_rank FROM result_projection WHERE has_graphable_measure = 1 ) SELECT ranked.exercise_key, MAX(CASE WHEN ranked.name_rank = 1 THEN ranked.exercise_name_snapshot END) AS exercise_name_snapshot, MAX(ranked.started_at) AS last_performed_at, CASE WHEN exercises.id IS NOT NULL AND exercises.deleted_at IS NULL AND exercises.archived_at IS NULL THEN 0 ELSE 1 END AS is_archived FROM ranked LEFT JOIN exercises ON exercises.id = ranked.source_exercise_id_snapshot GROUP BY ranked.exercise_key ORDER BY is_archived ASC, last_performed_at DESC, exercise_name_snapshot COLLATE NOCASE ASC, ranked.exercise_key ASC ''', variables: _projectionVariables(range)).get(); return rows .map( (row) => ProgressionExerciseOptionData( exerciseKey: row.data['exercise_key'] as String, nameSnapshot: row.data['exercise_name_snapshot'] as String, isArchived: (row.data['is_archived'] as int) == 1, lastPerformedAt: _dateTimeFromData(row.data, 'last_performed_at'), ), ) .toList(); } @override Future> listMeasureOptions({ required ProgressionDateRange range, required String exerciseKey, }) async { final rows = await database .customSelect( ''' WITH result_projection AS ( ${_progressionResultProjection(range)} ), measure_rows AS ( SELECT 0 AS sort_order, 'manualScore' AS measure, score_label_snapshot, score_unit_snapshot, started_at FROM result_projection WHERE exercise_key = ? AND score_input_mode_snapshot = 'manual' AND actual_score IS NOT NULL UNION ALL SELECT 1, 'stopwatchScore', NULL, NULL, started_at FROM result_projection WHERE exercise_key = ? AND score_input_mode_snapshot = 'stopwatch' AND actual_score_time_ms IS NOT NULL UNION ALL SELECT 2, 'reps', NULL, NULL, started_at FROM result_projection WHERE exercise_key = ? AND actual_reps IS NOT NULL UNION ALL SELECT 3, 'time', NULL, NULL, started_at FROM result_projection WHERE exercise_key = ? AND actual_time_ms IS NOT NULL ), ranked AS ( SELECT *, ROW_NUMBER() OVER (PARTITION BY measure ORDER BY started_at DESC) AS value_rank FROM measure_rows ) SELECT measure, MAX(CASE WHEN value_rank = 1 THEN score_label_snapshot END) AS score_label, MAX(CASE WHEN value_rank = 1 THEN score_unit_snapshot END) AS score_unit, MIN(sort_order) AS sort_order FROM ranked GROUP BY measure ORDER BY sort_order ''', variables: [ ..._projectionVariables(range), for (var i = 0; i < 4; i++) Variable(exerciseKey), ], ) .get(); return rows.map(_progressionMeasureOptionFromRow).toList(); } @override Future readExerciseSeries({ required ProgressionDateRange range, required String exerciseKey, required ProgressionMeasure measure, }) async { final measureOption = (await listMeasureOptions( range: range, exerciseKey: exerciseKey, )).where((option) => option.measure == measure).firstOrNull ?? _defaultProgressionMeasureOption(measure); final points = await _readExercisePoints( range: range, exerciseKey: exerciseKey, measure: measure, ); final allTimePoints = range.startedAt == null ? points : await _readExercisePoints( range: ProgressionDateRange( startedAt: null, endedAt: range.endedAt, ), exerciseKey: exerciseKey, measure: measure, ); final exerciseName = points.isNotEmpty ? points.last.exerciseNameSnapshot : allTimePoints.isNotEmpty ? allTimePoints.last.exerciseNameSnapshot : exerciseKey; final hasAnyCompletedExerciseResult = await _hasAnyCompletedExerciseResult( range: range, exerciseKey: exerciseKey, ); return ProgressionExerciseSeriesData( exerciseKey: exerciseKey, exerciseNameSnapshot: exerciseName, measure: measureOption, points: [ for (final point in points) ProgressionPointData( workoutHistoryId: point.workoutHistoryId, workoutNameSnapshot: point.workoutNameSnapshot, startedAt: point.startedAt, value: point.value, rawValue: point.rawValue, ), ], hasAnyAllTimeData: allTimePoints.isNotEmpty, hasAnyCompletedExerciseResult: hasAnyCompletedExerciseResult, ); } Future> _readExercisePoints({ required ProgressionDateRange range, required String exerciseKey, required ProgressionMeasure measure, }) async { final aggregation = _progressionAggregationSql(measure); final rows = await database .customSelect( ''' WITH result_projection AS ( ${_progressionResultProjection(range)} ) SELECT workout_history_id, workout_name_snapshot, MAX(started_at) AS started_at, MAX(exercise_name_snapshot) AS exercise_name_snapshot, ${aggregation.expression} AS raw_value FROM result_projection WHERE exercise_key = ? AND ${aggregation.predicate} GROUP BY workout_history_id, workout_name_snapshot ORDER BY started_at ASC ''', variables: [ ..._projectionVariables(range), Variable(exerciseKey), ], ) .get(); return rows.map((row) => _progressionPointRow(row, measure)).toList(); } Future _hasAnyCompletedExerciseResult({ required ProgressionDateRange range, required String exerciseKey, }) async { final row = await database .customSelect( ''' WITH result_projection AS ( ${_progressionResultProjection(range)} ) SELECT 1 FROM result_projection WHERE exercise_key = ? LIMIT 1 ''', variables: [ ..._projectionVariables(range), Variable(exerciseKey), ], ) .getSingleOrNull(); return row != null; } } List> _rangeVariables(ProgressionDateRange range) { return [ Variable(range.endedAt.toUtc()), if (range.startedAt != null) Variable(range.startedAt!.toUtc()), ]; } List> _projectionVariables(ProgressionDateRange range) { final variables = _rangeVariables(range); return [...variables, ...variables]; } String _progressionResultProjection(ProgressionDateRange range) { final rangePredicate = 'history.started_at <= ? ' "${range.startedAt == null ? '' : 'AND history.started_at >= ?'}"; final sharedWhere = 'history.deleted_at IS NULL ' 'AND history.completed = 1 ' 'AND $rangePredicate'; return ''' SELECT history.id AS workout_history_id, history.name_snapshot AS workout_name_snapshot, history.started_at, COALESCE(result.source_exercise_id_snapshot, result.exercise_snapshot_id) AS exercise_key, result.source_exercise_id_snapshot, result.exercise_snapshot_id, result.exercise_name_snapshot, result.score_input_mode_snapshot, result.score_label_snapshot, result.score_unit_snapshot, result.actual_time_ms, result.actual_reps, result.actual_score, result.actual_score_time_ms, CASE WHEN result.actual_time_ms IS NOT NULL OR result.actual_reps IS NOT NULL OR result.actual_score IS NOT NULL OR result.actual_score_time_ms IS NOT NULL THEN 1 ELSE 0 END AS has_graphable_measure FROM workout_history_set_results AS result INNER JOIN workout_history AS history ON history.id = result.workout_history_id WHERE result.deleted_at IS NULL AND result.status = 'completed' AND $sharedWhere UNION ALL SELECT history.id AS workout_history_id, history.name_snapshot AS workout_name_snapshot, history.started_at, COALESCE(step.source_exercise_id_snapshot, step.exercise_snapshot_id) AS exercise_key, step.source_exercise_id_snapshot, step.exercise_snapshot_id, COALESCE(parent.exercise_name_snapshot, step.step_name_snapshot) AS exercise_name_snapshot, step.score_input_mode_snapshot, step.score_label_snapshot, step.score_unit_snapshot, step.actual_time_ms, step.actual_reps, step.actual_score, step.actual_score_time_ms, CASE WHEN step.actual_time_ms IS NOT NULL OR step.actual_reps IS NOT NULL OR step.actual_score IS NOT NULL OR step.actual_score_time_ms IS NOT NULL THEN 1 ELSE 0 END AS has_graphable_measure FROM workout_history_step_results AS step INNER JOIN workout_history AS history ON history.id = step.workout_history_id LEFT JOIN workout_history_set_results AS parent ON parent.workout_history_id = step.workout_history_id AND parent.program_index = step.program_index AND parent.exercise_index = step.exercise_index AND parent.set_index = step.set_index AND parent.deleted_at IS NULL WHERE step.deleted_at IS NULL AND step.status = 'completed' AND $sharedWhere '''; } ProgressionMeasureOptionData _progressionMeasureOptionFromRow(QueryRow row) { final measure = _progressionMeasureFromDb(row.data['measure'] as String); final defaults = _defaultProgressionMeasureOption(measure); return ProgressionMeasureOptionData( measure: measure, label: defaults.label, scoreLabel: row.data['score_label'] as String?, scoreUnit: row.data['score_unit'] as String?, lowerIsBetter: defaults.lowerIsBetter, ); } ProgressionMeasure _progressionMeasureFromDb(String value) { switch (value) { case 'manualScore': return ProgressionMeasure.manualScore; case 'stopwatchScore': return ProgressionMeasure.stopwatchScore; case 'reps': return ProgressionMeasure.reps; case 'time': return ProgressionMeasure.time; } throw domain.DomainException('Unknown progression measure: $value'); } ProgressionMeasureOptionData _defaultProgressionMeasureOption( ProgressionMeasure measure, ) { switch (measure) { case ProgressionMeasure.manualScore: return const ProgressionMeasureOptionData( measure: ProgressionMeasure.manualScore, label: 'Score', lowerIsBetter: false, ); case ProgressionMeasure.stopwatchScore: return const ProgressionMeasureOptionData( measure: ProgressionMeasure.stopwatchScore, label: 'Temps réalisé', lowerIsBetter: true, ); case ProgressionMeasure.reps: return const ProgressionMeasureOptionData( measure: ProgressionMeasure.reps, label: 'Répétitions', lowerIsBetter: false, ); case ProgressionMeasure.time: return const ProgressionMeasureOptionData( measure: ProgressionMeasure.time, label: 'Temps', lowerIsBetter: false, ); } } _ProgressionAggregationSql _progressionAggregationSql( ProgressionMeasure measure, ) { switch (measure) { case ProgressionMeasure.manualScore: return const _ProgressionAggregationSql( expression: 'MAX(actual_score)', predicate: "score_input_mode_snapshot = 'manual' AND actual_score IS NOT NULL", ); case ProgressionMeasure.stopwatchScore: return const _ProgressionAggregationSql( expression: 'MIN(actual_score_time_ms)', predicate: "score_input_mode_snapshot = 'stopwatch' " 'AND actual_score_time_ms IS NOT NULL', ); case ProgressionMeasure.reps: return const _ProgressionAggregationSql( expression: 'SUM(actual_reps)', predicate: 'actual_reps IS NOT NULL', ); case ProgressionMeasure.time: return const _ProgressionAggregationSql( expression: 'SUM(actual_time_ms)', predicate: 'actual_time_ms IS NOT NULL', ); } } _ProgressionPointRow _progressionPointRow( QueryRow row, ProgressionMeasure measure, ) { final raw = row.data['raw_value'] as num; final rawValue = measure == ProgressionMeasure.manualScore ? raw.toDouble() : raw.toInt(); return _ProgressionPointRow( workoutHistoryId: row.data['workout_history_id'] as String, workoutNameSnapshot: row.data['workout_name_snapshot'] as String, exerciseNameSnapshot: row.data['exercise_name_snapshot'] as String, startedAt: _dateTimeFromData(row.data, 'started_at'), value: raw.toDouble(), rawValue: rawValue, ); } DateTime _startOfLocalWeekFromUtc(DateTime value) { final local = value.toLocal(); final localDay = DateTime(local.year, local.month, local.day); return localDay.subtract(Duration(days: localDay.weekday - DateTime.monday)); } final class _ProgressionAggregationSql { const _ProgressionAggregationSql({ required this.expression, required this.predicate, }); final String expression; final String predicate; } final class _ProgressionPointRow { const _ProgressionPointRow({ required this.workoutHistoryId, required this.workoutNameSnapshot, required this.exerciseNameSnapshot, required this.startedAt, required this.value, required this.rawValue, }); final String workoutHistoryId; final String workoutNameSnapshot; final String exerciseNameSnapshot; final DateTime startedAt; final double value; final Object rawValue; } Future _upsertWithChangeLog({ required db.AppDatabase database, required String tableName, required String entityType, required domain.EntityMetadata metadata, required Future Function() write, }) async { await database.transaction(() async { final operation = await _operationForMutation( database: database, tableName: tableName, entityId: metadata.id, deletedAt: metadata.deletedAt, ); await write(); await _writeChangeLog( database: database, entityType: entityType, entityId: metadata.id, operation: operation, localRevision: metadata.localRevision, originDeviceId: metadata.originDeviceId, createdAt: metadata.updatedAt, ); }); } Future _replaceExerciseImages( db.AppDatabase database, domain.Exercise exercise, ) async { final rows = await (database.select( database.exerciseImages, )..where((table) => table.exerciseId.equals(exercise.metadata.id))).get(); final activeRowsByMediaId = { for (final row in rows) if (row.deletedAt == null) row.mediaAssetId: row, }; final desiredIds = exercise.imageMediaIds.toSet(); final removedRows = activeRowsByMediaId.values .where((row) => !desiredIds.contains(row.mediaAssetId)) .toList(); await _softDeleteExerciseImageRows( database, removedRows, exercise.metadata.updatedAt, ); for (var index = 0; index < exercise.imageMediaIds.length; index++) { final mediaId = exercise.imageMediaIds[index]; final existing = activeRowsByMediaId[mediaId]; final metadata = existing == null ? domain.EntityMetadata( id: _exerciseImageId(exercise.metadata.id, mediaId), createdAt: exercise.metadata.updatedAt, updatedAt: exercise.metadata.updatedAt, originDeviceId: exercise.metadata.originDeviceId, ) : _metadataFromRow(existing).touch(exercise.metadata.updatedAt); await _upsertWithChangeLog( database: database, tableName: 'exercise_images', entityType: 'ExerciseImage', metadata: metadata, write: () => database .into(database.exerciseImages) .insertOnConflictUpdate( db.ExerciseImagesCompanion( id: Value(metadata.id), createdAt: Value(metadata.createdAt.toUtc()), updatedAt: Value(metadata.updatedAt.toUtc()), deletedAt: Value(_utcOrNull(metadata.deletedAt)), schemaVersion: Value(metadata.schemaVersion), syncState: Value(_syncStateToDb(metadata.syncState)), localRevision: Value(metadata.localRevision), originDeviceId: Value(metadata.originDeviceId), futureOwnerProfileId: Value(metadata.futureOwnerProfileId), lastSyncedAt: Value(_utcOrNull(metadata.lastSyncedAt)), remoteRevision: Value(metadata.remoteRevision), exerciseId: Value(exercise.metadata.id), mediaAssetId: Value(mediaId), position: Value(index), ), ), ); } } Future _replaceExerciseSteps( db.AppDatabase database, domain.Exercise exercise, ) async { final rows = await (database.select( database.exerciseSteps, )..where((table) => table.exerciseId.equals(exercise.metadata.id))).get(); final activeRowsByStepId = { for (final row in rows) if (row.deletedAt == null) row.id: row, }; final desiredIds = exercise.steps.map((step) => step.id).toSet(); final removedRows = activeRowsByStepId.values .where((row) => !desiredIds.contains(row.id)) .toList(); await _softDeleteExerciseStepRows( database, removedRows, exercise.metadata.updatedAt, ); for (final step in exercise.steps) { final existing = activeRowsByStepId[step.id]; final metadata = existing == null ? domain.EntityMetadata( id: step.id, createdAt: exercise.metadata.updatedAt, updatedAt: exercise.metadata.updatedAt, originDeviceId: exercise.metadata.originDeviceId, ) : _metadataFromRow(existing).touch(exercise.metadata.updatedAt); await _upsertWithChangeLog( database: database, tableName: 'exercise_steps', entityType: 'ExerciseStep', metadata: metadata, write: () => database .into(database.exerciseSteps) .insertOnConflictUpdate( db.ExerciseStepsCompanion( id: Value(metadata.id), createdAt: Value(metadata.createdAt.toUtc()), updatedAt: Value(metadata.updatedAt.toUtc()), deletedAt: Value(_utcOrNull(metadata.deletedAt)), schemaVersion: Value(metadata.schemaVersion), syncState: Value(_syncStateToDb(metadata.syncState)), localRevision: Value(metadata.localRevision), originDeviceId: Value(metadata.originDeviceId), futureOwnerProfileId: Value(metadata.futureOwnerProfileId), lastSyncedAt: Value(_utcOrNull(metadata.lastSyncedAt)), remoteRevision: Value(metadata.remoteRevision), exerciseId: Value(exercise.metadata.id), position: Value(step.position), name: Value(step.name), type: Value(_exerciseStepTypeToDb(step.type)), defaultTargetValue: Value(step.defaultTargetValue), hasScore: Value(step.hasScore), scoreInputMode: Value( step.hasScore ? _scoreInputModeToDb(step.scoreInputMode) : null, ), scoreLabel: Value(step.scoreLabel), scoreUnit: Value(step.scoreUnit), defaultTargetScore: Value(step.defaultTargetScore), defaultTargetScoreTimeMs: Value(step.defaultTargetScoreTimeMs), linkedToSeriesScore: Value(step.linkedToSeriesScore), ), ), ); } } Future _softDeleteExerciseImageRows( db.AppDatabase database, List rows, DateTime deletedAt, ) async { for (final row in rows) { final revision = row.localRevision + 1; await (database.update( database.exerciseImages, )..where((table) => table.id.equals(row.id))).write( db.ExerciseImagesCompanion( deletedAt: Value(deletedAt.toUtc()), updatedAt: Value(deletedAt.toUtc()), localRevision: Value(revision), syncState: const Value('deleted'), ), ); await _writeChangeLog( database: database, entityType: 'ExerciseImage', entityId: row.id, operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, ); } } Future _softDeleteExerciseStepRows( db.AppDatabase database, List rows, DateTime deletedAt, ) async { for (final row in rows) { final revision = row.localRevision + 1; await (database.update( database.exerciseSteps, )..where((table) => table.id.equals(row.id))).write( db.ExerciseStepsCompanion( deletedAt: Value(deletedAt.toUtc()), updatedAt: Value(deletedAt.toUtc()), localRevision: Value(revision), syncState: const Value('deleted'), ), ); await _writeChangeLog( database: database, entityType: 'ExerciseStep', entityId: row.id, operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, ); } } String _exerciseImageId(String exerciseId, String mediaId) { return 'exercise-image:$exerciseId:$mediaId'; } final class _ExerciseStarterMetadata { const _ExerciseStarterMetadata({ this.category = domain.ExerciseCategory.uncategorized, this.isExample = false, }); final domain.ExerciseCategory category; final bool isExample; } Future _writeExerciseStarterMetadata( db.AppDatabase database, domain.Exercise exercise, ) async { await database.customStatement( 'UPDATE exercises SET category = ?, is_example = ? WHERE id = ?', [ _exerciseCategoryToDb(exercise.category), exercise.isExample ? 1 : 0, exercise.metadata.id, ], ); } Future _writeProgramStarterMetadata( db.AppDatabase database, domain.Program program, ) async { await database.customStatement( 'UPDATE programs SET is_example = ? WHERE id = ?', [program.isExample ? 1 : 0, program.metadata.id], ); } Future _writeWorkoutTemplateStarterMetadata( db.AppDatabase database, domain.WorkoutTemplate template, ) async { await database.customStatement( 'UPDATE workout_templates SET is_example = ? WHERE id = ?', [template.isExample ? 1 : 0, template.metadata.id], ); } Future _operationForMutation({ required db.AppDatabase database, required String tableName, required String entityId, required DateTime? deletedAt, }) async { final existing = await database .customSelect( 'SELECT deleted_at FROM $tableName WHERE id = ? LIMIT 1', variables: [Variable(entityId)], ) .getSingleOrNull(); if (existing == null) { return 'insert'; } final wasDeleted = existing.data['deleted_at'] != null; if (deletedAt != null) { return 'softDelete'; } if (wasDeleted) { return 'restore'; } return 'update'; } Future _writeChangeLog({ required db.AppDatabase database, required String entityType, required String entityId, required String operation, required int localRevision, required String originDeviceId, required DateTime createdAt, }) async { final id = [ entityType, entityId, operation, localRevision, createdAt.microsecondsSinceEpoch, ].join(':'); await database .into(database.changeLogEntries) .insertOnConflictUpdate( db.ChangeLogEntriesCompanion.insert( id: id, entityType: entityType, entityId: entityId, operation: operation, localRevision: localRevision, originDeviceId: originDeviceId, createdAt: createdAt.toUtc(), ), ); } Future _softDeleteProgramExerciseRows( db.AppDatabase database, List rows, DateTime deletedAt, ) async { for (final row in rows) { final revision = row.localRevision + 1; await (database.update( database.programExercises, )..where((table) => table.id.equals(row.id))).write( db.ProgramExercisesCompanion( deletedAt: Value(deletedAt.toUtc()), updatedAt: Value(deletedAt.toUtc()), localRevision: Value(revision), syncState: const Value('deleted'), ), ); await _writeChangeLog( database: database, entityType: 'ProgramExercise', entityId: row.id, operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, ); } } Future _softDeleteWorkoutTemplateProgramRows( db.AppDatabase database, List rows, DateTime deletedAt, ) async { for (final row in rows) { final revision = row.localRevision + 1; await (database.update( database.workoutTemplatePrograms, )..where((table) => table.id.equals(row.id))).write( db.WorkoutTemplateProgramsCompanion( deletedAt: Value(deletedAt.toUtc()), updatedAt: Value(deletedAt.toUtc()), localRevision: Value(revision), syncState: const Value('deleted'), ), ); await _writeChangeLog( database: database, entityType: 'WorkoutTemplateProgram', entityId: row.id, operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, ); } } Future _softDeleteWorkoutTemplateExerciseOverrideRows( db.AppDatabase database, List rows, DateTime deletedAt, ) async { for (final row in rows) { final revision = row.localRevision + 1; await (database.update( database.workoutTemplateExerciseOverrides, )..where((table) => table.id.equals(row.id))).write( db.WorkoutTemplateExerciseOverridesCompanion( deletedAt: Value(deletedAt.toUtc()), updatedAt: Value(deletedAt.toUtc()), localRevision: Value(revision), syncState: const Value('deleted'), ), ); await _writeChangeLog( database: database, entityType: 'WorkoutTemplateExerciseOverride', entityId: row.id, operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, ); } } Future _softDeleteWorkoutHistorySetResultRows( db.AppDatabase database, List rows, DateTime deletedAt, ) async { for (final row in rows) { final revision = row.localRevision + 1; await (database.update( database.workoutHistorySetResults, )..where((table) => table.id.equals(row.id))).write( db.WorkoutHistorySetResultsCompanion( deletedAt: Value(deletedAt.toUtc()), updatedAt: Value(deletedAt.toUtc()), localRevision: Value(revision), syncState: const Value('deleted'), ), ); await _writeChangeLog( database: database, entityType: 'WorkoutHistorySetResult', entityId: row.id, operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, ); } } Future _softDeleteWorkoutHistoryStepResultRows( db.AppDatabase database, List rows, DateTime deletedAt, ) async { for (final row in rows) { final revision = row.localRevision + 1; await (database.update( database.workoutHistoryStepResults, )..where((table) => table.id.equals(row.id))).write( db.WorkoutHistoryStepResultsCompanion( deletedAt: Value(deletedAt.toUtc()), updatedAt: Value(deletedAt.toUtc()), localRevision: Value(revision), syncState: const Value('deleted'), ), ); await _writeChangeLog( database: database, entityType: 'WorkoutHistoryStepResult', entityId: row.id, operation: 'softDelete', localRevision: revision, originDeviceId: row.originDeviceId, createdAt: deletedAt, ); } } DateTime _utc(DateTime value) => value.toUtc(); DateTime? _utcOrNull(DateTime? value) => value?.toUtc(); domain.EntityMetadata _metadataFromRow(dynamic row) { return domain.EntityMetadata( id: row.id as String, createdAt: _utc(row.createdAt as DateTime), updatedAt: _utc(row.updatedAt as DateTime), deletedAt: _utcOrNull(row.deletedAt as DateTime?), schemaVersion: row.schemaVersion as int, syncState: _syncStateFromDb(row.syncState as String), localRevision: row.localRevision as int, originDeviceId: row.originDeviceId as String, futureOwnerProfileId: row.futureOwnerProfileId as String?, lastSyncedAt: _utcOrNull(row.lastSyncedAt as DateTime?), remoteRevision: row.remoteRevision as String?, ); } domain.EntityMetadata _metadataFromData(Map data) { return domain.EntityMetadata( id: data['id'] as String, createdAt: _dateTimeFromData(data, 'created_at'), updatedAt: _dateTimeFromData(data, 'updated_at'), deletedAt: _dateTimeOrNullFromData(data, 'deleted_at'), schemaVersion: data['schema_version'] as int, syncState: _syncStateFromDb(data['sync_state'] as String), localRevision: data['local_revision'] as int, originDeviceId: data['origin_device_id'] as String, futureOwnerProfileId: data['future_owner_profile_id'] as String?, lastSyncedAt: _dateTimeOrNullFromData(data, 'last_synced_at'), remoteRevision: data['remote_revision'] as String?, ); } DateTime _dateTimeFromData(Map data, String key) { final value = data[key]; if (value is DateTime) { return value.toUtc(); } if (value is int) { if (value.abs() < 100000000000) { return DateTime.fromMillisecondsSinceEpoch(value * 1000, isUtc: true); } return DateTime.fromMillisecondsSinceEpoch(value, isUtc: true); } throw domain.DomainException('Invalid timestamp column: $key'); } DateTime? _dateTimeOrNullFromData(Map data, String key) { final value = data[key]; if (value == null) { return null; } if (value is DateTime) { return value.toUtc(); } if (value is int) { if (value.abs() < 100000000000) { return DateTime.fromMillisecondsSinceEpoch(value * 1000, isUtc: true); } return DateTime.fromMillisecondsSinceEpoch(value, isUtc: true); } throw domain.DomainException('Invalid timestamp column: $key'); } List _metadataValues(domain.EntityMetadata metadata) => [ Value(metadata.id), Value(metadata.createdAt.toUtc()), Value(metadata.updatedAt.toUtc()), Value(_utcOrNull(metadata.deletedAt)), Value(metadata.schemaVersion), Value(_syncStateToDb(metadata.syncState)), Value(metadata.localRevision), Value(metadata.originDeviceId), Value(metadata.futureOwnerProfileId), Value(_utcOrNull(metadata.lastSyncedAt)), Value(metadata.remoteRevision), ]; db.ExercisesCompanion _exerciseCompanion(domain.Exercise exercise) { final values = _metadataValues(exercise.metadata); return db.ExercisesCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, name: Value(exercise.name), description: Value(exercise.description), iconMediaId: Value(exercise.iconMediaId), videoMediaId: Value(exercise.videoMediaId), hasTimeMeasure: Value(exercise.hasTimeMeasure), hasRepsMeasure: Value(exercise.hasRepsMeasure), hasScoreMeasure: Value(exercise.hasScoreMeasure), scoreInputMode: Value(_scoreInputModeToDb(exercise.scoreInputMode)), scoreLabel: Value(exercise.scoreLabel), scoreUnit: Value(exercise.scoreUnit), defaultTargetTimeSeconds: Value(exercise.defaultTargetTimeSeconds), defaultTargetReps: Value(exercise.defaultTargetReps), defaultTargetScore: Value(exercise.defaultTargetScore), defaultTargetScoreTimeMs: Value(exercise.defaultTargetScoreTimeMs), autoStartNextTimedStep: Value(exercise.autoStartNextTimedStep), tagsJson: Value(_encodeTags(exercise.tags)), archivedAt: Value(_utcOrNull(exercise.archivedAt)), ); } domain.Exercise _exerciseFromRow( db.Exercise row, List imageMediaIds, List steps, _ExerciseStarterMetadata starterMetadata, ) { return domain.Exercise( metadata: _metadataFromRow(row), name: row.name, description: row.description, imageMediaIds: imageMediaIds, iconMediaId: row.iconMediaId, videoMediaId: row.videoMediaId, hasTimeMeasure: row.hasTimeMeasure, hasRepsMeasure: row.hasRepsMeasure, hasScoreMeasure: row.hasScoreMeasure, scoreInputMode: _scoreInputModeFromDb(row.scoreInputMode), scoreLabel: row.scoreLabel, scoreUnit: row.scoreUnit, defaultTargetTimeSeconds: row.defaultTargetTimeSeconds, defaultTargetReps: row.defaultTargetReps, defaultTargetScore: row.defaultTargetScore, defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs, autoStartNextTimedStep: row.autoStartNextTimedStep, category: starterMetadata.category, isExample: starterMetadata.isExample, tags: _decodeTags(row.tagsJson), steps: steps, archivedAt: _utcOrNull(row.archivedAt), ); } domain.ExerciseStep _exerciseStepFromRow(db.ExerciseStep row) { return domain.ExerciseStep( id: row.id, position: row.position, name: row.name, type: _exerciseStepTypeFromDb(row.type), defaultTargetValue: row.defaultTargetValue, hasScore: row.hasScore, scoreInputMode: row.scoreInputMode == null ? domain.ScoreInputMode.manual : _scoreInputModeFromDb(row.scoreInputMode!), scoreLabel: row.scoreLabel, scoreUnit: row.scoreUnit, defaultTargetScore: row.defaultTargetScore, defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs, linkedToSeriesScore: row.linkedToSeriesScore, ); } db.MediaAssetsCompanion _mediaAssetCompanion(domain.MediaAsset asset) { final values = _metadataValues(asset.metadata); return db.MediaAssetsCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, kind: Value(_mediaKindToDb(asset.kind)), localUri: Value(asset.localUri), mimeType: Value(asset.mimeType), sizeBytes: Value(asset.sizeBytes), width: Value(asset.width), height: Value(asset.height), durationMs: Value(asset.durationMs), checksum: Value(asset.checksum), remoteUri: Value(asset.remoteUri), thumbnailLocalUri: Value(asset.thumbnailLocalUri), ); } domain.MediaAsset _mediaAssetFromRow(db.MediaAsset row) { return domain.MediaAsset( metadata: _metadataFromRow(row), kind: _mediaKindFromDb(row.kind), localUri: row.localUri, mimeType: row.mimeType, sizeBytes: row.sizeBytes, width: row.width, height: row.height, durationMs: row.durationMs, checksum: row.checksum, remoteUri: row.remoteUri, thumbnailLocalUri: row.thumbnailLocalUri, ); } db.OnlineAccountSessionsCompanion _userAccountSessionCompanion( domain.UserAccountSession session, ) { return db.OnlineAccountSessionsCompanion( id: Value(session.id), serverUserId: Value(session.serverUserId), email: Value(session.email), displayName: Value(session.displayName), isLoggedIn: Value(session.isLoggedIn), createdAt: Value(session.createdAt.toUtc()), updatedAt: Value(session.updatedAt.toUtc()), lastAuthenticatedAt: Value( _utcOrNull(session.lastAuthenticatedAt), ), ); } domain.UserAccountSession _userAccountSessionFromRow( db.OnlineAccountSession row, ) { return domain.UserAccountSession( id: row.id, serverUserId: row.serverUserId, email: row.email, displayName: row.displayName, isLoggedIn: row.isLoggedIn, createdAt: _utc(row.createdAt), updatedAt: _utc(row.updatedAt), lastAuthenticatedAt: _utcOrNull(row.lastAuthenticatedAt), ); } db.ShareInboxItemsCompanion _shareInboxItemCompanion( domain.ShareInboxItem item, ) { final updatedAt = item.respondedAt ?? item.createdAt; return db.ShareInboxItemsCompanion.insert( shareId: item.shareId, senderUserId: item.senderUserId, resourceType: _shareResourceTypeToDb(item.resourceType), payloadJson: item.payloadJson, status: _shareInboxStatusToDb(item.status), createdAt: item.createdAt.toUtc(), updatedAt: updatedAt.toUtc(), respondedAt: Value(_utcOrNull(item.respondedAt)), ); } domain.ShareInboxItem _shareInboxItemFromRow(db.ShareInboxItem row) { return domain.ShareInboxItem( shareId: row.shareId, senderUserId: row.senderUserId, resourceType: _shareResourceTypeFromDb(row.resourceType), payloadJson: row.payloadJson, status: _shareInboxStatusFromDb(row.status), createdAt: _utc(row.createdAt), respondedAt: _utcOrNull(row.respondedAt), ); } db.PendingShareActionsCompanion _pendingShareActionCompanion( domain.PendingShareAction action, ) { return db.PendingShareActionsCompanion.insert( id: action.id, actionType: _pendingShareActionTypeToDb(action.actionType), shareId: Value(action.shareId), resourceType: Value( action.resourceType == null ? null : _shareResourceTypeToDb(action.resourceType!), ), payloadJson: Value(action.payloadJson), recipientEmailsJson: Value(action.recipientEmailsJson), createdAt: action.createdAt.toUtc(), lastAttemptAt: Value(_utcOrNull(action.lastAttemptAt)), attemptCount: action.attemptCount, status: _pendingShareActionStatusToDb(action.status), ); } domain.PendingShareAction _pendingShareActionFromRow( db.PendingShareAction row, ) { return domain.PendingShareAction( id: row.id, actionType: _pendingShareActionTypeFromDb(row.actionType), shareId: row.shareId, resourceType: row.resourceType == null ? null : _shareResourceTypeFromDb(row.resourceType!), payloadJson: row.payloadJson, recipientEmailsJson: row.recipientEmailsJson, createdAt: _utc(row.createdAt), lastAttemptAt: _utcOrNull(row.lastAttemptAt), attemptCount: row.attemptCount, status: _pendingShareActionStatusFromDb(row.status), ); } db.ProgramsCompanion _programCompanion(domain.Program program) { final values = _metadataValues(program.metadata); return db.ProgramsCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, name: Value(program.name), defaultRestSeconds: Value(program.defaultRestSeconds), tagsJson: Value(_encodeTags(program.tags)), ); } domain.Program _programFromRow( db.Program row, List exercises, { bool isExample = false, }) { return domain.Program( metadata: _metadataFromRow(row), name: row.name, defaultRestSeconds: row.defaultRestSeconds, isExample: isExample, tags: _decodeTags(row.tagsJson), exercises: exercises, ); } db.ProgramExercisesCompanion _programExerciseCompanion( domain.ProgramExercise exercise, ) { final values = _metadataValues(exercise.metadata); return db.ProgramExercisesCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, programId: Value(exercise.programId), sourceExerciseId: Value(exercise.sourceExerciseId), position: Value(exercise.position), exerciseNameSnapshot: Value(exercise.exerciseNameSnapshot), exerciseDescriptionSnapshot: Value(exercise.exerciseDescriptionSnapshot), exerciseImageMediaIdSnapshot: Value(exercise.exerciseImageMediaIdSnapshot), exerciseImageMediaIdsSnapshotJson: Value( _encodeImageMediaIdsSnapshot(exercise.exerciseImageMediaIdsSnapshot), ), exerciseStepsSnapshotJson: Value( _encodeExerciseStepsSnapshot(exercise.exerciseStepsSnapshot), ), autoStartNextTimedStepSnapshot: Value( exercise.autoStartNextTimedStepSnapshot, ), autoStartNextTimedStepOverride: Value( exercise.autoStartNextTimedStepOverride, ), exerciseVideoMediaIdSnapshot: Value(exercise.exerciseVideoMediaIdSnapshot), exerciseArchivedSnapshot: Value(exercise.exerciseArchivedSnapshot), availableTimeSnapshot: Value(exercise.availableTimeSnapshot), availableRepsSnapshot: Value(exercise.availableRepsSnapshot), availableScoreSnapshot: Value(exercise.availableScoreSnapshot), scoreInputModeSnapshot: Value( _scoreInputModeToDb(exercise.scoreInputModeSnapshot), ), scoreLabelSnapshot: Value(exercise.scoreLabelSnapshot), scoreUnitSnapshot: Value(exercise.scoreUnitSnapshot), setsCount: Value(exercise.setsCount), timeEnabled: Value(exercise.timeEnabled), repsEnabled: Value(exercise.repsEnabled), scoreEnabled: Value(exercise.scoreEnabled), targetTimeSeconds: Value(exercise.targetTimeSeconds), targetReps: Value(exercise.targetReps), targetScore: Value(exercise.targetScore), targetScoreTimeMs: Value(exercise.targetScoreTimeMs), restSecondsOverride: Value(exercise.restSecondsOverride), ); } domain.ProgramExercise _programExerciseFromRow(db.ProgramExercise row) { return domain.ProgramExercise( metadata: _metadataFromRow(row), programId: row.programId, sourceExerciseId: row.sourceExerciseId, position: row.position, exerciseNameSnapshot: row.exerciseNameSnapshot, exerciseDescriptionSnapshot: row.exerciseDescriptionSnapshot, exerciseImageMediaIdSnapshot: row.exerciseImageMediaIdSnapshot, exerciseImageMediaIdsSnapshot: _decodeImageMediaIdsSnapshot( row.exerciseImageMediaIdsSnapshotJson, row.exerciseImageMediaIdSnapshot, ), exerciseStepsSnapshot: _decodeExerciseStepsSnapshot( row.exerciseStepsSnapshotJson, ), autoStartNextTimedStepSnapshot: row.autoStartNextTimedStepSnapshot, autoStartNextTimedStepOverride: row.autoStartNextTimedStepOverride, exerciseVideoMediaIdSnapshot: row.exerciseVideoMediaIdSnapshot, exerciseArchivedSnapshot: row.exerciseArchivedSnapshot, availableTimeSnapshot: row.availableTimeSnapshot, availableRepsSnapshot: row.availableRepsSnapshot, availableScoreSnapshot: row.availableScoreSnapshot, scoreInputModeSnapshot: _scoreInputModeFromDb(row.scoreInputModeSnapshot), scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, setsCount: row.setsCount, timeEnabled: row.timeEnabled, repsEnabled: row.repsEnabled, scoreEnabled: row.scoreEnabled, targetTimeSeconds: row.targetTimeSeconds, targetReps: row.targetReps, targetScore: row.targetScore, targetScoreTimeMs: row.targetScoreTimeMs, restSecondsOverride: row.restSecondsOverride, ); } db.WorkoutTemplatesCompanion _workoutTemplateCompanion( domain.WorkoutTemplate template, ) { final values = _metadataValues(template.metadata); return db.WorkoutTemplatesCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, name: Value(template.name), lastStartedAt: Value(_utcOrNull(template.lastStartedAt)), tagsJson: Value(_encodeTags(template.tags)), ); } domain.WorkoutTemplate _workoutTemplateFromRow( db.WorkoutTemplate row, List programs, List overrides, { bool isExample = false, }) { return domain.WorkoutTemplate( metadata: _metadataFromRow(row), name: row.name, lastStartedAt: _utcOrNull(row.lastStartedAt), isExample: isExample, tags: _decodeTags(row.tagsJson), programs: programs, overrides: overrides, ); } db.WorkoutTemplateProgramsCompanion _workoutTemplateProgramCompanion( domain.WorkoutTemplateProgram program, ) { final values = _metadataValues(program.metadata); return db.WorkoutTemplateProgramsCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, workoutTemplateId: Value(program.workoutTemplateId), sourceProgramId: Value(program.sourceProgramId), position: Value(program.position), programNameSnapshot: Value(program.programNameSnapshot), defaultRestSecondsSnapshot: Value(program.defaultRestSecondsSnapshot), programSnapshotJson: Value(program.programSnapshotJson), ); } domain.WorkoutTemplateProgram _workoutTemplateProgramFromRow( db.WorkoutTemplateProgram row, ) { return domain.WorkoutTemplateProgram( metadata: _metadataFromRow(row), workoutTemplateId: row.workoutTemplateId, sourceProgramId: row.sourceProgramId, position: row.position, programNameSnapshot: row.programNameSnapshot, defaultRestSecondsSnapshot: row.defaultRestSecondsSnapshot, programSnapshotJson: row.programSnapshotJson, ); } db.WorkoutTemplateExerciseOverridesCompanion _workoutTemplateExerciseOverrideCompanion( domain.WorkoutTemplateExerciseOverride override, ) { final values = _metadataValues(override.metadata); return db.WorkoutTemplateExerciseOverridesCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, workoutTemplateProgramId: Value(override.workoutTemplateProgramId), snapshotProgramExerciseId: Value(override.snapshotProgramExerciseId), setsCountOverride: Value(override.setsCountOverride), targetTimeSecondsOverride: Value(override.targetTimeSecondsOverride), targetRepsOverride: Value(override.targetRepsOverride), targetScoreOverride: Value(override.targetScoreOverride), targetScoreTimeMsOverride: Value(override.targetScoreTimeMsOverride), autoStartNextTimedStepOverride: Value( override.autoStartNextTimedStepOverride, ), ); } domain.WorkoutTemplateExerciseOverride _workoutTemplateExerciseOverrideFromRow( db.WorkoutTemplateExerciseOverride row, ) { return domain.WorkoutTemplateExerciseOverride( metadata: _metadataFromRow(row), workoutTemplateProgramId: row.workoutTemplateProgramId, snapshotProgramExerciseId: row.snapshotProgramExerciseId, setsCountOverride: row.setsCountOverride, targetTimeSecondsOverride: row.targetTimeSecondsOverride, targetRepsOverride: row.targetRepsOverride, targetScoreOverride: row.targetScoreOverride, targetScoreTimeMsOverride: row.targetScoreTimeMsOverride, autoStartNextTimedStepOverride: row.autoStartNextTimedStepOverride, ); } db.ActiveWorkoutSessionsCompanion _activeWorkoutSessionCompanion( domain.ActiveWorkoutSession session, ) { final values = _metadataValues(session.metadata); return db.ActiveWorkoutSessionsCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, sourceWorkoutTemplateId: Value(session.sourceWorkoutTemplateId), status: Value(_activeStatusToDb(session.status)), startedAt: Value(session.startedAt.toUtc()), pausedAt: Value(_utcOrNull(session.pausedAt)), endedAt: Value(_utcOrNull(session.endedAt)), lastPersistedAt: Value(session.lastPersistedAt.toUtc()), elapsedActiveMs: Value(session.elapsedActiveMs), currentProgramIndex: Value(session.currentProgramIndex), currentExerciseIndex: Value(session.currentExerciseIndex), currentSetIndex: Value(session.currentSetIndex), resolvedTemplateSnapshotJson: Value(session.resolvedTemplateSnapshotJson), ); } domain.ActiveWorkoutSession _activeWorkoutSessionFromRow( db.ActiveWorkoutSession row, ) { return domain.ActiveWorkoutSession( metadata: _metadataFromRow(row), sourceWorkoutTemplateId: row.sourceWorkoutTemplateId, status: _activeStatusFromDb(row.status), startedAt: _utc(row.startedAt), pausedAt: _utcOrNull(row.pausedAt), endedAt: _utcOrNull(row.endedAt), lastPersistedAt: _utc(row.lastPersistedAt), elapsedActiveMs: row.elapsedActiveMs, currentProgramIndex: row.currentProgramIndex, currentExerciseIndex: row.currentExerciseIndex, currentSetIndex: row.currentSetIndex, resolvedTemplateSnapshotJson: row.resolvedTemplateSnapshotJson, ); } db.ActiveSetResultsCompanion _activeSetResultCompanion( domain.ActiveSetResult result, ) { final values = _metadataValues(result.metadata); return db.ActiveSetResultsCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, activeWorkoutSessionId: Value(result.activeWorkoutSessionId), programSnapshotId: Value(result.programSnapshotId), exerciseSnapshotId: Value(result.exerciseSnapshotId), programIndex: Value(result.programIndex), exerciseIndex: Value(result.exerciseIndex), setIndex: Value(result.setIndex), startedAt: Value(_utcOrNull(result.startedAt)), completedAt: Value(_utcOrNull(result.completedAt)), actualTimeMs: Value(result.actualTimeMs), actualReps: Value(result.actualReps), actualScore: Value(result.actualScore), actualScoreTimeMs: Value(result.actualScoreTimeMs), scoreInputModeSnapshot: Value( _scoreInputModeToDb(result.scoreInputModeSnapshot), ), scoreLabelSnapshot: Value(result.scoreLabelSnapshot), scoreUnitSnapshot: Value(result.scoreUnitSnapshot), note: Value(result.note), status: Value(_setResultStatusToDb(result.status)), ); } domain.ActiveSetResult _activeSetResultFromRow(db.ActiveSetResult row) { return domain.ActiveSetResult( metadata: _metadataFromRow(row), activeWorkoutSessionId: row.activeWorkoutSessionId, programSnapshotId: row.programSnapshotId, exerciseSnapshotId: row.exerciseSnapshotId, programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, startedAt: _utcOrNull(row.startedAt), completedAt: _utcOrNull(row.completedAt), actualTimeMs: row.actualTimeMs, actualReps: row.actualReps, actualScore: row.actualScore, actualScoreTimeMs: row.actualScoreTimeMs, scoreInputModeSnapshot: _scoreInputModeFromDb(row.scoreInputModeSnapshot), scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, note: row.note, status: _setResultStatusFromDb(row.status), ); } db.ActiveRestStatesCompanion _activeRestStateCompanion( domain.ActiveRestState restState, ) { final values = _metadataValues(restState.metadata); return db.ActiveRestStatesCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, activeWorkoutSessionId: Value(restState.activeWorkoutSessionId), afterProgramIndex: Value(restState.afterProgramIndex), afterExerciseIndex: Value(restState.afterExerciseIndex), afterSetIndex: Value(restState.afterSetIndex), plannedRestSeconds: Value(restState.plannedRestSeconds), adjustedRestSeconds: Value(restState.adjustedRestSeconds), startedAt: Value(restState.startedAt.toUtc()), endedAt: Value(_utcOrNull(restState.endedAt)), skippedAt: Value(_utcOrNull(restState.skippedAt)), ); } Future _updateActiveRestPauseFields( db.AppDatabase database, domain.ActiveRestState restState, ) async { await database.customUpdate( 'UPDATE active_rest_states SET paused_at = ?, accumulated_paused_ms = ? ' 'WHERE id = ?', variables: [ Variable(_utcOrNull(restState.pausedAt)), Variable(restState.accumulatedPausedMs), Variable(restState.metadata.id), ], ); } Future _upsertActiveSetTimerState( db.AppDatabase database, domain.ActiveSetTimerState state, ) async { final metadata = state.metadata; await database.customInsert( 'INSERT INTO active_set_timer_states (' 'id, created_at, updated_at, deleted_at, schema_version, sync_state, ' 'local_revision, origin_device_id, future_owner_profile_id, ' 'last_synced_at, remote_revision, active_workout_session_id, ' 'program_index, exercise_index, set_index, status, started_at, ' 'accumulated_ms, stopped_at, skipped_at' ') VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' 'ON CONFLICT(active_workout_session_id, program_index, exercise_index, ' 'set_index) DO UPDATE SET ' 'id = excluded.id, ' 'created_at = excluded.created_at, ' 'updated_at = excluded.updated_at, ' 'deleted_at = excluded.deleted_at, ' 'schema_version = excluded.schema_version, ' 'sync_state = excluded.sync_state, ' 'local_revision = excluded.local_revision, ' 'origin_device_id = excluded.origin_device_id, ' 'future_owner_profile_id = excluded.future_owner_profile_id, ' 'last_synced_at = excluded.last_synced_at, ' 'remote_revision = excluded.remote_revision, ' 'active_workout_session_id = excluded.active_workout_session_id, ' 'program_index = excluded.program_index, ' 'exercise_index = excluded.exercise_index, ' 'set_index = excluded.set_index, ' 'status = excluded.status, ' 'started_at = excluded.started_at, ' 'accumulated_ms = excluded.accumulated_ms, ' 'stopped_at = excluded.stopped_at, ' 'skipped_at = excluded.skipped_at', variables: [ Variable(metadata.id), Variable(metadata.createdAt.toUtc()), Variable(metadata.updatedAt.toUtc()), Variable(_utcOrNull(metadata.deletedAt)), Variable(metadata.schemaVersion), Variable(_syncStateToDb(metadata.syncState)), Variable(metadata.localRevision), Variable(metadata.originDeviceId), Variable(metadata.futureOwnerProfileId), Variable(_utcOrNull(metadata.lastSyncedAt)), Variable(metadata.remoteRevision), Variable(state.activeWorkoutSessionId), Variable(state.programIndex), Variable(state.exerciseIndex), Variable(state.setIndex), Variable(_setTimerStatusToDb(state.status)), Variable(_utcOrNull(state.startedAt)), Variable(state.accumulatedMs), Variable(_utcOrNull(state.stoppedAt)), Variable(_utcOrNull(state.skippedAt)), ], ); } domain.ActiveSetTimerState _activeSetTimerStateFromCustomRow(QueryRow row) { final data = row.data; return domain.ActiveSetTimerState( metadata: _metadataFromData(data), activeWorkoutSessionId: data['active_workout_session_id'] as String, programIndex: data['program_index'] as int, exerciseIndex: data['exercise_index'] as int, setIndex: data['set_index'] as int, status: _setTimerStatusFromDb(data['status'] as String), startedAt: _dateTimeOrNullFromData(data, 'started_at'), accumulatedMs: data['accumulated_ms'] as int, stoppedAt: _dateTimeOrNullFromData(data, 'stopped_at'), skippedAt: _dateTimeOrNullFromData(data, 'skipped_at'), ); } domain.ActiveRestState _activeRestStateFromCustomRow(QueryRow row) { final data = row.data; return domain.ActiveRestState( metadata: _metadataFromData(data), activeWorkoutSessionId: data['active_workout_session_id'] as String, afterProgramIndex: data['after_program_index'] as int, afterExerciseIndex: data['after_exercise_index'] as int, afterSetIndex: data['after_set_index'] as int, plannedRestSeconds: data['planned_rest_seconds'] as int, adjustedRestSeconds: data['adjusted_rest_seconds'] as int, startedAt: _dateTimeFromData(data, 'started_at'), endedAt: _dateTimeOrNullFromData(data, 'ended_at'), skippedAt: _dateTimeOrNullFromData(data, 'skipped_at'), pausedAt: _dateTimeOrNullFromData(data, 'paused_at'), accumulatedPausedMs: data['accumulated_paused_ms'] as int? ?? 0, ); } WorkoutHistorySetPerformance _setPerformanceFromRow(QueryRow row) { final data = row.data; return WorkoutHistorySetPerformance( workoutHistoryId: data['workout_history_id'] as String, startedAt: _dateTimeFromData(data, 'history_started_at'), setIndex: data['set_index'] as int, exerciseNameSnapshot: data['exercise_name_snapshot'] as String, scoreInputModeSnapshot: _scoreInputModeFromDb( data['score_input_mode_snapshot'] as String, ), actualTimeMs: data['actual_time_ms'] as int?, actualReps: data['actual_reps'] as int?, actualScore: (data['actual_score'] as num?)?.toDouble(), actualScoreTimeMs: data['actual_score_time_ms'] as int?, completedAt: _dateTimeOrNullFromData(data, 'completed_at'), ); } WorkoutHistoryMetricPerformance _metricPerformanceFromRow( QueryRow row, { required PerformanceMetric metric, }) { final set = _setPerformanceFromRow(row); return WorkoutHistoryMetricPerformance( workoutHistoryId: set.workoutHistoryId, startedAt: set.startedAt, setIndex: set.setIndex, exerciseNameSnapshot: set.exerciseNameSnapshot, metric: metric, scoreInputModeSnapshot: set.scoreInputModeSnapshot, actualTimeMs: set.actualTimeMs, actualReps: set.actualReps, actualScore: set.actualScore, actualScoreTimeMs: set.actualScoreTimeMs, completedAt: set.completedAt, ); } String? _activeMeasuresPredicate(ActivePerformanceMeasures measures) { final predicates = []; if (measures.scoreEnabled) { predicates.add( measures.scoreInputMode == domain.ScoreInputMode.stopwatch ? "(result.score_input_mode_snapshot = 'stopwatch' " 'AND result.actual_score_time_ms IS NOT NULL)' : "(result.score_input_mode_snapshot = 'manual' " 'AND result.actual_score IS NOT NULL)', ); } if (measures.repsEnabled) { predicates.add('result.actual_reps IS NOT NULL'); } if (measures.timeEnabled) { predicates.add('result.actual_time_ms IS NOT NULL'); } return predicates.isEmpty ? null : predicates.join(' OR '); } ({String predicate, String ordering}) _metricSql( PerformanceMetric metric, domain.ScoreInputMode scoreInputMode, ) { return switch (metric) { PerformanceMetric.score => scoreInputMode == domain.ScoreInputMode.stopwatch ? ( predicate: "result.score_input_mode_snapshot = 'stopwatch' " 'AND result.actual_score_time_ms IS NOT NULL', ordering: 'result.actual_score_time_ms ASC', ) : ( predicate: "result.score_input_mode_snapshot = 'manual' " 'AND result.actual_score IS NOT NULL', ordering: 'result.actual_score DESC', ), PerformanceMetric.reps => ( predicate: 'result.actual_reps IS NOT NULL', ordering: 'result.actual_reps DESC', ), PerformanceMetric.time => ( predicate: 'result.actual_time_ms IS NOT NULL', ordering: 'result.actual_time_ms DESC', ), }; } db.ActiveScoreStopwatchStatesCompanion _activeScoreStopwatchStateCompanion( domain.ActiveScoreStopwatchState state, ) { final values = _metadataValues(state.metadata); return db.ActiveScoreStopwatchStatesCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, activeWorkoutSessionId: Value(state.activeWorkoutSessionId), programIndex: Value(state.programIndex), exerciseIndex: Value(state.exerciseIndex), setIndex: Value(state.setIndex), status: Value(_scoreStopwatchStatusToDb(state.status)), startedAt: Value(state.startedAt.toUtc()), accumulatedMs: Value(state.accumulatedMs), stoppedAt: Value(_utcOrNull(state.stoppedAt)), ); } db.ActiveManualScoreStatesCompanion _activeManualScoreStateCompanion( domain.ActiveManualScoreState state, ) { final values = _metadataValues(state.metadata); return db.ActiveManualScoreStatesCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, activeWorkoutSessionId: Value(state.activeWorkoutSessionId), programIndex: Value(state.programIndex), exerciseIndex: Value(state.exerciseIndex), setIndex: Value(state.setIndex), value: Value(state.value), scoreUpdatedAt: Value(state.updatedAt.toUtc()), ); } domain.ActiveManualScoreState _activeManualScoreStateFromRow( db.ActiveManualScoreState row, ) { return domain.ActiveManualScoreState( metadata: _metadataFromRow(row), activeWorkoutSessionId: row.activeWorkoutSessionId, programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, value: row.value, updatedAt: _utc(row.scoreUpdatedAt), ); } domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow( db.ActiveScoreStopwatchState row, ) { return domain.ActiveScoreStopwatchState( metadata: _metadataFromRow(row), activeWorkoutSessionId: row.activeWorkoutSessionId, programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, status: _scoreStopwatchStatusFromDb(row.status), startedAt: _utc(row.startedAt), accumulatedMs: row.accumulatedMs, stoppedAt: _utcOrNull(row.stoppedAt), ); } db.ActiveExerciseStepProgressStatesCompanion _activeExerciseStepProgressStateCompanion( domain.ActiveExerciseStepProgressState state, ) { final values = _metadataValues(state.metadata); return db.ActiveExerciseStepProgressStatesCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, activeWorkoutSessionId: Value(state.activeWorkoutSessionId), programIndex: Value(state.programIndex), exerciseIndex: Value(state.exerciseIndex), setIndex: Value(state.setIndex), currentPassageIndex: Value(state.currentPassageIndex), currentStepIndex: Value(state.currentStepIndex), currentStepSnapshotId: Value(state.currentStepSnapshotId), status: Value(_exerciseStepProgressStatusToDb(state.status)), startedAt: Value(_utcOrNull(state.startedAt)), accumulatedMs: Value(state.accumulatedMs), lastTransitionAt: Value(state.lastTransitionAt.toUtc()), ); } domain.ActiveExerciseStepProgressState _activeExerciseStepProgressStateFromRow( db.ActiveExerciseStepProgressState row, ) { return domain.ActiveExerciseStepProgressState( metadata: _metadataFromRow(row), activeWorkoutSessionId: row.activeWorkoutSessionId, programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, currentPassageIndex: row.currentPassageIndex, currentStepIndex: row.currentStepIndex, currentStepSnapshotId: row.currentStepSnapshotId, status: _exerciseStepProgressStatusFromDb(row.status), startedAt: _utcOrNull(row.startedAt), accumulatedMs: row.accumulatedMs, lastTransitionAt: _utc(row.lastTransitionAt), ); } db.ActiveExerciseStepResultsCompanion _activeExerciseStepResultCompanion( domain.ActiveExerciseStepResult result, ) { final values = _metadataValues(result.metadata); return db.ActiveExerciseStepResultsCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, activeWorkoutSessionId: Value(result.activeWorkoutSessionId), programSnapshotId: Value(result.programSnapshotId), exerciseSnapshotId: Value(result.exerciseSnapshotId), programIndex: Value(result.programIndex), exerciseIndex: Value(result.exerciseIndex), setIndex: Value(result.setIndex), passageIndex: Value(result.passageIndex), stepIndex: Value(result.stepIndex), stepSnapshotId: Value(result.stepSnapshotId), stepNameSnapshot: Value(result.stepNameSnapshot), stepTypeSnapshot: Value(_exerciseStepTypeToDb(result.stepTypeSnapshot)), targetValueSnapshot: Value(result.targetValueSnapshot), hasScoreSnapshot: Value(result.hasScoreSnapshot), scoreInputModeSnapshot: Value( result.scoreInputModeSnapshot == null ? null : _scoreInputModeToDb(result.scoreInputModeSnapshot!), ), scoreLabelSnapshot: Value(result.scoreLabelSnapshot), scoreUnitSnapshot: Value(result.scoreUnitSnapshot), targetScoreSnapshot: Value(result.targetScoreSnapshot), targetScoreTimeMsSnapshot: Value(result.targetScoreTimeMsSnapshot), status: Value(_setResultStatusToDb(result.status)), startedAt: Value(_utcOrNull(result.startedAt)), completedAt: Value(_utcOrNull(result.completedAt)), actualTimeMs: Value(result.actualTimeMs), actualReps: Value(result.actualReps), actualScore: Value(result.actualScore), actualScoreTimeMs: Value(result.actualScoreTimeMs), note: Value(result.note), ); } domain.ActiveExerciseStepResult _activeExerciseStepResultFromRow( db.ActiveExerciseStepResult row, ) { return domain.ActiveExerciseStepResult( metadata: _metadataFromRow(row), activeWorkoutSessionId: row.activeWorkoutSessionId, programSnapshotId: row.programSnapshotId, exerciseSnapshotId: row.exerciseSnapshotId, programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, passageIndex: row.passageIndex, stepIndex: row.stepIndex, stepSnapshotId: row.stepSnapshotId, stepNameSnapshot: row.stepNameSnapshot, stepTypeSnapshot: _exerciseStepTypeFromDb(row.stepTypeSnapshot), targetValueSnapshot: row.targetValueSnapshot, hasScoreSnapshot: row.hasScoreSnapshot, scoreInputModeSnapshot: row.scoreInputModeSnapshot == null ? null : _scoreInputModeFromDb(row.scoreInputModeSnapshot!), scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, targetScoreSnapshot: row.targetScoreSnapshot, targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot, status: _setResultStatusFromDb(row.status), startedAt: _utcOrNull(row.startedAt), completedAt: _utcOrNull(row.completedAt), actualTimeMs: row.actualTimeMs, actualReps: row.actualReps, actualScore: row.actualScore, actualScoreTimeMs: row.actualScoreTimeMs, note: row.note, ); } db.WorkoutHistoriesCompanion _workoutHistoryCompanion( domain.WorkoutHistory history, ) { final values = _metadataValues(history.metadata); return db.WorkoutHistoriesCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, sourceWorkoutTemplateId: Value(history.sourceWorkoutTemplateId), sourceActiveWorkoutSessionId: Value(history.sourceActiveWorkoutSessionId), nameSnapshot: Value(history.nameSnapshot), startedAt: Value(history.startedAt.toUtc()), endedAt: Value(history.endedAt.toUtc()), totalActiveMs: Value(history.totalActiveMs), completed: Value(history.completed), historySnapshotJson: Value(history.historySnapshotJson), minHeartRateBpm: Value(history.minHeartRateBpm), averageHeartRateBpm: Value(history.averageHeartRateBpm), maxHeartRateBpm: Value(history.maxHeartRateBpm), totalDistanceMeters: Value(history.totalDistanceMeters), totalCaloriesKcal: Value(history.totalCaloriesKcal), ); } db.WorkoutTelemetrySamplesCompanion _workoutTelemetrySampleCompanion( domain.WorkoutTelemetrySample sample, ) { return db.WorkoutTelemetrySamplesCompanion.insert( id: sample.id, sessionId: sample.sessionId, capturedAt: sample.capturedAt.toUtc(), programIndex: Value(sample.programIndex), exerciseIndex: Value(sample.exerciseIndex), setIndex: Value(sample.setIndex), passageIndex: Value(sample.passageIndex), stepIndex: Value(sample.stepIndex), programSnapshotId: Value(sample.programSnapshotId), exerciseSnapshotId: Value(sample.exerciseSnapshotId), stepSnapshotId: Value(sample.stepSnapshotId), heartRateBpm: Value(sample.heartRateBpm), distanceMeters: Value(sample.distanceMeters), caloriesKcal: Value(sample.caloriesKcal), ); } db.WorkoutTelemetryAggregatesCompanion _workoutTelemetryAggregateCompanion( domain.WorkoutTelemetryAggregate aggregate, ) { return db.WorkoutTelemetryAggregatesCompanion.insert( sessionId: aggregate.sessionId, scope: aggregate.scope.wireName, programIndex: Value(aggregate.programIndex), exerciseIndex: Value(aggregate.exerciseIndex), setIndex: Value(aggregate.setIndex), passageIndex: Value(aggregate.passageIndex), stepIndex: Value(aggregate.stepIndex), sampleCount: aggregate.sampleCount, minHeartRateBpm: Value(aggregate.minHeartRateBpm), averageHeartRateBpm: Value(aggregate.averageHeartRateBpm), maxHeartRateBpm: Value(aggregate.maxHeartRateBpm), totalDistanceMeters: Value(aggregate.totalDistanceMeters), totalCaloriesKcal: Value(aggregate.totalCaloriesKcal), ); } db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion( domain.WorkoutHistorySetResult result, ) { final values = _metadataValues(result.metadata); return db.WorkoutHistorySetResultsCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, workoutHistoryId: Value(result.workoutHistoryId), programSnapshotId: Value(result.programSnapshotId), exerciseSnapshotId: Value(result.exerciseSnapshotId), programIndex: Value(result.programIndex), exerciseIndex: Value(result.exerciseIndex), setIndex: Value(result.setIndex), programNameSnapshot: Value(result.programNameSnapshot), exerciseNameSnapshot: Value(result.exerciseNameSnapshot), timeEnabledSnapshot: Value(result.timeEnabledSnapshot), repsEnabledSnapshot: Value(result.repsEnabledSnapshot), scoreEnabledSnapshot: Value(result.scoreEnabledSnapshot), scoreInputModeSnapshot: Value( _scoreInputModeToDb(result.scoreInputModeSnapshot), ), targetTimeSecondsSnapshot: Value(result.targetTimeSecondsSnapshot), targetRepsSnapshot: Value(result.targetRepsSnapshot), targetScoreSnapshot: Value(result.targetScoreSnapshot), targetScoreTimeMsSnapshot: Value(result.targetScoreTimeMsSnapshot), actualTimeMs: Value(result.actualTimeMs), actualReps: Value(result.actualReps), actualScore: Value(result.actualScore), actualScoreTimeMs: Value(result.actualScoreTimeMs), scoreLabelSnapshot: Value(result.scoreLabelSnapshot), scoreUnitSnapshot: Value(result.scoreUnitSnapshot), sourceExerciseIdSnapshot: Value(result.sourceExerciseIdSnapshot), startedAt: Value(_utcOrNull(result.startedAt)), completedAt: Value(_utcOrNull(result.completedAt)), status: Value(_setResultStatusToDb(result.status)), ); } db.WorkoutHistoryStepResultsCompanion _workoutHistoryStepResultCompanion( domain.WorkoutHistoryStepResult result, ) { final values = _metadataValues(result.metadata); return db.WorkoutHistoryStepResultsCompanion( id: values[0] as Value, createdAt: values[1] as Value, updatedAt: values[2] as Value, deletedAt: values[3] as Value, schemaVersion: values[4] as Value, syncState: values[5] as Value, localRevision: values[6] as Value, originDeviceId: values[7] as Value, futureOwnerProfileId: values[8] as Value, lastSyncedAt: values[9] as Value, remoteRevision: values[10] as Value, workoutHistoryId: Value(result.workoutHistoryId), programSnapshotId: Value(result.programSnapshotId), exerciseSnapshotId: Value(result.exerciseSnapshotId), programIndex: Value(result.programIndex), exerciseIndex: Value(result.exerciseIndex), setIndex: Value(result.setIndex), passageIndex: Value(result.passageIndex), stepIndex: Value(result.stepIndex), stepSnapshotId: Value(result.stepSnapshotId), stepNameSnapshot: Value(result.stepNameSnapshot), stepTypeSnapshot: Value(_exerciseStepTypeToDb(result.stepTypeSnapshot)), targetValueSnapshot: Value(result.targetValueSnapshot), hasScoreSnapshot: Value(result.hasScoreSnapshot), scoreInputModeSnapshot: Value( result.scoreInputModeSnapshot == null ? null : _scoreInputModeToDb(result.scoreInputModeSnapshot!), ), scoreLabelSnapshot: Value(result.scoreLabelSnapshot), scoreUnitSnapshot: Value(result.scoreUnitSnapshot), targetScoreSnapshot: Value(result.targetScoreSnapshot), targetScoreTimeMsSnapshot: Value(result.targetScoreTimeMsSnapshot), status: Value(_setResultStatusToDb(result.status)), startedAt: Value(_utcOrNull(result.startedAt)), completedAt: Value(_utcOrNull(result.completedAt)), actualTimeMs: Value(result.actualTimeMs), actualReps: Value(result.actualReps), actualScore: Value(result.actualScore), actualScoreTimeMs: Value(result.actualScoreTimeMs), note: Value(result.note), sourceExerciseIdSnapshot: Value(result.sourceExerciseIdSnapshot), ); } domain.WorkoutHistory _workoutHistoryFromRow( db.WorkoutHistory row, List results, List stepResults, ) { return domain.WorkoutHistory( metadata: _metadataFromRow(row), sourceWorkoutTemplateId: row.sourceWorkoutTemplateId, sourceActiveWorkoutSessionId: row.sourceActiveWorkoutSessionId, nameSnapshot: row.nameSnapshot, startedAt: _utc(row.startedAt), endedAt: _utc(row.endedAt), totalActiveMs: row.totalActiveMs, completed: row.completed, historySnapshotJson: row.historySnapshotJson, minHeartRateBpm: row.minHeartRateBpm, averageHeartRateBpm: row.averageHeartRateBpm, maxHeartRateBpm: row.maxHeartRateBpm, totalDistanceMeters: row.totalDistanceMeters, totalCaloriesKcal: row.totalCaloriesKcal, results: results, stepResults: stepResults, ); } domain.WorkoutTelemetrySample _workoutTelemetrySampleFromRow( db.WorkoutTelemetrySample row, ) { return domain.WorkoutTelemetrySample( id: row.id, sessionId: row.sessionId, capturedAt: _utc(row.capturedAt), programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, passageIndex: row.passageIndex, stepIndex: row.stepIndex, programSnapshotId: row.programSnapshotId, exerciseSnapshotId: row.exerciseSnapshotId, stepSnapshotId: row.stepSnapshotId, heartRateBpm: row.heartRateBpm, distanceMeters: row.distanceMeters, caloriesKcal: row.caloriesKcal, ); } domain.WorkoutTelemetryAggregate _workoutTelemetryAggregateFromRow( db.WorkoutTelemetryAggregate row, ) { return domain.WorkoutTelemetryAggregate( sessionId: row.sessionId, scope: domain.WorkoutTelemetryAggregateScope.parse(row.scope), programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, passageIndex: row.passageIndex, stepIndex: row.stepIndex, sampleCount: row.sampleCount, minHeartRateBpm: row.minHeartRateBpm, averageHeartRateBpm: row.averageHeartRateBpm, maxHeartRateBpm: row.maxHeartRateBpm, totalDistanceMeters: row.totalDistanceMeters, totalCaloriesKcal: row.totalCaloriesKcal, ); } domain.WorkoutHistorySetResult _workoutHistorySetResultFromRow( db.WorkoutHistorySetResult row, ) { return domain.WorkoutHistorySetResult( metadata: _metadataFromRow(row), workoutHistoryId: row.workoutHistoryId, programSnapshotId: row.programSnapshotId, exerciseSnapshotId: row.exerciseSnapshotId, programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, programNameSnapshot: row.programNameSnapshot, exerciseNameSnapshot: row.exerciseNameSnapshot, timeEnabledSnapshot: row.timeEnabledSnapshot, repsEnabledSnapshot: row.repsEnabledSnapshot, scoreEnabledSnapshot: row.scoreEnabledSnapshot, scoreInputModeSnapshot: _scoreInputModeFromDb(row.scoreInputModeSnapshot), targetTimeSecondsSnapshot: row.targetTimeSecondsSnapshot, targetRepsSnapshot: row.targetRepsSnapshot, targetScoreSnapshot: row.targetScoreSnapshot, targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot, actualTimeMs: row.actualTimeMs, actualReps: row.actualReps, actualScore: row.actualScore, actualScoreTimeMs: row.actualScoreTimeMs, scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, sourceExerciseIdSnapshot: row.sourceExerciseIdSnapshot, startedAt: _utcOrNull(row.startedAt), completedAt: _utcOrNull(row.completedAt), status: _setResultStatusFromDb(row.status), ); } domain.WorkoutHistoryStepResult _workoutHistoryStepResultFromRow( db.WorkoutHistoryStepResult row, ) { return domain.WorkoutHistoryStepResult( metadata: _metadataFromRow(row), workoutHistoryId: row.workoutHistoryId, programSnapshotId: row.programSnapshotId, exerciseSnapshotId: row.exerciseSnapshotId, programIndex: row.programIndex, exerciseIndex: row.exerciseIndex, setIndex: row.setIndex, passageIndex: row.passageIndex, stepIndex: row.stepIndex, stepSnapshotId: row.stepSnapshotId, stepNameSnapshot: row.stepNameSnapshot, stepTypeSnapshot: _exerciseStepTypeFromDb(row.stepTypeSnapshot), targetValueSnapshot: row.targetValueSnapshot, hasScoreSnapshot: row.hasScoreSnapshot, scoreInputModeSnapshot: row.scoreInputModeSnapshot == null ? null : _scoreInputModeFromDb(row.scoreInputModeSnapshot!), scoreLabelSnapshot: row.scoreLabelSnapshot, scoreUnitSnapshot: row.scoreUnitSnapshot, targetScoreSnapshot: row.targetScoreSnapshot, targetScoreTimeMsSnapshot: row.targetScoreTimeMsSnapshot, status: _setResultStatusFromDb(row.status), startedAt: _utcOrNull(row.startedAt), completedAt: _utcOrNull(row.completedAt), actualTimeMs: row.actualTimeMs, actualReps: row.actualReps, actualScore: row.actualScore, actualScoreTimeMs: row.actualScoreTimeMs, note: row.note, sourceExerciseIdSnapshot: row.sourceExerciseIdSnapshot, ); } db.RemoteResourceMappingsCompanion _remoteResourceMappingCompanion( RemoteResourceMapping mapping, ) { return db.RemoteResourceMappingsCompanion.insert( id: _remoteResourceMappingId(mapping.resourceType, mapping.clientId), resourceType: _syncResourceTypeToDb(mapping.resourceType), clientId: mapping.clientId, serverId: mapping.serverId, serverUpdatedAt: mapping.serverUpdatedAt.toUtc(), ); } RemoteResourceMapping _remoteResourceMappingFromRow( db.RemoteResourceMapping row, ) { return RemoteResourceMapping( resourceType: _syncResourceTypeFromDb(row.resourceType), clientId: row.clientId, serverId: row.serverId, serverUpdatedAt: _utc(row.serverUpdatedAt), ); } String _remoteResourceMappingId( SyncResourceType resourceType, String clientId, ) { return '${_syncResourceTypeToDb(resourceType)}:$clientId'; } final class _LocalSyncSnapshot { const _LocalSyncSnapshot({ required this.schemaVersion, required this.updatedAt, required this.deletedAt, required this.payload, }); factory _LocalSyncSnapshot.fromMetadata( domain.EntityMetadata metadata, Map payload, ) { return _LocalSyncSnapshot( schemaVersion: metadata.schemaVersion, updatedAt: metadata.updatedAt.toUtc(), deletedAt: _utcOrNull(metadata.deletedAt), payload: payload, ); } final int schemaVersion; final DateTime updatedAt; final DateTime? deletedAt; final Map payload; } final class _BackupLocalRow { const _BackupLocalRow({ required this.updatedAt, required this.deletedAt, required this.localRevision, }); final DateTime updatedAt; final DateTime? deletedAt; final int localRevision; } Map _metadataPayload(domain.EntityMetadata metadata) => { 'id': metadata.id, 'createdAt': metadata.createdAt.toUtc().toIso8601String(), 'updatedAt': metadata.updatedAt.toUtc().toIso8601String(), 'deletedAt': metadata.deletedAt?.toUtc().toIso8601String(), 'schemaVersion': metadata.schemaVersion, 'syncState': metadata.syncState.name, 'localRevision': metadata.localRevision, 'originDeviceId': metadata.originDeviceId, }; Map _exercisePayload(domain.Exercise exercise) => { 'metadata': _metadataPayload(exercise.metadata), 'id': exercise.metadata.id, 'name': exercise.name, 'description': exercise.description, 'imageMediaIds': exercise.imageMediaIds, 'iconMediaId': exercise.iconMediaId, 'videoMediaId': exercise.videoMediaId, 'hasTimeMeasure': exercise.hasTimeMeasure, 'hasRepsMeasure': exercise.hasRepsMeasure, 'hasScoreMeasure': exercise.hasScoreMeasure, 'scoreInputMode': exercise.scoreInputMode.name, 'scoreLabel': exercise.scoreLabel, 'scoreUnit': exercise.scoreUnit, 'defaultTargetTimeSeconds': exercise.defaultTargetTimeSeconds, 'defaultTargetReps': exercise.defaultTargetReps, 'defaultTargetScore': exercise.defaultTargetScore, 'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs, '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(), }; Map _mediaAssetPayload(domain.MediaAsset asset) => { 'metadata': _metadataPayload(asset.metadata), 'id': asset.metadata.id, 'kind': _mediaKindToDb(asset.kind), 'localUri': asset.localUri, 'mimeType': asset.mimeType, 'sizeBytes': asset.sizeBytes, 'width': asset.width, 'height': asset.height, 'durationMs': asset.durationMs, 'checksum': asset.checksum, 'remoteUri': asset.remoteUri, 'thumbnailLocalUri': asset.thumbnailLocalUri, }; Map _programPayload(domain.Program program) => { 'metadata': _metadataPayload(program.metadata), 'id': program.metadata.id, 'name': program.name, 'defaultRestSeconds': program.defaultRestSeconds, 'isExample': program.isExample, 'tags': program.tags, 'exercises': program.exercises .map((exercise) => exercise.toSnapshotJson()) .toList(), }; Map _workoutTemplatePayload(domain.WorkoutTemplate template) => { 'metadata': _metadataPayload(template.metadata), 'id': template.metadata.id, 'name': template.name, 'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(), 'isExample': template.isExample, 'tags': template.tags, 'programs': template.programs .map( (program) => { 'id': program.metadata.id, 'sourceProgramId': program.sourceProgramId, 'position': program.position, 'programNameSnapshot': program.programNameSnapshot, 'defaultRestSecondsSnapshot': program.defaultRestSecondsSnapshot, 'programSnapshotJson': program.programSnapshotJson, }, ) .toList(), 'overrides': template.overrides .map( (override) => { 'id': override.metadata.id, 'workoutTemplateProgramId': override.workoutTemplateProgramId, 'snapshotProgramExerciseId': override.snapshotProgramExerciseId, 'setsCountOverride': override.setsCountOverride, 'targetTimeSecondsOverride': override.targetTimeSecondsOverride, 'targetRepsOverride': override.targetRepsOverride, 'targetScoreOverride': override.targetScoreOverride, 'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride, 'autoStartNextTimedStepOverride': override.autoStartNextTimedStepOverride, }, ) .toList(), }; Map _workoutHistoryPayload(domain.WorkoutHistory history) => { 'metadata': _metadataPayload(history.metadata), 'id': history.metadata.id, 'sourceWorkoutTemplateId': history.sourceWorkoutTemplateId, 'sourceActiveWorkoutSessionId': history.sourceActiveWorkoutSessionId, 'nameSnapshot': history.nameSnapshot, 'startedAt': history.startedAt.toUtc().toIso8601String(), 'endedAt': history.endedAt.toUtc().toIso8601String(), 'totalActiveMs': history.totalActiveMs, 'completed': history.completed, 'historySnapshotJson': history.historySnapshotJson, 'minHeartRateBpm': history.minHeartRateBpm, 'averageHeartRateBpm': history.averageHeartRateBpm, 'maxHeartRateBpm': history.maxHeartRateBpm, 'totalDistanceMeters': history.totalDistanceMeters, 'totalCaloriesKcal': history.totalCaloriesKcal, }; Map _localWorkoutHistoryPayload( domain.WorkoutHistory history, ) => { ..._workoutHistoryPayload(history), 'results': history.results.map(_workoutHistorySetResultPayload).toList(), 'stepResults': history.stepResults .map(_workoutHistoryStepResultPayload) .toList(), }; Map _workoutHistorySetResultPayload( domain.WorkoutHistorySetResult result, ) => { 'metadata': _metadataPayload(result.metadata), 'id': result.metadata.id, 'workoutHistoryId': result.workoutHistoryId, 'programSnapshotId': result.programSnapshotId, 'exerciseSnapshotId': result.exerciseSnapshotId, 'programIndex': result.programIndex, 'exerciseIndex': result.exerciseIndex, 'setIndex': result.setIndex, 'programNameSnapshot': result.programNameSnapshot, 'exerciseNameSnapshot': result.exerciseNameSnapshot, 'timeEnabledSnapshot': result.timeEnabledSnapshot, 'repsEnabledSnapshot': result.repsEnabledSnapshot, 'scoreEnabledSnapshot': result.scoreEnabledSnapshot, 'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name, 'targetTimeSecondsSnapshot': result.targetTimeSecondsSnapshot, 'targetRepsSnapshot': result.targetRepsSnapshot, 'targetScoreSnapshot': result.targetScoreSnapshot, 'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot, 'actualTimeMs': result.actualTimeMs, 'actualReps': result.actualReps, 'actualScore': result.actualScore, 'actualScoreTimeMs': result.actualScoreTimeMs, 'scoreLabelSnapshot': result.scoreLabelSnapshot, 'scoreUnitSnapshot': result.scoreUnitSnapshot, 'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot, 'startedAt': result.startedAt?.toUtc().toIso8601String(), 'completedAt': result.completedAt?.toUtc().toIso8601String(), 'status': result.status.name, }; Map _workoutHistoryStepResultPayload( domain.WorkoutHistoryStepResult result, ) => { 'metadata': _metadataPayload(result.metadata), 'id': result.metadata.id, 'workoutHistoryId': result.workoutHistoryId, 'programSnapshotId': result.programSnapshotId, 'exerciseSnapshotId': result.exerciseSnapshotId, 'programIndex': result.programIndex, 'exerciseIndex': result.exerciseIndex, 'setIndex': result.setIndex, 'passageIndex': result.passageIndex, 'stepIndex': result.stepIndex, 'stepSnapshotId': result.stepSnapshotId, 'stepNameSnapshot': result.stepNameSnapshot, 'stepTypeSnapshot': result.stepTypeSnapshot.name, 'targetValueSnapshot': result.targetValueSnapshot, 'hasScoreSnapshot': result.hasScoreSnapshot, 'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name, 'scoreLabelSnapshot': result.scoreLabelSnapshot, 'scoreUnitSnapshot': result.scoreUnitSnapshot, 'targetScoreSnapshot': result.targetScoreSnapshot, 'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot, 'status': result.status.name, 'startedAt': result.startedAt?.toUtc().toIso8601String(), 'completedAt': result.completedAt?.toUtc().toIso8601String(), 'actualTimeMs': result.actualTimeMs, 'actualReps': result.actualReps, 'actualScore': result.actualScore, 'actualScoreTimeMs': result.actualScoreTimeMs, 'note': result.note, 'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot, }; LocalBackupResource _backupResource( domain.EntityMetadata metadata, Map payload, ) { return LocalBackupResource( id: metadata.id, updatedAt: metadata.updatedAt.toUtc(), payload: payload, ); } RemoteSyncedItem _remoteItemFromBackupResource( SyncResourceType resourceType, LocalBackupResource resource, ) { final metadata = resource.payload['metadata']; final metadataMap = metadata is Map ? Map.from(metadata) : const {}; return RemoteSyncedItem( resourceType: resourceType, clientId: resource.id, serverId: '', schemaVersion: metadataMap['schemaVersion'] as int? ?? 1, clientUpdatedAt: resource.updatedAt.toUtc(), serverUpdatedAt: resource.updatedAt.toUtc(), deletedAt: _dateTimeFromPayload(metadataMap['deletedAt']), payload: resource.payload, ); } Map _payloadWithImportedMetadata( Map payload, DateTime importedAt, int localRevision, { String? snapshotOriginDeviceId, }) { final metadata = payload['metadata']; final metadataMap = metadata is Map ? Map.from(metadata) : {}; return { ...payload, 'metadata': { ...metadataMap, 'updatedAt': importedAt.toUtc().toIso8601String(), 'deletedAt': null, 'syncState': domain.SyncState.dirty.name, 'localRevision': localRevision, 'originDeviceId': metadataMap['originDeviceId'] as String? ?? snapshotOriginDeviceId ?? 'local-device', }, }; } domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload( RemoteSyncedItem item, ) { final payload = item.payload; final metadata = _metadataFromPayload(item); return domain.WorkoutHistory( metadata: metadata, sourceWorkoutTemplateId: payload['sourceWorkoutTemplateId'] as String?, sourceActiveWorkoutSessionId: payload['sourceActiveWorkoutSessionId'] as String?, nameSnapshot: _stringFromPayload(payload, 'nameSnapshot', item.clientId), startedAt: _dateTimeFromPayload(payload['startedAt']) ?? item.clientUpdatedAt, endedAt: _dateTimeFromPayload(payload['endedAt']) ?? item.clientUpdatedAt, totalActiveMs: payload['totalActiveMs'] as int? ?? 0, completed: payload['completed'] as bool? ?? false, historySnapshotJson: payload['historySnapshotJson'] as String? ?? '{"programs":[]}', minHeartRateBpm: payload['minHeartRateBpm'] as int?, averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(), maxHeartRateBpm: payload['maxHeartRateBpm'] as int?, totalDistanceMeters: (payload['totalDistanceMeters'] as num?)?.toDouble(), totalCaloriesKcal: (payload['totalCaloriesKcal'] as num?)?.toDouble(), results: _workoutHistorySetResultsFromPayload(payload['results'], metadata), stepResults: _workoutHistoryStepResultsFromPayload( payload['stepResults'], metadata, ), ); } domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) { final payload = item.payload; return domain.Exercise( metadata: _metadataFromPayload(item), name: _stringFromPayload(payload, 'name', item.clientId), description: payload['description'] as String?, imageMediaIds: _stringListFromPayload(payload['imageMediaIds']), iconMediaId: payload['iconMediaId'] as String?, videoMediaId: payload['videoMediaId'] as String?, hasTimeMeasure: payload['hasTimeMeasure'] == true, hasRepsMeasure: payload['hasRepsMeasure'] == true, hasScoreMeasure: payload['hasScoreMeasure'] == true, scoreInputMode: _scoreInputModeFromDb( payload['scoreInputMode'] as String? ?? 'manual', ), scoreLabel: payload['scoreLabel'] as String?, scoreUnit: payload['scoreUnit'] as String?, defaultTargetTimeSeconds: payload['defaultTargetTimeSeconds'] as int?, defaultTargetReps: payload['defaultTargetReps'] as int?, defaultTargetScore: (payload['defaultTargetScore'] as num?)?.toDouble(), defaultTargetScoreTimeMs: payload['defaultTargetScoreTimeMs'] as int?, autoStartNextTimedStep: payload['autoStartNextTimedStep'] as bool? ?? true, category: _exerciseCategoryFromDb( payload['category'] as String? ?? 'uncategorized', ), isExample: payload['isExample'] as bool? ?? false, tags: _stringListFromPayload(payload['tags']), steps: _stepsFromPayload(payload['steps']), archivedAt: _dateTimeFromPayload(payload['archivedAt']), ); } domain.MediaAsset _mediaAssetFromPayload(RemoteSyncedItem item) { final payload = item.payload; return domain.MediaAsset( metadata: _metadataFromPayload(item), kind: _mediaKindFromDb(payload['kind'] as String? ?? 'image'), localUri: _stringFromPayload(payload, 'localUri', ''), mimeType: payload['mimeType'] as String?, sizeBytes: payload['sizeBytes'] as int?, width: payload['width'] as int?, height: payload['height'] as int?, durationMs: payload['durationMs'] as int?, checksum: payload['checksum'] as String?, remoteUri: payload['remoteUri'] as String?, thumbnailLocalUri: payload['thumbnailLocalUri'] as String?, ); } domain.Program _programFromPayload(RemoteSyncedItem item) { final payload = item.payload; final metadata = _metadataFromPayload(item); return domain.Program( metadata: metadata, 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), ); } domain.WorkoutTemplate _workoutTemplateFromPayload(RemoteSyncedItem item) { final payload = item.payload; final metadata = _metadataFromPayload(item); return domain.WorkoutTemplate( metadata: metadata, 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, ), overrides: _workoutTemplateOverridesFromPayload( payload['overrides'], metadata, ), ); } domain.EntityMetadata _metadataFromPayload(RemoteSyncedItem item) { final metadata = item.payload['metadata']; final map = metadata is Map ? Map.from(metadata) : null; return domain.EntityMetadata( id: item.clientId, createdAt: _dateTimeFromPayload(map?['createdAt']) ?? item.clientUpdatedAt, updatedAt: item.clientUpdatedAt, deletedAt: item.deletedAt, schemaVersion: item.schemaVersion, syncState: map?['syncState'] is String ? _syncStateFromDb(map!['syncState'] as String) : item.deletedAt == null ? domain.SyncState.synced : domain.SyncState.deleted, localRevision: map?['localRevision'] as int? ?? 0, originDeviceId: map?['originDeviceId'] as String? ?? 'remote', ); } List _programExercisesFromPayload( Object? value, domain.EntityMetadata parentMetadata, ) { if (value is! List) { return const []; } return value .whereType() .map((entry) { final map = Map.from(entry); final id = _stringFromPayload(map, 'id', 'program-exercise'); return domain.ProgramExercise( metadata: _childMetadataFromPayload(map, id, parentMetadata), programId: parentMetadata.id, sourceExerciseId: map['sourceExerciseId'] as String?, position: map['position'] as int? ?? 0, exerciseNameSnapshot: _stringFromPayload( map, 'exerciseNameSnapshot', id, ), exerciseDescriptionSnapshot: map['exerciseDescriptionSnapshot'] as String?, exerciseImageMediaIdSnapshot: map['exerciseImageMediaIdSnapshot'] as String?, exerciseImageMediaIdsSnapshot: _stringListFromPayload( map['exerciseImageMediaIdsSnapshot'] ?? map['imageMediaIdsSnapshot'], ), exerciseVideoMediaIdSnapshot: map['exerciseVideoMediaIdSnapshot'] as String?, exerciseStepsSnapshot: _stepsFromPayload( map['exerciseStepsSnapshot'], ), autoStartNextTimedStepSnapshot: map['autoStartNextTimedStepSnapshot'] as bool? ?? true, autoStartNextTimedStepOverride: map['autoStartNextTimedStepOverride'] as bool?, exerciseArchivedSnapshot: map['exerciseArchivedSnapshot'] == true, availableTimeSnapshot: map['availableTimeSnapshot'] == true, availableRepsSnapshot: map['availableRepsSnapshot'] == true, availableScoreSnapshot: map['availableScoreSnapshot'] == true, scoreInputModeSnapshot: _scoreInputModeFromDb( map['scoreInputModeSnapshot'] as String? ?? 'manual', ), scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?, setsCount: map['setsCount'] as int? ?? 1, timeEnabled: map['timeEnabled'] == true, repsEnabled: map['repsEnabled'] == true, scoreEnabled: map['scoreEnabled'] == true, targetTimeSeconds: map['targetTimeSeconds'] as int?, targetReps: map['targetReps'] as int?, targetScore: (map['targetScore'] as num?)?.toDouble(), targetScoreTimeMs: map['targetScoreTimeMs'] as int?, restSecondsOverride: map['restSecondsOverride'] as int?, ); }) .toList(growable: false); } List _workoutTemplateProgramsFromPayload( Object? value, domain.EntityMetadata parentMetadata, ) { if (value is! List) { return const []; } return value .whereType() .map((entry) { final map = Map.from(entry); final id = _stringFromPayload(map, 'id', 'template-program'); return domain.WorkoutTemplateProgram( metadata: _childMetadataFromPayload(map, id, parentMetadata), workoutTemplateId: parentMetadata.id, sourceProgramId: map['sourceProgramId'] as String?, position: map['position'] as int? ?? 0, programNameSnapshot: _stringFromPayload( map, 'programNameSnapshot', id, ), defaultRestSecondsSnapshot: map['defaultRestSecondsSnapshot'] as int? ?? 0, programSnapshotJson: map['programSnapshotJson'] as String? ?? '{"exercises":[]}', ); }) .toList(growable: false); } List _workoutTemplateOverridesFromPayload( Object? value, domain.EntityMetadata parentMetadata, ) { if (value is! List) { return const []; } return value .whereType() .map((entry) { final map = Map.from(entry); final id = _stringFromPayload(map, 'id', 'template-override'); return domain.WorkoutTemplateExerciseOverride( metadata: _childMetadataFromPayload(map, id, parentMetadata), workoutTemplateProgramId: _stringFromPayload( map, 'workoutTemplateProgramId', '', ), snapshotProgramExerciseId: _stringFromPayload( map, 'snapshotProgramExerciseId', '', ), setsCountOverride: map['setsCountOverride'] as int?, targetTimeSecondsOverride: map['targetTimeSecondsOverride'] as int?, targetRepsOverride: map['targetRepsOverride'] as int?, targetScoreOverride: (map['targetScoreOverride'] as num?)?.toDouble(), targetScoreTimeMsOverride: map['targetScoreTimeMsOverride'] as int?, autoStartNextTimedStepOverride: map['autoStartNextTimedStepOverride'] as bool?, ); }) .toList(growable: false); } List _workoutHistorySetResultsFromPayload( Object? value, domain.EntityMetadata parentMetadata, ) { if (value is! List) { return const []; } return value .whereType() .map((entry) { final map = Map.from(entry); final id = _stringFromPayload(map, 'id', 'history-set-result'); return domain.WorkoutHistorySetResult( metadata: _childMetadataFromPayload(map, id, parentMetadata), workoutHistoryId: parentMetadata.id, programSnapshotId: _stringFromPayload(map, 'programSnapshotId', ''), exerciseSnapshotId: _stringFromPayload(map, 'exerciseSnapshotId', ''), programIndex: map['programIndex'] as int? ?? 0, exerciseIndex: map['exerciseIndex'] as int? ?? 0, setIndex: map['setIndex'] as int? ?? 0, programNameSnapshot: _stringFromPayload( map, 'programNameSnapshot', '', ), exerciseNameSnapshot: _stringFromPayload( map, 'exerciseNameSnapshot', '', ), timeEnabledSnapshot: map['timeEnabledSnapshot'] == true, repsEnabledSnapshot: map['repsEnabledSnapshot'] == true, scoreEnabledSnapshot: map['scoreEnabledSnapshot'] == true, scoreInputModeSnapshot: _scoreInputModeFromDb( map['scoreInputModeSnapshot'] as String? ?? 'manual', ), targetTimeSecondsSnapshot: map['targetTimeSecondsSnapshot'] as int?, targetRepsSnapshot: map['targetRepsSnapshot'] as int?, targetScoreSnapshot: (map['targetScoreSnapshot'] as num?)?.toDouble(), targetScoreTimeMsSnapshot: map['targetScoreTimeMsSnapshot'] as int?, actualTimeMs: map['actualTimeMs'] as int?, actualReps: map['actualReps'] as int?, actualScore: (map['actualScore'] as num?)?.toDouble(), actualScoreTimeMs: map['actualScoreTimeMs'] as int?, scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?, sourceExerciseIdSnapshot: map['sourceExerciseIdSnapshot'] as String?, startedAt: _dateTimeFromPayload(map['startedAt']), completedAt: _dateTimeFromPayload(map['completedAt']), status: _setResultStatusFromDb( map['status'] as String? ?? 'completed', ), ); }) .toList(growable: false); } List _workoutHistoryStepResultsFromPayload( Object? value, domain.EntityMetadata parentMetadata, ) { if (value is! List) { return const []; } return value .whereType() .map((entry) { final map = Map.from(entry); final id = _stringFromPayload(map, 'id', 'history-step-result'); return domain.WorkoutHistoryStepResult( metadata: _childMetadataFromPayload(map, id, parentMetadata), workoutHistoryId: parentMetadata.id, programSnapshotId: _stringFromPayload(map, 'programSnapshotId', ''), exerciseSnapshotId: _stringFromPayload(map, 'exerciseSnapshotId', ''), programIndex: map['programIndex'] as int? ?? 0, exerciseIndex: map['exerciseIndex'] as int? ?? 0, setIndex: map['setIndex'] as int? ?? 0, passageIndex: map['passageIndex'] as int? ?? 0, stepIndex: map['stepIndex'] as int? ?? 0, stepSnapshotId: _stringFromPayload(map, 'stepSnapshotId', ''), stepNameSnapshot: _stringFromPayload(map, 'stepNameSnapshot', ''), stepTypeSnapshot: _exerciseStepTypeFromDb( map['stepTypeSnapshot'] as String? ?? 'reps', ), targetValueSnapshot: map['targetValueSnapshot'] as int? ?? 0, hasScoreSnapshot: map['hasScoreSnapshot'] == true, scoreInputModeSnapshot: map['scoreInputModeSnapshot'] == null ? null : _scoreInputModeFromDb(map['scoreInputModeSnapshot'] as String), scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?, scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?, targetScoreSnapshot: (map['targetScoreSnapshot'] as num?)?.toDouble(), targetScoreTimeMsSnapshot: map['targetScoreTimeMsSnapshot'] as int?, status: _setResultStatusFromDb( map['status'] as String? ?? 'completed', ), startedAt: _dateTimeFromPayload(map['startedAt']), completedAt: _dateTimeFromPayload(map['completedAt']), actualTimeMs: map['actualTimeMs'] as int?, actualReps: map['actualReps'] as int?, actualScore: (map['actualScore'] as num?)?.toDouble(), actualScoreTimeMs: map['actualScoreTimeMs'] as int?, note: map['note'] as String?, sourceExerciseIdSnapshot: map['sourceExerciseIdSnapshot'] as String?, ); }) .toList(growable: false); } domain.EntityMetadata _childMetadataFromPayload( Map payload, String id, domain.EntityMetadata parentMetadata, ) { final metadata = payload['metadata']; final map = metadata is Map ? Map.from(metadata) : null; return domain.EntityMetadata( id: id, createdAt: _dateTimeFromPayload(map?['createdAt']) ?? parentMetadata.createdAt, updatedAt: _dateTimeFromPayload(map?['updatedAt']) ?? parentMetadata.updatedAt, deletedAt: _dateTimeFromPayload(map?['deletedAt']), schemaVersion: map?['schemaVersion'] as int? ?? parentMetadata.schemaVersion, syncState: parentMetadata.syncState, localRevision: map?['localRevision'] as int? ?? 0, originDeviceId: map?['originDeviceId'] as String? ?? parentMetadata.originDeviceId, ); } String _stringFromPayload( Map payload, String key, String fallback, ) { final value = payload[key]; return value is String && value.trim().isNotEmpty ? value : fallback; } List _stringListFromPayload(Object? value) { if (value is! List) { return const []; } 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 []; } return value .map((raw) { final json = Map.from(raw as Map); return domain.ExerciseStep( id: _requiredString(json, 'id'), position: _requiredInt(json, 'position'), name: _requiredString(json, 'name'), type: _exerciseStepTypeFromDb(_requiredString(json, 'type')), defaultTargetValue: _requiredInt(json, 'defaultTargetValue'), hasScore: _requiredBool(json, 'hasScore'), scoreInputMode: _scoreInputModeFromDb( _optionalString(json, 'scoreInputMode') ?? 'manual', ), scoreLabel: _optionalString(json, 'scoreLabel'), scoreUnit: _optionalString(json, 'scoreUnit'), defaultTargetScore: _optionalDouble(json, 'defaultTargetScore'), defaultTargetScoreTimeMs: _optionalInt( json, 'defaultTargetScoreTimeMs', ), linkedToSeriesScore: json['linkedToSeriesScore'] == true, ); }) .toList(growable: false); } DateTime? _dateTimeFromPayload(Object? value) { return value is String ? DateTime.tryParse(value)?.toUtc() : null; } const _syncableEntityTypes = [ 'Exercise', 'Program', 'WorkoutTemplate', 'WorkoutHistory', 'MediaAsset', ]; SyncResourceType? _syncResourceTypeFromEntityType(String entityType) { return switch (entityType) { 'Exercise' => SyncResourceType.exercise, 'Program' => SyncResourceType.program, 'WorkoutTemplate' => SyncResourceType.workoutTemplate, 'WorkoutHistory' => SyncResourceType.workoutHistory, 'MediaAsset' => SyncResourceType.mediaAsset, _ => null, }; } String _tableNameForResourceType(SyncResourceType type) => switch (type) { SyncResourceType.exercise => 'exercises', SyncResourceType.program => 'programs', SyncResourceType.workoutTemplate => 'workout_templates', SyncResourceType.workoutHistory => 'workout_history', SyncResourceType.mediaAsset => 'media_assets', }; String _syncStateToDb(domain.SyncState state) => switch (state) { domain.SyncState.localOnly => 'localOnly', domain.SyncState.dirty => 'dirty', domain.SyncState.synced => 'synced', domain.SyncState.deleted => 'deleted', }; domain.SyncState _syncStateFromDb(String value) => switch (value) { 'localOnly' => domain.SyncState.localOnly, 'dirty' => domain.SyncState.dirty, 'synced' => domain.SyncState.synced, 'deleted' => domain.SyncState.deleted, _ => throw domain.DomainException('Unknown sync state: $value'), }; String _syncResourceTypeToDb(SyncResourceType type) => switch (type) { SyncResourceType.exercise => 'exercise', SyncResourceType.program => 'program', SyncResourceType.workoutTemplate => 'workoutTemplate', SyncResourceType.workoutHistory => 'workoutHistory', SyncResourceType.mediaAsset => 'mediaAsset', }; SyncResourceType _syncResourceTypeFromDb(String value) => switch (value) { 'exercise' => SyncResourceType.exercise, 'program' => SyncResourceType.program, 'workoutTemplate' => SyncResourceType.workoutTemplate, 'workoutHistory' => SyncResourceType.workoutHistory, 'mediaAsset' => SyncResourceType.mediaAsset, _ => throw domain.DomainException('Unknown sync resource type: $value'), }; String _onlineSyncStatusToDb(OnlineSyncStatus status) => switch (status) { OnlineSyncStatus.idle => 'idle', OnlineSyncStatus.syncing => 'syncing', OnlineSyncStatus.success => 'success', OnlineSyncStatus.failure => 'failure', }; OnlineSyncStatus _onlineSyncStatusFromDb(String value) => switch (value) { 'idle' => OnlineSyncStatus.idle, 'syncing' => OnlineSyncStatus.syncing, 'success' => OnlineSyncStatus.success, 'failure' => OnlineSyncStatus.failure, _ => throw domain.DomainException('Unknown sync status: $value'), }; String _shareResourceTypeToDb(domain.ShareResourceType type) => switch (type) { domain.ShareResourceType.program => 'program', domain.ShareResourceType.workoutTemplate => 'workoutTemplate', domain.ShareResourceType.pack => 'pack', }; domain.ShareResourceType _shareResourceTypeFromDb(String value) => switch (value) { 'program' => domain.ShareResourceType.program, 'workoutTemplate' => domain.ShareResourceType.workoutTemplate, 'pack' => domain.ShareResourceType.pack, _ => throw domain.DomainException('Unknown share resource type: $value'), }; String _shareInboxStatusToDb(domain.ShareInboxStatus status) => switch (status) { domain.ShareInboxStatus.pending => 'pending', domain.ShareInboxStatus.accepted => 'accepted', domain.ShareInboxStatus.declined => 'declined', domain.ShareInboxStatus.revoked => 'revoked', }; domain.ShareInboxStatus _shareInboxStatusFromDb(String value) => switch (value) { 'pending' => domain.ShareInboxStatus.pending, 'accepted' => domain.ShareInboxStatus.accepted, 'declined' => domain.ShareInboxStatus.declined, 'revoked' => domain.ShareInboxStatus.revoked, _ => throw domain.DomainException('Unknown share inbox status: $value'), }; String _pendingShareActionTypeToDb(domain.PendingShareActionType type) => switch (type) { domain.PendingShareActionType.send => 'send', domain.PendingShareActionType.accept => 'accept', domain.PendingShareActionType.decline => 'decline', domain.PendingShareActionType.revoke => 'revoke', }; domain.PendingShareActionType _pendingShareActionTypeFromDb(String value) => switch (value) { 'send' => domain.PendingShareActionType.send, 'accept' => domain.PendingShareActionType.accept, 'decline' => domain.PendingShareActionType.decline, 'revoke' => domain.PendingShareActionType.revoke, _ => throw domain.DomainException('Unknown share action type: $value'), }; String _pendingShareActionStatusToDb(domain.PendingShareActionStatus status) => switch (status) { domain.PendingShareActionStatus.pending => 'pending', domain.PendingShareActionStatus.succeeded => 'succeeded', domain.PendingShareActionStatus.failed => 'failed', }; domain.PendingShareActionStatus _pendingShareActionStatusFromDb(String value) => switch (value) { 'pending' => domain.PendingShareActionStatus.pending, 'succeeded' => domain.PendingShareActionStatus.succeeded, 'failed' => domain.PendingShareActionStatus.failed, _ => throw domain.DomainException('Unknown share action status: $value'), }; String _activeStatusToDb(domain.ActiveWorkoutStatus status) => switch (status) { domain.ActiveWorkoutStatus.running => 'running', domain.ActiveWorkoutStatus.paused => 'paused', domain.ActiveWorkoutStatus.savedExit => 'savedExit', domain.ActiveWorkoutStatus.completed => 'completed', domain.ActiveWorkoutStatus.abandoned => 'abandoned', }; String _mediaKindToDb(domain.MediaKind kind) => switch (kind) { domain.MediaKind.image => 'image', domain.MediaKind.video => 'video', }; domain.MediaKind _mediaKindFromDb(String value) => switch (value) { 'image' => domain.MediaKind.image, 'video' => domain.MediaKind.video, _ => throw domain.DomainException('Unknown media kind: $value'), }; String _scoreInputModeToDb(domain.ScoreInputMode mode) => switch (mode) { domain.ScoreInputMode.manual => 'manual', domain.ScoreInputMode.stopwatch => 'stopwatch', }; domain.ScoreInputMode _scoreInputModeFromDb(String value) => switch (value) { 'manual' => domain.ScoreInputMode.manual, 'stopwatch' => domain.ScoreInputMode.stopwatch, _ => throw domain.DomainException('Unknown score input mode: $value'), }; String _exerciseStepTypeToDb(domain.ExerciseStepType type) => switch (type) { domain.ExerciseStepType.time => 'time', domain.ExerciseStepType.reps => 'reps', }; domain.ExerciseStepType _exerciseStepTypeFromDb(String value) => switch (value) { 'time' => domain.ExerciseStepType.time, 'reps' => domain.ExerciseStepType.reps, _ => throw domain.DomainException('Unknown exercise step type: $value'), }; String _exerciseCategoryToDb(domain.ExerciseCategory category) => switch (category) { domain.ExerciseCategory.shoot => 'shoot', domain.ExerciseCategory.freeThrows => 'freeThrows', domain.ExerciseCategory.dribble => 'dribble', domain.ExerciseCategory.finishing => 'finishing', domain.ExerciseCategory.conditioning => 'conditioning', domain.ExerciseCategory.defense => 'defense', domain.ExerciseCategory.mobility => 'mobility', domain.ExerciseCategory.uncategorized => 'uncategorized', }; domain.ExerciseCategory _exerciseCategoryFromDb(String value) => switch (value) { 'shoot' => domain.ExerciseCategory.shoot, 'freeThrows' => domain.ExerciseCategory.freeThrows, 'dribble' => domain.ExerciseCategory.dribble, 'finishing' => domain.ExerciseCategory.finishing, 'conditioning' => domain.ExerciseCategory.conditioning, 'defense' => domain.ExerciseCategory.defense, 'mobility' => domain.ExerciseCategory.mobility, 'uncategorized' => domain.ExerciseCategory.uncategorized, _ => throw domain.DomainException('Unknown exercise category: $value'), }; String _setResultStatusToDb(domain.SetResultStatus status) => switch (status) { domain.SetResultStatus.completed => 'completed', domain.SetResultStatus.skipped => 'skipped', }; domain.SetResultStatus _setResultStatusFromDb(String value) => switch (value) { 'completed' => domain.SetResultStatus.completed, 'skipped' => domain.SetResultStatus.skipped, _ => throw domain.DomainException('Unknown set result status: $value'), }; String _scoreStopwatchStatusToDb(domain.ActiveScoreStopwatchStatus status) => switch (status) { domain.ActiveScoreStopwatchStatus.running => 'running', domain.ActiveScoreStopwatchStatus.paused => 'paused', domain.ActiveScoreStopwatchStatus.stopped => 'stopped', }; domain.ActiveScoreStopwatchStatus _scoreStopwatchStatusFromDb(String value) => switch (value) { 'running' => domain.ActiveScoreStopwatchStatus.running, 'paused' => domain.ActiveScoreStopwatchStatus.paused, 'stopped' => domain.ActiveScoreStopwatchStatus.stopped, _ => throw domain.DomainException( 'Unknown active score stopwatch status: $value', ), }; String _setTimerStatusToDb(domain.ActiveSetTimerStatus status) => switch (status) { domain.ActiveSetTimerStatus.running => 'running', domain.ActiveSetTimerStatus.paused => 'paused', domain.ActiveSetTimerStatus.stopped => 'stopped', domain.ActiveSetTimerStatus.skipped => 'skipped', }; domain.ActiveSetTimerStatus _setTimerStatusFromDb(String value) => switch (value) { 'running' => domain.ActiveSetTimerStatus.running, 'paused' => domain.ActiveSetTimerStatus.paused, 'stopped' => domain.ActiveSetTimerStatus.stopped, 'skipped' => domain.ActiveSetTimerStatus.skipped, _ => throw domain.DomainException( 'Unknown active set timer status: $value', ), }; String _exerciseStepProgressStatusToDb( domain.ActiveExerciseStepProgressStatus status, ) => switch (status) { domain.ActiveExerciseStepProgressStatus.notStarted => 'notStarted', domain.ActiveExerciseStepProgressStatus.waitingManual => 'waitingManual', domain.ActiveExerciseStepProgressStatus.runningTimer => 'runningTimer', domain.ActiveExerciseStepProgressStatus.pausedTimer => 'pausedTimer', domain.ActiveExerciseStepProgressStatus.stoppedTimer => 'stoppedTimer', domain.ActiveExerciseStepProgressStatus.sequenceComplete => 'sequenceComplete', }; domain.ActiveExerciseStepProgressStatus _exerciseStepProgressStatusFromDb( String value, ) => switch (value) { 'notStarted' => domain.ActiveExerciseStepProgressStatus.notStarted, 'waitingManual' => domain.ActiveExerciseStepProgressStatus.waitingManual, 'runningTimer' => domain.ActiveExerciseStepProgressStatus.runningTimer, 'pausedTimer' => domain.ActiveExerciseStepProgressStatus.pausedTimer, 'stoppedTimer' => domain.ActiveExerciseStepProgressStatus.stoppedTimer, 'sequenceComplete' => domain.ActiveExerciseStepProgressStatus.sequenceComplete, _ => throw domain.DomainException( 'Unknown active exercise step progress status: $value', ), }; String? _encodeImageMediaIdsSnapshot(List imageMediaIds) { return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds); } List _decodeImageMediaIdsSnapshot( String? encoded, String? fallbackImageMediaId, ) { if (encoded == null || encoded.trim().isEmpty) { return [?fallbackImageMediaId]; } final decoded = jsonDecode(encoded); if (decoded is! List) { throw const domain.DomainException( 'Invalid exercise image media ids snapshot.', ); } return decoded .whereType() .where((id) => id.trim().isNotEmpty) .toList(growable: false); } String? _encodeExerciseStepsSnapshot(List steps) { return steps.isEmpty ? null : jsonEncode(steps.map((step) => step.toSnapshotJson()).toList()); } List _decodeExerciseStepsSnapshot(String? encoded) { if (encoded == null || encoded.trim().isEmpty) { return const []; } final decoded = jsonDecode(encoded); if (decoded is! List) { throw const domain.DomainException('Invalid exercise steps snapshot.'); } return decoded .map((item) { if (item is! Map) { throw const domain.DomainException('Invalid exercise step snapshot.'); } final json = Map.from(item); return domain.ExerciseStep( id: _requiredString(json, 'id'), position: _requiredInt(json, 'position'), name: _requiredString(json, 'name'), type: _exerciseStepTypeFromDb(_requiredString(json, 'type')), defaultTargetValue: _requiredInt(json, 'defaultTargetValue'), hasScore: _requiredBool(json, 'hasScore'), scoreInputMode: _scoreInputModeFromDb( _optionalString(json, 'scoreInputMode') ?? 'manual', ), scoreLabel: _optionalString(json, 'scoreLabel'), scoreUnit: _optionalString(json, 'scoreUnit'), defaultTargetScore: _optionalDouble(json, 'defaultTargetScore'), defaultTargetScoreTimeMs: _optionalInt( json, 'defaultTargetScoreTimeMs', ), linkedToSeriesScore: json['linkedToSeriesScore'] == true, ); }) .toList(growable: false); } String _requiredString(Map json, String key) { final value = json[key]; if (value is String && value.trim().isNotEmpty) { return value; } throw domain.DomainException('$key must be a non-empty string.'); } String? _optionalString(Map json, String key) { final value = json[key]; if (value == null) { return null; } return value is String ? value : null; } int _requiredInt(Map json, String key) { final value = json[key]; if (value is int) { return value; } throw domain.DomainException('$key must be an integer.'); } int? _optionalInt(Map json, String key) { final value = json[key]; return value is int ? value : null; } double? _optionalDouble(Map json, String key) { final value = json[key]; return (value as num?)?.toDouble(); } bool _requiredBool(Map json, String key) { final value = json[key]; if (value is bool) { return value; } throw domain.DomainException('$key must be a boolean.'); } domain.ActiveWorkoutStatus _activeStatusFromDb(String value) => switch (value) { 'running' => domain.ActiveWorkoutStatus.running, 'paused' => domain.ActiveWorkoutStatus.paused, 'savedExit' => domain.ActiveWorkoutStatus.savedExit, 'completed' => domain.ActiveWorkoutStatus.completed, 'abandoned' => domain.ActiveWorkoutStatus.abandoned, _ => throw domain.DomainException('Unknown active workout status: $value'), };