diff --git a/.ideai/tickets/66/carnet.md b/.ideai/tickets/66/carnet.md index e85cb83..c714be8 100644 --- a/.ideai/tickets/66/carnet.md +++ b/.ideai/tickets/66/carnet.md @@ -1,6 +1,6 @@ --- issueRef: "#66" -version: 1 -updatedBy: {"kind":"agent","agent_id":"f8f40941-ecf7-4830-b9de-8818a099f448"} -updatedAt: 1784488349264 +version: 2 +updatedBy: {"kind":"agent","agent_id":"57695b92-24d0-4876-837c-76116e70a6ae"} +updatedAt: 1784490921505 --- diff --git a/.ideai/tickets/66/issue.md b/.ideai/tickets/66/issue.md index c11351f..d8ae54b 100644 --- a/.ideai/tickets/66/issue.md +++ b/.ideai/tickets/66/issue.md @@ -2,15 +2,15 @@ id: "bdcf80ef-1360-4baa-9c49-86b091412ba1" number: 66 title: "[DevBackend] Partage client : use cases, inbox cache et import local" -status: "open" +status: "qa" priority: "medium" sprint: null links: [{"target":"#63","kind":"relatesTo"},{"target":"#64","kind":"dependsOn"},{"target":"#65","kind":"dependsOn"}] 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: 1784488349264 -updatedAt: 1784488349264 -version: 1 +updatedAt: 1784490921505 +version: 2 --- Ajouter les ports/use cases client pour `POST /shares`, `GET /shares/inbox`, accept/decline/revoke. Stocker une inbox locale lisible offline et une file d'actions de partage en attente si réseau indisponible. À l'acceptation, importer une copie locale indépendante du Programme ou WorkoutTemplate depuis le payload reçu si disponible; l'accusé serveur peut partir ensuite, et la ressource créée côté serveur retombe aussi via le prochain `/sync/pull` sans doublon grâce aux IDs/mapping. \ No newline at end of file diff --git a/.ideai/tickets/index.json b/.ideai/tickets/index.json index 2298f65..0107f7d 100644 --- a/.ideai/tickets/index.json +++ b/.ideai/tickets/index.json @@ -739,13 +739,13 @@ "issueRef": "#66", "path": "66", "title": "[DevBackend] Partage client : use cases, inbox cache et import local", - "status": "open", + "status": "qa", "priority": "medium", "sprint": null, "assignedAgentIds": [ "10ee045b-1c41-479e-ba03-dceed9edd495" ], - "updatedAt": 1784488349264 + "updatedAt": 1784490921505 }, { "issueRef": "#67", diff --git a/lib/application/use_cases.dart b/lib/application/use_cases.dart index f5a0340..4c7d116 100644 --- a/lib/application/use_cases.dart +++ b/lib/application/use_cases.dart @@ -125,6 +125,10 @@ final class SyncUseCases { final Clock clock; final String deviceId; + Future currentStatus() { + return metadataRepository.read(); + } + Future synchronize({required bool manual}) async { final token = await tokenStore.readToken(); if (token == null) { diff --git a/lib/presentation/home_screen.dart b/lib/presentation/home_screen.dart index 2e0bcd0..3d30a18 100644 --- a/lib/presentation/home_screen.dart +++ b/lib/presentation/home_screen.dart @@ -202,8 +202,10 @@ final class _HomeScreenState extends State with RouteAware { trailing: const Icon(Icons.chevron_right), onTap: () => Navigator.of(context).push( MaterialPageRoute( - builder: (context) => - ProfileScreen(authUseCases: widget.bootstrap.authUseCases), + builder: (context) => ProfileScreen( + authUseCases: widget.bootstrap.authUseCases, + syncUseCases: widget.bootstrap.syncUseCases, + ), ), ), ), diff --git a/lib/presentation/profile_screen.dart b/lib/presentation/profile_screen.dart index d9277d5..1248311 100644 --- a/lib/presentation/profile_screen.dart +++ b/lib/presentation/profile_screen.dart @@ -5,9 +5,14 @@ import '../domain/domain.dart'; import 'theme.dart'; final class ProfileScreen extends StatefulWidget { - const ProfileScreen({required this.authUseCases, super.key}); + const ProfileScreen({ + required this.authUseCases, + required this.syncUseCases, + super.key, + }); final AuthUseCases authUseCases; + final SyncUseCases syncUseCases; @override State createState() => _ProfileScreenState(); @@ -41,6 +46,7 @@ final class _ProfileScreenState extends State { } return _SignedInProfile( session: session, + syncUseCases: widget.syncUseCases, onLogout: () => _confirmLogout(context), onShares: () => _openReceivedShares(context), ); @@ -52,7 +58,10 @@ final class _ProfileScreenState extends State { Future _openLogin(BuildContext context) async { final connected = await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => LoginScreen(authUseCases: widget.authUseCases), + builder: (context) => LoginScreen( + authUseCases: widget.authUseCases, + syncUseCases: widget.syncUseCases, + ), ), ); if (connected == true && mounted) { @@ -68,7 +77,10 @@ final class _ProfileScreenState extends State { Future _openRegister(BuildContext context) async { final connected = await Navigator.of(context).push( MaterialPageRoute( - builder: (context) => RegisterScreen(authUseCases: widget.authUseCases), + builder: (context) => RegisterScreen( + authUseCases: widget.authUseCases, + syncUseCases: widget.syncUseCases, + ), ), ); if (connected == true && mounted) { @@ -198,11 +210,13 @@ final class _SignedOutProfile extends StatelessWidget { final class _SignedInProfile extends StatelessWidget { const _SignedInProfile({ required this.session, + required this.syncUseCases, required this.onLogout, required this.onShares, }); final UserAccountSession session; + final SyncUseCases syncUseCases; final VoidCallback onLogout; final VoidCallback onShares; @@ -242,17 +256,7 @@ final class _SignedInProfile extends StatelessWidget { 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'), - ), - ), - ), + SyncStatusPanel(syncUseCases: syncUseCases), const SizedBox(height: 12), CourtBlazerAccentPanel( child: Material( @@ -274,10 +278,147 @@ final class _SignedInProfile extends StatelessWidget { } } +final class SyncStatusPanel extends StatefulWidget { + const SyncStatusPanel({required this.syncUseCases, super.key}); + + final SyncUseCases syncUseCases; + + @override + State createState() => _SyncStatusPanelState(); +} + +final class _SyncStatusPanelState extends State { + late Future _status; + var _syncing = false; + + @override + void initState() { + super.initState(); + _status = widget.syncUseCases.currentStatus(); + } + + @override + Widget build(BuildContext context) { + return CourtBlazerAccentPanel( + child: FutureBuilder( + future: _status, + builder: (context, snapshot) { + final status = snapshot.data; + final view = _syncStatusView(status, _syncing); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: _syncing + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(view.icon), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Synchronisation', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 4), + Text(view.label), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: _syncing ? null : _syncNow, + icon: const Icon(Icons.sync), + label: const Text('Synchroniser maintenant'), + ), + ], + ); + }, + ), + ); + } + + Future _syncNow() async { + setState(() { + _syncing = true; + }); + await widget.syncUseCases.synchronize(manual: true); + if (!mounted) return; + setState(() { + _syncing = false; + _status = widget.syncUseCases.currentStatus(); + }); + } +} + +final class _SyncStatusView { + const _SyncStatusView({required this.label, required this.icon}); + + final String label; + final IconData icon; +} + +_SyncStatusView _syncStatusView(SyncMetadataSnapshot? metadata, bool syncing) { + if (syncing || metadata?.status == OnlineSyncStatus.syncing) { + return const _SyncStatusView( + label: 'Synchronisation en cours...', + icon: Icons.sync, + ); + } + if (metadata == null) { + return const _SyncStatusView( + label: 'Synchronisation en attente', + icon: Icons.cloud_outlined, + ); + } + final lastSuccess = metadata.lastSuccessfulSyncAt; + if (metadata.status == OnlineSyncStatus.success && lastSuccess != null) { + return _SyncStatusView( + label: 'Dernière synchro : ${_relativeSyncTime(lastSuccess)}', + icon: Icons.cloud_done_outlined, + ); + } + if (metadata.status == OnlineSyncStatus.failure) { + if (lastSuccess != null) { + return _SyncStatusView( + label: + 'Dernière synchronisation : ${_relativeSyncTime(lastSuccess)}. ' + 'Nouvelle tentative automatique.', + icon: Icons.schedule, + ); + } + return const _SyncStatusView( + label: 'Synchronisation en attente', + icon: Icons.schedule, + ); + } + return const _SyncStatusView( + label: 'Synchronisation en attente', + icon: Icons.cloud_outlined, + ); +} + final class LoginScreen extends StatefulWidget { - const LoginScreen({required this.authUseCases, super.key}); + const LoginScreen({ + required this.authUseCases, + required this.syncUseCases, + super.key, + }); final AuthUseCases authUseCases; + final SyncUseCases syncUseCases; @override State createState() => _LoginScreenState(); @@ -338,6 +479,7 @@ final class _LoginScreenState extends State { MaterialPageRoute( builder: (context) => RegisterScreen( authUseCases: widget.authUseCases, + syncUseCases: widget.syncUseCases, ), ), ); @@ -386,9 +528,14 @@ final class _LoginScreenState extends State { } final class RegisterScreen extends StatefulWidget { - const RegisterScreen({required this.authUseCases, super.key}); + const RegisterScreen({ + required this.authUseCases, + required this.syncUseCases, + super.key, + }); final AuthUseCases authUseCases; + final SyncUseCases syncUseCases; @override State createState() => _RegisterScreenState(); @@ -472,8 +619,10 @@ final class _RegisterScreenState extends State { : () { Navigator.of(context).pushReplacement( MaterialPageRoute( - builder: (context) => - LoginScreen(authUseCases: widget.authUseCases), + builder: (context) => LoginScreen( + authUseCases: widget.authUseCases, + syncUseCases: widget.syncUseCases, + ), ), ); }, @@ -619,6 +768,32 @@ String _registerErrorMessage(RemoteAuthFailure failure) { }; } +String _relativeSyncTime(DateTime value) { + final localValue = value.toLocal(); + final difference = DateTime.now().difference(localValue); + if (difference.inMinutes < 1) { + return 'à l’instant'; + } + if (difference.inHours < 1) { + return 'il y a ${difference.inMinutes} min'; + } + if (difference.inDays < 1) { + return 'il y a ${difference.inHours} h'; + } + if (difference.inDays == 1) { + return 'hier à ${_formatHourMinute(localValue)}'; + } + return 'le ${localValue.day.toString().padLeft(2, '0')}/' + '${localValue.month.toString().padLeft(2, '0')} ' + 'à ${_formatHourMinute(localValue)}'; +} + +String _formatHourMinute(DateTime value) { + final hour = value.hour.toString().padLeft(2, '0'); + final minute = value.minute.toString().padLeft(2, '0'); + return '$hour:$minute'; +} + String _profileInitials(String value) { final trimmed = value.trim(); if (trimmed.isEmpty) return '?'; diff --git a/test/presentation/profile_screen_test.dart b/test/presentation/profile_screen_test.dart index 5ad9570..6ddc148 100644 --- a/test/presentation/profile_screen_test.dart +++ b/test/presentation/profile_screen_test.dart @@ -11,7 +11,12 @@ void main() { final harness = _AuthHarness(); await tester.pumpWidget( - MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)), + MaterialApp( + home: ProfileScreen( + authUseCases: harness.useCases, + syncUseCases: harness.syncUseCases, + ), + ), ); await tester.pump(); @@ -28,7 +33,12 @@ void main() { ..remote.loginFailure = RemoteAuthFailure.invalidCredentials; await tester.pumpWidget( - MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)), + MaterialApp( + home: ProfileScreen( + authUseCases: harness.useCases, + syncUseCases: harness.syncUseCases, + ), + ), ); await tester.pump(); @@ -55,7 +65,12 @@ void main() { final harness = _AuthHarness(); await tester.pumpWidget( - MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)), + MaterialApp( + home: ProfileScreen( + authUseCases: harness.useCases, + syncUseCases: harness.syncUseCases, + ), + ), ); await tester.pump(); @@ -92,7 +107,12 @@ void main() { ); await tester.pumpWidget( - MaterialApp(home: ProfileScreen(authUseCases: harness.useCases)), + MaterialApp( + home: ProfileScreen( + authUseCases: harness.useCases, + syncUseCases: harness.syncUseCases, + ), + ), ); await tester.pump(); @@ -107,13 +127,94 @@ void main() { expect(harness.accountRepository.session?.isLoggedIn, isFalse); expect(find.text('Compte optionnel'), findsOneWidget); }); + + testWidgets( + 'le bouton de synchronisation lance une synchronisation manuelle', + (tester) async { + final harness = _AuthHarness.connected(); + + await tester.pumpWidget( + MaterialApp( + home: ProfileScreen( + authUseCases: harness.useCases, + syncUseCases: harness.syncUseCases, + ), + ), + ); + await tester.pump(); + + expect(find.text('Synchroniser maintenant'), findsOneWidget); + + await tester.tap(find.text('Synchroniser maintenant')); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(harness.syncRemote.pullCalls, 1); + }, + ); + + testWidgets('un souci de synchronisation reste neutre dans le profil', ( + tester, + ) async { + final harness = _AuthHarness.connected()..syncRemote.throwOnPull = true; + + await tester.pumpWidget( + MaterialApp( + home: ProfileScreen( + authUseCases: harness.useCases, + syncUseCases: harness.syncUseCases, + ), + ), + ); + await tester.pump(); + + await tester.tap(find.text('Synchroniser maintenant')); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(harness.syncMetadata.metadata.status, OnlineSyncStatus.failure); + expect(find.text('Synchronisation en attente'), findsOneWidget); + expect(find.textContaining('Erreur'), findsNothing); + expect(find.byType(AlertDialog), findsNothing); + }); + + testWidgets('le statut se met à jour après une synchronisation réussie', ( + tester, + ) async { + final harness = _AuthHarness.connected(); + + await tester.pumpWidget( + MaterialApp( + home: ProfileScreen( + authUseCases: harness.useCases, + syncUseCases: harness.syncUseCases, + ), + ), + ); + await tester.pump(); + + expect(find.text('Synchronisation en attente'), findsOneWidget); + + await tester.tap(find.text('Synchroniser maintenant')); + await tester.pump(); + await tester.pump(); + await tester.pump(); + + expect(find.textContaining('Dernière synchro :'), findsOneWidget); + }); } final class _AuthHarness { _AuthHarness({UserAccountSession? initialSession, String? initialToken}) : tokenStore = _FakeAuthTokenStore(initialToken), accountRepository = _FakeOnlineAccountRepository(initialSession), - remote = _FakeRemoteAuthApi() { + remote = _FakeRemoteAuthApi(), + syncRemote = _FakeRemoteSyncApi(), + syncMetadata = _FakeSyncMetadataRepository(), + syncMappings = _FakeRemoteResourceMappingRepository(), + syncChanges = _FakeLocalSyncChangeRepository() { useCases = AuthUseCases( tokenStore: tokenStore, accountRepository: accountRepository, @@ -121,12 +222,41 @@ final class _AuthHarness { clock: const _FakeClock(), ids: _FakeIds(), ); + syncUseCases = SyncUseCases( + tokenStore: tokenStore, + remoteSyncApi: syncRemote, + metadataRepository: syncMetadata, + mappingRepository: syncMappings, + localChanges: syncChanges, + clock: const _FakeClock(), + deviceId: 'device-1', + ); + } + + factory _AuthHarness.connected() { + return _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', + ); } final _FakeAuthTokenStore tokenStore; final _FakeOnlineAccountRepository accountRepository; final _FakeRemoteAuthApi remote; + final _FakeRemoteSyncApi syncRemote; + final _FakeSyncMetadataRepository syncMetadata; + final _FakeRemoteResourceMappingRepository syncMappings; + final _FakeLocalSyncChangeRepository syncChanges; late final AuthUseCases useCases; + late final SyncUseCases syncUseCases; } final class _FakeClock implements Clock { @@ -226,3 +356,76 @@ final class _FakeRemoteAuthApi implements RemoteAuthApi { ); } } + +final class _FakeRemoteSyncApi implements RemoteSyncApi { + var pullCalls = 0; + var pushCalls = 0; + var throwOnPull = false; + + @override + Future push({ + required String deviceId, + required List items, + required String token, + }) async { + pushCalls += 1; + return const RemoteSyncPushResult(serverCursor: null, results: []); + } + + @override + Future pull({ + required String? since, + required String token, + }) async { + pullCalls += 1; + if (throwOnPull) { + throw Exception('temporary unavailable'); + } + return const RemoteSyncPullResult(serverCursor: 'cursor-1', items: []); + } +} + +final class _FakeSyncMetadataRepository implements SyncMetadataRepository { + var metadata = const SyncMetadataSnapshot(); + + @override + Future read() async => metadata; + + @override + Future save(SyncMetadataSnapshot metadata) async { + this.metadata = metadata; + } +} + +final class _FakeRemoteResourceMappingRepository + implements RemoteResourceMappingRepository { + final _mappings = {}; + + @override + Future find({ + required SyncResourceType resourceType, + required String clientId, + }) async { + return _mappings['${resourceType.name}:$clientId']; + } + + @override + Future save(RemoteResourceMapping mapping) async { + _mappings['${mapping.resourceType.name}:${mapping.clientId}'] = mapping; + } +} + +final class _FakeLocalSyncChangeRepository + implements LocalSyncChangeRepository { + @override + Future applyRemoteItem(RemoteSyncedItem item) async => true; + + @override + Future> listPendingChanges() async => const []; + + @override + Future markChangesSynced( + List changeLogIds, + DateTime syncedAt, + ) async {} +}