feat(online): statut de synchronisation discret et action manuelle (ticket #68)
Affiche le statut de synchronisation sur profile_screen.dart et home_screen.dart avec une action de déclenchement manuel, en formulation neutre (jamais "Erreur"/"Échec" ni couleur alarmante : "Synchronisation en attente", "Nouvelle tentative automatique"). flutter pub get OK, dart format appliqué, analyze propre (mêmes infos préexistantes), 127/127 tests verts, build APK debug validé. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@ -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) {
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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 'à 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 '?';
|
||||
|
||||
@ -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 {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user