diff --git a/lib/application/app_bootstrap.dart b/lib/application/app_bootstrap.dart index 60e25ff..7729c89 100644 --- a/lib/application/app_bootstrap.dart +++ b/lib/application/app_bootstrap.dart @@ -13,6 +13,7 @@ abstract interface class AppDependencies { ActiveExerciseStepUseCases get activeExerciseStepUseCases; CloseWorkoutSessionUseCase get closeWorkoutSessionUseCase; WorkoutHistoryUseCases get workoutHistoryUseCases; + SyncUseCases get syncUseCases; } final class AppBootstrap implements AppDependencies { @@ -27,6 +28,7 @@ final class AppBootstrap implements AppDependencies { required this.activeExerciseStepUseCases, required this.closeWorkoutSessionUseCase, required this.workoutHistoryUseCases, + required this.syncUseCases, required this.syncGateway, }); @@ -49,6 +51,8 @@ final class AppBootstrap implements AppDependencies { final CloseWorkoutSessionUseCase closeWorkoutSessionUseCase; @override final WorkoutHistoryUseCases workoutHistoryUseCases; + @override + final SyncUseCases syncUseCases; final SyncGateway syncGateway; static Future create() async { @@ -57,6 +61,9 @@ final class AppBootstrap implements AppDependencies { final exerciseRepository = DriftExerciseRepository(database); final mediaRepository = DriftMediaAssetRepository(database); final onlineAccountRepository = DriftOnlineAccountRepository(database); + final syncMetadataRepository = DriftSyncMetadataRepository(database); + final mappingRepository = DriftRemoteResourceMappingRepository(database); + final localSyncChangeRepository = DriftLocalSyncChangeRepository(database); final programRepository = DriftProgramRepository(database); final templateRepository = DriftWorkoutTemplateRepository(database); final activeSessionRepository = DriftActiveSessionRepository(database); @@ -67,6 +74,9 @@ final class AppBootstrap implements AppDependencies { final remoteAuthApi = HttpRemoteAuthApi( HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)), ); + final remoteSyncApi = HttpRemoteSyncApi( + HttpApiClient(baseUrl: Uri.parse(HttpApiClient.defaultBaseUrl)), + ); return AppBootstrap._( database: database, @@ -131,6 +141,15 @@ final class AppBootstrap implements AppDependencies { repository: historyRepository, clock: clock, ), + syncUseCases: SyncUseCases( + tokenStore: const SecureStorageAuthTokenStore(), + remoteSyncApi: remoteSyncApi, + metadataRepository: syncMetadataRepository, + mappingRepository: mappingRepository, + localChanges: localSyncChangeRepository, + clock: clock, + deviceId: originDeviceId, + ), syncGateway: const NoOpSyncGateway(), ); } diff --git a/lib/application/ports.dart b/lib/application/ports.dart index bb9b6d2..c4b6d49 100644 --- a/lib/application/ports.dart +++ b/lib/application/ports.dart @@ -73,6 +73,194 @@ abstract interface class RemoteAuthApi { Future logout(String token); } +enum SyncResourceType { + exercise, + program, + workoutTemplate, + workoutHistory, + mediaAsset, +} + +enum RemoteSyncPushStatus { accepted, ignoredOlder, error } + +enum OnlineSyncStatus { idle, syncing, success, failure } + +final class RemoteSyncPushItem { + const RemoteSyncPushItem({ + required this.resourceType, + required this.clientId, + required this.schemaVersion, + required this.clientUpdatedAt, + required this.deletedAt, + required this.payload, + }); + + final SyncResourceType resourceType; + final String clientId; + final int schemaVersion; + final DateTime clientUpdatedAt; + final DateTime? deletedAt; + final Map payload; +} + +final class RemoteSyncPushItemResult { + const RemoteSyncPushItemResult({ + required this.resourceType, + required this.clientId, + this.serverId, + required this.status, + this.serverUpdatedAt, + this.errorMessage, + }); + + final SyncResourceType resourceType; + final String clientId; + final String? serverId; + final RemoteSyncPushStatus status; + final DateTime? serverUpdatedAt; + final String? errorMessage; +} + +final class RemoteSyncPushResult { + const RemoteSyncPushResult({ + required this.serverCursor, + required this.results, + }); + + final String? serverCursor; + final List results; +} + +final class RemoteSyncedItem { + const RemoteSyncedItem({ + required this.resourceType, + required this.clientId, + required this.serverId, + required this.schemaVersion, + required this.clientUpdatedAt, + required this.serverUpdatedAt, + required this.deletedAt, + required this.payload, + }); + + final SyncResourceType resourceType; + final String clientId; + final String serverId; + final int schemaVersion; + final DateTime clientUpdatedAt; + final DateTime serverUpdatedAt; + final DateTime? deletedAt; + final Map payload; +} + +final class RemoteSyncPullResult { + const RemoteSyncPullResult({required this.serverCursor, required this.items}); + + final String? serverCursor; + final List items; +} + +abstract interface class RemoteSyncApi { + Future push({ + required String deviceId, + required List items, + required String token, + }); + + Future pull({ + required String? since, + required String token, + }); +} + +final class SyncMetadataSnapshot { + const SyncMetadataSnapshot({ + this.serverCursor, + this.lastSuccessfulSyncAt, + this.lastAttemptAt, + this.lastFailureAt, + this.status = OnlineSyncStatus.idle, + this.pendingPushCount, + }); + + final String? serverCursor; + final DateTime? lastSuccessfulSyncAt; + final DateTime? lastAttemptAt; + final DateTime? lastFailureAt; + final OnlineSyncStatus status; + final int? pendingPushCount; + + SyncMetadataSnapshot copyWith({ + Object? serverCursor = _portsUnchanged, + Object? lastSuccessfulSyncAt = _portsUnchanged, + Object? lastAttemptAt = _portsUnchanged, + Object? lastFailureAt = _portsUnchanged, + OnlineSyncStatus? status, + Object? pendingPushCount = _portsUnchanged, + }) { + return SyncMetadataSnapshot( + serverCursor: serverCursor == _portsUnchanged + ? this.serverCursor + : serverCursor as String?, + lastSuccessfulSyncAt: lastSuccessfulSyncAt == _portsUnchanged + ? this.lastSuccessfulSyncAt + : lastSuccessfulSyncAt as DateTime?, + lastAttemptAt: lastAttemptAt == _portsUnchanged + ? this.lastAttemptAt + : lastAttemptAt as DateTime?, + lastFailureAt: lastFailureAt == _portsUnchanged + ? this.lastFailureAt + : lastFailureAt as DateTime?, + status: status ?? this.status, + pendingPushCount: pendingPushCount == _portsUnchanged + ? this.pendingPushCount + : pendingPushCount as int?, + ); + } +} + +final class RemoteResourceMapping { + const RemoteResourceMapping({ + required this.resourceType, + required this.clientId, + required this.serverId, + required this.serverUpdatedAt, + }); + + final SyncResourceType resourceType; + final String clientId; + final String serverId; + final DateTime serverUpdatedAt; +} + +final class PendingSyncChange { + const PendingSyncChange({required this.changeLogIds, required this.item}); + + final List changeLogIds; + final RemoteSyncPushItem item; +} + +abstract interface class SyncMetadataRepository { + Future read(); + Future save(SyncMetadataSnapshot metadata); +} + +abstract interface class RemoteResourceMappingRepository { + Future find({ + required SyncResourceType resourceType, + required String clientId, + }); + Future save(RemoteResourceMapping mapping); +} + +abstract interface class LocalSyncChangeRepository { + Future> listPendingChanges(); + Future markChangesSynced(List changeLogIds, DateTime syncedAt); + Future applyRemoteItem(RemoteSyncedItem item); +} + +const Object _portsUnchanged = Object(); + abstract interface class ExerciseRepository { Future findById(String id); Future> listActive(); @@ -189,10 +377,16 @@ final class SyncRunSummary { const SyncRunSummary({ required this.pushedChanges, required this.pulledChanges, + this.skipped = false, + this.failed = false, + this.message, }); final int pushedChanges; final int pulledChanges; + final bool skipped; + final bool failed; + final String? message; } abstract interface class SyncGateway { diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index b798e01..c3adec3 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -106,6 +106,135 @@ final class AuthUseCases { } } +final class SyncUseCases { + const SyncUseCases({ + required this.tokenStore, + required this.remoteSyncApi, + required this.metadataRepository, + required this.mappingRepository, + required this.localChanges, + required this.clock, + required this.deviceId, + }); + + final AuthTokenStore tokenStore; + final RemoteSyncApi remoteSyncApi; + final SyncMetadataRepository metadataRepository; + final RemoteResourceMappingRepository mappingRepository; + final LocalSyncChangeRepository localChanges; + final Clock clock; + final String deviceId; + + Future synchronize({required bool manual}) async { + final token = await tokenStore.readToken(); + if (token == null) { + return const SyncRunSummary( + pushedChanges: 0, + pulledChanges: 0, + skipped: true, + message: 'Not connected.', + ); + } + + final previousMetadata = await metadataRepository.read(); + final attemptAt = clock.now(); + await metadataRepository.save( + previousMetadata.copyWith( + lastAttemptAt: attemptAt, + status: OnlineSyncStatus.syncing, + ), + ); + + try { + final pendingChanges = await localChanges.listPendingChanges(); + var pushedCount = 0; + if (pendingChanges.isNotEmpty) { + final pushResult = await remoteSyncApi.push( + deviceId: deviceId, + items: pendingChanges.map((change) => change.item).toList(), + token: token, + ); + final changesByKey = { + for (final change in pendingChanges) + _syncKey(change.item.resourceType, change.item.clientId): change, + }; + final syncedChangeLogIds = []; + for (final result in pushResult.results) { + final change = + changesByKey[_syncKey(result.resourceType, result.clientId)]; + if (change == null || result.status == RemoteSyncPushStatus.error) { + continue; + } + pushedCount += result.status == RemoteSyncPushStatus.accepted ? 1 : 0; + syncedChangeLogIds.addAll(change.changeLogIds); + if (result.serverId != null && result.serverUpdatedAt != null) { + await mappingRepository.save( + RemoteResourceMapping( + resourceType: result.resourceType, + clientId: result.clientId, + serverId: result.serverId!, + serverUpdatedAt: result.serverUpdatedAt!, + ), + ); + } + } + if (syncedChangeLogIds.isNotEmpty) { + await localChanges.markChangesSynced(syncedChangeLogIds, clock.now()); + } + } + + final metadataBeforePull = await metadataRepository.read(); + final pullResult = await remoteSyncApi.pull( + since: metadataBeforePull.serverCursor, + token: token, + ); + var pulledCount = 0; + for (final item in pullResult.items) { + await mappingRepository.save( + RemoteResourceMapping( + resourceType: item.resourceType, + clientId: item.clientId, + serverId: item.serverId, + serverUpdatedAt: item.serverUpdatedAt, + ), + ); + final applied = await localChanges.applyRemoteItem(item); + if (applied) { + pulledCount += 1; + } + } + + final completedAt = clock.now(); + await metadataRepository.save( + (await metadataRepository.read()).copyWith( + serverCursor: pullResult.serverCursor, + lastSuccessfulSyncAt: completedAt, + lastFailureAt: null, + status: OnlineSyncStatus.success, + pendingPushCount: 0, + ), + ); + return SyncRunSummary( + pushedChanges: pushedCount, + pulledChanges: pulledCount, + ); + } catch (error) { + await metadataRepository.save( + (await metadataRepository.read()).copyWith( + lastFailureAt: clock.now(), + status: OnlineSyncStatus.failure, + ), + ); + return SyncRunSummary( + pushedChanges: 0, + pulledChanges: 0, + failed: true, + message: error.toString(), + ); + } + } +} + final class ExerciseUseCases { const ExerciseUseCases({ required this.repository, @@ -2509,6 +2638,10 @@ String _positionKey(int programIndex, int exerciseIndex, int setIndex) { return '$programIndex:$exerciseIndex:$setIndex'; } +String _syncKey(SyncResourceType resourceType, String clientId) { + return '${resourceType.name}:$clientId'; +} + ScoreInputMode _scoreInputModeFromSnapshot(Object? value) { return switch (value) { 'stopwatch' => ScoreInputMode.stopwatch, diff --git a/lib/infrastructure/local/app_database.dart b/lib/infrastructure/local/app_database.dart index 0371bb9..c3e0347 100644 --- a/lib/infrastructure/local/app_database.dart +++ b/lib/infrastructure/local/app_database.dart @@ -21,6 +21,8 @@ part 'app_database.g.dart'; OnlineAccountSessions, ProgramExercises, Programs, + RemoteResourceMappings, + SyncMetadataEntries, WorkoutHistories, WorkoutHistorySetResults, WorkoutHistoryStepResults, @@ -42,7 +44,7 @@ final class AppDatabase extends _$AppDatabase { } @override - int get schemaVersion => 10; + int get schemaVersion => 11; @override MigrationStrategy get migration { @@ -87,6 +89,9 @@ final class AppDatabase extends _$AppDatabase { if (from < 10) { await _migrateToSchema10(migrator); } + if (from < 11) { + await _migrateToSchema11(migrator); + } await _createIndexes(); }, beforeOpen: (details) async { @@ -131,6 +136,10 @@ final class AppDatabase extends _$AppDatabase { 'CREATE INDEX IF NOT EXISTS idx_online_account_sessions_logged_in ' 'ON online_account_sessions (is_logged_in, updated_at)', ); + await customStatement( + 'CREATE INDEX IF NOT EXISTS idx_remote_resource_mappings_resource ' + 'ON remote_resource_mappings (resource_type, client_id)', + ); await customStatement( 'CREATE INDEX IF NOT EXISTS idx_workout_template_programs_template_id ' 'ON workout_template_programs (workout_template_id)', @@ -327,4 +336,9 @@ extension on AppDatabase { Future _migrateToSchema10(Migrator migrator) async { await migrator.createTable(onlineAccountSessions); } + + Future _migrateToSchema11(Migrator migrator) async { + await migrator.createTable(syncMetadataEntries); + await migrator.createTable(remoteResourceMappings); + } } diff --git a/lib/infrastructure/local/app_database.g.dart b/lib/infrastructure/local/app_database.g.dart index 0825180..b39d02d 100644 --- a/lib/infrastructure/local/app_database.g.dart +++ b/lib/infrastructure/local/app_database.g.dart @@ -18459,6 +18459,893 @@ class ProgramExercisesCompanion extends UpdateCompanion { } } +class $RemoteResourceMappingsTable extends RemoteResourceMappings + with TableInfo<$RemoteResourceMappingsTable, RemoteResourceMapping> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $RemoteResourceMappingsTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _resourceTypeMeta = const VerificationMeta( + 'resourceType', + ); + @override + late final GeneratedColumn resourceType = GeneratedColumn( + 'resource_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _clientIdMeta = const VerificationMeta( + 'clientId', + ); + @override + late final GeneratedColumn clientId = GeneratedColumn( + 'client_id', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _serverIdMeta = const VerificationMeta( + 'serverId', + ); + @override + late final GeneratedColumn serverId = GeneratedColumn( + 'server_id', + aliasedName, + false, + additionalChecks: GeneratedColumn.checkTextLength(minTextLength: 1), + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _serverUpdatedAtMeta = const VerificationMeta( + 'serverUpdatedAt', + ); + @override + late final GeneratedColumn serverUpdatedAt = + GeneratedColumn( + 'server_updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + resourceType, + clientId, + serverId, + serverUpdatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'remote_resource_mappings'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('resource_type')) { + context.handle( + _resourceTypeMeta, + resourceType.isAcceptableOrUnknown( + data['resource_type']!, + _resourceTypeMeta, + ), + ); + } else if (isInserting) { + context.missing(_resourceTypeMeta); + } + if (data.containsKey('client_id')) { + context.handle( + _clientIdMeta, + clientId.isAcceptableOrUnknown(data['client_id']!, _clientIdMeta), + ); + } else if (isInserting) { + context.missing(_clientIdMeta); + } + if (data.containsKey('server_id')) { + context.handle( + _serverIdMeta, + serverId.isAcceptableOrUnknown(data['server_id']!, _serverIdMeta), + ); + } else if (isInserting) { + context.missing(_serverIdMeta); + } + if (data.containsKey('server_updated_at')) { + context.handle( + _serverUpdatedAtMeta, + serverUpdatedAt.isAcceptableOrUnknown( + data['server_updated_at']!, + _serverUpdatedAtMeta, + ), + ); + } else if (isInserting) { + context.missing(_serverUpdatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + RemoteResourceMapping map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return RemoteResourceMapping( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + resourceType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}resource_type'], + )!, + clientId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}client_id'], + )!, + serverId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_id'], + )!, + serverUpdatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}server_updated_at'], + )!, + ); + } + + @override + $RemoteResourceMappingsTable createAlias(String alias) { + return $RemoteResourceMappingsTable(attachedDatabase, alias); + } +} + +class RemoteResourceMapping extends DataClass + implements Insertable { + final String id; + final String resourceType; + final String clientId; + final String serverId; + final DateTime serverUpdatedAt; + const RemoteResourceMapping({ + required this.id, + required this.resourceType, + required this.clientId, + required this.serverId, + required this.serverUpdatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['resource_type'] = Variable(resourceType); + map['client_id'] = Variable(clientId); + map['server_id'] = Variable(serverId); + map['server_updated_at'] = Variable(serverUpdatedAt); + return map; + } + + RemoteResourceMappingsCompanion toCompanion(bool nullToAbsent) { + return RemoteResourceMappingsCompanion( + id: Value(id), + resourceType: Value(resourceType), + clientId: Value(clientId), + serverId: Value(serverId), + serverUpdatedAt: Value(serverUpdatedAt), + ); + } + + factory RemoteResourceMapping.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return RemoteResourceMapping( + id: serializer.fromJson(json['id']), + resourceType: serializer.fromJson(json['resourceType']), + clientId: serializer.fromJson(json['clientId']), + serverId: serializer.fromJson(json['serverId']), + serverUpdatedAt: serializer.fromJson(json['serverUpdatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'resourceType': serializer.toJson(resourceType), + 'clientId': serializer.toJson(clientId), + 'serverId': serializer.toJson(serverId), + 'serverUpdatedAt': serializer.toJson(serverUpdatedAt), + }; + } + + RemoteResourceMapping copyWith({ + String? id, + String? resourceType, + String? clientId, + String? serverId, + DateTime? serverUpdatedAt, + }) => RemoteResourceMapping( + id: id ?? this.id, + resourceType: resourceType ?? this.resourceType, + clientId: clientId ?? this.clientId, + serverId: serverId ?? this.serverId, + serverUpdatedAt: serverUpdatedAt ?? this.serverUpdatedAt, + ); + RemoteResourceMapping copyWithCompanion( + RemoteResourceMappingsCompanion data, + ) { + return RemoteResourceMapping( + id: data.id.present ? data.id.value : this.id, + resourceType: data.resourceType.present + ? data.resourceType.value + : this.resourceType, + clientId: data.clientId.present ? data.clientId.value : this.clientId, + serverId: data.serverId.present ? data.serverId.value : this.serverId, + serverUpdatedAt: data.serverUpdatedAt.present + ? data.serverUpdatedAt.value + : this.serverUpdatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('RemoteResourceMapping(') + ..write('id: $id, ') + ..write('resourceType: $resourceType, ') + ..write('clientId: $clientId, ') + ..write('serverId: $serverId, ') + ..write('serverUpdatedAt: $serverUpdatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => + Object.hash(id, resourceType, clientId, serverId, serverUpdatedAt); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is RemoteResourceMapping && + other.id == this.id && + other.resourceType == this.resourceType && + other.clientId == this.clientId && + other.serverId == this.serverId && + other.serverUpdatedAt == this.serverUpdatedAt); +} + +class RemoteResourceMappingsCompanion + extends UpdateCompanion { + final Value id; + final Value resourceType; + final Value clientId; + final Value serverId; + final Value serverUpdatedAt; + final Value rowid; + const RemoteResourceMappingsCompanion({ + this.id = const Value.absent(), + this.resourceType = const Value.absent(), + this.clientId = const Value.absent(), + this.serverId = const Value.absent(), + this.serverUpdatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + RemoteResourceMappingsCompanion.insert({ + required String id, + required String resourceType, + required String clientId, + required String serverId, + required DateTime serverUpdatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + resourceType = Value(resourceType), + clientId = Value(clientId), + serverId = Value(serverId), + serverUpdatedAt = Value(serverUpdatedAt); + static Insertable custom({ + Expression? id, + Expression? resourceType, + Expression? clientId, + Expression? serverId, + Expression? serverUpdatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (resourceType != null) 'resource_type': resourceType, + if (clientId != null) 'client_id': clientId, + if (serverId != null) 'server_id': serverId, + if (serverUpdatedAt != null) 'server_updated_at': serverUpdatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + RemoteResourceMappingsCompanion copyWith({ + Value? id, + Value? resourceType, + Value? clientId, + Value? serverId, + Value? serverUpdatedAt, + Value? rowid, + }) { + return RemoteResourceMappingsCompanion( + id: id ?? this.id, + resourceType: resourceType ?? this.resourceType, + clientId: clientId ?? this.clientId, + serverId: serverId ?? this.serverId, + serverUpdatedAt: serverUpdatedAt ?? this.serverUpdatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (resourceType.present) { + map['resource_type'] = Variable(resourceType.value); + } + if (clientId.present) { + map['client_id'] = Variable(clientId.value); + } + if (serverId.present) { + map['server_id'] = Variable(serverId.value); + } + if (serverUpdatedAt.present) { + map['server_updated_at'] = Variable(serverUpdatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('RemoteResourceMappingsCompanion(') + ..write('id: $id, ') + ..write('resourceType: $resourceType, ') + ..write('clientId: $clientId, ') + ..write('serverId: $serverId, ') + ..write('serverUpdatedAt: $serverUpdatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +class $SyncMetadataEntriesTable extends SyncMetadataEntries + with TableInfo<$SyncMetadataEntriesTable, SyncMetadataEntry> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $SyncMetadataEntriesTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('singleton'), + ); + static const VerificationMeta _serverCursorMeta = const VerificationMeta( + 'serverCursor', + ); + @override + late final GeneratedColumn serverCursor = GeneratedColumn( + 'server_cursor', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastSuccessfulSyncAtMeta = + const VerificationMeta('lastSuccessfulSyncAt'); + @override + late final GeneratedColumn lastSuccessfulSyncAt = + GeneratedColumn( + 'last_successful_sync_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastAttemptAtMeta = const VerificationMeta( + 'lastAttemptAt', + ); + @override + late final GeneratedColumn lastAttemptAt = + GeneratedColumn( + 'last_attempt_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastFailureAtMeta = const VerificationMeta( + 'lastFailureAt', + ); + @override + late final GeneratedColumn lastFailureAt = + GeneratedColumn( + 'last_failure_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _statusMeta = const VerificationMeta('status'); + @override + late final GeneratedColumn status = GeneratedColumn( + 'status', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: false, + defaultValue: const Constant('idle'), + ); + static const VerificationMeta _pendingPushCountMeta = const VerificationMeta( + 'pendingPushCount', + ); + @override + late final GeneratedColumn pendingPushCount = GeneratedColumn( + 'pending_push_count', + aliasedName, + true, + type: DriftSqlType.int, + requiredDuringInsert: false, + ); + @override + List get $columns => [ + id, + serverCursor, + lastSuccessfulSyncAt, + lastAttemptAt, + lastFailureAt, + status, + pendingPushCount, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'sync_metadata'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } + if (data.containsKey('server_cursor')) { + context.handle( + _serverCursorMeta, + serverCursor.isAcceptableOrUnknown( + data['server_cursor']!, + _serverCursorMeta, + ), + ); + } + if (data.containsKey('last_successful_sync_at')) { + context.handle( + _lastSuccessfulSyncAtMeta, + lastSuccessfulSyncAt.isAcceptableOrUnknown( + data['last_successful_sync_at']!, + _lastSuccessfulSyncAtMeta, + ), + ); + } + if (data.containsKey('last_attempt_at')) { + context.handle( + _lastAttemptAtMeta, + lastAttemptAt.isAcceptableOrUnknown( + data['last_attempt_at']!, + _lastAttemptAtMeta, + ), + ); + } + if (data.containsKey('last_failure_at')) { + context.handle( + _lastFailureAtMeta, + lastFailureAt.isAcceptableOrUnknown( + data['last_failure_at']!, + _lastFailureAtMeta, + ), + ); + } + if (data.containsKey('status')) { + context.handle( + _statusMeta, + status.isAcceptableOrUnknown(data['status']!, _statusMeta), + ); + } + if (data.containsKey('pending_push_count')) { + context.handle( + _pendingPushCountMeta, + pendingPushCount.isAcceptableOrUnknown( + data['pending_push_count']!, + _pendingPushCountMeta, + ), + ); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + SyncMetadataEntry map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return SyncMetadataEntry( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + serverCursor: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_cursor'], + ), + lastSuccessfulSyncAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_successful_sync_at'], + ), + lastAttemptAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_attempt_at'], + ), + lastFailureAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}last_failure_at'], + ), + status: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}status'], + )!, + pendingPushCount: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}pending_push_count'], + ), + ); + } + + @override + $SyncMetadataEntriesTable createAlias(String alias) { + return $SyncMetadataEntriesTable(attachedDatabase, alias); + } +} + +class SyncMetadataEntry extends DataClass + implements Insertable { + final String id; + final String? serverCursor; + final DateTime? lastSuccessfulSyncAt; + final DateTime? lastAttemptAt; + final DateTime? lastFailureAt; + final String status; + final int? pendingPushCount; + const SyncMetadataEntry({ + required this.id, + this.serverCursor, + this.lastSuccessfulSyncAt, + this.lastAttemptAt, + this.lastFailureAt, + required this.status, + this.pendingPushCount, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + if (!nullToAbsent || serverCursor != null) { + map['server_cursor'] = Variable(serverCursor); + } + if (!nullToAbsent || lastSuccessfulSyncAt != null) { + map['last_successful_sync_at'] = Variable(lastSuccessfulSyncAt); + } + if (!nullToAbsent || lastAttemptAt != null) { + map['last_attempt_at'] = Variable(lastAttemptAt); + } + if (!nullToAbsent || lastFailureAt != null) { + map['last_failure_at'] = Variable(lastFailureAt); + } + map['status'] = Variable(status); + if (!nullToAbsent || pendingPushCount != null) { + map['pending_push_count'] = Variable(pendingPushCount); + } + return map; + } + + SyncMetadataEntriesCompanion toCompanion(bool nullToAbsent) { + return SyncMetadataEntriesCompanion( + id: Value(id), + serverCursor: serverCursor == null && nullToAbsent + ? const Value.absent() + : Value(serverCursor), + lastSuccessfulSyncAt: lastSuccessfulSyncAt == null && nullToAbsent + ? const Value.absent() + : Value(lastSuccessfulSyncAt), + lastAttemptAt: lastAttemptAt == null && nullToAbsent + ? const Value.absent() + : Value(lastAttemptAt), + lastFailureAt: lastFailureAt == null && nullToAbsent + ? const Value.absent() + : Value(lastFailureAt), + status: Value(status), + pendingPushCount: pendingPushCount == null && nullToAbsent + ? const Value.absent() + : Value(pendingPushCount), + ); + } + + factory SyncMetadataEntry.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return SyncMetadataEntry( + id: serializer.fromJson(json['id']), + serverCursor: serializer.fromJson(json['serverCursor']), + lastSuccessfulSyncAt: serializer.fromJson( + json['lastSuccessfulSyncAt'], + ), + lastAttemptAt: serializer.fromJson(json['lastAttemptAt']), + lastFailureAt: serializer.fromJson(json['lastFailureAt']), + status: serializer.fromJson(json['status']), + pendingPushCount: serializer.fromJson(json['pendingPushCount']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'serverCursor': serializer.toJson(serverCursor), + 'lastSuccessfulSyncAt': serializer.toJson( + lastSuccessfulSyncAt, + ), + 'lastAttemptAt': serializer.toJson(lastAttemptAt), + 'lastFailureAt': serializer.toJson(lastFailureAt), + 'status': serializer.toJson(status), + 'pendingPushCount': serializer.toJson(pendingPushCount), + }; + } + + SyncMetadataEntry copyWith({ + String? id, + Value serverCursor = const Value.absent(), + Value lastSuccessfulSyncAt = const Value.absent(), + Value lastAttemptAt = const Value.absent(), + Value lastFailureAt = const Value.absent(), + String? status, + Value pendingPushCount = const Value.absent(), + }) => SyncMetadataEntry( + id: id ?? this.id, + serverCursor: serverCursor.present ? serverCursor.value : this.serverCursor, + lastSuccessfulSyncAt: lastSuccessfulSyncAt.present + ? lastSuccessfulSyncAt.value + : this.lastSuccessfulSyncAt, + lastAttemptAt: lastAttemptAt.present + ? lastAttemptAt.value + : this.lastAttemptAt, + lastFailureAt: lastFailureAt.present + ? lastFailureAt.value + : this.lastFailureAt, + status: status ?? this.status, + pendingPushCount: pendingPushCount.present + ? pendingPushCount.value + : this.pendingPushCount, + ); + SyncMetadataEntry copyWithCompanion(SyncMetadataEntriesCompanion data) { + return SyncMetadataEntry( + id: data.id.present ? data.id.value : this.id, + serverCursor: data.serverCursor.present + ? data.serverCursor.value + : this.serverCursor, + lastSuccessfulSyncAt: data.lastSuccessfulSyncAt.present + ? data.lastSuccessfulSyncAt.value + : this.lastSuccessfulSyncAt, + lastAttemptAt: data.lastAttemptAt.present + ? data.lastAttemptAt.value + : this.lastAttemptAt, + lastFailureAt: data.lastFailureAt.present + ? data.lastFailureAt.value + : this.lastFailureAt, + status: data.status.present ? data.status.value : this.status, + pendingPushCount: data.pendingPushCount.present + ? data.pendingPushCount.value + : this.pendingPushCount, + ); + } + + @override + String toString() { + return (StringBuffer('SyncMetadataEntry(') + ..write('id: $id, ') + ..write('serverCursor: $serverCursor, ') + ..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ') + ..write('lastAttemptAt: $lastAttemptAt, ') + ..write('lastFailureAt: $lastFailureAt, ') + ..write('status: $status, ') + ..write('pendingPushCount: $pendingPushCount') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + serverCursor, + lastSuccessfulSyncAt, + lastAttemptAt, + lastFailureAt, + status, + pendingPushCount, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is SyncMetadataEntry && + other.id == this.id && + other.serverCursor == this.serverCursor && + other.lastSuccessfulSyncAt == this.lastSuccessfulSyncAt && + other.lastAttemptAt == this.lastAttemptAt && + other.lastFailureAt == this.lastFailureAt && + other.status == this.status && + other.pendingPushCount == this.pendingPushCount); +} + +class SyncMetadataEntriesCompanion extends UpdateCompanion { + final Value id; + final Value serverCursor; + final Value lastSuccessfulSyncAt; + final Value lastAttemptAt; + final Value lastFailureAt; + final Value status; + final Value pendingPushCount; + final Value rowid; + const SyncMetadataEntriesCompanion({ + this.id = const Value.absent(), + this.serverCursor = const Value.absent(), + this.lastSuccessfulSyncAt = const Value.absent(), + this.lastAttemptAt = const Value.absent(), + this.lastFailureAt = const Value.absent(), + this.status = const Value.absent(), + this.pendingPushCount = const Value.absent(), + this.rowid = const Value.absent(), + }); + SyncMetadataEntriesCompanion.insert({ + this.id = const Value.absent(), + this.serverCursor = const Value.absent(), + this.lastSuccessfulSyncAt = const Value.absent(), + this.lastAttemptAt = const Value.absent(), + this.lastFailureAt = const Value.absent(), + this.status = const Value.absent(), + this.pendingPushCount = const Value.absent(), + this.rowid = const Value.absent(), + }); + static Insertable custom({ + Expression? id, + Expression? serverCursor, + Expression? lastSuccessfulSyncAt, + Expression? lastAttemptAt, + Expression? lastFailureAt, + Expression? status, + Expression? pendingPushCount, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (serverCursor != null) 'server_cursor': serverCursor, + if (lastSuccessfulSyncAt != null) + 'last_successful_sync_at': lastSuccessfulSyncAt, + if (lastAttemptAt != null) 'last_attempt_at': lastAttemptAt, + if (lastFailureAt != null) 'last_failure_at': lastFailureAt, + if (status != null) 'status': status, + if (pendingPushCount != null) 'pending_push_count': pendingPushCount, + if (rowid != null) 'rowid': rowid, + }); + } + + SyncMetadataEntriesCompanion copyWith({ + Value? id, + Value? serverCursor, + Value? lastSuccessfulSyncAt, + Value? lastAttemptAt, + Value? lastFailureAt, + Value? status, + Value? pendingPushCount, + Value? rowid, + }) { + return SyncMetadataEntriesCompanion( + id: id ?? this.id, + serverCursor: serverCursor ?? this.serverCursor, + lastSuccessfulSyncAt: lastSuccessfulSyncAt ?? this.lastSuccessfulSyncAt, + lastAttemptAt: lastAttemptAt ?? this.lastAttemptAt, + lastFailureAt: lastFailureAt ?? this.lastFailureAt, + status: status ?? this.status, + pendingPushCount: pendingPushCount ?? this.pendingPushCount, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (serverCursor.present) { + map['server_cursor'] = Variable(serverCursor.value); + } + if (lastSuccessfulSyncAt.present) { + map['last_successful_sync_at'] = Variable( + lastSuccessfulSyncAt.value, + ); + } + if (lastAttemptAt.present) { + map['last_attempt_at'] = Variable(lastAttemptAt.value); + } + if (lastFailureAt.present) { + map['last_failure_at'] = Variable(lastFailureAt.value); + } + if (status.present) { + map['status'] = Variable(status.value); + } + if (pendingPushCount.present) { + map['pending_push_count'] = Variable(pendingPushCount.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('SyncMetadataEntriesCompanion(') + ..write('id: $id, ') + ..write('serverCursor: $serverCursor, ') + ..write('lastSuccessfulSyncAt: $lastSuccessfulSyncAt, ') + ..write('lastAttemptAt: $lastAttemptAt, ') + ..write('lastFailureAt: $lastFailureAt, ') + ..write('status: $status, ') + ..write('pendingPushCount: $pendingPushCount, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + class $WorkoutHistoriesTable extends WorkoutHistories with TableInfo<$WorkoutHistoriesTable, WorkoutHistory> { @override @@ -26094,6 +26981,10 @@ abstract class _$AppDatabase extends GeneratedDatabase { late final $ProgramExercisesTable programExercises = $ProgramExercisesTable( this, ); + late final $RemoteResourceMappingsTable remoteResourceMappings = + $RemoteResourceMappingsTable(this); + late final $SyncMetadataEntriesTable syncMetadataEntries = + $SyncMetadataEntriesTable(this); late final $WorkoutHistoriesTable workoutHistories = $WorkoutHistoriesTable( this, ); @@ -26127,6 +27018,8 @@ abstract class _$AppDatabase extends GeneratedDatabase { onlineAccountSessions, programs, programExercises, + remoteResourceMappings, + syncMetadataEntries, workoutHistories, workoutHistorySetResults, workoutHistoryStepResults, @@ -37848,6 +38741,493 @@ typedef $$ProgramExercisesTableProcessedTableManager = bool exerciseVideoMediaIdSnapshot, }) >; +typedef $$RemoteResourceMappingsTableCreateCompanionBuilder = + RemoteResourceMappingsCompanion Function({ + required String id, + required String resourceType, + required String clientId, + required String serverId, + required DateTime serverUpdatedAt, + Value rowid, + }); +typedef $$RemoteResourceMappingsTableUpdateCompanionBuilder = + RemoteResourceMappingsCompanion Function({ + Value id, + Value resourceType, + Value clientId, + Value serverId, + Value serverUpdatedAt, + Value rowid, + }); + +class $$RemoteResourceMappingsTableFilterComposer + extends Composer<_$AppDatabase, $RemoteResourceMappingsTable> { + $$RemoteResourceMappingsTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get resourceType => $composableBuilder( + column: $table.resourceType, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get clientId => $composableBuilder( + column: $table.clientId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverId => $composableBuilder( + column: $table.serverId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverUpdatedAt => $composableBuilder( + column: $table.serverUpdatedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$RemoteResourceMappingsTableOrderingComposer + extends Composer<_$AppDatabase, $RemoteResourceMappingsTable> { + $$RemoteResourceMappingsTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get resourceType => $composableBuilder( + column: $table.resourceType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get clientId => $composableBuilder( + column: $table.clientId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverId => $composableBuilder( + column: $table.serverId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverUpdatedAt => $composableBuilder( + column: $table.serverUpdatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$RemoteResourceMappingsTableAnnotationComposer + extends Composer<_$AppDatabase, $RemoteResourceMappingsTable> { + $$RemoteResourceMappingsTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get resourceType => $composableBuilder( + column: $table.resourceType, + builder: (column) => column, + ); + + GeneratedColumn get clientId => + $composableBuilder(column: $table.clientId, builder: (column) => column); + + GeneratedColumn get serverId => + $composableBuilder(column: $table.serverId, builder: (column) => column); + + GeneratedColumn get serverUpdatedAt => $composableBuilder( + column: $table.serverUpdatedAt, + builder: (column) => column, + ); +} + +class $$RemoteResourceMappingsTableTableManager + extends + RootTableManager< + _$AppDatabase, + $RemoteResourceMappingsTable, + RemoteResourceMapping, + $$RemoteResourceMappingsTableFilterComposer, + $$RemoteResourceMappingsTableOrderingComposer, + $$RemoteResourceMappingsTableAnnotationComposer, + $$RemoteResourceMappingsTableCreateCompanionBuilder, + $$RemoteResourceMappingsTableUpdateCompanionBuilder, + ( + RemoteResourceMapping, + BaseReferences< + _$AppDatabase, + $RemoteResourceMappingsTable, + RemoteResourceMapping + >, + ), + RemoteResourceMapping, + PrefetchHooks Function() + > { + $$RemoteResourceMappingsTableTableManager( + _$AppDatabase db, + $RemoteResourceMappingsTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$RemoteResourceMappingsTableFilterComposer( + $db: db, + $table: table, + ), + createOrderingComposer: () => + $$RemoteResourceMappingsTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$RemoteResourceMappingsTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value resourceType = const Value.absent(), + Value clientId = const Value.absent(), + Value serverId = const Value.absent(), + Value serverUpdatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => RemoteResourceMappingsCompanion( + id: id, + resourceType: resourceType, + clientId: clientId, + serverId: serverId, + serverUpdatedAt: serverUpdatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String resourceType, + required String clientId, + required String serverId, + required DateTime serverUpdatedAt, + Value rowid = const Value.absent(), + }) => RemoteResourceMappingsCompanion.insert( + id: id, + resourceType: resourceType, + clientId: clientId, + serverId: serverId, + serverUpdatedAt: serverUpdatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$RemoteResourceMappingsTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $RemoteResourceMappingsTable, + RemoteResourceMapping, + $$RemoteResourceMappingsTableFilterComposer, + $$RemoteResourceMappingsTableOrderingComposer, + $$RemoteResourceMappingsTableAnnotationComposer, + $$RemoteResourceMappingsTableCreateCompanionBuilder, + $$RemoteResourceMappingsTableUpdateCompanionBuilder, + ( + RemoteResourceMapping, + BaseReferences< + _$AppDatabase, + $RemoteResourceMappingsTable, + RemoteResourceMapping + >, + ), + RemoteResourceMapping, + PrefetchHooks Function() + >; +typedef $$SyncMetadataEntriesTableCreateCompanionBuilder = + SyncMetadataEntriesCompanion Function({ + Value id, + Value serverCursor, + Value lastSuccessfulSyncAt, + Value lastAttemptAt, + Value lastFailureAt, + Value status, + Value pendingPushCount, + Value rowid, + }); +typedef $$SyncMetadataEntriesTableUpdateCompanionBuilder = + SyncMetadataEntriesCompanion Function({ + Value id, + Value serverCursor, + Value lastSuccessfulSyncAt, + Value lastAttemptAt, + Value lastFailureAt, + Value status, + Value pendingPushCount, + Value rowid, + }); + +class $$SyncMetadataEntriesTableFilterComposer + extends Composer<_$AppDatabase, $SyncMetadataEntriesTable> { + $$SyncMetadataEntriesTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverCursor => $composableBuilder( + column: $table.serverCursor, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastSuccessfulSyncAt => $composableBuilder( + column: $table.lastSuccessfulSyncAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastAttemptAt => $composableBuilder( + column: $table.lastAttemptAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastFailureAt => $composableBuilder( + column: $table.lastFailureAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get pendingPushCount => $composableBuilder( + column: $table.pendingPushCount, + builder: (column) => ColumnFilters(column), + ); +} + +class $$SyncMetadataEntriesTableOrderingComposer + extends Composer<_$AppDatabase, $SyncMetadataEntriesTable> { + $$SyncMetadataEntriesTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverCursor => $composableBuilder( + column: $table.serverCursor, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastSuccessfulSyncAt => $composableBuilder( + column: $table.lastSuccessfulSyncAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastAttemptAt => $composableBuilder( + column: $table.lastAttemptAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastFailureAt => $composableBuilder( + column: $table.lastFailureAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get status => $composableBuilder( + column: $table.status, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get pendingPushCount => $composableBuilder( + column: $table.pendingPushCount, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$SyncMetadataEntriesTableAnnotationComposer + extends Composer<_$AppDatabase, $SyncMetadataEntriesTable> { + $$SyncMetadataEntriesTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get serverCursor => $composableBuilder( + column: $table.serverCursor, + builder: (column) => column, + ); + + GeneratedColumn get lastSuccessfulSyncAt => $composableBuilder( + column: $table.lastSuccessfulSyncAt, + builder: (column) => column, + ); + + GeneratedColumn get lastAttemptAt => $composableBuilder( + column: $table.lastAttemptAt, + builder: (column) => column, + ); + + GeneratedColumn get lastFailureAt => $composableBuilder( + column: $table.lastFailureAt, + builder: (column) => column, + ); + + GeneratedColumn get status => + $composableBuilder(column: $table.status, builder: (column) => column); + + GeneratedColumn get pendingPushCount => $composableBuilder( + column: $table.pendingPushCount, + builder: (column) => column, + ); +} + +class $$SyncMetadataEntriesTableTableManager + extends + RootTableManager< + _$AppDatabase, + $SyncMetadataEntriesTable, + SyncMetadataEntry, + $$SyncMetadataEntriesTableFilterComposer, + $$SyncMetadataEntriesTableOrderingComposer, + $$SyncMetadataEntriesTableAnnotationComposer, + $$SyncMetadataEntriesTableCreateCompanionBuilder, + $$SyncMetadataEntriesTableUpdateCompanionBuilder, + ( + SyncMetadataEntry, + BaseReferences< + _$AppDatabase, + $SyncMetadataEntriesTable, + SyncMetadataEntry + >, + ), + SyncMetadataEntry, + PrefetchHooks Function() + > { + $$SyncMetadataEntriesTableTableManager( + _$AppDatabase db, + $SyncMetadataEntriesTable table, + ) : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$SyncMetadataEntriesTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$SyncMetadataEntriesTableOrderingComposer( + $db: db, + $table: table, + ), + createComputedFieldComposer: () => + $$SyncMetadataEntriesTableAnnotationComposer( + $db: db, + $table: table, + ), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value serverCursor = const Value.absent(), + Value lastSuccessfulSyncAt = const Value.absent(), + Value lastAttemptAt = const Value.absent(), + Value lastFailureAt = const Value.absent(), + Value status = const Value.absent(), + Value pendingPushCount = const Value.absent(), + Value rowid = const Value.absent(), + }) => SyncMetadataEntriesCompanion( + id: id, + serverCursor: serverCursor, + lastSuccessfulSyncAt: lastSuccessfulSyncAt, + lastAttemptAt: lastAttemptAt, + lastFailureAt: lastFailureAt, + status: status, + pendingPushCount: pendingPushCount, + rowid: rowid, + ), + createCompanionCallback: + ({ + Value id = const Value.absent(), + Value serverCursor = const Value.absent(), + Value lastSuccessfulSyncAt = const Value.absent(), + Value lastAttemptAt = const Value.absent(), + Value lastFailureAt = const Value.absent(), + Value status = const Value.absent(), + Value pendingPushCount = const Value.absent(), + Value rowid = const Value.absent(), + }) => SyncMetadataEntriesCompanion.insert( + id: id, + serverCursor: serverCursor, + lastSuccessfulSyncAt: lastSuccessfulSyncAt, + lastAttemptAt: lastAttemptAt, + lastFailureAt: lastFailureAt, + status: status, + pendingPushCount: pendingPushCount, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map((e) => (e.readTable(table), BaseReferences(db, table, e))) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$SyncMetadataEntriesTableProcessedTableManager = + ProcessedTableManager< + _$AppDatabase, + $SyncMetadataEntriesTable, + SyncMetadataEntry, + $$SyncMetadataEntriesTableFilterComposer, + $$SyncMetadataEntriesTableOrderingComposer, + $$SyncMetadataEntriesTableAnnotationComposer, + $$SyncMetadataEntriesTableCreateCompanionBuilder, + $$SyncMetadataEntriesTableUpdateCompanionBuilder, + ( + SyncMetadataEntry, + BaseReferences< + _$AppDatabase, + $SyncMetadataEntriesTable, + SyncMetadataEntry + >, + ), + SyncMetadataEntry, + PrefetchHooks Function() + >; typedef $$WorkoutHistoriesTableCreateCompanionBuilder = WorkoutHistoriesCompanion Function({ required String id, @@ -42273,6 +43653,13 @@ class $AppDatabaseManager { $$ProgramsTableTableManager(_db, _db.programs); $$ProgramExercisesTableTableManager get programExercises => $$ProgramExercisesTableTableManager(_db, _db.programExercises); + $$RemoteResourceMappingsTableTableManager get remoteResourceMappings => + $$RemoteResourceMappingsTableTableManager( + _db, + _db.remoteResourceMappings, + ); + $$SyncMetadataEntriesTableTableManager get syncMetadataEntries => + $$SyncMetadataEntriesTableTableManager(_db, _db.syncMetadataEntries); $$WorkoutHistoriesTableTableManager get workoutHistories => $$WorkoutHistoriesTableTableManager(_db, _db.workoutHistories); $$WorkoutHistorySetResultsTableTableManager get workoutHistorySetResults => diff --git a/lib/infrastructure/local/drift_repositories.dart b/lib/infrastructure/local/drift_repositories.dart index 21ad754..e24f9ea 100644 --- a/lib/infrastructure/local/drift_repositories.dart +++ b/lib/infrastructure/local/drift_repositories.dart @@ -168,6 +168,321 @@ final class DriftOnlineAccountRepository implements OnlineAccountRepository { } } +final class DriftSyncMetadataRepository implements SyncMetadataRepository { + const DriftSyncMetadataRepository(this.database); + + static const _id = 'singleton'; + + final db.AppDatabase database; + + @override + Future read() async { + final row = await (database.select( + database.syncMetadataEntries, + )..where((table) => table.id.equals(_id))).getSingleOrNull(); + if (row == null) { + return const SyncMetadataSnapshot(); + } + return SyncMetadataSnapshot( + serverCursor: row.serverCursor, + lastSuccessfulSyncAt: _utcOrNull(row.lastSuccessfulSyncAt), + lastAttemptAt: _utcOrNull(row.lastAttemptAt), + lastFailureAt: _utcOrNull(row.lastFailureAt), + status: _onlineSyncStatusFromDb(row.status), + pendingPushCount: row.pendingPushCount, + ); + } + + @override + Future save(SyncMetadataSnapshot metadata) async { + await database + .into(database.syncMetadataEntries) + .insertOnConflictUpdate( + db.SyncMetadataEntriesCompanion.insert( + id: const Value(_id), + serverCursor: Value(metadata.serverCursor), + lastSuccessfulSyncAt: Value( + _utcOrNull(metadata.lastSuccessfulSyncAt), + ), + lastAttemptAt: Value(_utcOrNull(metadata.lastAttemptAt)), + lastFailureAt: Value(_utcOrNull(metadata.lastFailureAt)), + status: Value(_onlineSyncStatusToDb(metadata.status)), + pendingPushCount: Value(metadata.pendingPushCount), + ), + ); + } +} + +final class DriftRemoteResourceMappingRepository + implements RemoteResourceMappingRepository { + const DriftRemoteResourceMappingRepository(this.database); + + final db.AppDatabase database; + + @override + Future find({ + required SyncResourceType resourceType, + required String clientId, + }) async { + final row = + await (database.select(database.remoteResourceMappings)..where( + (table) => + table.resourceType.equals( + _syncResourceTypeToDb(resourceType), + ) & + table.clientId.equals(clientId), + )) + .getSingleOrNull(); + return row == null ? null : _remoteResourceMappingFromRow(row); + } + + @override + Future save(RemoteResourceMapping mapping) async { + await database + .into(database.remoteResourceMappings) + .insertOnConflictUpdate(_remoteResourceMappingCompanion(mapping)); + } +} + +final class DriftLocalSyncChangeRepository + implements LocalSyncChangeRepository { + const DriftLocalSyncChangeRepository(this.database); + + final db.AppDatabase database; + + @override + Future> listPendingChanges() async { + final rows = + await (database.select(database.changeLogEntries) + ..where( + (table) => + table.syncedAt.isNull() & + table.entityType.isIn(_syncableEntityTypes), + ) + ..orderBy([(table) => OrderingTerm.asc(table.createdAt)])) + .get(); + final grouped = >{}; + for (final row in rows) { + grouped + .putIfAbsent('${row.entityType}:${row.entityId}', () => []) + .add(row); + } + final changes = []; + for (final group in grouped.values) { + group.sort((left, right) => left.createdAt.compareTo(right.createdAt)); + final latest = group.last; + final item = await _pushItemForChange(latest); + if (item == null) { + continue; + } + changes.add( + PendingSyncChange( + changeLogIds: group.map((row) => row.id).toList(), + item: item, + ), + ); + } + return changes; + } + + @override + Future markChangesSynced( + List changeLogIds, + DateTime syncedAt, + ) async { + if (changeLogIds.isEmpty) { + return; + } + await (database.update( + database.changeLogEntries, + )..where((table) => table.id.isIn(changeLogIds))).write( + db.ChangeLogEntriesCompanion( + syncedAt: Value(syncedAt.toUtc()), + ), + ); + } + + @override + Future applyRemoteItem(RemoteSyncedItem item) async { + final localUpdatedAt = await _localUpdatedAt( + item.resourceType, + item.clientId, + ); + if (localUpdatedAt != null && + !item.clientUpdatedAt.isAfter(localUpdatedAt)) { + return false; + } + if (item.deletedAt != null) { + return _applyRemoteDelete(item); + } + return _applyRemotePayload(item); + } + + Future _pushItemForChange( + db.ChangeLogEntry change, + ) async { + final resourceType = _syncResourceTypeFromEntityType(change.entityType); + if (resourceType == null) { + return null; + } + final snapshot = await _localSnapshot(resourceType, change.entityId); + return RemoteSyncPushItem( + resourceType: resourceType, + clientId: change.entityId, + schemaVersion: snapshot.schemaVersion, + clientUpdatedAt: snapshot.updatedAt, + deletedAt: snapshot.deletedAt, + payload: snapshot.payload, + ); + } + + Future<_LocalSyncSnapshot> _localSnapshot( + SyncResourceType resourceType, + String id, + ) async { + return switch (resourceType) { + SyncResourceType.exercise => _exerciseSnapshot(id), + SyncResourceType.program => _programSnapshot(id), + SyncResourceType.workoutTemplate => _workoutTemplateSnapshot(id), + SyncResourceType.workoutHistory => _workoutHistorySnapshot(id), + SyncResourceType.mediaAsset => _mediaAssetSnapshot(id), + }; + } + + Future _localUpdatedAt( + SyncResourceType resourceType, + String id, + ) async { + final tableName = _tableNameForResourceType(resourceType); + final row = await database + .customSelect( + 'SELECT updated_at FROM $tableName WHERE id = ? LIMIT 1', + variables: [Variable(id)], + ) + .getSingleOrNull(); + final value = row?.data['updated_at']; + return value is DateTime ? value.toUtc() : null; + } + + Future _applyRemoteDelete(RemoteSyncedItem item) async { + final tableName = _tableNameForResourceType(item.resourceType); + final updated = await database.customUpdate( + 'UPDATE $tableName SET deleted_at = ?, updated_at = ?, ' + "sync_state = 'deleted' WHERE id = ?", + variables: [ + Variable(item.deletedAt!.toUtc()), + Variable(item.clientUpdatedAt.toUtc()), + Variable(item.clientId), + ], + ); + return updated > 0; + } + + Future _applyRemotePayload(RemoteSyncedItem item) async { + switch (item.resourceType) { + case SyncResourceType.exercise: + await database + .into(database.exercises) + .insertOnConflictUpdate( + _exerciseCompanion(_exerciseFromPayload(item)), + ); + return true; + case SyncResourceType.mediaAsset: + await database + .into(database.mediaAssets) + .insertOnConflictUpdate( + _mediaAssetCompanion(_mediaAssetFromPayload(item)), + ); + return true; + case SyncResourceType.program: + case SyncResourceType.workoutTemplate: + 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), + ); + return _LocalSyncSnapshot.fromMetadata( + exercise.metadata, + _exercisePayload(exercise), + ); + } + + Future<_LocalSyncSnapshot> _mediaAssetSnapshot(String id) async { + final row = await (database.select( + database.mediaAssets, + )..where((table) => table.id.equals(id))).getSingle(); + final asset = _mediaAssetFromRow(row); + return _LocalSyncSnapshot.fromMetadata( + asset.metadata, + _mediaAssetPayload(asset), + ); + } + + Future<_LocalSyncSnapshot> _programSnapshot(String id) async { + final program = await DriftProgramRepository(database).findById(id); + if (program == null) { + return _deletedFallbackSnapshot(SyncResourceType.program, id); + } + return _LocalSyncSnapshot.fromMetadata( + program.metadata, + _programPayload(program), + ); + } + + Future<_LocalSyncSnapshot> _workoutTemplateSnapshot(String id) async { + final template = await DriftWorkoutTemplateRepository( + database, + ).findById(id); + if (template == null) { + return _deletedFallbackSnapshot(SyncResourceType.workoutTemplate, id); + } + return _LocalSyncSnapshot.fromMetadata( + template.metadata, + _workoutTemplatePayload(template), + ); + } + + Future<_LocalSyncSnapshot> _workoutHistorySnapshot(String id) async { + final history = await DriftWorkoutHistoryRepository(database).findById(id); + if (history == null) { + return _deletedFallbackSnapshot(SyncResourceType.workoutHistory, id); + } + return _LocalSyncSnapshot.fromMetadata( + history.metadata, + _workoutHistoryPayload(history), + ); + } + + Future<_LocalSyncSnapshot> _deletedFallbackSnapshot( + SyncResourceType resourceType, + String id, + ) async { + final tableName = _tableNameForResourceType(resourceType); + final row = await database + .customSelect( + 'SELECT updated_at, deleted_at, schema_version FROM $tableName ' + 'WHERE id = ? LIMIT 1', + variables: [Variable(id)], + ) + .getSingle(); + return _LocalSyncSnapshot( + schemaVersion: row.data['schema_version'] as int? ?? 1, + updatedAt: (row.data['updated_at'] as DateTime).toUtc(), + deletedAt: (row.data['deleted_at'] as DateTime?)?.toUtc(), + payload: {'id': id}, + ); + } +} + final class DriftProgramRepository implements ProgramRepository { const DriftProgramRepository(this.database); @@ -2306,6 +2621,303 @@ domain.WorkoutHistoryStepResult _workoutHistoryStepResultFromRow( ); } +db.RemoteResourceMappingsCompanion _remoteResourceMappingCompanion( + RemoteResourceMapping mapping, +) { + return db.RemoteResourceMappingsCompanion.insert( + id: _remoteResourceMappingId(mapping.resourceType, mapping.clientId), + resourceType: _syncResourceTypeToDb(mapping.resourceType), + clientId: mapping.clientId, + serverId: mapping.serverId, + serverUpdatedAt: mapping.serverUpdatedAt.toUtc(), + ); +} + +RemoteResourceMapping _remoteResourceMappingFromRow( + db.RemoteResourceMapping row, +) { + return RemoteResourceMapping( + resourceType: _syncResourceTypeFromDb(row.resourceType), + clientId: row.clientId, + serverId: row.serverId, + serverUpdatedAt: _utc(row.serverUpdatedAt), + ); +} + +String _remoteResourceMappingId( + SyncResourceType resourceType, + String clientId, +) { + return '${_syncResourceTypeToDb(resourceType)}:$clientId'; +} + +final class _LocalSyncSnapshot { + const _LocalSyncSnapshot({ + required this.schemaVersion, + required this.updatedAt, + required this.deletedAt, + required this.payload, + }); + + factory _LocalSyncSnapshot.fromMetadata( + domain.EntityMetadata metadata, + Map payload, + ) { + return _LocalSyncSnapshot( + schemaVersion: metadata.schemaVersion, + updatedAt: metadata.updatedAt.toUtc(), + deletedAt: _utcOrNull(metadata.deletedAt), + payload: payload, + ); + } + + final int schemaVersion; + final DateTime updatedAt; + final DateTime? deletedAt; + final Map payload; +} + +Map _metadataPayload(domain.EntityMetadata metadata) => { + 'id': metadata.id, + 'createdAt': metadata.createdAt.toUtc().toIso8601String(), + 'updatedAt': metadata.updatedAt.toUtc().toIso8601String(), + 'deletedAt': metadata.deletedAt?.toUtc().toIso8601String(), + 'schemaVersion': metadata.schemaVersion, + 'syncState': metadata.syncState.name, + 'localRevision': metadata.localRevision, + 'originDeviceId': metadata.originDeviceId, +}; + +Map _exercisePayload(domain.Exercise exercise) => { + 'metadata': _metadataPayload(exercise.metadata), + 'id': exercise.metadata.id, + 'name': exercise.name, + 'description': exercise.description, + 'imageMediaIds': exercise.imageMediaIds, + 'iconMediaId': exercise.iconMediaId, + 'videoMediaId': exercise.videoMediaId, + 'hasTimeMeasure': exercise.hasTimeMeasure, + 'hasRepsMeasure': exercise.hasRepsMeasure, + 'hasScoreMeasure': exercise.hasScoreMeasure, + 'scoreInputMode': exercise.scoreInputMode.name, + 'scoreLabel': exercise.scoreLabel, + 'scoreUnit': exercise.scoreUnit, + 'defaultTargetTimeSeconds': exercise.defaultTargetTimeSeconds, + 'defaultTargetReps': exercise.defaultTargetReps, + 'defaultTargetScore': exercise.defaultTargetScore, + 'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs, + 'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(), + 'archivedAt': exercise.archivedAt?.toUtc().toIso8601String(), +}; + +Map _mediaAssetPayload(domain.MediaAsset asset) => { + 'metadata': _metadataPayload(asset.metadata), + 'id': asset.metadata.id, + 'kind': _mediaKindToDb(asset.kind), + 'localUri': asset.localUri, + 'mimeType': asset.mimeType, + 'sizeBytes': asset.sizeBytes, + 'width': asset.width, + 'height': asset.height, + 'durationMs': asset.durationMs, + 'checksum': asset.checksum, + 'remoteUri': asset.remoteUri, + 'thumbnailLocalUri': asset.thumbnailLocalUri, +}; + +Map _programPayload(domain.Program program) => { + 'metadata': _metadataPayload(program.metadata), + 'id': program.metadata.id, + 'name': program.name, + 'defaultRestSeconds': program.defaultRestSeconds, + 'exercises': program.exercises + .map((exercise) => exercise.toSnapshotJson()) + .toList(), +}; + +Map _workoutTemplatePayload(domain.WorkoutTemplate template) => + { + 'metadata': _metadataPayload(template.metadata), + 'id': template.metadata.id, + 'name': template.name, + 'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(), + '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, + }, + ) + .toList(), + }; + +Map _workoutHistoryPayload(domain.WorkoutHistory history) => { + 'metadata': _metadataPayload(history.metadata), + 'id': history.metadata.id, + 'sourceWorkoutTemplateId': history.sourceWorkoutTemplateId, + 'sourceActiveWorkoutSessionId': history.sourceActiveWorkoutSessionId, + 'nameSnapshot': history.nameSnapshot, + 'startedAt': history.startedAt.toUtc().toIso8601String(), + 'endedAt': history.endedAt.toUtc().toIso8601String(), + 'totalActiveMs': history.totalActiveMs, + 'completed': history.completed, + 'historySnapshotJson': history.historySnapshotJson, +}; + +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?, + 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.EntityMetadata _metadataFromPayload(RemoteSyncedItem item) { + final metadata = item.payload['metadata']; + final map = metadata is Map ? Map.from(metadata) : null; + return domain.EntityMetadata( + id: item.clientId, + createdAt: _dateTimeFromPayload(map?['createdAt']) ?? item.clientUpdatedAt, + updatedAt: item.clientUpdatedAt, + deletedAt: item.deletedAt, + schemaVersion: item.schemaVersion, + syncState: item.deletedAt == null + ? domain.SyncState.synced + : domain.SyncState.deleted, + localRevision: map?['localRevision'] as int? ?? 0, + originDeviceId: map?['originDeviceId'] as String? ?? 'remote', + ); +} + +String _stringFromPayload( + Map payload, + String key, + String fallback, +) { + final value = payload[key]; + return value is String && value.trim().isNotEmpty ? value : fallback; +} + +List _stringListFromPayload(Object? value) { + if (value is! List) { + return const []; + } + return value.whereType().toList(growable: false); +} + +List _stepsFromPayload(Object? value) { + if (value is! List) { + return const []; + } + return value + .map((raw) { + final json = Map.from(raw as Map); + return domain.ExerciseStep( + id: _requiredString(json, 'id'), + position: _requiredInt(json, 'position'), + name: _requiredString(json, 'name'), + type: _exerciseStepTypeFromDb(_requiredString(json, 'type')), + defaultTargetValue: _requiredInt(json, 'defaultTargetValue'), + hasScore: _requiredBool(json, 'hasScore'), + scoreInputMode: _scoreInputModeFromDb( + _optionalString(json, 'scoreInputMode') ?? 'manual', + ), + scoreLabel: _optionalString(json, 'scoreLabel'), + scoreUnit: _optionalString(json, 'scoreUnit'), + defaultTargetScore: _optionalDouble(json, 'defaultTargetScore'), + defaultTargetScoreTimeMs: _optionalInt( + json, + 'defaultTargetScoreTimeMs', + ), + ); + }) + .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', @@ -2321,6 +2933,38 @@ domain.SyncState _syncStateFromDb(String value) => switch (value) { _ => 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 _activeStatusToDb(domain.ActiveWorkoutStatus status) => switch (status) { domain.ActiveWorkoutStatus.running => 'running', domain.ActiveWorkoutStatus.paused => 'paused', diff --git a/lib/infrastructure/local/tables.dart b/lib/infrastructure/local/tables.dart index eb2a280..3e7f315 100644 --- a/lib/infrastructure/local/tables.dart +++ b/lib/infrastructure/local/tables.dart @@ -48,6 +48,50 @@ class OnlineAccountSessions extends Table { ]; } +class SyncMetadataEntries extends Table { + @override + String get tableName => 'sync_metadata'; + + TextColumn get id => text().withDefault(const Constant('singleton'))(); + TextColumn get serverCursor => text().nullable()(); + DateTimeColumn get lastSuccessfulSyncAt => dateTime().nullable()(); + DateTimeColumn get lastAttemptAt => dateTime().nullable()(); + DateTimeColumn get lastFailureAt => dateTime().nullable()(); + TextColumn get status => text().withDefault(const Constant('idle'))(); + IntColumn get pendingPushCount => integer().nullable()(); + + @override + Set get primaryKey => {id}; + + @override + List get customConstraints => [ + "CHECK (id = 'singleton')", + "CHECK (status IN ('idle', 'syncing', 'success', 'failure'))", + 'CHECK (pending_push_count IS NULL OR pending_push_count >= 0)', + ]; +} + +class RemoteResourceMappings extends Table { + @override + String get tableName => 'remote_resource_mappings'; + + TextColumn get id => text()(); + TextColumn get resourceType => text()(); + TextColumn get clientId => text().withLength(min: 1)(); + TextColumn get serverId => text().withLength(min: 1)(); + DateTimeColumn get serverUpdatedAt => dateTime()(); + + @override + Set get primaryKey => {id}; + + @override + List get customConstraints => [ + "CHECK (resource_type IN ('exercise', 'program', 'workoutTemplate', " + "'workoutHistory', 'mediaAsset'))", + 'UNIQUE (resource_type, client_id)', + ]; +} + class MediaAssets extends SyncableTable { @override String get tableName => 'media_assets'; diff --git a/lib/infrastructure/remote/http_api_client.dart b/lib/infrastructure/remote/http_api_client.dart index bc7520d..cdfdfb1 100644 --- a/lib/infrastructure/remote/http_api_client.dart +++ b/lib/infrastructure/remote/http_api_client.dart @@ -68,12 +68,55 @@ final class HttpApiClient { } } - Uri _resolve(String path) { + Future> getJson( + String path, { + Map queryParameters = const {}, + String? bearerToken, + Set expectedStatuses = const {200}, + }) async { + final response = await _send( + () => client.get( + _resolve(path, queryParameters: queryParameters), + headers: _headers(bearerToken), + ), + ); + if (!expectedStatuses.contains(response.statusCode)) { + throw _exceptionForStatus(response.statusCode, response.body); + } + if (response.body.trim().isEmpty) { + return const {}; + } + final Object? decoded; + try { + decoded = jsonDecode(response.body); + } on FormatException catch (error) { + throw RemoteAuthException(RemoteAuthFailure.unknown, error.message); + } + if (decoded is Map) { + return Map.from(decoded); + } + throw const RemoteAuthException( + RemoteAuthFailure.unknown, + 'Unexpected JSON response.', + ); + } + + Uri _resolve(String path, {Map queryParameters = const {}}) { final normalized = path.startsWith('/') ? path.substring(1) : path; final base = baseUrl.toString().endsWith('/') ? baseUrl : Uri.parse('${baseUrl.toString()}/'); - return base.resolve(normalized); + final resolved = base.resolve(normalized); + final cleanQuery = { + for (final entry in queryParameters.entries) + if (entry.value != null) entry.key: entry.value!, + }; + if (cleanQuery.isEmpty) { + return resolved; + } + return resolved.replace( + queryParameters: {...resolved.queryParameters, ...cleanQuery}, + ); } Map _headers(String? bearerToken) => { diff --git a/lib/infrastructure/remote/remote.dart b/lib/infrastructure/remote/remote.dart index 37d10b8..c6e54b6 100644 --- a/lib/infrastructure/remote/remote.dart +++ b/lib/infrastructure/remote/remote.dart @@ -1,2 +1,3 @@ export 'auth_api.dart'; export 'http_api_client.dart'; +export 'sync_api.dart'; diff --git a/lib/infrastructure/remote/sync_api.dart b/lib/infrastructure/remote/sync_api.dart new file mode 100644 index 0000000..79f0ee7 --- /dev/null +++ b/lib/infrastructure/remote/sync_api.dart @@ -0,0 +1,171 @@ +import '../../application/application.dart'; +import 'http_api_client.dart'; + +final class HttpRemoteSyncApi implements RemoteSyncApi { + const HttpRemoteSyncApi(this.client); + + final HttpApiClient client; + + @override + Future push({ + required String deviceId, + required List items, + required String token, + }) async { + final response = await client.postJson( + '/sync/push', + bearerToken: token, + body: { + 'deviceId': deviceId, + 'items': items.map(_pushItemToJson).toList(), + }, + ); + return RemoteSyncPushResult( + serverCursor: _optionalString(response, 'serverCursor'), + results: (_list( + response, + 'results', + )).map((item) => _pushItemResultFromJson(_map(item))).toList(), + ); + } + + @override + Future pull({ + required String? since, + required String token, + }) async { + final response = await client.getJson( + '/sync/pull', + bearerToken: token, + queryParameters: {'since': since}, + ); + return RemoteSyncPullResult( + serverCursor: _optionalString(response, 'serverCursor'), + items: (_list( + response, + 'items', + )).map((item) => _syncedItemFromJson(_map(item))).toList(), + ); + } + + Map _pushItemToJson(RemoteSyncPushItem item) => { + 'resourceType': _resourceTypeToWire(item.resourceType), + 'clientId': item.clientId, + 'schemaVersion': item.schemaVersion, + 'clientUpdatedAt': item.clientUpdatedAt.toUtc().toIso8601String(), + 'deletedAt': item.deletedAt?.toUtc().toIso8601String(), + 'payload': item.payload, + }; + + RemoteSyncPushItemResult _pushItemResultFromJson(Map json) { + return RemoteSyncPushItemResult( + resourceType: _resourceTypeFromWire( + _requiredString(json, 'resourceType'), + ), + clientId: _requiredString(json, 'clientId'), + serverId: _optionalString(json, 'serverId'), + status: _pushStatusFromWire(_requiredString(json, 'status')), + serverUpdatedAt: _optionalDateTime(json, 'serverUpdatedAt'), + errorMessage: _optionalString(json, 'message'), + ); + } + + RemoteSyncedItem _syncedItemFromJson(Map json) { + return RemoteSyncedItem( + resourceType: _resourceTypeFromWire( + _requiredString(json, 'resourceType'), + ), + clientId: _requiredString(json, 'clientId'), + serverId: _requiredString(json, 'serverId'), + schemaVersion: _requiredInt(json, 'schemaVersion'), + clientUpdatedAt: _requiredDateTime(json, 'clientUpdatedAt'), + serverUpdatedAt: _requiredDateTime(json, 'serverUpdatedAt'), + deletedAt: _optionalDateTime(json, 'deletedAt'), + payload: _map(json['payload']), + ); + } +} + +String _resourceTypeToWire(SyncResourceType type) => switch (type) { + SyncResourceType.exercise => 'exercise', + SyncResourceType.program => 'program', + SyncResourceType.workoutTemplate => 'workoutTemplate', + SyncResourceType.workoutHistory => 'workoutHistory', + SyncResourceType.mediaAsset => 'mediaAsset', +}; + +SyncResourceType _resourceTypeFromWire(String value) => switch (value) { + 'exercise' => SyncResourceType.exercise, + 'program' => SyncResourceType.program, + 'workoutTemplate' => SyncResourceType.workoutTemplate, + 'workoutHistory' => SyncResourceType.workoutHistory, + 'mediaAsset' => SyncResourceType.mediaAsset, + _ => throw RemoteAuthException( + RemoteAuthFailure.unknown, + 'Unknown sync resource type: $value', + ), +}; + +RemoteSyncPushStatus _pushStatusFromWire(String value) => switch (value) { + 'accepted' => RemoteSyncPushStatus.accepted, + 'ignoredOlder' => RemoteSyncPushStatus.ignoredOlder, + 'error' => RemoteSyncPushStatus.error, + _ => throw RemoteAuthException( + RemoteAuthFailure.unknown, + 'Unknown sync push status: $value', + ), +}; + +List _list(Map json, String key) { + final value = json[key]; + if (value is List) { + return value.cast(); + } + return const []; +} + +Map _map(Object? value) { + if (value is Map) { + return Map.from(value); + } + throw const RemoteAuthException( + RemoteAuthFailure.unknown, + 'Expected JSON object.', + ); +} + +String _requiredString(Map json, String key) { + final value = json[key]; + if (value is String && value.trim().isNotEmpty) { + return value; + } + throw RemoteAuthException( + RemoteAuthFailure.unknown, + '$key is missing from sync response.', + ); +} + +String? _optionalString(Map json, String key) { + final value = json[key]; + return value is String && value.trim().isNotEmpty ? value : null; +} + +int _requiredInt(Map json, String key) { + final value = json[key]; + if (value is int) { + return value; + } + throw RemoteAuthException( + RemoteAuthFailure.unknown, + '$key is missing from sync response.', + ); +} + +DateTime _requiredDateTime(Map json, String key) { + return DateTime.parse(_requiredString(json, key)).toUtc(); +} + +DateTime? _optionalDateTime(Map json, String key) { + final value = _optionalString(json, key); + return value == null ? null : DateTime.parse(value).toUtc(); +} diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 112ad93..2e0bcd0 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -4,6 +4,7 @@ import '../application/app_bootstrap.dart'; import '../domain/domain.dart'; import 'exercise_library_screen.dart'; import 'history_screen.dart'; +import 'profile_screen.dart'; import 'program_screen.dart'; import 'theme.dart'; import 'workout_execution_screen.dart'; @@ -178,6 +179,34 @@ final class _HomeScreenState extends State with RouteAware { ), ), ), + ListTile( + leading: FutureBuilder( + future: widget.bootstrap.authUseCases.currentSession(), + builder: (context, snapshot) { + final session = snapshot.data; + if (session == null || !session.isLoggedIn) { + return const Icon(Icons.account_circle_outlined); + } + final displayName = session.displayName?.trim(); + final label = displayName == null || displayName.isEmpty + ? session.email + : displayName; + return CircleAvatar( + radius: 12, + child: Text(_profileInitials(label)), + ); + }, + ), + title: const Text('Profil'), + subtitle: const Text('Compte, synchronisation et partages'), + trailing: const Icon(Icons.chevron_right), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => + ProfileScreen(authUseCases: widget.bootstrap.authUseCases), + ), + ), + ), ], ), ); @@ -211,3 +240,14 @@ final class _HomeScreenState extends State with RouteAware { }); } } + +String _profileInitials(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return '?'; + final words = trimmed.split(RegExp(r'\s+')); + if (words.length == 1) { + return words.first.substring(0, 1).toUpperCase(); + } + return '${words.first.substring(0, 1)}${words.last.substring(0, 1)}' + .toUpperCase(); +} diff --git a/lib/presentation/presentation.dart b/lib/presentation/presentation.dart index 77ab5d1..aab8ecf 100644 --- a/lib/presentation/presentation.dart +++ b/lib/presentation/presentation.dart @@ -3,6 +3,7 @@ export 'exercise_step_audio.dart'; export 'exercise_library_screen.dart'; export 'history_screen.dart'; export 'home_screen.dart'; +export 'profile_screen.dart'; export 'program_screen.dart'; export 'theme.dart'; export 'workout_execution_screen.dart'; diff --git a/lib/presentation/profile_screen.dart b/lib/presentation/profile_screen.dart new file mode 100644 index 0000000..d9277d5 --- /dev/null +++ b/lib/presentation/profile_screen.dart @@ -0,0 +1,631 @@ +import 'package:flutter/material.dart'; + +import '../application/application.dart'; +import '../domain/domain.dart'; +import 'theme.dart'; + +final class ProfileScreen extends StatefulWidget { + const ProfileScreen({required this.authUseCases, super.key}); + + final AuthUseCases authUseCases; + + @override + State createState() => _ProfileScreenState(); +} + +final class _ProfileScreenState extends State { + late Future _session; + + @override + void initState() { + super.initState(); + _session = widget.authUseCases.currentSession(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Profil')), + body: FutureBuilder( + future: _session, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final session = snapshot.data; + if (session == null || !session.isLoggedIn) { + return _SignedOutProfile( + onRegister: () => _openRegister(context), + onLogin: () => _openLogin(context), + ); + } + return _SignedInProfile( + session: session, + onLogout: () => _confirmLogout(context), + onShares: () => _openReceivedShares(context), + ); + }, + ), + ); + } + + Future _openLogin(BuildContext context) async { + final connected = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => LoginScreen(authUseCases: widget.authUseCases), + ), + ); + if (connected == true && mounted) { + _reloadSession(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Compte connecté. Synchronisation en arrière-plan.'), + ), + ); + } + } + + Future _openRegister(BuildContext context) async { + final connected = await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => RegisterScreen(authUseCases: widget.authUseCases), + ), + ); + if (connected == true && mounted) { + _reloadSession(); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Compte connecté. Synchronisation en arrière-plan.'), + ), + ); + } + } + + Future _confirmLogout(BuildContext context) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Se déconnecter ?'), + content: const Text( + 'Les données restent sur cet appareil. La synchronisation et les ' + 'partages seront suspendus jusqu’à une prochaine connexion.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Se déconnecter'), + ), + ], + ), + ); + if (confirmed != true) return; + await widget.authUseCases.logout(); + if (!mounted) return; + _reloadSession(); + } + + void _openReceivedShares(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const ReceivedSharesScreen()), + ); + } + + void _reloadSession() { + setState(() { + _session = widget.authUseCases.currentSession(); + }); + } +} + +final class _SignedOutProfile extends StatelessWidget { + const _SignedOutProfile({required this.onRegister, required this.onLogin}); + + final VoidCallback onRegister; + final VoidCallback onLogin; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + CourtBlazerAccentPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Compte optionnel', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 8), + const Text( + 'GameTime fonctionne entièrement sans compte. Connecte-toi ' + 'seulement si tu veux sauvegarder tes données en ligne ou ' + 'partager des programmes et séances.', + ), + const SizedBox(height: 16), + FilledButton( + onPressed: onRegister, + child: const Text('Créer un compte'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: onLogin, + child: const Text('Se connecter'), + ), + ], + ), + ), + const SizedBox(height: 16), + CourtBlazerAccentPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Données locales', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + const Text( + 'Tes exercices, programmes, séances et historiques sont ' + 'enregistrés sur cet appareil.', + ), + ], + ), + ), + const SizedBox(height: 16), + CourtBlazerAccentPanel( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Partages', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + const Text( + 'Connecte-toi pour envoyer et recevoir des programmes ou des ' + 'séances.', + ), + ], + ), + ), + ], + ); + } +} + +final class _SignedInProfile extends StatelessWidget { + const _SignedInProfile({ + required this.session, + required this.onLogout, + required this.onShares, + }); + + final UserAccountSession session; + final VoidCallback onLogout; + final VoidCallback onShares; + + @override + Widget build(BuildContext context) { + final displayName = session.displayName?.trim(); + final title = displayName == null || displayName.isEmpty + ? session.email + : displayName; + return ListView( + padding: const EdgeInsets.all(16), + children: [ + CourtBlazerAccentPanel( + child: Row( + children: [ + CircleAvatar(radius: 28, child: Text(_profileInitials(title))), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: Theme.of(context).textTheme.titleLarge), + if (title != session.email) ...[ + const SizedBox(height: 4), + Text(session.email), + ], + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: onLogout, + icon: const Icon(Icons.logout), + label: const Text('Se déconnecter'), + ), + const SizedBox(height: 16), + CourtBlazerAccentPanel( + child: Material( + type: MaterialType.transparency, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.cloud_outlined), + title: const Text('Synchronisation'), + subtitle: const Text('Synchronisation : à venir'), + ), + ), + ), + const SizedBox(height: 12), + CourtBlazerAccentPanel( + child: Material( + type: MaterialType.transparency, + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.inbox_outlined), + title: const Text('Partages reçus'), + subtitle: const Text( + "Programmes et séances reçus d'autres comptes", + ), + trailing: const Icon(Icons.chevron_right), + onTap: onShares, + ), + ), + ), + ], + ); + } +} + +final class LoginScreen extends StatefulWidget { + const LoginScreen({required this.authUseCases, super.key}); + + final AuthUseCases authUseCases; + + @override + State createState() => _LoginScreenState(); +} + +final class _LoginScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + String? _error; + var _submitting = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Se connecter')), + body: _AuthFormScaffold( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email'), + keyboardType: TextInputType.emailAddress, + validator: _emailValidator, + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration(labelText: 'Mot de passe'), + obscureText: true, + validator: _passwordValidator, + ), + if (_error != null) ...[ + const SizedBox(height: 12), + _InlineAuthError(message: _error!), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: _submitting ? null : _submit, + child: Text(_submitting ? 'Connexion...' : 'Se connecter'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: _submitting + ? null + : () { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => RegisterScreen( + authUseCases: widget.authUseCases, + ), + ), + ); + }, + child: const Text('Créer un compte'), + ), + const SizedBox(height: 16), + Text( + 'Tu peux continuer à utiliser GameTime sans compte.', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _error = null; + _submitting = true; + }); + try { + await widget.authUseCases.login( + email: _emailController.text, + password: _passwordController.text, + ); + if (!mounted) return; + Navigator.of(context).pop(true); + } on RemoteAuthException catch (error) { + if (!mounted) return; + setState(() { + _error = _loginErrorMessage(error.failure); + _submitting = false; + }); + } on DomainException { + if (!mounted) return; + setState(() { + _error = 'Vérifie les informations saisies.'; + _submitting = false; + }); + } + } +} + +final class RegisterScreen extends StatefulWidget { + const RegisterScreen({required this.authUseCases, super.key}); + + final AuthUseCases authUseCases; + + @override + State createState() => _RegisterScreenState(); +} + +final class _RegisterScreenState extends State { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + final _displayNameController = TextEditingController(); + String? _error; + var _submitting = false; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + _displayNameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Créer un compte')), + body: _AuthFormScaffold( + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email'), + keyboardType: TextInputType.emailAddress, + validator: _emailValidator, + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + decoration: const InputDecoration(labelText: 'Mot de passe'), + obscureText: true, + validator: _newPasswordValidator, + ), + const SizedBox(height: 12), + TextFormField( + controller: _confirmPasswordController, + decoration: const InputDecoration( + labelText: 'Confirmer le mot de passe', + ), + obscureText: true, + validator: (value) { + if (value != _passwordController.text) { + return 'Les mots de passe ne correspondent pas.'; + } + return null; + }, + ), + const SizedBox(height: 12), + TextFormField( + controller: _displayNameController, + decoration: const InputDecoration( + labelText: 'Pseudo (optionnel)', + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + _InlineAuthError(message: _error!), + ], + const SizedBox(height: 16), + FilledButton( + onPressed: _submitting ? null : _submit, + child: Text(_submitting ? 'Création...' : 'Créer le compte'), + ), + const SizedBox(height: 8), + OutlinedButton( + onPressed: _submitting + ? null + : () { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) => + LoginScreen(authUseCases: widget.authUseCases), + ), + ); + }, + child: const Text('Déjà un compte ? Se connecter'), + ), + const SizedBox(height: 16), + Text( + 'Le compte sert à synchroniser tes données et partager tes ' + "contenus. L'app reste utilisable sans compte.", + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() { + _error = null; + _submitting = true; + }); + final displayName = _displayNameController.text.trim(); + try { + await widget.authUseCases.register( + email: _emailController.text, + password: _passwordController.text, + displayName: displayName.isEmpty ? null : displayName, + ); + if (!mounted) return; + Navigator.of(context).pop(true); + } on RemoteAuthException catch (error) { + if (!mounted) return; + setState(() { + _error = _registerErrorMessage(error.failure); + _submitting = false; + }); + } on DomainException { + if (!mounted) return; + setState(() { + _error = 'Vérifie les informations saisies.'; + _submitting = false; + }); + } + } +} + +final class ReceivedSharesScreen extends StatelessWidget { + const ReceivedSharesScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Partages reçus')), + body: const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + "Les programmes et séances qu'on t'envoie apparaîtront ici.", + textAlign: TextAlign.center, + ), + ), + ), + ); + } +} + +final class _AuthFormScaffold extends StatelessWidget { + const _AuthFormScaffold({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [CourtBlazerAccentPanel(child: child)], + ); + } +} + +final class _InlineAuthError extends StatelessWidget { + const _InlineAuthError({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.error, + ), + ); + } +} + +String? _emailValidator(String? value) { + final email = value?.trim() ?? ''; + if (!RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email)) { + return 'Saisis une adresse email valide.'; + } + return null; +} + +String? _passwordValidator(String? value) { + final password = value ?? ''; + if (password.isEmpty) { + return 'Saisis ton mot de passe.'; + } + if (password.length < 8) { + return 'Saisis un mot de passe d’au moins 8 caractères.'; + } + return null; +} + +String? _newPasswordValidator(String? value) { + if ((value ?? '').length < 8) { + return 'Saisis un mot de passe d’au moins 8 caractères.'; + } + return null; +} + +String _loginErrorMessage(RemoteAuthFailure failure) { + return switch (failure) { + RemoteAuthFailure.invalidCredentials => 'Email ou mot de passe incorrect.', + RemoteAuthFailure.network => + 'Connexion impossible pour le moment. Réessaie plus tard.', + _ => 'Connexion impossible pour le moment. Réessaie plus tard.', + }; +} + +String _registerErrorMessage(RemoteAuthFailure failure) { + return switch (failure) { + RemoteAuthFailure.emailAlreadyUsed => + 'Un compte existe déjà avec cet email.', + RemoteAuthFailure.network => + 'Création impossible pour le moment. Réessaie plus tard.', + _ => 'Création impossible pour le moment. Réessaie plus tard.', + }; +} + +String _profileInitials(String value) { + final trimmed = value.trim(); + if (trimmed.isEmpty) return '?'; + final words = trimmed.split(RegExp(r'\s+')); + if (words.length == 1) { + return words.first.substring(0, 1).toUpperCase(); + } + return '${words.first.substring(0, 1)}${words.last.substring(0, 1)}' + .toUpperCase(); +} diff --git a/test/application/use_cases_test.dart b/test/application/use_cases_test.dart index dca299d..1c044ec 100644 --- a/test/application/use_cases_test.dart +++ b/test/application/use_cases_test.dart @@ -1251,6 +1251,116 @@ void main() { expect(remoteAuthApi.logoutCalls, 0); }); + test('SyncUseCases skips silently without token', () async { + final remoteSyncApi = _FakeRemoteSyncApi(); + final metadataRepository = _FakeSyncMetadataRepository(); + final localChanges = _FakeLocalSyncChangeRepository(); + + final summary = await _syncUseCase( + tokenStore: _FakeAuthTokenStore(), + remoteSyncApi: remoteSyncApi, + metadataRepository: metadataRepository, + localChanges: localChanges, + ).synchronize(manual: true); + + expect(summary.skipped, isTrue); + expect(remoteSyncApi.pushCalls, 0); + expect(remoteSyncApi.pullCalls, 0); + expect(metadataRepository.saved, isEmpty); + }); + + test('SyncUseCases successful push marks change log rows synced', () async { + final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; + final localChanges = _FakeLocalSyncChangeRepository() + ..pendingChanges.add(_pendingExerciseChange()); + final remoteSyncApi = _FakeRemoteSyncApi() + ..pushResult = RemoteSyncPushResult( + serverCursor: 'cursor-1', + results: [ + RemoteSyncPushItemResult( + resourceType: SyncResourceType.exercise, + clientId: 'exercise-1', + serverId: 'server-exercise-1', + status: RemoteSyncPushStatus.accepted, + serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1), + ), + ], + ); + final mappingRepository = _FakeRemoteResourceMappingRepository(); + + final summary = await _syncUseCase( + tokenStore: tokenStore, + remoteSyncApi: remoteSyncApi, + mappingRepository: mappingRepository, + localChanges: localChanges, + ).synchronize(manual: true); + + expect(summary.pushedChanges, 1); + expect(localChanges.syncedChangeLogIds, ['change-1']); + expect(mappingRepository.saved.single.serverId, 'server-exercise-1'); + }); + + test('SyncUseCases pull applies a newer remote item', () async { + final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; + final localChanges = _FakeLocalSyncChangeRepository() + ..localUpdatedAt = DateTime.utc(2026, 7, 17, 11); + final remoteSyncApi = _FakeRemoteSyncApi() + ..pullResult = RemoteSyncPullResult( + serverCursor: 'cursor-2', + items: [ + _remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 12)), + ], + ); + + final summary = await _syncUseCase( + tokenStore: tokenStore, + remoteSyncApi: remoteSyncApi, + localChanges: localChanges, + ).synchronize(manual: true); + + expect(summary.pulledChanges, 1); + expect(localChanges.appliedItems.single.clientId, 'exercise-1'); + }); + + test('SyncUseCases pull ignores an older remote item', () async { + final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; + final localChanges = _FakeLocalSyncChangeRepository() + ..localUpdatedAt = DateTime.utc(2026, 7, 17, 12); + final remoteSyncApi = _FakeRemoteSyncApi() + ..pullResult = RemoteSyncPullResult( + serverCursor: 'cursor-2', + items: [ + _remoteExerciseItem(clientUpdatedAt: DateTime.utc(2026, 7, 17, 11)), + ], + ); + + final summary = await _syncUseCase( + tokenStore: tokenStore, + remoteSyncApi: remoteSyncApi, + localChanges: localChanges, + ).synchronize(manual: true); + + expect(summary.pulledChanges, 0); + expect(localChanges.appliedItems, isEmpty); + }); + + test('SyncUseCases stores failure status without throwing', () async { + final tokenStore = _FakeAuthTokenStore()..token = 'token-1'; + final metadataRepository = _FakeSyncMetadataRepository(); + final remoteSyncApi = _FakeRemoteSyncApi() + ..exception = const RemoteAuthException(RemoteAuthFailure.network); + + final summary = await _syncUseCase( + tokenStore: tokenStore, + remoteSyncApi: remoteSyncApi, + metadataRepository: metadataRepository, + ).synchronize(manual: true); + + expect(summary.failed, isTrue); + expect(metadataRepository.metadata.status, OnlineSyncStatus.failure); + expect(metadataRepository.metadata.lastFailureAt, isNotNull); + }); + test('score result enforces manual xor stopwatch values', () { expect( () => ActiveSetResult( @@ -1461,6 +1571,169 @@ final class _FakeRemoteAuthApi implements RemoteAuthApi { } } +SyncUseCases _syncUseCase({ + _FakeAuthTokenStore? tokenStore, + _FakeRemoteSyncApi? remoteSyncApi, + _FakeSyncMetadataRepository? metadataRepository, + _FakeRemoteResourceMappingRepository? mappingRepository, + _FakeLocalSyncChangeRepository? localChanges, + _FakeClock? clock, +}) { + return SyncUseCases( + tokenStore: tokenStore ?? (_FakeAuthTokenStore()..token = 'token-1'), + remoteSyncApi: remoteSyncApi ?? _FakeRemoteSyncApi(), + metadataRepository: metadataRepository ?? _FakeSyncMetadataRepository(), + mappingRepository: + mappingRepository ?? _FakeRemoteResourceMappingRepository(), + localChanges: localChanges ?? _FakeLocalSyncChangeRepository(), + clock: clock ?? _FakeClock(DateTime.utc(2026, 7, 17, 12)), + deviceId: 'device-1', + ); +} + +final class _FakeRemoteSyncApi implements RemoteSyncApi { + RemoteSyncPushResult pushResult = const RemoteSyncPushResult( + serverCursor: null, + results: [], + ); + RemoteSyncPullResult pullResult = const RemoteSyncPullResult( + serverCursor: null, + items: [], + ); + Exception? exception; + var pushCalls = 0; + var pullCalls = 0; + + @override + Future push({ + required String deviceId, + required List items, + required String token, + }) async { + pushCalls += 1; + final error = exception; + if (error != null) { + throw error; + } + return pushResult; + } + + @override + Future pull({ + required String? since, + required String token, + }) async { + pullCalls += 1; + final error = exception; + if (error != null) { + throw error; + } + return pullResult; + } +} + +final class _FakeSyncMetadataRepository implements SyncMetadataRepository { + SyncMetadataSnapshot metadata = const SyncMetadataSnapshot(); + final saved = []; + + @override + Future read() async => metadata; + + @override + Future save(SyncMetadataSnapshot metadata) async { + this.metadata = metadata; + saved.add(metadata); + } +} + +final class _FakeRemoteResourceMappingRepository + implements RemoteResourceMappingRepository { + final saved = []; + + @override + Future find({ + required SyncResourceType resourceType, + required String clientId, + }) async { + return saved + .where( + (mapping) => + mapping.resourceType == resourceType && + mapping.clientId == clientId, + ) + .firstOrNull; + } + + @override + Future save(RemoteResourceMapping mapping) async { + saved.removeWhere( + (existing) => + existing.resourceType == mapping.resourceType && + existing.clientId == mapping.clientId, + ); + saved.add(mapping); + } +} + +final class _FakeLocalSyncChangeRepository + implements LocalSyncChangeRepository { + final pendingChanges = []; + final syncedChangeLogIds = []; + final appliedItems = []; + DateTime? localUpdatedAt; + + @override + Future> listPendingChanges() async { + return pendingChanges; + } + + @override + Future markChangesSynced( + List changeLogIds, + DateTime syncedAt, + ) async { + syncedChangeLogIds.addAll(changeLogIds); + } + + @override + Future applyRemoteItem(RemoteSyncedItem item) async { + final updatedAt = localUpdatedAt; + if (updatedAt != null && !item.clientUpdatedAt.isAfter(updatedAt)) { + return false; + } + appliedItems.add(item); + localUpdatedAt = item.clientUpdatedAt; + return true; + } +} + +PendingSyncChange _pendingExerciseChange() { + return PendingSyncChange( + changeLogIds: const ['change-1'], + item: RemoteSyncPushItem( + resourceType: SyncResourceType.exercise, + clientId: 'exercise-1', + schemaVersion: 1, + clientUpdatedAt: DateTime.utc(2026, 7, 17, 12), + deletedAt: null, + payload: const {'id': 'exercise-1', 'name': 'Squat'}, + ), + ); +} + +RemoteSyncedItem _remoteExerciseItem({required DateTime clientUpdatedAt}) { + return RemoteSyncedItem( + resourceType: SyncResourceType.exercise, + clientId: 'exercise-1', + serverId: 'server-exercise-1', + schemaVersion: 1, + clientUpdatedAt: clientUpdatedAt, + serverUpdatedAt: DateTime.utc(2026, 7, 17, 12, 1), + deletedAt: null, + payload: const {'id': 'exercise-1', 'name': 'Squat'}, + ); +} + ExerciseUseCases _exerciseUseCase( _FakeExerciseRepository repository, { _FakeProgramRepository? programRepository, diff --git a/test/presentation/home_screen_test.dart b/test/presentation/home_screen_test.dart index c1086a1..ab5c238 100644 --- a/test/presentation/home_screen_test.dart +++ b/test/presentation/home_screen_test.dart @@ -9,6 +9,21 @@ import 'package:gametime/domain/domain.dart'; import 'package:gametime/presentation/home_screen.dart'; void main() { + testWidgets('l’entrée Profil est présente sur l’accueil', (tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [homeRouteObserver], + home: HomeScreen( + bootstrap: _FakeBootstrap(_FakeActiveSessionRepository()), + ), + ), + ); + await tester.pump(); + + expect(find.text('Profil'), findsOneWidget); + expect(find.text('Compte, synchronisation et partages'), findsOneWidget); + }); + testWidgets('le bandeau de reprise apparaît au retour sur l’accueil', ( tester, ) async { @@ -122,6 +137,15 @@ final class _FakeBootstrap implements AppDependencies { clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), ids: _FakeIds(), ), + syncUseCases = SyncUseCases( + tokenStore: _FakeAuthTokenStore(), + remoteSyncApi: _FakeRemoteSyncApi(), + metadataRepository: _FakeSyncMetadataRepository(), + mappingRepository: _FakeRemoteResourceMappingRepository(), + localChanges: _FakeLocalSyncChangeRepository(), + clock: _FakeClock(DateTime.utc(2026, 7, 17, 12)), + deviceId: 'device-1', + ), activeWorkoutSessionUseCases = ActiveWorkoutSessionUseCases( sessionRepository: activeRepository, templateRepository: _FakeWorkoutTemplateRepository(), @@ -180,6 +204,9 @@ final class _FakeBootstrap implements AppDependencies { @override final AuthUseCases authUseCases; + @override + final SyncUseCases syncUseCases; + @override final ExerciseUseCases exerciseUseCases; @@ -303,6 +330,66 @@ final class _FakeRemoteAuthApi implements RemoteAuthApi { } } +final class _FakeRemoteSyncApi implements RemoteSyncApi { + @override + Future pull({ + required String? since, + required String token, + }) async { + return const RemoteSyncPullResult(serverCursor: null, items: []); + } + + @override + Future push({ + required String deviceId, + required List items, + required String token, + }) async { + return const RemoteSyncPushResult(serverCursor: null, results: []); + } +} + +final class _FakeSyncMetadataRepository implements SyncMetadataRepository { + SyncMetadataSnapshot _metadata = const SyncMetadataSnapshot(); + + @override + Future read() async => _metadata; + + @override + Future save(SyncMetadataSnapshot metadata) async { + _metadata = metadata; + } +} + +final class _FakeRemoteResourceMappingRepository + implements RemoteResourceMappingRepository { + @override + Future find({ + required SyncResourceType resourceType, + required String clientId, + }) async { + return null; + } + + @override + Future save(RemoteResourceMapping mapping) async {} +} + +final class _FakeLocalSyncChangeRepository + implements LocalSyncChangeRepository { + @override + Future applyRemoteItem(RemoteSyncedItem item) async => false; + + @override + Future> listPendingChanges() async => const []; + + @override + Future markChangesSynced( + List changeLogIds, + DateTime syncedAt, + ) async {} +} + final class _FakeActiveSessionRepository implements ActiveSessionRepository { ActiveWorkoutSession? session; final restStates = []; diff --git a/test/presentation/profile_screen_test.dart b/test/presentation/profile_screen_test.dart new file mode 100644 index 0000000..5ad9570 --- /dev/null +++ b/test/presentation/profile_screen_test.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:gametime/application/application.dart'; +import 'package:gametime/domain/domain.dart'; +import 'package:gametime/presentation/profile_screen.dart'; + +void main() { + testWidgets('le profil déconnecté invite sans bloquer le reste', ( + tester, + ) async { + final harness = _AuthHarness(); + + await tester.pumpWidget( + MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)), + ); + await tester.pump(); + + expect(find.text('Compte optionnel'), findsOneWidget); + expect(find.text('Créer un compte'), findsOneWidget); + expect(find.text('Se connecter'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('la connexion invalide affiche une erreur inline', ( + tester, + ) async { + final harness = _AuthHarness() + ..remote.loginFailure = RemoteAuthFailure.invalidCredentials; + + await tester.pumpWidget( + MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)), + ); + await tester.pump(); + + await tester.tap(find.text('Se connecter')); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextFormField, 'Email'), + 'a@b.fr', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Mot de passe'), + 'password1', + ); + await tester.tap(find.widgetWithText(FilledButton, 'Se connecter')); + await tester.pumpAndSettle(); + + expect(find.text('Email ou mot de passe incorrect.'), findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('la connexion réussie affiche le profil connecté', ( + tester, + ) async { + final harness = _AuthHarness(); + + await tester.pumpWidget( + MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)), + ); + await tester.pump(); + + await tester.tap(find.text('Se connecter')); + await tester.pumpAndSettle(); + await tester.enterText( + find.widgetWithText(TextFormField, 'Email'), + 'alex@example.com', + ); + await tester.enterText( + find.widgetWithText(TextFormField, 'Mot de passe'), + 'password1', + ); + await tester.tap(find.widgetWithText(FilledButton, 'Se connecter')); + await tester.pumpAndSettle(); + + expect(find.text('alex@example.com'), findsOneWidget); + expect(find.text('Synchronisation'), findsOneWidget); + expect(find.text('Partages reçus'), findsOneWidget); + }); + + testWidgets('la déconnexion repasse au profil déconnecté', (tester) async { + final harness = _AuthHarness( + initialSession: UserAccountSession( + id: 'account-1', + serverUserId: 'server-user-1', + email: 'alex@example.com', + displayName: 'Alex', + isLoggedIn: true, + createdAt: DateTime.utc(2026, 7, 17), + updatedAt: DateTime.utc(2026, 7, 17), + ), + initialToken: 'token-1', + ); + + await tester.pumpWidget( + MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)), + ); + await tester.pump(); + + expect(find.text('Alex'), findsOneWidget); + + await tester.tap(find.text('Se déconnecter')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Se déconnecter')); + await tester.pumpAndSettle(); + + expect(harness.tokenStore.token, isNull); + expect(harness.accountRepository.session?.isLoggedIn, isFalse); + expect(find.text('Compte optionnel'), findsOneWidget); + }); +} + +final class _AuthHarness { + _AuthHarness({UserAccountSession? initialSession, String? initialToken}) + : tokenStore = _FakeAuthTokenStore(initialToken), + accountRepository = _FakeOnlineAccountRepository(initialSession), + remote = _FakeRemoteAuthApi() { + useCases = AuthUseCases( + tokenStore: tokenStore, + accountRepository: accountRepository, + remoteAuthApi: remote, + clock: const _FakeClock(), + ids: _FakeIds(), + ); + } + + final _FakeAuthTokenStore tokenStore; + final _FakeOnlineAccountRepository accountRepository; + final _FakeRemoteAuthApi remote; + late final AuthUseCases useCases; +} + +final class _FakeClock implements Clock { + const _FakeClock(); + + @override + DateTime now() => DateTime.utc(2026, 7, 17, 12); +} + +final class _FakeIds implements IdGenerator { + var _next = 0; + + @override + String newId() { + _next += 1; + return 'local-account-$_next'; + } +} + +final class _FakeAuthTokenStore implements AuthTokenStore { + _FakeAuthTokenStore(this.token); + + String? token; + + @override + Future clearToken() async { + token = null; + } + + @override + Future readToken() async => token; + + @override + Future saveToken(String token, DateTime expiresAt) async { + this.token = token; + } +} + +final class _FakeOnlineAccountRepository implements OnlineAccountRepository { + _FakeOnlineAccountRepository(this.session); + + UserAccountSession? session; + + @override + Future clearSession() async { + session = null; + } + + @override + Future currentSession() async => session; + + @override + Future saveSession(UserAccountSession session) async { + this.session = session; + } +} + +final class _FakeRemoteAuthApi implements RemoteAuthApi { + RemoteAuthFailure? loginFailure; + RemoteAuthFailure? registerFailure; + + @override + Future login({ + required String email, + required String password, + }) async { + final failure = loginFailure; + if (failure != null) { + throw RemoteAuthException(failure); + } + return RemoteAuthResult( + userId: 'server-user-1', + email: email.trim(), + token: 'token-1', + expiresAt: DateTime.utc(2026, 7, 18), + ); + } + + @override + Future logout(String token) async {} + + @override + Future register({ + required String email, + required String password, + String? displayName, + }) async { + final failure = registerFailure; + if (failure != null) { + throw RemoteAuthException(failure); + } + return RemoteAuthResult( + userId: 'server-user-1', + email: email.trim(), + token: 'token-1', + expiresAt: DateTime.utc(2026, 7, 18), + ); + } +}