Files
GameTime/lib/infrastructure/local/drift_repositories.dart
Blomios a8baa3169c fix(statistiques): corrige dates et tri progression
Corrige le décodage des timestamps issus des customSelect (secondes vs millisecondes) et le tri des options d'exercice pour privilégier les exercices actifs avant les archivés, avec tie-breakers (ticket #82).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 08:42:55 +02:00

4849 lines
165 KiB
Dart

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<domain.Exercise?> 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<List<domain.Exercise>> 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 = <domain.Exercise>[];
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<bool> 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<void> 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<List<String>> _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<List<domain.ExerciseStep>> _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<String>(exerciseId)],
)
.getSingleOrNull();
if (row == null) {
return const _ExerciseStarterMetadata();
}
return _ExerciseStarterMetadata(
category: _exerciseCategoryFromDb(
row.readNullable<String>('category') ?? 'uncategorized',
),
isExample: (row.readNullable<int>('is_example') ?? 0) == 1,
);
}
}
final class DriftStarterSeedRepository
implements StarterSeedStateRepository, StarterContentRepository {
const DriftStarterSeedRepository(this.database);
static const _starterSeedKey = 'starter';
final db.AppDatabase database;
@override
Future<int> readAppliedStarterSeedVersion() async {
final row = await database
.customSelect(
'SELECT version FROM local_seed_metadata WHERE key = ? LIMIT 1',
variables: [Variable<String>(_starterSeedKey)],
)
.getSingleOrNull();
return row?.read<int>('version') ?? 0;
}
@override
Future<void> 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<bool> 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<void> 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<int> _tableCount(String tableName) async {
final row = await database
.customSelect('SELECT COUNT(*) AS count FROM $tableName')
.getSingle();
return row.read<int>('count');
}
}
final class DriftMediaAssetRepository implements MediaAssetRepository {
const DriftMediaAssetRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.MediaAsset?> 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<List<domain.MediaAsset>> listActive() async {
final rows = await (database.select(
database.mediaAssets,
)..where((table) => table.deletedAt.isNull())).get();
return rows.map(_mediaAssetFromRow).toList();
}
@override
Future<void> 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<domain.UserAccountSession?> 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<void> saveSession(domain.UserAccountSession session) async {
await database
.into(database.onlineAccountSessions)
.insertOnConflictUpdate(_userAccountSessionCompanion(session));
}
@override
Future<void> 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<SyncMetadataSnapshot> 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<void> save(SyncMetadataSnapshot metadata) async {
await database
.into(database.syncMetadataEntries)
.insertOnConflictUpdate(
db.SyncMetadataEntriesCompanion.insert(
id: const Value(_id),
serverCursor: Value<String?>(metadata.serverCursor),
lastSuccessfulSyncAt: Value<DateTime?>(
_utcOrNull(metadata.lastSuccessfulSyncAt),
),
lastAttemptAt: Value<DateTime?>(_utcOrNull(metadata.lastAttemptAt)),
lastFailureAt: Value<DateTime?>(_utcOrNull(metadata.lastFailureAt)),
status: Value(_onlineSyncStatusToDb(metadata.status)),
pendingPushCount: Value<int?>(metadata.pendingPushCount),
),
);
}
}
final class DriftRemoteResourceMappingRepository
implements RemoteResourceMappingRepository {
const DriftRemoteResourceMappingRepository(this.database);
final db.AppDatabase database;
@override
Future<RemoteResourceMapping?> 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<void> 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<List<PendingSyncChange>> 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 = <String, List<db.ChangeLogEntry>>{};
for (final row in rows) {
grouped
.putIfAbsent('${row.entityType}:${row.entityId}', () => [])
.add(row);
}
final changes = <PendingSyncChange>[];
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<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {
if (changeLogIds.isEmpty) {
return;
}
await (database.update(
database.changeLogEntries,
)..where((table) => table.id.isIn(changeLogIds))).write(
db.ChangeLogEntriesCompanion(
syncedAt: Value<DateTime?>(syncedAt.toUtc()),
),
);
}
@override
Future<bool> 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<RemoteSyncPushItem?> _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<DateTime?> _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<String>(id)],
)
.getSingleOrNull();
final value = row?.data['updated_at'];
return value is DateTime ? value.toUtc() : null;
}
Future<bool> _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<DateTime>(item.deletedAt!.toUtc()),
Variable<DateTime>(item.clientUpdatedAt.toUtc()),
Variable<String>(item.clientId),
],
);
return updated > 0;
}
Future<bool> _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<String>(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<List<domain.ShareInboxItem>> 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<domain.ShareInboxItem?> 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<void> upsert(domain.ShareInboxItem item) async {
await database
.into(database.shareInboxItems)
.insertOnConflictUpdate(_shareInboxItemCompanion(item));
}
@override
Future<void> upsertAll(List<domain.ShareInboxItem> items) async {
await database.batch((batch) {
batch.insertAllOnConflictUpdate(
database.shareInboxItems,
items.map(_shareInboxItemCompanion).toList(),
);
});
}
@override
Future<void> 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<String>(_shareInboxStatusToDb(status)),
respondedAt: Value<DateTime?>(respondedAt.toUtc()),
updatedAt: Value<DateTime>(respondedAt.toUtc()),
),
);
}
}
final class DriftPendingShareActionRepository
implements PendingShareActionRepository {
const DriftPendingShareActionRepository(this.database);
final db.AppDatabase database;
@override
Future<List<domain.PendingShareAction>> 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<void> add(domain.PendingShareAction action) async {
await database
.into(database.pendingShareActions)
.insertOnConflictUpdate(_pendingShareActionCompanion(action));
}
@override
Future<void> markSucceeded(String id, DateTime attemptedAt) async {
await _markActionAttempt(
id,
attemptedAt,
domain.PendingShareActionStatus.succeeded,
);
}
@override
Future<void> markFailed(String id, DateTime attemptedAt) async {
await _markActionAttempt(
id,
attemptedAt,
domain.PendingShareActionStatus.failed,
);
}
Future<void> _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<DateTime?>(attemptedAt.toUtc()),
attemptCount: Value<int>((row?.attemptCount ?? 0) + 1),
status: Value<String>(_pendingShareActionStatusToDb(status)),
),
);
}
}
final class DriftProgramRepository implements ProgramRepository {
const DriftProgramRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.Program?> 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<List<domain.Program>> listActive() async {
final rows =
await (database.select(database.programs)
..where((table) => table.deletedAt.isNull())
..orderBy([(table) => OrderingTerm.asc(table.name)]))
.get();
final programs = <domain.Program>[];
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<void> 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<void> 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<void> 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<bool> _isProgramExample(String id) async {
final row = await database
.customSelect(
'SELECT is_example FROM programs WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingleOrNull();
return (row?.readNullable<int>('is_example') ?? 0) == 1;
}
}
final class DriftWorkoutTemplateRepository
implements WorkoutTemplateRepository {
const DriftWorkoutTemplateRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.WorkoutTemplate?> 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
? <db.WorkoutTemplateExerciseOverride>[]
: 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<List<domain.WorkoutTemplate>> listActive() async {
final rows =
await (database.select(database.workoutTemplates)
..where((table) => table.deletedAt.isNull())
..orderBy([(table) => OrderingTerm.asc(table.name)]))
.get();
final templates = <domain.WorkoutTemplate>[];
for (final row in rows) {
final template = await findById(row.id);
if (template != null) {
templates.add(template);
}
}
return templates;
}
@override
Future<void> save(domain.WorkoutTemplate template) async {
await database.transaction(() 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<void> 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<void> 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<void> 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<bool> _isWorkoutTemplateExample(String id) async {
final row = await database
.customSelect(
'SELECT is_example FROM workout_templates WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingleOrNull();
return (row?.readNullable<int>('is_example') ?? 0) == 1;
}
}
final class DriftActiveSessionRepository implements ActiveSessionRepository {
const DriftActiveSessionRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.ActiveWorkoutSession?> 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<domain.ActiveWorkoutSession?> 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<void> 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<void> 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<void> 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<void> 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<domain.ActiveRestState?> findRestStateById(String id) async {
final row = await database
.customSelect(
'SELECT * FROM active_rest_states WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingleOrNull();
return row == null ? null : _activeRestStateFromCustomRow(row);
}
@override
Future<void> 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<void> 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<void> 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<void> 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<domain.ActiveScoreStopwatchState?> 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<domain.ActiveSetTimerState?> 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<String>(sessionId),
Variable<int>(programIndex),
Variable<int>(exerciseIndex),
Variable<int>(setIndex),
],
)
.getSingleOrNull();
return row == null ? null : _activeSetTimerStateFromCustomRow(row);
}
@override
Future<domain.ActiveExerciseStepProgressState?>
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<List<domain.ActiveSetResult>> 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<List<domain.ActiveSetTimerState>> 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<String>(sessionId)],
)
.get();
return rows.map(_activeSetTimerStateFromCustomRow).toList();
}
@override
Future<List<domain.ActiveExerciseStepProgressState>>
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<List<domain.ActiveExerciseStepResult>> 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<List<domain.ActiveRestState>> 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<String>(sessionId)],
)
.get();
return rows.map(_activeRestStateFromCustomRow).toList();
}
@override
Future<List<domain.ActiveScoreStopwatchState>> 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();
}
}
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
const DriftWorkoutHistoryRepository(this.database);
final db.AppDatabase database;
@override
Future<domain.WorkoutHistory?> 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<List<domain.WorkoutHistory>> listActive() async {
final rows =
await (database.select(database.workoutHistories)
..where((table) => table.deletedAt.isNull())
..orderBy([(table) => OrderingTerm.desc(table.startedAt)]))
.get();
final histories = <domain.WorkoutHistory>[];
for (final row in rows) {
final history = await findById(row.id);
if (history != null) {
histories.add(history);
}
}
return histories;
}
@override
Future<void> 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<void> 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<void> 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<void> 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 DriftExercisePerformanceReferenceRepository
implements ExercisePerformanceReferenceRepository {
const DriftExercisePerformanceReferenceRepository(this.database);
final db.AppDatabase database;
@override
Future<bool> 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<String>('completed'),
Variable<String>(exerciseId),
],
)
.getSingleOrNull();
return row != null;
}
@override
Future<WorkoutHistorySetPerformance?> 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<String>('completed'),
Variable<String>(exerciseId),
Variable<int>(currentSetIndex),
],
)
.getSingleOrNull();
return row == null ? null : _setPerformanceFromRow(row);
}
@override
Future<WorkoutHistoryMetricPerformance?> 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<String>('completed'),
Variable<String>(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<ProgressionGlobalStatsData> 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<List<ProgressionExerciseOptionData>> 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<List<ProgressionMeasureOptionData>> 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<String>(exerciseKey),
],
)
.get();
return rows.map(_progressionMeasureOptionFromRow).toList();
}
@override
Future<ProgressionExerciseSeriesData> 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<List<_ProgressionPointRow>> _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<String>(exerciseKey),
],
)
.get();
return rows.map((row) => _progressionPointRow(row, measure)).toList();
}
Future<bool> _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<String>(exerciseKey),
],
)
.getSingleOrNull();
return row != null;
}
}
List<Variable<Object>> _rangeVariables(ProgressionDateRange range) {
return [
Variable<DateTime>(range.endedAt.toUtc()),
if (range.startedAt != null) Variable<DateTime>(range.startedAt!.toUtc()),
];
}
List<Variable<Object>> _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<void> _upsertWithChangeLog({
required db.AppDatabase database,
required String tableName,
required String entityType,
required domain.EntityMetadata metadata,
required Future<void> 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<void> _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<void> _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),
),
),
);
}
}
Future<void> _softDeleteExerciseImageRows(
db.AppDatabase database,
List<db.ExerciseImage> 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<void> _softDeleteExerciseStepRows(
db.AppDatabase database,
List<db.ExerciseStep> 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<void> _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<void> _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<void> _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<String> _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<String>(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<void> _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<void> _softDeleteProgramExerciseRows(
db.AppDatabase database,
List<db.ProgramExercise> 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<void> _softDeleteWorkoutTemplateProgramRows(
db.AppDatabase database,
List<db.WorkoutTemplateProgram> 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<void> _softDeleteWorkoutTemplateExerciseOverrideRows(
db.AppDatabase database,
List<db.WorkoutTemplateExerciseOverride> 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<void> _softDeleteWorkoutHistorySetResultRows(
db.AppDatabase database,
List<db.WorkoutHistorySetResult> 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<void> _softDeleteWorkoutHistoryStepResultRows(
db.AppDatabase database,
List<db.WorkoutHistoryStepResult> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<dynamic> _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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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),
archivedAt: Value(_utcOrNull(exercise.archivedAt)),
);
}
domain.Exercise _exerciseFromRow(
db.Exercise row,
List<String> imageMediaIds,
List<domain.ExerciseStep> 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,
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,
);
}
db.MediaAssetsCompanion _mediaAssetCompanion(domain.MediaAsset asset) {
final values = _metadataValues(asset.metadata);
return db.MediaAssetsCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String?>(session.displayName),
isLoggedIn: Value(session.isLoggedIn),
createdAt: Value(session.createdAt.toUtc()),
updatedAt: Value(session.updatedAt.toUtc()),
lastAuthenticatedAt: Value<DateTime?>(
_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<DateTime?>(_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<String?>(action.shareId),
resourceType: Value<String?>(
action.resourceType == null
? null
: _shareResourceTypeToDb(action.resourceType!),
),
payloadJson: Value<String?>(action.payloadJson),
recipientEmailsJson: Value<String?>(action.recipientEmailsJson),
createdAt: action.createdAt.toUtc(),
lastAttemptAt: Value<DateTime?>(_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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
name: Value(program.name),
defaultRestSeconds: Value(program.defaultRestSeconds),
);
}
domain.Program _programFromRow(
db.Program row,
List<domain.ProgramExercise> exercises, {
bool isExample = false,
}) {
return domain.Program(
metadata: _metadataFromRow(row),
name: row.name,
defaultRestSeconds: row.defaultRestSeconds,
isExample: isExample,
exercises: exercises,
);
}
db.ProgramExercisesCompanion _programExerciseCompanion(
domain.ProgramExercise exercise,
) {
final values = _metadataValues(exercise.metadata);
return db.ProgramExercisesCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
name: Value(template.name),
lastStartedAt: Value(_utcOrNull(template.lastStartedAt)),
);
}
domain.WorkoutTemplate _workoutTemplateFromRow(
db.WorkoutTemplate row,
List<domain.WorkoutTemplateProgram> programs,
List<domain.WorkoutTemplateExerciseOverride> overrides, {
bool isExample = false,
}) {
return domain.WorkoutTemplate(
metadata: _metadataFromRow(row),
name: row.name,
lastStartedAt: _utcOrNull(row.lastStartedAt),
isExample: isExample,
programs: programs,
overrides: overrides,
);
}
db.WorkoutTemplateProgramsCompanion _workoutTemplateProgramCompanion(
domain.WorkoutTemplateProgram program,
) {
final values = _metadataValues(program.metadata);
return db.WorkoutTemplateProgramsCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<void> _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<DateTime>(_utcOrNull(restState.pausedAt)),
Variable<int>(restState.accumulatedPausedMs),
Variable<String>(restState.metadata.id),
],
);
}
Future<void> _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<String>(metadata.id),
Variable<DateTime>(metadata.createdAt.toUtc()),
Variable<DateTime>(metadata.updatedAt.toUtc()),
Variable<DateTime>(_utcOrNull(metadata.deletedAt)),
Variable<int>(metadata.schemaVersion),
Variable<String>(_syncStateToDb(metadata.syncState)),
Variable<int>(metadata.localRevision),
Variable<String>(metadata.originDeviceId),
Variable<String>(metadata.futureOwnerProfileId),
Variable<DateTime>(_utcOrNull(metadata.lastSyncedAt)),
Variable<String>(metadata.remoteRevision),
Variable<String>(state.activeWorkoutSessionId),
Variable<int>(state.programIndex),
Variable<int>(state.exerciseIndex),
Variable<int>(state.setIndex),
Variable<String>(_setTimerStatusToDb(state.status)),
Variable<DateTime>(_utcOrNull(state.startedAt)),
Variable<int>(state.accumulatedMs),
Variable<DateTime>(_utcOrNull(state.stoppedAt)),
Variable<DateTime>(_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 = <String>[];
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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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)),
);
}
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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<DateTime?>(_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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String?>(
result.scoreInputModeSnapshot == null
? null
: _scoreInputModeToDb(result.scoreInputModeSnapshot!),
),
scoreLabelSnapshot: Value<String?>(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value<String?>(result.scoreUnitSnapshot),
targetScoreSnapshot: Value<double?>(result.targetScoreSnapshot),
targetScoreTimeMsSnapshot: Value<int?>(result.targetScoreTimeMsSnapshot),
status: Value(_setResultStatusToDb(result.status)),
startedAt: Value<DateTime?>(_utcOrNull(result.startedAt)),
completedAt: Value<DateTime?>(_utcOrNull(result.completedAt)),
actualTimeMs: Value<int?>(result.actualTimeMs),
actualReps: Value<int?>(result.actualReps),
actualScore: Value<double?>(result.actualScore),
actualScoreTimeMs: Value<int?>(result.actualScoreTimeMs),
note: Value<String?>(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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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),
);
}
db.WorkoutHistorySetResultsCompanion _workoutHistorySetResultCompanion(
domain.WorkoutHistorySetResult result,
) {
final values = _metadataValues(result.metadata);
return db.WorkoutHistorySetResultsCompanion(
id: values[0] as Value<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String>,
createdAt: values[1] as Value<DateTime>,
updatedAt: values[2] as Value<DateTime>,
deletedAt: values[3] as Value<DateTime?>,
schemaVersion: values[4] as Value<int>,
syncState: values[5] as Value<String>,
localRevision: values[6] as Value<int>,
originDeviceId: values[7] as Value<String>,
futureOwnerProfileId: values[8] as Value<String?>,
lastSyncedAt: values[9] as Value<DateTime?>,
remoteRevision: values[10] as Value<String?>,
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<String?>(
result.scoreInputModeSnapshot == null
? null
: _scoreInputModeToDb(result.scoreInputModeSnapshot!),
),
scoreLabelSnapshot: Value<String?>(result.scoreLabelSnapshot),
scoreUnitSnapshot: Value<String?>(result.scoreUnitSnapshot),
targetScoreSnapshot: Value<double?>(result.targetScoreSnapshot),
targetScoreTimeMsSnapshot: Value<int?>(result.targetScoreTimeMsSnapshot),
status: Value(_setResultStatusToDb(result.status)),
startedAt: Value<DateTime?>(_utcOrNull(result.startedAt)),
completedAt: Value<DateTime?>(_utcOrNull(result.completedAt)),
actualTimeMs: Value<int?>(result.actualTimeMs),
actualReps: Value<int?>(result.actualReps),
actualScore: Value<double?>(result.actualScore),
actualScoreTimeMs: Value<int?>(result.actualScoreTimeMs),
note: Value<String?>(result.note),
sourceExerciseIdSnapshot: Value<String?>(result.sourceExerciseIdSnapshot),
);
}
domain.WorkoutHistory _workoutHistoryFromRow(
db.WorkoutHistory row,
List<domain.WorkoutHistorySetResult> results,
List<domain.WorkoutHistoryStepResult> 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,
results: results,
stepResults: stepResults,
);
}
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<String, Object?> 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<String, Object?> payload;
}
Map<String, Object?> _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<String, Object?> _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,
'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(),
'archivedAt': exercise.archivedAt?.toUtc().toIso8601String(),
};
Map<String, Object?> _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<String, Object?> _programPayload(domain.Program program) => {
'metadata': _metadataPayload(program.metadata),
'id': program.metadata.id,
'name': program.name,
'defaultRestSeconds': program.defaultRestSeconds,
'isExample': program.isExample,
'exercises': program.exercises
.map((exercise) => exercise.toSnapshotJson())
.toList(),
};
Map<String, Object?> _workoutTemplatePayload(domain.WorkoutTemplate template) =>
{
'metadata': _metadataPayload(template.metadata),
'id': template.metadata.id,
'name': template.name,
'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(),
'isExample': template.isExample,
'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<String, Object?> _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,
};
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,
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,
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,
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<String, Object?>.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: item.deletedAt == null
? domain.SyncState.synced
: domain.SyncState.deleted,
localRevision: map?['localRevision'] as int? ?? 0,
originDeviceId: map?['originDeviceId'] as String? ?? 'remote',
);
}
List<domain.ProgramExercise> _programExercisesFromPayload(
Object? value,
domain.EntityMetadata parentMetadata,
) {
if (value is! List) {
return const [];
}
return value
.whereType<Map>()
.map((entry) {
final map = Map<String, Object?>.from(entry);
final id = _stringFromPayload(map, 'id', 'program-exercise');
return domain.ProgramExercise(
metadata: _childMetadataFromPayload(map, id, parentMetadata),
programId: parentMetadata.id,
sourceExerciseId: null,
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<domain.WorkoutTemplateProgram> _workoutTemplateProgramsFromPayload(
Object? value,
domain.EntityMetadata parentMetadata,
) {
if (value is! List) {
return const [];
}
return value
.whereType<Map>()
.map((entry) {
final map = Map<String, Object?>.from(entry);
final id = _stringFromPayload(map, 'id', 'template-program');
return domain.WorkoutTemplateProgram(
metadata: _childMetadataFromPayload(map, id, parentMetadata),
workoutTemplateId: parentMetadata.id,
sourceProgramId: null,
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<domain.WorkoutTemplateExerciseOverride>
_workoutTemplateOverridesFromPayload(
Object? value,
domain.EntityMetadata parentMetadata,
) {
if (value is! List) {
return const [];
}
return value
.whereType<Map>()
.map((entry) {
final map = Map<String, Object?>.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);
}
domain.EntityMetadata _childMetadataFromPayload(
Map<String, Object?> payload,
String id,
domain.EntityMetadata parentMetadata,
) {
final metadata = payload['metadata'];
final map = metadata is Map ? Map<String, Object?>.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<String, Object?> payload,
String key,
String fallback,
) {
final value = payload[key];
return value is String && value.trim().isNotEmpty ? value : fallback;
}
List<String> _stringListFromPayload(Object? value) {
if (value is! List) {
return const [];
}
return value.whereType<String>().toList(growable: false);
}
List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
if (value is! List) {
return const [];
}
return value
.map((raw) {
final json = Map<String, Object?>.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',
),
);
})
.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 _shareResourceTypeFromDb(String value) =>
switch (value) {
'program' => domain.ShareResourceType.program,
'workoutTemplate' => domain.ShareResourceType.workoutTemplate,
_ => 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<String> imageMediaIds) {
return imageMediaIds.isEmpty ? null : jsonEncode(imageMediaIds);
}
List<String> _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<String>()
.where((id) => id.trim().isNotEmpty)
.toList(growable: false);
}
String? _encodeExerciseStepsSnapshot(List<domain.ExerciseStep> steps) {
return steps.isEmpty
? null
: jsonEncode(steps.map((step) => step.toSnapshotJson()).toList());
}
List<domain.ExerciseStep> _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<String, Object?>.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',
),
);
})
.toList(growable: false);
}
String _requiredString(Map<String, Object?> 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<String, Object?> json, String key) {
final value = json[key];
if (value == null) {
return null;
}
return value is String ? value : null;
}
int _requiredInt(Map<String, Object?> json, String key) {
final value = json[key];
if (value is int) {
return value;
}
throw domain.DomainException('$key must be an integer.');
}
int? _optionalInt(Map<String, Object?> json, String key) {
final value = json[key];
return value is int ? value : null;
}
double? _optionalDouble(Map<String, Object?> json, String key) {
final value = json[key];
return (value as num?)?.toDouble();
}
bool _requiredBool(Map<String, Object?> 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'),
};