merge(main): statut de synchronisation discret et action manuelle (ticket #68)

Fusionne feature/online-client-68-sync-status — pub get OK, dart
format appliqué, analyze propre (mêmes infos préexistantes), 127/127
tests verts, build APK debug validé. Ton du statut de sync vérifié
manuellement : formulation neutre, jamais alarmante.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:06:12 +02:00
7 changed files with 418 additions and 34 deletions

View File

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

View File

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

View File

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

View File

@ -125,6 +125,10 @@ final class SyncUseCases {
final Clock clock;
final String deviceId;
Future<SyncMetadataSnapshot> currentStatus() {
return metadataRepository.read();
}
Future<SyncRunSummary> synchronize({required bool manual}) async {
final token = await tokenStore.readToken();
if (token == null) {

View File

@ -202,8 +202,10 @@ final class _HomeScreenState extends State<HomeScreen> 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,
),
),
),
),

View File

@ -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<ProfileScreen> createState() => _ProfileScreenState();
@ -41,6 +46,7 @@ final class _ProfileScreenState extends State<ProfileScreen> {
}
return _SignedInProfile(
session: session,
syncUseCases: widget.syncUseCases,
onLogout: () => _confirmLogout(context),
onShares: () => _openReceivedShares(context),
);
@ -52,7 +58,10 @@ final class _ProfileScreenState extends State<ProfileScreen> {
Future<void> _openLogin(BuildContext context) async {
final connected = await Navigator.of(context).push<bool>(
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<ProfileScreen> {
Future<void> _openRegister(BuildContext context) async {
final connected = await Navigator.of(context).push<bool>(
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<SyncStatusPanel> createState() => _SyncStatusPanelState();
}
final class _SyncStatusPanelState extends State<SyncStatusPanel> {
late Future<SyncMetadataSnapshot> _status;
var _syncing = false;
@override
void initState() {
super.initState();
_status = widget.syncUseCases.currentStatus();
}
@override
Widget build(BuildContext context) {
return CourtBlazerAccentPanel(
child: FutureBuilder<SyncMetadataSnapshot>(
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<void> _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<LoginScreen> createState() => _LoginScreenState();
@ -338,6 +479,7 @@ final class _LoginScreenState extends State<LoginScreen> {
MaterialPageRoute(
builder: (context) => RegisterScreen(
authUseCases: widget.authUseCases,
syncUseCases: widget.syncUseCases,
),
),
);
@ -386,9 +528,14 @@ final class _LoginScreenState extends State<LoginScreen> {
}
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<RegisterScreen> createState() => _RegisterScreenState();
@ -472,8 +619,10 @@ final class _RegisterScreenState extends State<RegisterScreen> {
: () {
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 'à linstant';
}
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 '?';

View File

@ -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<RemoteSyncPushResult> push({
required String deviceId,
required List<RemoteSyncPushItem> items,
required String token,
}) async {
pushCalls += 1;
return const RemoteSyncPushResult(serverCursor: null, results: []);
}
@override
Future<RemoteSyncPullResult> 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<SyncMetadataSnapshot> read() async => metadata;
@override
Future<void> save(SyncMetadataSnapshot metadata) async {
this.metadata = metadata;
}
}
final class _FakeRemoteResourceMappingRepository
implements RemoteResourceMappingRepository {
final _mappings = <String, RemoteResourceMapping>{};
@override
Future<RemoteResourceMapping?> find({
required SyncResourceType resourceType,
required String clientId,
}) async {
return _mappings['${resourceType.name}:$clientId'];
}
@override
Future<void> save(RemoteResourceMapping mapping) async {
_mappings['${mapping.resourceType.name}:${mapping.clientId}'] = mapping;
}
}
final class _FakeLocalSyncChangeRepository
implements LocalSyncChangeRepository {
@override
Future<bool> applyRemoteItem(RemoteSyncedItem item) async => true;
@override
Future<List<PendingSyncChange>> listPendingChanges() async => const [];
@override
Future<void> markChangesSynced(
List<String> changeLogIds,
DateTime syncedAt,
) async {}
}