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:
2026-07-19 22:05:55 +02:00
parent 7b63792a85
commit 557bfbb37e
4 changed files with 409 additions and 25 deletions

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