Ajoute la synchronisation client incrémentale LWW vers l'API serveur (application/use_cases.dart: SyncUseCases, ports.dart, migration Drift schemaVersion 10→11 pour les métadonnées de sync et mappings de ressources, infrastructure/remote/sync_api.dart) et l'écran de profil avec entrée sur l'accueil (presentation/profile_screen.dart, home_screen.dart, presentation.dart). Développés dans le même worktree partagé par DevBackend et DevFrontend ; commit combiné car test/presentation/home_screen_test.dart mélange authentiquement les deux tickets (le test de l'entrée Profil et la mise à jour du fake de bootstrap requise par le nouveau getter syncUseCases sur AppDependencies). Corrige au passage une couleur `crimson` inexistante dans le thème (remplacée par colorScheme.error) et une signature de paramètres positionnels/nommés incohérente sur un fake de test. flutter pub get OK, build_runner OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 119/119 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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<AppBootstrap> 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(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -73,6 +73,194 @@ abstract interface class RemoteAuthApi {
|
||||
Future<void> 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<String, Object?> 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<RemoteSyncPushItemResult> 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<String, Object?> payload;
|
||||
}
|
||||
|
||||
final class RemoteSyncPullResult {
|
||||
const RemoteSyncPullResult({required this.serverCursor, required this.items});
|
||||
|
||||
final String? serverCursor;
|
||||
final List<RemoteSyncedItem> items;
|
||||
}
|
||||
|
||||
abstract interface class RemoteSyncApi {
|
||||
Future<RemoteSyncPushResult> push({
|
||||
required String deviceId,
|
||||
required List<RemoteSyncPushItem> items,
|
||||
required String token,
|
||||
});
|
||||
|
||||
Future<RemoteSyncPullResult> 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<String> changeLogIds;
|
||||
final RemoteSyncPushItem item;
|
||||
}
|
||||
|
||||
abstract interface class SyncMetadataRepository {
|
||||
Future<SyncMetadataSnapshot> read();
|
||||
Future<void> save(SyncMetadataSnapshot metadata);
|
||||
}
|
||||
|
||||
abstract interface class RemoteResourceMappingRepository {
|
||||
Future<RemoteResourceMapping?> find({
|
||||
required SyncResourceType resourceType,
|
||||
required String clientId,
|
||||
});
|
||||
Future<void> save(RemoteResourceMapping mapping);
|
||||
}
|
||||
|
||||
abstract interface class LocalSyncChangeRepository {
|
||||
Future<List<PendingSyncChange>> listPendingChanges();
|
||||
Future<void> markChangesSynced(List<String> changeLogIds, DateTime syncedAt);
|
||||
Future<bool> applyRemoteItem(RemoteSyncedItem item);
|
||||
}
|
||||
|
||||
const Object _portsUnchanged = Object();
|
||||
|
||||
abstract interface class ExerciseRepository {
|
||||
Future<Exercise?> findById(String id);
|
||||
Future<List<Exercise>> 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 {
|
||||
|
||||
@ -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<SyncRunSummary> 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 = <String>[];
|
||||
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,
|
||||
|
||||
@ -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<void> _migrateToSchema10(Migrator migrator) async {
|
||||
await migrator.createTable(onlineAccountSessions);
|
||||
}
|
||||
|
||||
Future<void> _migrateToSchema11(Migrator migrator) async {
|
||||
await migrator.createTable(syncMetadataEntries);
|
||||
await migrator.createTable(remoteResourceMappings);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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<SyncMetadataSnapshot> read() async {
|
||||
final row = await (database.select(
|
||||
database.syncMetadataEntries,
|
||||
)..where((table) => table.id.equals(_id))).getSingleOrNull();
|
||||
if (row == null) {
|
||||
return const SyncMetadataSnapshot();
|
||||
}
|
||||
return SyncMetadataSnapshot(
|
||||
serverCursor: row.serverCursor,
|
||||
lastSuccessfulSyncAt: _utcOrNull(row.lastSuccessfulSyncAt),
|
||||
lastAttemptAt: _utcOrNull(row.lastAttemptAt),
|
||||
lastFailureAt: _utcOrNull(row.lastFailureAt),
|
||||
status: _onlineSyncStatusFromDb(row.status),
|
||||
pendingPushCount: row.pendingPushCount,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(SyncMetadataSnapshot metadata) async {
|
||||
await database
|
||||
.into(database.syncMetadataEntries)
|
||||
.insertOnConflictUpdate(
|
||||
db.SyncMetadataEntriesCompanion.insert(
|
||||
id: const Value(_id),
|
||||
serverCursor: Value<String?>(metadata.serverCursor),
|
||||
lastSuccessfulSyncAt: Value<DateTime?>(
|
||||
_utcOrNull(metadata.lastSuccessfulSyncAt),
|
||||
),
|
||||
lastAttemptAt: Value<DateTime?>(_utcOrNull(metadata.lastAttemptAt)),
|
||||
lastFailureAt: Value<DateTime?>(_utcOrNull(metadata.lastFailureAt)),
|
||||
status: Value(_onlineSyncStatusToDb(metadata.status)),
|
||||
pendingPushCount: Value<int?>(metadata.pendingPushCount),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftRemoteResourceMappingRepository
|
||||
implements RemoteResourceMappingRepository {
|
||||
const DriftRemoteResourceMappingRepository(this.database);
|
||||
|
||||
final db.AppDatabase database;
|
||||
|
||||
@override
|
||||
Future<RemoteResourceMapping?> find({
|
||||
required SyncResourceType resourceType,
|
||||
required String clientId,
|
||||
}) async {
|
||||
final row =
|
||||
await (database.select(database.remoteResourceMappings)..where(
|
||||
(table) =>
|
||||
table.resourceType.equals(
|
||||
_syncResourceTypeToDb(resourceType),
|
||||
) &
|
||||
table.clientId.equals(clientId),
|
||||
))
|
||||
.getSingleOrNull();
|
||||
return row == null ? null : _remoteResourceMappingFromRow(row);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(RemoteResourceMapping mapping) async {
|
||||
await database
|
||||
.into(database.remoteResourceMappings)
|
||||
.insertOnConflictUpdate(_remoteResourceMappingCompanion(mapping));
|
||||
}
|
||||
}
|
||||
|
||||
final class DriftLocalSyncChangeRepository
|
||||
implements LocalSyncChangeRepository {
|
||||
const DriftLocalSyncChangeRepository(this.database);
|
||||
|
||||
final db.AppDatabase database;
|
||||
|
||||
@override
|
||||
Future<List<PendingSyncChange>> listPendingChanges() async {
|
||||
final rows =
|
||||
await (database.select(database.changeLogEntries)
|
||||
..where(
|
||||
(table) =>
|
||||
table.syncedAt.isNull() &
|
||||
table.entityType.isIn(_syncableEntityTypes),
|
||||
)
|
||||
..orderBy([(table) => OrderingTerm.asc(table.createdAt)]))
|
||||
.get();
|
||||
final grouped = <String, List<db.ChangeLogEntry>>{};
|
||||
for (final row in rows) {
|
||||
grouped
|
||||
.putIfAbsent('${row.entityType}:${row.entityId}', () => [])
|
||||
.add(row);
|
||||
}
|
||||
final changes = <PendingSyncChange>[];
|
||||
for (final group in grouped.values) {
|
||||
group.sort((left, right) => left.createdAt.compareTo(right.createdAt));
|
||||
final latest = group.last;
|
||||
final item = await _pushItemForChange(latest);
|
||||
if (item == null) {
|
||||
continue;
|
||||
}
|
||||
changes.add(
|
||||
PendingSyncChange(
|
||||
changeLogIds: group.map((row) => row.id).toList(),
|
||||
item: item,
|
||||
),
|
||||
);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markChangesSynced(
|
||||
List<String> changeLogIds,
|
||||
DateTime syncedAt,
|
||||
) async {
|
||||
if (changeLogIds.isEmpty) {
|
||||
return;
|
||||
}
|
||||
await (database.update(
|
||||
database.changeLogEntries,
|
||||
)..where((table) => table.id.isIn(changeLogIds))).write(
|
||||
db.ChangeLogEntriesCompanion(
|
||||
syncedAt: Value<DateTime?>(syncedAt.toUtc()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyRemoteItem(RemoteSyncedItem item) async {
|
||||
final localUpdatedAt = await _localUpdatedAt(
|
||||
item.resourceType,
|
||||
item.clientId,
|
||||
);
|
||||
if (localUpdatedAt != null &&
|
||||
!item.clientUpdatedAt.isAfter(localUpdatedAt)) {
|
||||
return false;
|
||||
}
|
||||
if (item.deletedAt != null) {
|
||||
return _applyRemoteDelete(item);
|
||||
}
|
||||
return _applyRemotePayload(item);
|
||||
}
|
||||
|
||||
Future<RemoteSyncPushItem?> _pushItemForChange(
|
||||
db.ChangeLogEntry change,
|
||||
) async {
|
||||
final resourceType = _syncResourceTypeFromEntityType(change.entityType);
|
||||
if (resourceType == null) {
|
||||
return null;
|
||||
}
|
||||
final snapshot = await _localSnapshot(resourceType, change.entityId);
|
||||
return RemoteSyncPushItem(
|
||||
resourceType: resourceType,
|
||||
clientId: change.entityId,
|
||||
schemaVersion: snapshot.schemaVersion,
|
||||
clientUpdatedAt: snapshot.updatedAt,
|
||||
deletedAt: snapshot.deletedAt,
|
||||
payload: snapshot.payload,
|
||||
);
|
||||
}
|
||||
|
||||
Future<_LocalSyncSnapshot> _localSnapshot(
|
||||
SyncResourceType resourceType,
|
||||
String id,
|
||||
) async {
|
||||
return switch (resourceType) {
|
||||
SyncResourceType.exercise => _exerciseSnapshot(id),
|
||||
SyncResourceType.program => _programSnapshot(id),
|
||||
SyncResourceType.workoutTemplate => _workoutTemplateSnapshot(id),
|
||||
SyncResourceType.workoutHistory => _workoutHistorySnapshot(id),
|
||||
SyncResourceType.mediaAsset => _mediaAssetSnapshot(id),
|
||||
};
|
||||
}
|
||||
|
||||
Future<DateTime?> _localUpdatedAt(
|
||||
SyncResourceType resourceType,
|
||||
String id,
|
||||
) async {
|
||||
final tableName = _tableNameForResourceType(resourceType);
|
||||
final row = await database
|
||||
.customSelect(
|
||||
'SELECT updated_at FROM $tableName WHERE id = ? LIMIT 1',
|
||||
variables: [Variable<String>(id)],
|
||||
)
|
||||
.getSingleOrNull();
|
||||
final value = row?.data['updated_at'];
|
||||
return value is DateTime ? value.toUtc() : null;
|
||||
}
|
||||
|
||||
Future<bool> _applyRemoteDelete(RemoteSyncedItem item) async {
|
||||
final tableName = _tableNameForResourceType(item.resourceType);
|
||||
final updated = await database.customUpdate(
|
||||
'UPDATE $tableName SET deleted_at = ?, updated_at = ?, '
|
||||
"sync_state = 'deleted' WHERE id = ?",
|
||||
variables: [
|
||||
Variable<DateTime>(item.deletedAt!.toUtc()),
|
||||
Variable<DateTime>(item.clientUpdatedAt.toUtc()),
|
||||
Variable<String>(item.clientId),
|
||||
],
|
||||
);
|
||||
return updated > 0;
|
||||
}
|
||||
|
||||
Future<bool> _applyRemotePayload(RemoteSyncedItem item) async {
|
||||
switch (item.resourceType) {
|
||||
case SyncResourceType.exercise:
|
||||
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<String>(id)],
|
||||
)
|
||||
.getSingle();
|
||||
return _LocalSyncSnapshot(
|
||||
schemaVersion: row.data['schema_version'] as int? ?? 1,
|
||||
updatedAt: (row.data['updated_at'] as DateTime).toUtc(),
|
||||
deletedAt: (row.data['deleted_at'] as DateTime?)?.toUtc(),
|
||||
payload: {'id': id},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class 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<String, Object?> payload,
|
||||
) {
|
||||
return _LocalSyncSnapshot(
|
||||
schemaVersion: metadata.schemaVersion,
|
||||
updatedAt: metadata.updatedAt.toUtc(),
|
||||
deletedAt: _utcOrNull(metadata.deletedAt),
|
||||
payload: payload,
|
||||
);
|
||||
}
|
||||
|
||||
final int schemaVersion;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? deletedAt;
|
||||
final Map<String, Object?> payload;
|
||||
}
|
||||
|
||||
Map<String, Object?> _metadataPayload(domain.EntityMetadata metadata) => {
|
||||
'id': metadata.id,
|
||||
'createdAt': metadata.createdAt.toUtc().toIso8601String(),
|
||||
'updatedAt': metadata.updatedAt.toUtc().toIso8601String(),
|
||||
'deletedAt': metadata.deletedAt?.toUtc().toIso8601String(),
|
||||
'schemaVersion': metadata.schemaVersion,
|
||||
'syncState': metadata.syncState.name,
|
||||
'localRevision': metadata.localRevision,
|
||||
'originDeviceId': metadata.originDeviceId,
|
||||
};
|
||||
|
||||
Map<String, Object?> _exercisePayload(domain.Exercise exercise) => {
|
||||
'metadata': _metadataPayload(exercise.metadata),
|
||||
'id': exercise.metadata.id,
|
||||
'name': exercise.name,
|
||||
'description': exercise.description,
|
||||
'imageMediaIds': exercise.imageMediaIds,
|
||||
'iconMediaId': exercise.iconMediaId,
|
||||
'videoMediaId': exercise.videoMediaId,
|
||||
'hasTimeMeasure': exercise.hasTimeMeasure,
|
||||
'hasRepsMeasure': exercise.hasRepsMeasure,
|
||||
'hasScoreMeasure': exercise.hasScoreMeasure,
|
||||
'scoreInputMode': exercise.scoreInputMode.name,
|
||||
'scoreLabel': exercise.scoreLabel,
|
||||
'scoreUnit': exercise.scoreUnit,
|
||||
'defaultTargetTimeSeconds': exercise.defaultTargetTimeSeconds,
|
||||
'defaultTargetReps': exercise.defaultTargetReps,
|
||||
'defaultTargetScore': exercise.defaultTargetScore,
|
||||
'defaultTargetScoreTimeMs': exercise.defaultTargetScoreTimeMs,
|
||||
'steps': exercise.steps.map((step) => step.toSnapshotJson()).toList(),
|
||||
'archivedAt': exercise.archivedAt?.toUtc().toIso8601String(),
|
||||
};
|
||||
|
||||
Map<String, Object?> _mediaAssetPayload(domain.MediaAsset asset) => {
|
||||
'metadata': _metadataPayload(asset.metadata),
|
||||
'id': asset.metadata.id,
|
||||
'kind': _mediaKindToDb(asset.kind),
|
||||
'localUri': asset.localUri,
|
||||
'mimeType': asset.mimeType,
|
||||
'sizeBytes': asset.sizeBytes,
|
||||
'width': asset.width,
|
||||
'height': asset.height,
|
||||
'durationMs': asset.durationMs,
|
||||
'checksum': asset.checksum,
|
||||
'remoteUri': asset.remoteUri,
|
||||
'thumbnailLocalUri': asset.thumbnailLocalUri,
|
||||
};
|
||||
|
||||
Map<String, Object?> _programPayload(domain.Program program) => {
|
||||
'metadata': _metadataPayload(program.metadata),
|
||||
'id': program.metadata.id,
|
||||
'name': program.name,
|
||||
'defaultRestSeconds': program.defaultRestSeconds,
|
||||
'exercises': program.exercises
|
||||
.map((exercise) => exercise.toSnapshotJson())
|
||||
.toList(),
|
||||
};
|
||||
|
||||
Map<String, Object?> _workoutTemplatePayload(domain.WorkoutTemplate template) =>
|
||||
{
|
||||
'metadata': _metadataPayload(template.metadata),
|
||||
'id': template.metadata.id,
|
||||
'name': template.name,
|
||||
'lastStartedAt': template.lastStartedAt?.toUtc().toIso8601String(),
|
||||
'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<String, Object?> _workoutHistoryPayload(domain.WorkoutHistory history) => {
|
||||
'metadata': _metadataPayload(history.metadata),
|
||||
'id': history.metadata.id,
|
||||
'sourceWorkoutTemplateId': history.sourceWorkoutTemplateId,
|
||||
'sourceActiveWorkoutSessionId': history.sourceActiveWorkoutSessionId,
|
||||
'nameSnapshot': history.nameSnapshot,
|
||||
'startedAt': history.startedAt.toUtc().toIso8601String(),
|
||||
'endedAt': history.endedAt.toUtc().toIso8601String(),
|
||||
'totalActiveMs': history.totalActiveMs,
|
||||
'completed': history.completed,
|
||||
'historySnapshotJson': history.historySnapshotJson,
|
||||
};
|
||||
|
||||
domain.Exercise _exerciseFromPayload(RemoteSyncedItem item) {
|
||||
final payload = item.payload;
|
||||
return domain.Exercise(
|
||||
metadata: _metadataFromPayload(item),
|
||||
name: _stringFromPayload(payload, 'name', item.clientId),
|
||||
description: payload['description'] as String?,
|
||||
imageMediaIds: _stringListFromPayload(payload['imageMediaIds']),
|
||||
iconMediaId: payload['iconMediaId'] as String?,
|
||||
videoMediaId: payload['videoMediaId'] as String?,
|
||||
hasTimeMeasure: payload['hasTimeMeasure'] == true,
|
||||
hasRepsMeasure: payload['hasRepsMeasure'] == true,
|
||||
hasScoreMeasure: payload['hasScoreMeasure'] == true,
|
||||
scoreInputMode: _scoreInputModeFromDb(
|
||||
payload['scoreInputMode'] as String? ?? 'manual',
|
||||
),
|
||||
scoreLabel: payload['scoreLabel'] as String?,
|
||||
scoreUnit: payload['scoreUnit'] as String?,
|
||||
defaultTargetTimeSeconds: payload['defaultTargetTimeSeconds'] as int?,
|
||||
defaultTargetReps: payload['defaultTargetReps'] as int?,
|
||||
defaultTargetScore: (payload['defaultTargetScore'] as num?)?.toDouble(),
|
||||
defaultTargetScoreTimeMs: payload['defaultTargetScoreTimeMs'] as int?,
|
||||
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<String, Object?>.from(metadata) : null;
|
||||
return domain.EntityMetadata(
|
||||
id: item.clientId,
|
||||
createdAt: _dateTimeFromPayload(map?['createdAt']) ?? item.clientUpdatedAt,
|
||||
updatedAt: item.clientUpdatedAt,
|
||||
deletedAt: item.deletedAt,
|
||||
schemaVersion: item.schemaVersion,
|
||||
syncState: item.deletedAt == null
|
||||
? domain.SyncState.synced
|
||||
: domain.SyncState.deleted,
|
||||
localRevision: map?['localRevision'] as int? ?? 0,
|
||||
originDeviceId: map?['originDeviceId'] as String? ?? 'remote',
|
||||
);
|
||||
}
|
||||
|
||||
String _stringFromPayload(
|
||||
Map<String, Object?> payload,
|
||||
String key,
|
||||
String fallback,
|
||||
) {
|
||||
final value = payload[key];
|
||||
return value is String && value.trim().isNotEmpty ? value : fallback;
|
||||
}
|
||||
|
||||
List<String> _stringListFromPayload(Object? value) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
}
|
||||
return value.whereType<String>().toList(growable: false);
|
||||
}
|
||||
|
||||
List<domain.ExerciseStep> _stepsFromPayload(Object? value) {
|
||||
if (value is! List) {
|
||||
return const [];
|
||||
}
|
||||
return value
|
||||
.map((raw) {
|
||||
final json = Map<String, Object?>.from(raw as Map);
|
||||
return domain.ExerciseStep(
|
||||
id: _requiredString(json, 'id'),
|
||||
position: _requiredInt(json, 'position'),
|
||||
name: _requiredString(json, 'name'),
|
||||
type: _exerciseStepTypeFromDb(_requiredString(json, 'type')),
|
||||
defaultTargetValue: _requiredInt(json, 'defaultTargetValue'),
|
||||
hasScore: _requiredBool(json, 'hasScore'),
|
||||
scoreInputMode: _scoreInputModeFromDb(
|
||||
_optionalString(json, 'scoreInputMode') ?? 'manual',
|
||||
),
|
||||
scoreLabel: _optionalString(json, 'scoreLabel'),
|
||||
scoreUnit: _optionalString(json, 'scoreUnit'),
|
||||
defaultTargetScore: _optionalDouble(json, 'defaultTargetScore'),
|
||||
defaultTargetScoreTimeMs: _optionalInt(
|
||||
json,
|
||||
'defaultTargetScoreTimeMs',
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
DateTime? _dateTimeFromPayload(Object? value) {
|
||||
return value is String ? DateTime.tryParse(value)?.toUtc() : null;
|
||||
}
|
||||
|
||||
const _syncableEntityTypes = [
|
||||
'Exercise',
|
||||
'Program',
|
||||
'WorkoutTemplate',
|
||||
'WorkoutHistory',
|
||||
'MediaAsset',
|
||||
];
|
||||
|
||||
SyncResourceType? _syncResourceTypeFromEntityType(String entityType) {
|
||||
return switch (entityType) {
|
||||
'Exercise' => SyncResourceType.exercise,
|
||||
'Program' => SyncResourceType.program,
|
||||
'WorkoutTemplate' => SyncResourceType.workoutTemplate,
|
||||
'WorkoutHistory' => SyncResourceType.workoutHistory,
|
||||
'MediaAsset' => SyncResourceType.mediaAsset,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
String _tableNameForResourceType(SyncResourceType type) => switch (type) {
|
||||
SyncResourceType.exercise => 'exercises',
|
||||
SyncResourceType.program => 'programs',
|
||||
SyncResourceType.workoutTemplate => 'workout_templates',
|
||||
SyncResourceType.workoutHistory => 'workout_history',
|
||||
SyncResourceType.mediaAsset => 'media_assets',
|
||||
};
|
||||
|
||||
String _syncStateToDb(domain.SyncState state) => switch (state) {
|
||||
domain.SyncState.localOnly => 'localOnly',
|
||||
domain.SyncState.dirty => 'dirty',
|
||||
@ -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',
|
||||
|
||||
@ -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<Column> get primaryKey => {id};
|
||||
|
||||
@override
|
||||
List<String> 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<Column> get primaryKey => {id};
|
||||
|
||||
@override
|
||||
List<String> 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';
|
||||
|
||||
@ -68,12 +68,55 @@ final class HttpApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
Uri _resolve(String path) {
|
||||
Future<Map<String, Object?>> getJson(
|
||||
String path, {
|
||||
Map<String, String?> queryParameters = const {},
|
||||
String? bearerToken,
|
||||
Set<int> 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<String, Object?>.from(decoded);
|
||||
}
|
||||
throw const RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'Unexpected JSON response.',
|
||||
);
|
||||
}
|
||||
|
||||
Uri _resolve(String path, {Map<String, String?> 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<String, String> _headers(String? bearerToken) => {
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export 'auth_api.dart';
|
||||
export 'http_api_client.dart';
|
||||
export 'sync_api.dart';
|
||||
|
||||
171
lib/infrastructure/remote/sync_api.dart
Normal file
171
lib/infrastructure/remote/sync_api.dart
Normal file
@ -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<RemoteSyncPushResult> push({
|
||||
required String deviceId,
|
||||
required List<RemoteSyncPushItem> 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<RemoteSyncPullResult> 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<String, Object?> _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<String, Object?> 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<String, Object?> 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<Object?> _list(Map<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
if (value is List) {
|
||||
return value.cast<Object?>();
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
Map<String, Object?> _map(Object? value) {
|
||||
if (value is Map) {
|
||||
return Map<String, Object?>.from(value);
|
||||
}
|
||||
throw const RemoteAuthException(
|
||||
RemoteAuthFailure.unknown,
|
||||
'Expected JSON object.',
|
||||
);
|
||||
}
|
||||
|
||||
String _requiredString(Map<String, Object?> 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<String, Object?> json, String key) {
|
||||
final value = json[key];
|
||||
return value is String && value.trim().isNotEmpty ? value : null;
|
||||
}
|
||||
|
||||
int _requiredInt(Map<String, Object?> 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<String, Object?> json, String key) {
|
||||
return DateTime.parse(_requiredString(json, key)).toUtc();
|
||||
}
|
||||
|
||||
DateTime? _optionalDateTime(Map<String, Object?> json, String key) {
|
||||
final value = _optionalString(json, key);
|
||||
return value == null ? null : DateTime.parse(value).toUtc();
|
||||
}
|
||||
@ -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<HomeScreen> with RouteAware {
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: FutureBuilder<UserAccountSession?>(
|
||||
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<HomeScreen> 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();
|
||||
}
|
||||
|
||||
@ -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';
|
||||
|
||||
631
lib/presentation/profile_screen.dart
Normal file
631
lib/presentation/profile_screen.dart
Normal file
@ -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<ProfileScreen> createState() => _ProfileScreenState();
|
||||
}
|
||||
|
||||
final class _ProfileScreenState extends State<ProfileScreen> {
|
||||
late Future<UserAccountSession?> _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<UserAccountSession?>(
|
||||
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<void> _openLogin(BuildContext context) async {
|
||||
final connected = await Navigator.of(context).push<bool>(
|
||||
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<void> _openRegister(BuildContext context) async {
|
||||
final connected = await Navigator.of(context).push<bool>(
|
||||
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<void> _confirmLogout(BuildContext context) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<LoginScreen> createState() => _LoginScreenState();
|
||||
}
|
||||
|
||||
final class _LoginScreenState extends State<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
final class _RegisterScreenState extends State<RegisterScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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();
|
||||
}
|
||||
@ -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<RemoteSyncPushResult> push({
|
||||
required String deviceId,
|
||||
required List<RemoteSyncPushItem> items,
|
||||
required String token,
|
||||
}) async {
|
||||
pushCalls += 1;
|
||||
final error = exception;
|
||||
if (error != null) {
|
||||
throw error;
|
||||
}
|
||||
return pushResult;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteSyncPullResult> 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 = <SyncMetadataSnapshot>[];
|
||||
|
||||
@override
|
||||
Future<SyncMetadataSnapshot> read() async => metadata;
|
||||
|
||||
@override
|
||||
Future<void> save(SyncMetadataSnapshot metadata) async {
|
||||
this.metadata = metadata;
|
||||
saved.add(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeRemoteResourceMappingRepository
|
||||
implements RemoteResourceMappingRepository {
|
||||
final saved = <RemoteResourceMapping>[];
|
||||
|
||||
@override
|
||||
Future<RemoteResourceMapping?> find({
|
||||
required SyncResourceType resourceType,
|
||||
required String clientId,
|
||||
}) async {
|
||||
return saved
|
||||
.where(
|
||||
(mapping) =>
|
||||
mapping.resourceType == resourceType &&
|
||||
mapping.clientId == clientId,
|
||||
)
|
||||
.firstOrNull;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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 = <PendingSyncChange>[];
|
||||
final syncedChangeLogIds = <String>[];
|
||||
final appliedItems = <RemoteSyncedItem>[];
|
||||
DateTime? localUpdatedAt;
|
||||
|
||||
@override
|
||||
Future<List<PendingSyncChange>> listPendingChanges() async {
|
||||
return pendingChanges;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> markChangesSynced(
|
||||
List<String> changeLogIds,
|
||||
DateTime syncedAt,
|
||||
) async {
|
||||
syncedChangeLogIds.addAll(changeLogIds);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> 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,
|
||||
|
||||
@ -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<RemoteSyncPullResult> pull({
|
||||
required String? since,
|
||||
required String token,
|
||||
}) async {
|
||||
return const RemoteSyncPullResult(serverCursor: null, items: []);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<RemoteSyncPushResult> push({
|
||||
required String deviceId,
|
||||
required List<RemoteSyncPushItem> items,
|
||||
required String token,
|
||||
}) async {
|
||||
return const RemoteSyncPushResult(serverCursor: null, results: []);
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeSyncMetadataRepository implements SyncMetadataRepository {
|
||||
SyncMetadataSnapshot _metadata = const SyncMetadataSnapshot();
|
||||
|
||||
@override
|
||||
Future<SyncMetadataSnapshot> read() async => _metadata;
|
||||
|
||||
@override
|
||||
Future<void> save(SyncMetadataSnapshot metadata) async {
|
||||
_metadata = metadata;
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeRemoteResourceMappingRepository
|
||||
implements RemoteResourceMappingRepository {
|
||||
@override
|
||||
Future<RemoteResourceMapping?> find({
|
||||
required SyncResourceType resourceType,
|
||||
required String clientId,
|
||||
}) async {
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(RemoteResourceMapping mapping) async {}
|
||||
}
|
||||
|
||||
final class _FakeLocalSyncChangeRepository
|
||||
implements LocalSyncChangeRepository {
|
||||
@override
|
||||
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => false;
|
||||
|
||||
@override
|
||||
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
|
||||
|
||||
@override
|
||||
Future<void> markChangesSynced(
|
||||
List<String> changeLogIds,
|
||||
DateTime syncedAt,
|
||||
) async {}
|
||||
}
|
||||
|
||||
final class _FakeActiveSessionRepository implements ActiveSessionRepository {
|
||||
ActiveWorkoutSession? session;
|
||||
final restStates = <ActiveRestState>[];
|
||||
|
||||
228
test/presentation/profile_screen_test.dart
Normal file
228
test/presentation/profile_screen_test.dart
Normal file
@ -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<void> clearToken() async {
|
||||
token = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> readToken() async => token;
|
||||
|
||||
@override
|
||||
Future<void> saveToken(String token, DateTime expiresAt) async {
|
||||
this.token = token;
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeOnlineAccountRepository implements OnlineAccountRepository {
|
||||
_FakeOnlineAccountRepository(this.session);
|
||||
|
||||
UserAccountSession? session;
|
||||
|
||||
@override
|
||||
Future<void> clearSession() async {
|
||||
session = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<UserAccountSession?> currentSession() async => session;
|
||||
|
||||
@override
|
||||
Future<void> saveSession(UserAccountSession session) async {
|
||||
this.session = session;
|
||||
}
|
||||
}
|
||||
|
||||
final class _FakeRemoteAuthApi implements RemoteAuthApi {
|
||||
RemoteAuthFailure? loginFailure;
|
||||
RemoteAuthFailure? registerFailure;
|
||||
|
||||
@override
|
||||
Future<RemoteAuthResult> 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<void> logout(String token) async {}
|
||||
|
||||
@override
|
||||
Future<RemoteAuthResult> 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user