import 'package:flutter/material.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:geosector_app/core/data/models/pending_request.dart'; import 'package:geosector_app/core/services/api_service.dart'; /// Widget qui affiche l'état de la connexion Internet et le nombre de requêtes en attente class ConnectivityIndicator extends StatefulWidget { /// Si true, affiche un message d'erreur lorsque l'appareil est déconnecté final bool showErrorMessage; /// Si true, affiche un badge avec le type de connexion (WiFi, données mobiles) final bool showConnectionType; /// Callback appelé lorsque l'état de la connexion change final Function(bool isConnected)? onConnectivityChanged; const ConnectivityIndicator({ super.key, this.showErrorMessage = true, this.showConnectionType = true, this.onConnectivityChanged, }); @override State createState() => _ConnectivityIndicatorState(); } class _ConnectivityIndicatorState extends State with SingleTickerProviderStateMixin { late AnimationController _animationController; late Animation _animation; @override void initState() { super.initState(); // Configuration de l'animation de clignotement _animationController = AnimationController( duration: const Duration(milliseconds: 1000), vsync: this, ); _animation = Tween( begin: 1.0, end: 0.3, ).animate(CurvedAnimation( parent: _animationController, curve: Curves.easeInOut, )); } @override void dispose() { _animationController.dispose(); super.dispose(); } void _updateAnimation(int pendingCount) { if (pendingCount > 0) { // Démarrer l'animation de clignotement si des requêtes sont en attente if (!_animationController.isAnimating) { _animationController.repeat(reverse: true); } } else { // Arrêter l'animation quand il n'y a plus de requêtes _animationController.stop(); _animationController.value = 1.0; } } @override Widget build(BuildContext context) { final theme = Theme.of(context); // Utiliser l'instance globale de connectivityService définie dans app.dart final isConnected = connectivityService.isConnected; final connectionType = connectivityService.connectionType; final connectionStatus = connectivityService.connectionStatus; // Appeler le callback si fourni, mais pas directement dans le build // pour éviter les problèmes de rendu WidgetsBinding.instance.addPostFrameCallback((_) { if (widget.onConnectivityChanged != null) { widget.onConnectivityChanged!(isConnected); } }); // Vérifier si la box des requêtes en attente est ouverte if (!Hive.isBoxOpen(AppKeys.pendingRequestsBoxName)) { return _buildBasicIndicator(context, isConnected, connectionType, connectionStatus, theme, 0); } // Utiliser ValueListenableBuilder pour surveiller les requêtes en attente return ValueListenableBuilder>( valueListenable: Hive.box(AppKeys.pendingRequestsBoxName).listenable(), builder: (context, box, child) { final pendingCount = box.length; // Mettre à jour l'animation en fonction du nombre de requêtes _updateAnimation(pendingCount); if (!isConnected && widget.showErrorMessage) { return Container( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: theme.colorScheme.error.withOpacity(0.1), borderRadius: BorderRadius.circular(8), border: Border.all( color: theme.colorScheme.error.withOpacity(0.3), ), ), child: Row( children: [ Icon( Icons.wifi_off, color: theme.colorScheme.error, size: 18, ), const SizedBox(width: 8), Expanded( child: Text( pendingCount > 0 ? 'Hors ligne - $pendingCount requête${pendingCount > 1 ? 's' : ''} en attente' : 'Aucune connexion Internet. Certaines fonctionnalités peuvent être limitées.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ), if (pendingCount > 0) AnimatedBuilder( animation: _animation, builder: (context, child) { return Opacity( opacity: _animation.value, child: Container( margin: const EdgeInsets.only(left: 8), padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.orange, shape: BoxShape.circle, ), child: Text( pendingCount.toString(), style: const TextStyle( color: Colors.white, fontSize: 11, fontWeight: FontWeight.bold, ), ), ), ); }, ), ], ), ); } else if (isConnected && widget.showConnectionType) { return _buildConnectedIndicator( context, connectionStatus, connectionType, theme, pendingCount ); } // Si aucune condition n'est remplie return const SizedBox.shrink(); }, ); } Widget _buildConnectedIndicator( BuildContext context, List connectionStatus, String connectionType, ThemeData theme, int pendingCount, ) { // Obtenir la couleur et l'icône en fonction du type de connexion final color = _getConnectionColor(connectionStatus, theme); final icon = _getConnectionIcon(connectionStatus); return AnimatedBuilder( animation: _animation, builder: (context, child) { return GestureDetector( onTap: pendingCount > 0 ? () => _showPendingRequestsDialog(context) : null, child: Container( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), decoration: BoxDecoration( color: pendingCount > 0 ? Colors.orange.withOpacity(0.1 * _animation.value) : color.withOpacity(0.1), borderRadius: BorderRadius.circular(16), border: Border.all( color: pendingCount > 0 ? Colors.orange.withOpacity(0.3 * _animation.value) : color.withOpacity(0.3), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( pendingCount > 0 ? Icons.sync : icon, color: pendingCount > 0 ? Colors.orange : color, size: 14, ), const SizedBox(width: 4), Text( pendingCount > 0 ? '$pendingCount en attente' : connectionType, style: theme.textTheme.bodySmall?.copyWith( color: pendingCount > 0 ? Colors.orange : color, fontWeight: FontWeight.bold, ), ), ], ), ), ); }, ); } Widget _buildBasicIndicator( BuildContext context, bool isConnected, String connectionType, List connectionStatus, ThemeData theme, int pendingCount, ) { if (!isConnected && widget.showErrorMessage) { return Container( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12), margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration( color: theme.colorScheme.error.withOpacity(0.1), borderRadius: BorderRadius.circular(8), border: Border.all( color: theme.colorScheme.error.withOpacity(0.3), ), ), child: Row( children: [ Icon( Icons.wifi_off, color: theme.colorScheme.error, size: 18, ), const SizedBox(width: 8), Expanded( child: Text( 'Aucune connexion Internet. Certaines fonctionnalités peuvent être limitées.', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.error, ), ), ), ], ), ); } else if (isConnected && widget.showConnectionType) { final color = _getConnectionColor(connectionStatus, theme); final icon = _getConnectionIcon(connectionStatus); return Container( padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(16), border: Border.all( color: color.withOpacity(0.3), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, color: color, size: 14, ), const SizedBox(width: 4), Text( connectionType, style: theme.textTheme.bodySmall?.copyWith( color: color, fontWeight: FontWeight.bold, ), ), ], ), ); } return const SizedBox.shrink(); } /// Retourne l'icône correspondant au type de connexion IconData _getConnectionIcon(List statusList) { // Utiliser le premier type de connexion qui n'est pas 'none' ConnectivityResult status = statusList.firstWhere( (result) => result != ConnectivityResult.none, orElse: () => ConnectivityResult.none); switch (status) { case ConnectivityResult.wifi: return Icons.wifi; case ConnectivityResult.mobile: return Icons.signal_cellular_alt; case ConnectivityResult.ethernet: return Icons.lan; case ConnectivityResult.bluetooth: return Icons.bluetooth; case ConnectivityResult.vpn: return Icons.vpn_key; default: return Icons.wifi_off; } } /// Retourne la couleur correspondant au type de connexion Color _getConnectionColor( List statusList, ThemeData theme) { // Utiliser le premier type de connexion qui n'est pas 'none' ConnectivityResult status = statusList.firstWhere( (result) => result != ConnectivityResult.none, orElse: () => ConnectivityResult.none); switch (status) { case ConnectivityResult.wifi: return Colors.green; case ConnectivityResult.mobile: return Colors.blue; case ConnectivityResult.ethernet: return Colors.purple; case ConnectivityResult.bluetooth: return Colors.indigo; case ConnectivityResult.vpn: return Colors.orange; default: return theme.colorScheme.error; } } /// Affiche une boîte de dialogue pour gérer les requêtes en attente void _showPendingRequestsDialog(BuildContext context) { final box = Hive.box(AppKeys.pendingRequestsBoxName); showDialog( context: context, builder: (dialogContext) => ValueListenableBuilder>( valueListenable: box.listenable(), builder: (context, box, _) { final requests = box.values.toList() ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); // Si plus de requêtes, fermer la dialog if (requests.isEmpty) { WidgetsBinding.instance.addPostFrameCallback((_) { if (dialogContext.mounted) { Navigator.of(dialogContext).pop(); } }); } return AlertDialog( title: Row( children: [ const Icon(Icons.sync_problem, color: Colors.orange), const SizedBox(width: 8), Text('Requêtes en attente (${requests.length})'), ], ), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, children: [ // Actions globales Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ElevatedButton.icon( onPressed: () async { // Réessayer toutes les requêtes Navigator.of(dialogContext).pop(); await ApiService.instance.processPendingRequests(); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Traitement des requêtes en cours...'), backgroundColor: Colors.blue, ), ); } }, icon: const Icon(Icons.refresh), label: const Text('Tout réessayer'), style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, foregroundColor: Colors.white, ), ), ElevatedButton.icon( onPressed: () async { // Confirmer avant de tout supprimer final confirmed = await showDialog( context: dialogContext, builder: (confirmContext) => AlertDialog( title: const Text('Confirmation'), content: const Text( 'Êtes-vous sûr de vouloir supprimer toutes les requêtes en attente ?', ), actions: [ TextButton( onPressed: () => Navigator.of(confirmContext).pop(false), child: const Text('Annuler'), ), TextButton( onPressed: () => Navigator.of(confirmContext).pop(true), child: const Text('Supprimer'), ), ], ), ); if (confirmed == true) { await box.clear(); if (dialogContext.mounted) { Navigator.of(dialogContext).pop(); } if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Toutes les requêtes ont été supprimées'), backgroundColor: Colors.green, ), ); } } }, icon: const Icon(Icons.delete_sweep), label: const Text('Tout supprimer'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, ), ), ], ), const SizedBox(height: 16), const Divider(), const SizedBox(height: 8), // Liste des requêtes Flexible( child: ListView.builder( shrinkWrap: true, itemCount: requests.length, itemBuilder: (context, index) { final request = requests[index]; final hasConflict = request.metadata?['hasConflict'] == true; final hasErrors = request.retryCount >= 5; return Card( color: hasConflict ? Colors.red.shade50 : hasErrors ? Colors.orange.shade50 : null, child: ListTile( leading: Icon( hasConflict ? Icons.error : hasErrors ? Icons.warning : Icons.sync, color: hasConflict ? Colors.red : hasErrors ? Colors.orange : Colors.blue, ), title: Text('${request.method} ${request.path}'), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Créé: ${_formatDate(request.createdAt)}', style: const TextStyle(fontSize: 11), ), if (request.retryCount > 0) Text( 'Tentatives: ${request.retryCount}', style: const TextStyle(fontSize: 11), ), if (hasConflict) const Text( 'CONFLIT (409)', style: TextStyle( fontSize: 11, color: Colors.red, fontWeight: FontWeight.bold, ), ), if (hasErrors) const Text( 'ÉCHEC (5 tentatives)', style: TextStyle( fontSize: 11, color: Colors.orange, fontWeight: FontWeight.bold, ), ), ], ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ // Bouton détails IconButton( icon: const Icon(Icons.info_outline, size: 20), tooltip: 'Détails', onPressed: () => _showRequestDetails(dialogContext, request), ), // Bouton réessayer if (hasConflict || hasErrors) IconButton( icon: const Icon(Icons.refresh, size: 20), tooltip: 'Réessayer', color: Colors.blue, onPressed: () async { await ApiService.instance.resolveConflictByRetry(request.id); if (dialogContext.mounted) { Navigator.of(dialogContext).pop(); } if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Requête marquée pour réessai'), backgroundColor: Colors.blue, ), ); } }, ), // Bouton supprimer IconButton( icon: const Icon(Icons.delete_outline, size: 20), tooltip: 'Supprimer', color: Colors.red, onPressed: () async { if (hasConflict) { await ApiService.instance.resolveConflictByDeletion(request.id); } else { await box.delete(request.key); } // La dialog se ferme automatiquement via ValueListenableBuilder si box vide }, ), ], ), ), ); }, ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Fermer'), ), ], ); }, ), ); } /// Affiche les détails d'une requête void _showRequestDetails(BuildContext context, PendingRequest request) { showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('Détails de la requête'), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildDetailRow('Méthode', request.method), _buildDetailRow('Chemin', request.path), _buildDetailRow('Créé le', _formatDate(request.createdAt)), _buildDetailRow('Tentatives', request.retryCount.toString()), if (request.tempId != null) _buildDetailRow('ID temporaire', request.tempId!), if (request.errorMessage != null) _buildDetailRow('Erreur', request.errorMessage!, isError: true), if (request.metadata != null && request.metadata!.isNotEmpty) _buildDetailRow('Métadonnées', request.metadata.toString()), if (request.data != null) ...[ const SizedBox(height: 8), const Text( 'Données:', style: TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 4), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.grey.shade100, borderRadius: BorderRadius.circular(4), ), child: Text( request.data.toString(), style: const TextStyle(fontSize: 11, fontFamily: 'monospace'), ), ), ], ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: const Text('Fermer'), ), ], ), ); } Widget _buildDetailRow(String label, String value, {bool isError = false}) { return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 100, child: Text( '$label:', style: const TextStyle(fontWeight: FontWeight.bold), ), ), Expanded( child: Text( value, style: TextStyle( color: isError ? Colors.red : null, ), ), ), ], ), ); } String _formatDate(DateTime date) { final now = DateTime.now(); final diff = now.difference(date); if (diff.inMinutes < 1) { return 'Il y a quelques secondes'; } else if (diff.inHours < 1) { return 'Il y a ${diff.inMinutes} min'; } else if (diff.inDays < 1) { return 'Il y a ${diff.inHours} h'; } else { return 'Il y a ${diff.inDays} jour${diff.inDays > 1 ? 's' : ''}'; } } }