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:
@ -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 '?';
|
||||
|
||||
Reference in New Issue
Block a user