Files
GameTime/lib/infrastructure/local/drift_repositories.dart
Blomios 65d43b9768 feat(watch): clôture lot #91 - fréquence cardiaque live, notifications de séance et finitions montre
Consolide le lot applicatif watch companion validé :
- télémétrie fréquence cardiaque live remontée montre -> téléphone
  (collecteur watch, adapter Wear Data Layer, persistance Drift,
  propagation aux écrans historique/programme/profil/exécution)
- notifications de séance en arrière-plan côté téléphone (service
  foreground de statut + passerelle applicative)
- finitions montre : chrono d'étape, score d'étape, retrait du bouton
  "lancer une séance", thème, icônes et polices watch_app

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 05:56:12 +02:00

5730 lines
196 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> saveManualScoreState(domain.ActiveManualScoreState state) async {
await _upsertWithChangeLog(
database: database,
tableName: 'active_manual_score_states',
entityType: 'ActiveManualScoreState',
metadata: state.metadata,
write: () => database
.into(database.activeManualScoreStates)
.insertOnConflictUpdate(_activeManualScoreStateCompanion(state)),
);
}
@override
Future<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<void> deleteManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
required DateTime deletedAt,
}) async {
final row =
await (database.select(database.activeManualScoreStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
if (row == null) {
return;
}
final revision = row.localRevision + 1;
await (database.delete(
database.activeManualScoreStates,
)..where((table) => table.id.equals(row.id))).go();
await _writeChangeLog(
database: database,
entityType: 'ActiveManualScoreState',
entityId: row.id,
operation: 'delete',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: deletedAt,
);
}
@override
Future<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.ActiveManualScoreState?> findManualScoreState({
required String sessionId,
required int programIndex,
required int exerciseIndex,
required int setIndex,
}) async {
final row =
await (database.select(database.activeManualScoreStates)..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.programIndex.equals(programIndex) &
table.exerciseIndex.equals(exerciseIndex) &
table.setIndex.equals(setIndex) &
table.deletedAt.isNull(),
))
.getSingleOrNull();
return row == null ? null : _activeManualScoreStateFromRow(row);
}
@override
Future<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();
}
@override
Future<List<domain.ActiveManualScoreState>> listManualScoreStates(
String sessionId,
) async {
final rows =
await (database.select(database.activeManualScoreStates)
..where(
(table) =>
table.activeWorkoutSessionId.equals(sessionId) &
table.deletedAt.isNull(),
)
..orderBy([
(table) => OrderingTerm.asc(table.programIndex),
(table) => OrderingTerm.asc(table.exerciseIndex),
(table) => OrderingTerm.asc(table.setIndex),
]))
.get();
return rows.map(_activeManualScoreStateFromRow).toList();
}
}
final class DriftWorkoutHistoryRepository implements WorkoutHistoryRepository {
const DriftWorkoutHistoryRepository(this.database);
final db.AppDatabase database;
@override
Future<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> patchHeartRateSummary({
required String historyId,
required double averageHeartRateBpm,
required int maxHeartRateBpm,
required DateTime patchedAt,
}) async {
if (averageHeartRateBpm <= 0 || maxHeartRateBpm <= 0) {
return;
}
final row =
await (database.select(database.workoutHistories)..where(
(table) =>
table.id.equals(historyId) &
table.deletedAt.isNull() &
table.averageHeartRateBpm.isNull() &
table.maxHeartRateBpm.isNull(),
))
.getSingleOrNull();
if (row == null) {
return;
}
final revision = row.localRevision + 1;
await (database.update(
database.workoutHistories,
)..where((table) => table.id.equals(historyId))).write(
db.WorkoutHistoriesCompanion(
updatedAt: Value(patchedAt.toUtc()),
syncState: const Value('dirty'),
localRevision: Value(revision),
averageHeartRateBpm: Value(averageHeartRateBpm),
maxHeartRateBpm: Value(maxHeartRateBpm),
),
);
await _writeChangeLog(
database: database,
entityType: 'WorkoutHistory',
entityId: historyId,
operation: 'update',
localRevision: revision,
originDeviceId: row.originDeviceId,
createdAt: patchedAt,
);
}
@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 DriftLocalDataBackupRepository
implements LocalDataBackupRepository {
const DriftLocalDataBackupRepository(this.database);
final db.AppDatabase database;
@override
Future<LocalDataExportSnapshot> readExportSnapshot(
DateTime exportedAt,
) async {
final mediaAssets = await DriftMediaAssetRepository(database).listActive();
final exercises = await DriftExerciseRepository(database).listActive();
final programs = await DriftProgramRepository(database).listActive();
final templates = await DriftWorkoutTemplateRepository(
database,
).listActive();
final histories = await DriftWorkoutHistoryRepository(
database,
).listActive();
return LocalDataExportSnapshot(
exportedAt: exportedAt.toUtc(),
appSchemaVersion: database.schemaVersion,
originDeviceId: 'local-device',
mediaAssets: [
for (final asset in mediaAssets)
_backupResource(asset.metadata, _mediaAssetPayload(asset)),
],
exercises: [
for (final exercise in exercises)
_backupResource(exercise.metadata, _exercisePayload(exercise)),
],
programs: [
for (final program in programs)
_backupResource(program.metadata, _programPayload(program)),
],
workoutTemplates: [
for (final template in templates)
_backupResource(template.metadata, _workoutTemplatePayload(template)),
],
workoutHistories: [
for (final history in histories)
_backupResource(
history.metadata,
_localWorkoutHistoryPayload(history),
),
],
);
}
@override
Future<bool> hasOpenActiveWorkoutSession() async {
return DriftActiveSessionRepository(
database,
).findOpen().then((session) => session != null);
}
@override
Future<bool> hasAnyUserData() async {
final count = await database.customSelect('''
SELECT
(SELECT COUNT(*) FROM media_assets WHERE deleted_at IS NULL) +
(SELECT COUNT(*) FROM exercises WHERE deleted_at IS NULL AND archived_at IS NULL) +
(SELECT COUNT(*) FROM programs WHERE deleted_at IS NULL) +
(SELECT COUNT(*) FROM workout_templates WHERE deleted_at IS NULL) +
(SELECT COUNT(*) FROM workout_history WHERE deleted_at IS NULL) AS total
''').getSingle();
return count.read<int>('total') > 0;
}
@override
Future<LocalBackupImportResult> applyImportSnapshot({
required LocalDataExportSnapshot snapshot,
required LocalBackupImportMode mode,
required DateTime importedAt,
}) async {
if (await hasOpenActiveWorkoutSession()) {
throw const LocalBackupException(
LocalBackupValidationError.activeWorkoutInProgress,
);
}
var inserted = 0;
var updated = 0;
var ignored = 0;
var deleted = 0;
await database.transaction(() async {
if (mode == LocalBackupImportMode.replaceAll) {
deleted += await _softDeleteResourcesAbsentFromBackup(
snapshot,
importedAt,
);
}
final force = mode == LocalBackupImportMode.replaceAll;
final mediaResult = await _applyBackupResources(
resources: snapshot.mediaAssets,
resourceType: SyncResourceType.mediaAsset,
tableName: 'media_assets',
importedAt: importedAt,
force: force,
);
inserted += mediaResult.insertedCount;
updated += mediaResult.updatedCount;
ignored += mediaResult.ignoredOlderCount;
final exerciseResult = await _applyBackupResources(
resources: snapshot.exercises,
resourceType: SyncResourceType.exercise,
tableName: 'exercises',
importedAt: importedAt,
force: force,
);
inserted += exerciseResult.insertedCount;
updated += exerciseResult.updatedCount;
ignored += exerciseResult.ignoredOlderCount;
final programResult = await _applyBackupResources(
resources: snapshot.programs,
resourceType: SyncResourceType.program,
tableName: 'programs',
importedAt: importedAt,
force: force,
);
inserted += programResult.insertedCount;
updated += programResult.updatedCount;
ignored += programResult.ignoredOlderCount;
final templateResult = await _applyBackupResources(
resources: snapshot.workoutTemplates,
resourceType: SyncResourceType.workoutTemplate,
tableName: 'workout_templates',
importedAt: importedAt,
force: force,
);
inserted += templateResult.insertedCount;
updated += templateResult.updatedCount;
ignored += templateResult.ignoredOlderCount;
final historyResult = await _applyBackupResources(
resources: snapshot.workoutHistories,
resourceType: SyncResourceType.workoutHistory,
tableName: 'workout_history',
importedAt: importedAt,
force: force,
);
inserted += historyResult.insertedCount;
updated += historyResult.updatedCount;
ignored += historyResult.ignoredOlderCount;
});
return LocalBackupImportResult(
insertedCount: inserted,
updatedCount: updated,
ignoredOlderCount: ignored,
deletedByReplaceCount: deleted,
missingMediaCount: 0,
);
}
Future<LocalBackupImportResult> _applyBackupResources({
required List<LocalBackupResource> resources,
required SyncResourceType resourceType,
required String tableName,
required DateTime importedAt,
required bool force,
}) async {
var inserted = 0;
var updated = 0;
var ignored = 0;
for (final resource in resources) {
final local = await _localSyncRow(tableName, resource.id);
if (!force &&
local != null &&
!resource.updatedAt.isAfter(local.updatedAt)) {
ignored += 1;
continue;
}
final revision = (local?.localRevision ?? 0) + 1;
final payload = _payloadWithImportedMetadata(
resource.payload,
importedAt,
revision,
snapshotOriginDeviceId: resource.payload['originDeviceId'] as String?,
);
await _saveBackupResource(
resourceType: resourceType,
resource: resource.copyWith(payload: payload),
importedAt: importedAt,
);
if (local == null || local.deletedAt != null) {
inserted += 1;
} else {
updated += 1;
}
}
return LocalBackupImportResult(
insertedCount: inserted,
updatedCount: updated,
ignoredOlderCount: ignored,
deletedByReplaceCount: 0,
missingMediaCount: 0,
);
}
Future<void> _saveBackupResource({
required SyncResourceType resourceType,
required LocalBackupResource resource,
required DateTime importedAt,
}) async {
final remoteItem = _remoteItemFromBackupResource(resourceType, resource);
switch (resourceType) {
case SyncResourceType.mediaAsset:
await DriftMediaAssetRepository(
database,
).save(_mediaAssetFromPayload(remoteItem));
case SyncResourceType.exercise:
await DriftExerciseRepository(
database,
).save(_exerciseFromPayload(remoteItem));
case SyncResourceType.program:
await DriftProgramRepository(
database,
).replaceExercises(_programFromPayload(remoteItem), importedAt);
case SyncResourceType.workoutTemplate:
await DriftWorkoutTemplateRepository(database).replaceComposition(
_workoutTemplateFromPayload(remoteItem),
importedAt,
);
case SyncResourceType.workoutHistory:
await _replaceWorkoutHistory(
_workoutHistoryFromLocalBackupPayload(remoteItem),
importedAt,
);
}
}
Future<void> _replaceWorkoutHistory(
domain.WorkoutHistory history,
DateTime deletedAt,
) async {
await DriftWorkoutHistoryRepository(database).save(history);
final activeResultIds = history.results
.map((result) => result.metadata.id)
.toSet();
final resultRows =
await (database.select(database.workoutHistorySetResults)..where(
(table) =>
table.workoutHistoryId.equals(history.metadata.id) &
table.deletedAt.isNull() &
(activeResultIds.isEmpty
? const Constant<bool>(true)
: table.id.isNotIn(activeResultIds)),
))
.get();
await _softDeleteWorkoutHistorySetResultRows(
database,
resultRows,
deletedAt,
);
final activeStepResultIds = history.stepResults
.map((result) => result.metadata.id)
.toSet();
final stepRows =
await (database.select(database.workoutHistoryStepResults)..where(
(table) =>
table.workoutHistoryId.equals(history.metadata.id) &
table.deletedAt.isNull() &
(activeStepResultIds.isEmpty
? const Constant<bool>(true)
: table.id.isNotIn(activeStepResultIds)),
))
.get();
await _softDeleteWorkoutHistoryStepResultRows(
database,
stepRows,
deletedAt,
);
}
Future<int> _softDeleteResourcesAbsentFromBackup(
LocalDataExportSnapshot snapshot,
DateTime deletedAt,
) async {
var deleted = 0;
deleted += await _softDeleteMainRowsAbsent(
tableName: 'media_assets',
entityType: 'MediaAsset',
keepIds: snapshot.mediaAssets.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
deleted += await _softDeleteMainRowsAbsent(
tableName: 'exercises',
entityType: 'Exercise',
keepIds: snapshot.exercises.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
deleted += await _softDeleteMainRowsAbsent(
tableName: 'programs',
entityType: 'Program',
keepIds: snapshot.programs.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
deleted += await _softDeleteMainRowsAbsent(
tableName: 'workout_templates',
entityType: 'WorkoutTemplate',
keepIds: snapshot.workoutTemplates.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
deleted += await _softDeleteMainRowsAbsent(
tableName: 'workout_history',
entityType: 'WorkoutHistory',
keepIds: snapshot.workoutHistories.map((resource) => resource.id).toSet(),
deletedAt: deletedAt,
);
await _softDeleteAllActiveSessionRows(deletedAt);
return deleted;
}
Future<int> _softDeleteMainRowsAbsent({
required String tableName,
required String entityType,
required Set<String> keepIds,
required DateTime deletedAt,
}) async {
final where = keepIds.isEmpty
? 'deleted_at IS NULL'
: 'deleted_at IS NULL AND id NOT IN (${List.filled(keepIds.length, '?').join(', ')})';
final rows = await database
.customSelect(
'SELECT id, local_revision, origin_device_id FROM $tableName WHERE $where',
variables: [for (final id in keepIds) Variable<String>(id)],
)
.get();
for (final row in rows) {
final id = row.read<String>('id');
final revision = row.read<int>('local_revision') + 1;
await database.customUpdate(
'UPDATE $tableName SET deleted_at = ?, updated_at = ?, '
'local_revision = ?, sync_state = ? WHERE id = ?',
variables: [
Variable<DateTime>(deletedAt.toUtc()),
Variable<DateTime>(deletedAt.toUtc()),
Variable<int>(revision),
const Variable<String>('deleted'),
Variable<String>(id),
],
);
await _writeChangeLog(
database: database,
entityType: entityType,
entityId: id,
operation: 'softDelete',
localRevision: revision,
originDeviceId: row.read<String>('origin_device_id'),
createdAt: deletedAt,
);
}
return rows.length;
}
Future<void> _softDeleteAllActiveSessionRows(DateTime deletedAt) async {
await database.customUpdate(
'UPDATE active_workout_sessions SET deleted_at = ?, updated_at = ? '
'WHERE deleted_at IS NULL',
variables: [
Variable<DateTime>(deletedAt.toUtc()),
Variable<DateTime>(deletedAt.toUtc()),
],
updates: {database.activeWorkoutSessions},
);
}
Future<_BackupLocalRow?> _localSyncRow(String tableName, String id) async {
final row = await database
.customSelect(
'SELECT updated_at, deleted_at, local_revision FROM $tableName '
'WHERE id = ? LIMIT 1',
variables: [Variable<String>(id)],
)
.getSingleOrNull();
if (row == null) {
return null;
}
return _BackupLocalRow(
updatedAt: _dateTimeFromData(row.data, 'updated_at'),
deletedAt: _dateTimeOrNullFromData(row.data, 'deleted_at'),
localRevision: row.read<int>('local_revision'),
);
}
}
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),
linkedToSeriesScore: Value(step.linkedToSeriesScore),
),
),
);
}
}
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),
tagsJson: Value(_encodeTags(exercise.tags)),
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,
tags: _decodeTags(row.tagsJson),
steps: steps,
archivedAt: _utcOrNull(row.archivedAt),
);
}
domain.ExerciseStep _exerciseStepFromRow(db.ExerciseStep row) {
return domain.ExerciseStep(
id: row.id,
position: row.position,
name: row.name,
type: _exerciseStepTypeFromDb(row.type),
defaultTargetValue: row.defaultTargetValue,
hasScore: row.hasScore,
scoreInputMode: row.scoreInputMode == null
? domain.ScoreInputMode.manual
: _scoreInputModeFromDb(row.scoreInputMode!),
scoreLabel: row.scoreLabel,
scoreUnit: row.scoreUnit,
defaultTargetScore: row.defaultTargetScore,
defaultTargetScoreTimeMs: row.defaultTargetScoreTimeMs,
linkedToSeriesScore: row.linkedToSeriesScore,
);
}
db.MediaAssetsCompanion _mediaAssetCompanion(domain.MediaAsset asset) {
final values = _metadataValues(asset.metadata);
return db.MediaAssetsCompanion(
id: values[0] as Value<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),
tagsJson: Value(_encodeTags(program.tags)),
);
}
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,
tags: _decodeTags(row.tagsJson),
exercises: exercises,
);
}
db.ProgramExercisesCompanion _programExerciseCompanion(
domain.ProgramExercise exercise,
) {
final values = _metadataValues(exercise.metadata);
return db.ProgramExercisesCompanion(
id: values[0] as Value<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)),
tagsJson: Value(_encodeTags(template.tags)),
);
}
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,
tags: _decodeTags(row.tagsJson),
programs: programs,
overrides: overrides,
);
}
db.WorkoutTemplateProgramsCompanion _workoutTemplateProgramCompanion(
domain.WorkoutTemplateProgram program,
) {
final values = _metadataValues(program.metadata);
return db.WorkoutTemplateProgramsCompanion(
id: values[0] as Value<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)),
);
}
db.ActiveManualScoreStatesCompanion _activeManualScoreStateCompanion(
domain.ActiveManualScoreState state,
) {
final values = _metadataValues(state.metadata);
return db.ActiveManualScoreStatesCompanion(
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),
value: Value(state.value),
scoreUpdatedAt: Value(state.updatedAt.toUtc()),
);
}
domain.ActiveManualScoreState _activeManualScoreStateFromRow(
db.ActiveManualScoreState row,
) {
return domain.ActiveManualScoreState(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
value: row.value,
updatedAt: _utc(row.scoreUpdatedAt),
);
}
domain.ActiveScoreStopwatchState _activeScoreStopwatchStateFromRow(
db.ActiveScoreStopwatchState row,
) {
return domain.ActiveScoreStopwatchState(
metadata: _metadataFromRow(row),
activeWorkoutSessionId: row.activeWorkoutSessionId,
programIndex: row.programIndex,
exerciseIndex: row.exerciseIndex,
setIndex: row.setIndex,
status: _scoreStopwatchStatusFromDb(row.status),
startedAt: _utc(row.startedAt),
accumulatedMs: row.accumulatedMs,
stoppedAt: _utcOrNull(row.stoppedAt),
);
}
db.ActiveExerciseStepProgressStatesCompanion
_activeExerciseStepProgressStateCompanion(
domain.ActiveExerciseStepProgressState state,
) {
final values = _metadataValues(state.metadata);
return db.ActiveExerciseStepProgressStatesCompanion(
id: values[0] as Value<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),
averageHeartRateBpm: Value(history.averageHeartRateBpm),
maxHeartRateBpm: Value(history.maxHeartRateBpm),
);
}
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,
averageHeartRateBpm: row.averageHeartRateBpm,
maxHeartRateBpm: row.maxHeartRateBpm,
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;
}
final class _BackupLocalRow {
const _BackupLocalRow({
required this.updatedAt,
required this.deletedAt,
required this.localRevision,
});
final DateTime updatedAt;
final DateTime? deletedAt;
final int localRevision;
}
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,
'tags': exercise.tags,
'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,
'tags': program.tags,
'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,
'tags': template.tags,
'programs': template.programs
.map(
(program) => {
'id': program.metadata.id,
'sourceProgramId': program.sourceProgramId,
'position': program.position,
'programNameSnapshot': program.programNameSnapshot,
'defaultRestSecondsSnapshot': program.defaultRestSecondsSnapshot,
'programSnapshotJson': program.programSnapshotJson,
},
)
.toList(),
'overrides': template.overrides
.map(
(override) => {
'id': override.metadata.id,
'workoutTemplateProgramId': override.workoutTemplateProgramId,
'snapshotProgramExerciseId': override.snapshotProgramExerciseId,
'setsCountOverride': override.setsCountOverride,
'targetTimeSecondsOverride': override.targetTimeSecondsOverride,
'targetRepsOverride': override.targetRepsOverride,
'targetScoreOverride': override.targetScoreOverride,
'targetScoreTimeMsOverride': override.targetScoreTimeMsOverride,
'autoStartNextTimedStepOverride':
override.autoStartNextTimedStepOverride,
},
)
.toList(),
};
Map<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,
'averageHeartRateBpm': history.averageHeartRateBpm,
'maxHeartRateBpm': history.maxHeartRateBpm,
};
Map<String, Object?> _localWorkoutHistoryPayload(
domain.WorkoutHistory history,
) => {
..._workoutHistoryPayload(history),
'results': history.results.map(_workoutHistorySetResultPayload).toList(),
'stepResults': history.stepResults
.map(_workoutHistoryStepResultPayload)
.toList(),
};
Map<String, Object?> _workoutHistorySetResultPayload(
domain.WorkoutHistorySetResult result,
) => {
'metadata': _metadataPayload(result.metadata),
'id': result.metadata.id,
'workoutHistoryId': result.workoutHistoryId,
'programSnapshotId': result.programSnapshotId,
'exerciseSnapshotId': result.exerciseSnapshotId,
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'programNameSnapshot': result.programNameSnapshot,
'exerciseNameSnapshot': result.exerciseNameSnapshot,
'timeEnabledSnapshot': result.timeEnabledSnapshot,
'repsEnabledSnapshot': result.repsEnabledSnapshot,
'scoreEnabledSnapshot': result.scoreEnabledSnapshot,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot.name,
'targetTimeSecondsSnapshot': result.targetTimeSecondsSnapshot,
'targetRepsSnapshot': result.targetRepsSnapshot,
'targetScoreSnapshot': result.targetScoreSnapshot,
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot,
'startedAt': result.startedAt?.toUtc().toIso8601String(),
'completedAt': result.completedAt?.toUtc().toIso8601String(),
'status': result.status.name,
};
Map<String, Object?> _workoutHistoryStepResultPayload(
domain.WorkoutHistoryStepResult result,
) => {
'metadata': _metadataPayload(result.metadata),
'id': result.metadata.id,
'workoutHistoryId': result.workoutHistoryId,
'programSnapshotId': result.programSnapshotId,
'exerciseSnapshotId': result.exerciseSnapshotId,
'programIndex': result.programIndex,
'exerciseIndex': result.exerciseIndex,
'setIndex': result.setIndex,
'passageIndex': result.passageIndex,
'stepIndex': result.stepIndex,
'stepSnapshotId': result.stepSnapshotId,
'stepNameSnapshot': result.stepNameSnapshot,
'stepTypeSnapshot': result.stepTypeSnapshot.name,
'targetValueSnapshot': result.targetValueSnapshot,
'hasScoreSnapshot': result.hasScoreSnapshot,
'scoreInputModeSnapshot': result.scoreInputModeSnapshot?.name,
'scoreLabelSnapshot': result.scoreLabelSnapshot,
'scoreUnitSnapshot': result.scoreUnitSnapshot,
'targetScoreSnapshot': result.targetScoreSnapshot,
'targetScoreTimeMsSnapshot': result.targetScoreTimeMsSnapshot,
'status': result.status.name,
'startedAt': result.startedAt?.toUtc().toIso8601String(),
'completedAt': result.completedAt?.toUtc().toIso8601String(),
'actualTimeMs': result.actualTimeMs,
'actualReps': result.actualReps,
'actualScore': result.actualScore,
'actualScoreTimeMs': result.actualScoreTimeMs,
'note': result.note,
'sourceExerciseIdSnapshot': result.sourceExerciseIdSnapshot,
};
LocalBackupResource _backupResource(
domain.EntityMetadata metadata,
Map<String, Object?> payload,
) {
return LocalBackupResource(
id: metadata.id,
updatedAt: metadata.updatedAt.toUtc(),
payload: payload,
);
}
RemoteSyncedItem _remoteItemFromBackupResource(
SyncResourceType resourceType,
LocalBackupResource resource,
) {
final metadata = resource.payload['metadata'];
final metadataMap = metadata is Map
? Map<String, Object?>.from(metadata)
: const <String, Object?>{};
return RemoteSyncedItem(
resourceType: resourceType,
clientId: resource.id,
serverId: '',
schemaVersion: metadataMap['schemaVersion'] as int? ?? 1,
clientUpdatedAt: resource.updatedAt.toUtc(),
serverUpdatedAt: resource.updatedAt.toUtc(),
deletedAt: _dateTimeFromPayload(metadataMap['deletedAt']),
payload: resource.payload,
);
}
Map<String, Object?> _payloadWithImportedMetadata(
Map<String, Object?> payload,
DateTime importedAt,
int localRevision, {
String? snapshotOriginDeviceId,
}) {
final metadata = payload['metadata'];
final metadataMap = metadata is Map
? Map<String, Object?>.from(metadata)
: <String, Object?>{};
return {
...payload,
'metadata': {
...metadataMap,
'updatedAt': importedAt.toUtc().toIso8601String(),
'deletedAt': null,
'syncState': domain.SyncState.dirty.name,
'localRevision': localRevision,
'originDeviceId':
metadataMap['originDeviceId'] as String? ??
snapshotOriginDeviceId ??
'local-device',
},
};
}
domain.WorkoutHistory _workoutHistoryFromLocalBackupPayload(
RemoteSyncedItem item,
) {
final payload = item.payload;
final metadata = _metadataFromPayload(item);
return domain.WorkoutHistory(
metadata: metadata,
sourceWorkoutTemplateId: payload['sourceWorkoutTemplateId'] as String?,
sourceActiveWorkoutSessionId:
payload['sourceActiveWorkoutSessionId'] as String?,
nameSnapshot: _stringFromPayload(payload, 'nameSnapshot', item.clientId),
startedAt:
_dateTimeFromPayload(payload['startedAt']) ?? item.clientUpdatedAt,
endedAt: _dateTimeFromPayload(payload['endedAt']) ?? item.clientUpdatedAt,
totalActiveMs: payload['totalActiveMs'] as int? ?? 0,
completed: payload['completed'] as bool? ?? false,
historySnapshotJson:
payload['historySnapshotJson'] as String? ?? '{"programs":[]}',
averageHeartRateBpm: (payload['averageHeartRateBpm'] as num?)?.toDouble(),
maxHeartRateBpm: payload['maxHeartRateBpm'] as int?,
results: _workoutHistorySetResultsFromPayload(payload['results'], metadata),
stepResults: _workoutHistoryStepResultsFromPayload(
payload['stepResults'],
metadata,
),
);
}
domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
final payload = item.payload;
return domain.Exercise(
metadata: _metadataFromPayload(item),
name: _stringFromPayload(payload, 'name', item.clientId),
description: payload['description'] as String?,
imageMediaIds: _stringListFromPayload(payload['imageMediaIds']),
iconMediaId: payload['iconMediaId'] as String?,
videoMediaId: payload['videoMediaId'] as String?,
hasTimeMeasure: payload['hasTimeMeasure'] == true,
hasRepsMeasure: payload['hasRepsMeasure'] == true,
hasScoreMeasure: payload['hasScoreMeasure'] == true,
scoreInputMode: _scoreInputModeFromDb(
payload['scoreInputMode'] as String? ?? 'manual',
),
scoreLabel: payload['scoreLabel'] as String?,
scoreUnit: payload['scoreUnit'] as String?,
defaultTargetTimeSeconds: payload['defaultTargetTimeSeconds'] as int?,
defaultTargetReps: payload['defaultTargetReps'] as int?,
defaultTargetScore: (payload['defaultTargetScore'] as num?)?.toDouble(),
defaultTargetScoreTimeMs: payload['defaultTargetScoreTimeMs'] as int?,
autoStartNextTimedStep: payload['autoStartNextTimedStep'] as bool? ?? true,
category: _exerciseCategoryFromDb(
payload['category'] as String? ?? 'uncategorized',
),
isExample: payload['isExample'] as bool? ?? false,
tags: _stringListFromPayload(payload['tags']),
steps: _stepsFromPayload(payload['steps']),
archivedAt: _dateTimeFromPayload(payload['archivedAt']),
);
}
domain.MediaAsset _mediaAssetFromPayload(RemoteSyncedItem item) {
final payload = item.payload;
return domain.MediaAsset(
metadata: _metadataFromPayload(item),
kind: _mediaKindFromDb(payload['kind'] as String? ?? 'image'),
localUri: _stringFromPayload(payload, 'localUri', ''),
mimeType: payload['mimeType'] as String?,
sizeBytes: payload['sizeBytes'] as int?,
width: payload['width'] as int?,
height: payload['height'] as int?,
durationMs: payload['durationMs'] as int?,
checksum: payload['checksum'] as String?,
remoteUri: payload['remoteUri'] as String?,
thumbnailLocalUri: payload['thumbnailLocalUri'] as String?,
);
}
domain.Program _programFromPayload(RemoteSyncedItem item) {
final payload = item.payload;
final metadata = _metadataFromPayload(item);
return domain.Program(
metadata: metadata,
name: _stringFromPayload(payload, 'name', item.clientId),
defaultRestSeconds: payload['defaultRestSeconds'] as int? ?? 0,
isExample: payload['isExample'] as bool? ?? false,
tags: _stringListFromPayload(payload['tags']),
exercises: _programExercisesFromPayload(payload['exercises'], metadata),
);
}
domain.WorkoutTemplate _workoutTemplateFromPayload(RemoteSyncedItem item) {
final payload = item.payload;
final metadata = _metadataFromPayload(item);
return domain.WorkoutTemplate(
metadata: metadata,
name: _stringFromPayload(payload, 'name', item.clientId),
lastStartedAt: _dateTimeFromPayload(payload['lastStartedAt']),
isExample: payload['isExample'] as bool? ?? false,
tags: _stringListFromPayload(payload['tags']),
programs: _workoutTemplateProgramsFromPayload(
payload['programs'],
metadata,
),
overrides: _workoutTemplateOverridesFromPayload(
payload['overrides'],
metadata,
),
);
}
domain.EntityMetadata _metadataFromPayload(RemoteSyncedItem item) {
final metadata = item.payload['metadata'];
final map = metadata is Map ? Map<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: map?['syncState'] is String
? _syncStateFromDb(map!['syncState'] as String)
: item.deletedAt == null
? domain.SyncState.synced
: domain.SyncState.deleted,
localRevision: map?['localRevision'] as int? ?? 0,
originDeviceId: map?['originDeviceId'] as String? ?? 'remote',
);
}
List<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: map['sourceExerciseId'] as String?,
position: map['position'] as int? ?? 0,
exerciseNameSnapshot: _stringFromPayload(
map,
'exerciseNameSnapshot',
id,
),
exerciseDescriptionSnapshot:
map['exerciseDescriptionSnapshot'] as String?,
exerciseImageMediaIdSnapshot:
map['exerciseImageMediaIdSnapshot'] as String?,
exerciseImageMediaIdsSnapshot: _stringListFromPayload(
map['exerciseImageMediaIdsSnapshot'] ??
map['imageMediaIdsSnapshot'],
),
exerciseVideoMediaIdSnapshot:
map['exerciseVideoMediaIdSnapshot'] as String?,
exerciseStepsSnapshot: _stepsFromPayload(
map['exerciseStepsSnapshot'],
),
autoStartNextTimedStepSnapshot:
map['autoStartNextTimedStepSnapshot'] as bool? ?? true,
autoStartNextTimedStepOverride:
map['autoStartNextTimedStepOverride'] as bool?,
exerciseArchivedSnapshot: map['exerciseArchivedSnapshot'] == true,
availableTimeSnapshot: map['availableTimeSnapshot'] == true,
availableRepsSnapshot: map['availableRepsSnapshot'] == true,
availableScoreSnapshot: map['availableScoreSnapshot'] == true,
scoreInputModeSnapshot: _scoreInputModeFromDb(
map['scoreInputModeSnapshot'] as String? ?? 'manual',
),
scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?,
setsCount: map['setsCount'] as int? ?? 1,
timeEnabled: map['timeEnabled'] == true,
repsEnabled: map['repsEnabled'] == true,
scoreEnabled: map['scoreEnabled'] == true,
targetTimeSeconds: map['targetTimeSeconds'] as int?,
targetReps: map['targetReps'] as int?,
targetScore: (map['targetScore'] as num?)?.toDouble(),
targetScoreTimeMs: map['targetScoreTimeMs'] as int?,
restSecondsOverride: map['restSecondsOverride'] as int?,
);
})
.toList(growable: false);
}
List<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: map['sourceProgramId'] as String?,
position: map['position'] as int? ?? 0,
programNameSnapshot: _stringFromPayload(
map,
'programNameSnapshot',
id,
),
defaultRestSecondsSnapshot:
map['defaultRestSecondsSnapshot'] as int? ?? 0,
programSnapshotJson:
map['programSnapshotJson'] as String? ?? '{"exercises":[]}',
);
})
.toList(growable: false);
}
List<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);
}
List<domain.WorkoutHistorySetResult> _workoutHistorySetResultsFromPayload(
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', 'history-set-result');
return domain.WorkoutHistorySetResult(
metadata: _childMetadataFromPayload(map, id, parentMetadata),
workoutHistoryId: parentMetadata.id,
programSnapshotId: _stringFromPayload(map, 'programSnapshotId', ''),
exerciseSnapshotId: _stringFromPayload(map, 'exerciseSnapshotId', ''),
programIndex: map['programIndex'] as int? ?? 0,
exerciseIndex: map['exerciseIndex'] as int? ?? 0,
setIndex: map['setIndex'] as int? ?? 0,
programNameSnapshot: _stringFromPayload(
map,
'programNameSnapshot',
'',
),
exerciseNameSnapshot: _stringFromPayload(
map,
'exerciseNameSnapshot',
'',
),
timeEnabledSnapshot: map['timeEnabledSnapshot'] == true,
repsEnabledSnapshot: map['repsEnabledSnapshot'] == true,
scoreEnabledSnapshot: map['scoreEnabledSnapshot'] == true,
scoreInputModeSnapshot: _scoreInputModeFromDb(
map['scoreInputModeSnapshot'] as String? ?? 'manual',
),
targetTimeSecondsSnapshot: map['targetTimeSecondsSnapshot'] as int?,
targetRepsSnapshot: map['targetRepsSnapshot'] as int?,
targetScoreSnapshot: (map['targetScoreSnapshot'] as num?)?.toDouble(),
targetScoreTimeMsSnapshot: map['targetScoreTimeMsSnapshot'] as int?,
actualTimeMs: map['actualTimeMs'] as int?,
actualReps: map['actualReps'] as int?,
actualScore: (map['actualScore'] as num?)?.toDouble(),
actualScoreTimeMs: map['actualScoreTimeMs'] as int?,
scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?,
sourceExerciseIdSnapshot: map['sourceExerciseIdSnapshot'] as String?,
startedAt: _dateTimeFromPayload(map['startedAt']),
completedAt: _dateTimeFromPayload(map['completedAt']),
status: _setResultStatusFromDb(
map['status'] as String? ?? 'completed',
),
);
})
.toList(growable: false);
}
List<domain.WorkoutHistoryStepResult> _workoutHistoryStepResultsFromPayload(
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', 'history-step-result');
return domain.WorkoutHistoryStepResult(
metadata: _childMetadataFromPayload(map, id, parentMetadata),
workoutHistoryId: parentMetadata.id,
programSnapshotId: _stringFromPayload(map, 'programSnapshotId', ''),
exerciseSnapshotId: _stringFromPayload(map, 'exerciseSnapshotId', ''),
programIndex: map['programIndex'] as int? ?? 0,
exerciseIndex: map['exerciseIndex'] as int? ?? 0,
setIndex: map['setIndex'] as int? ?? 0,
passageIndex: map['passageIndex'] as int? ?? 0,
stepIndex: map['stepIndex'] as int? ?? 0,
stepSnapshotId: _stringFromPayload(map, 'stepSnapshotId', ''),
stepNameSnapshot: _stringFromPayload(map, 'stepNameSnapshot', ''),
stepTypeSnapshot: _exerciseStepTypeFromDb(
map['stepTypeSnapshot'] as String? ?? 'reps',
),
targetValueSnapshot: map['targetValueSnapshot'] as int? ?? 0,
hasScoreSnapshot: map['hasScoreSnapshot'] == true,
scoreInputModeSnapshot: map['scoreInputModeSnapshot'] == null
? null
: _scoreInputModeFromDb(map['scoreInputModeSnapshot'] as String),
scoreLabelSnapshot: map['scoreLabelSnapshot'] as String?,
scoreUnitSnapshot: map['scoreUnitSnapshot'] as String?,
targetScoreSnapshot: (map['targetScoreSnapshot'] as num?)?.toDouble(),
targetScoreTimeMsSnapshot: map['targetScoreTimeMsSnapshot'] as int?,
status: _setResultStatusFromDb(
map['status'] as String? ?? 'completed',
),
startedAt: _dateTimeFromPayload(map['startedAt']),
completedAt: _dateTimeFromPayload(map['completedAt']),
actualTimeMs: map['actualTimeMs'] as int?,
actualReps: map['actualReps'] as int?,
actualScore: (map['actualScore'] as num?)?.toDouble(),
actualScoreTimeMs: map['actualScoreTimeMs'] as int?,
note: map['note'] as String?,
sourceExerciseIdSnapshot: map['sourceExerciseIdSnapshot'] as String?,
);
})
.toList(growable: false);
}
domain.EntityMetadata _childMetadataFromPayload(
Map<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);
}
String _encodeTags(List<String> tags) => jsonEncode(domain.normalizeTags(tags));
List<String> _decodeTags(String? encoded) {
if (encoded == null || encoded.trim().isEmpty) {
return const [];
}
try {
final decoded = jsonDecode(encoded);
if (decoded is! List) {
return const [];
}
return domain.normalizeTags(decoded.whereType<String>());
} on Object {
return const [];
}
}
List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
if (value is! List) {
return const [];
}
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',
),
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
);
})
.toList(growable: false);
}
DateTime? _dateTimeFromPayload(Object? value) {
return value is String ? DateTime.tryParse(value)?.toUtc() : null;
}
const _syncableEntityTypes = [
'Exercise',
'Program',
'WorkoutTemplate',
'WorkoutHistory',
'MediaAsset',
];
SyncResourceType? _syncResourceTypeFromEntityType(String entityType) {
return switch (entityType) {
'Exercise' => SyncResourceType.exercise,
'Program' => SyncResourceType.program,
'WorkoutTemplate' => SyncResourceType.workoutTemplate,
'WorkoutHistory' => SyncResourceType.workoutHistory,
'MediaAsset' => SyncResourceType.mediaAsset,
_ => null,
};
}
String _tableNameForResourceType(SyncResourceType type) => switch (type) {
SyncResourceType.exercise => 'exercises',
SyncResourceType.program => 'programs',
SyncResourceType.workoutTemplate => 'workout_templates',
SyncResourceType.workoutHistory => 'workout_history',
SyncResourceType.mediaAsset => 'media_assets',
};
String _syncStateToDb(domain.SyncState state) => switch (state) {
domain.SyncState.localOnly => 'localOnly',
domain.SyncState.dirty => 'dirty',
domain.SyncState.synced => 'synced',
domain.SyncState.deleted => 'deleted',
};
domain.SyncState _syncStateFromDb(String value) => switch (value) {
'localOnly' => domain.SyncState.localOnly,
'dirty' => domain.SyncState.dirty,
'synced' => domain.SyncState.synced,
'deleted' => domain.SyncState.deleted,
_ => throw domain.DomainException('Unknown sync state: $value'),
};
String _syncResourceTypeToDb(SyncResourceType type) => switch (type) {
SyncResourceType.exercise => 'exercise',
SyncResourceType.program => 'program',
SyncResourceType.workoutTemplate => 'workoutTemplate',
SyncResourceType.workoutHistory => 'workoutHistory',
SyncResourceType.mediaAsset => 'mediaAsset',
};
SyncResourceType _syncResourceTypeFromDb(String value) => switch (value) {
'exercise' => SyncResourceType.exercise,
'program' => SyncResourceType.program,
'workoutTemplate' => SyncResourceType.workoutTemplate,
'workoutHistory' => SyncResourceType.workoutHistory,
'mediaAsset' => SyncResourceType.mediaAsset,
_ => throw domain.DomainException('Unknown sync resource type: $value'),
};
String _onlineSyncStatusToDb(OnlineSyncStatus status) => switch (status) {
OnlineSyncStatus.idle => 'idle',
OnlineSyncStatus.syncing => 'syncing',
OnlineSyncStatus.success => 'success',
OnlineSyncStatus.failure => 'failure',
};
OnlineSyncStatus _onlineSyncStatusFromDb(String value) => switch (value) {
'idle' => OnlineSyncStatus.idle,
'syncing' => OnlineSyncStatus.syncing,
'success' => OnlineSyncStatus.success,
'failure' => OnlineSyncStatus.failure,
_ => throw domain.DomainException('Unknown sync status: $value'),
};
String _shareResourceTypeToDb(domain.ShareResourceType type) => switch (type) {
domain.ShareResourceType.program => 'program',
domain.ShareResourceType.workoutTemplate => 'workoutTemplate',
};
domain.ShareResourceType _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',
),
linkedToSeriesScore: json['linkedToSeriesScore'] == true,
);
})
.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'),
};