merge(main): sync client incrémentale LWW et profil/écrans auth (tickets #65, #67)

Fusionne feature/online-client-65-sync — pub get OK, build_runner OK
(migration schemaVersion 10→11), dart format appliqué, analyze propre
(mêmes infos préexistantes), 119/119 tests verts, build APK debug
validé. Tickets #65 (DevBackend) et #67 (DevFrontend) développés dans
le même worktree partagé ; feature/online-client-67-profile-ui restée
vide, jamais divergée.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 21:41:32 +02:00
19 changed files with 3922 additions and 12 deletions

View File

@ -1,6 +1,6 @@
---
issueRef: "#64"
version: 1
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedAt: 1784488337677
version: 2
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
updatedAt: 1784489186446
---

View File

@ -2,15 +2,15 @@
id: "4a18ba38-9b18-4b39-99a4-ad5a8779e47f"
number: 64
title: "[DevBackend] Client online : session compte, stockage sécurisé et adapter API"
status: "open"
status: "qa"
priority: "medium"
sprint: null
links: [{"target":"#63","kind":"relatesTo"}]
agentRefs: [{"agentId":"10ee045b-1c41-479e-ba03-dceed9edd495","role":"assigned"}]
createdBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"}
updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"}
createdAt: 1784488337677
updatedAt: 1784488337677
version: 1
updatedAt: 1784489186446
version: 2
---
Ajouter le socle client online côté domain/application/infrastructure : entité locale `UserAccountSession` (profil cache non sensible), ports `AuthTokenStore`, `OnlineAccountRepository`/`RemoteGameTimeApi`, adapter HTTP sous `lib/infrastructure/remote/` basé sur `package:http` avec timeouts courts et erreurs typées silencieuses. Stocker access/refresh token via `flutter_secure_storage`; stocker email/pseudo/avatar/statut sync en Drift. Ne jamais bloquer l'usage offline si l'API échoue.

View File

@ -715,13 +715,13 @@
"issueRef": "#64",
"path": "64",
"title": "[DevBackend] Client online : session compte, stockage sécurisé et adapter API",
"status": "open",
"status": "qa",
"priority": "medium",
"sprint": null,
"assignedAgentIds": [
"10ee045b-1c41-479e-ba03-dceed9edd495"
],
"updatedAt": 1784488337677
"updatedAt": 1784489186446
},
{
"issueRef": "#65",

View File

@ -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(),
);
}

View File

@ -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 {

View File

@ -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,

View File

@ -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

View File

@ -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',

View File

@ -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';

View File

@ -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) => {

View File

@ -1,2 +1,3 @@
export 'auth_api.dart';
export 'http_api_client.dart';
export 'sync_api.dart';

View 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();
}

View File

@ -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();
}

View File

@ -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';

View 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 dau moins 8 caractères.';
}
return null;
}
String? _newPasswordValidator(String? value) {
if ((value ?? '').length < 8) {
return 'Saisis un mot de passe dau 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();
}

View File

@ -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,

View File

@ -9,6 +9,21 @@ import 'package:gametime/domain/domain.dart';
import 'package:gametime/presentation/home_screen.dart';
void main() {
testWidgets('lentrée Profil est présente sur laccueil', (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 laccueil', (
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>[];

View 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),
);
}
}