import 'dart:async'; import 'package:flutter/material.dart'; import 'package:geosector_app/core/theme/app_theme.dart'; import 'package:flutter/services.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; import 'package:geolocator/geolocator.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:hive_flutter/hive_flutter.dart'; import 'package:geosector_app/core/constants/app_keys.dart'; import 'package:geosector_app/core/data/models/passage_model.dart'; import 'package:geosector_app/core/services/api_service.dart'; import 'package:geosector_app/core/services/current_amicale_service.dart'; import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart'; import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart'; import 'package:geosector_app/presentation/widgets/grouped_passages_dialog.dart'; import 'package:geosector_app/app.dart'; import 'package:geosector_app/core/utils/api_exception.dart'; class UserFieldModePage extends StatefulWidget { const UserFieldModePage({super.key}); @override State createState() => _UserFieldModePageState(); } class _UserFieldModePageState extends State with TickerProviderStateMixin { // Controllers final MapController _mapController = MapController(); final TextEditingController _searchController = TextEditingController(); // Animation controllers pour le clignotement late AnimationController _gpsBlinkController; late AnimationController _networkBlinkController; late Animation _gpsBlinkAnimation; late Animation _networkBlinkAnimation; // Position et tracking Position? _currentPosition; StreamSubscription? _positionStreamSubscription; Timer? _qualityUpdateTimer; // Qualité des signaux double _gpsAccuracy = 999; List _connectivityResult = [ConnectivityResult.none]; bool _isGpsEnabled = false; // Filtrage et recherche String _searchQuery = ''; List _nearbyPassages = []; // État de chargement bool _isLoading = true; bool _locationPermissionGranted = false; String _statusMessage = ''; // Listener pour les changements de la box passages Box? _passagesBox; @override void initState() { super.initState(); _initializeAnimations(); // Écouter les changements de la Hive box passages pour rafraîchir la carte _passagesBox = Hive.box(AppKeys.passagesBoxName); _passagesBox?.listenable().addListener(_onPassagesChanged); if (kIsWeb) { // Sur web, utiliser une position simulée pour éviter le blocage _initializeWebMode(); } else { // Sur mobile, utiliser le GPS réel _checkPermissionsAndStartTracking(); _startQualityMonitoring(); } } // Callback appelé quand la box passages change void _onPassagesChanged() { if (mounted) { _updateNearbyPassages(); } } void _initializeWebMode() async { // Essayer d'obtenir la position réelle depuis le navigateur try { setState(() { _statusMessage = "Demande d'autorisation de géolocalisation..."; }); // Demander la permission et obtenir la position final position = await Geolocator.getCurrentPosition( locationSettings: const LocationSettings( accuracy: LocationAccuracy.high, ), ); setState(() { _currentPosition = position; _gpsAccuracy = position.accuracy; _isGpsEnabled = true; _connectivityResult = [ConnectivityResult.wifi]; _isLoading = false; _locationPermissionGranted = true; _statusMessage = ""; }); // Charger les passages proches de la position réelle _updateNearbyPassages(); // Démarrer le suivi de position même sur web _startLocationTracking(); } catch (e) { debugPrint('Erreur géolocalisation web: $e'); // Essayer d'utiliser les coordonnées GPS de l'amicale double fallbackLat = 46.603354; // Centre de la France par défaut double fallbackLng = 1.888334; String statusMessage = "Position approximative"; try { final amicale = CurrentAmicaleService.instance.currentAmicale; if (amicale != null && amicale.gpsLat.isNotEmpty && amicale.gpsLng.isNotEmpty) { final amicaleLat = double.tryParse(amicale.gpsLat); final amicaleLng = double.tryParse(amicale.gpsLng); if (amicaleLat != null && amicaleLng != null) { fallbackLat = amicaleLat; fallbackLng = amicaleLng; statusMessage = "Position de l'amicale"; debugPrint( 'Utilisation des coordonnées de l\'amicale: $fallbackLat, $fallbackLng'); } } } catch (amicaleError) { debugPrint('Erreur récupération coordonnées amicale: $amicaleError'); } // Utiliser la position de fallback (amicale ou centre France) setState(() { _currentPosition = Position( latitude: fallbackLat, longitude: fallbackLng, timestamp: DateTime.now(), accuracy: 100.0, altitude: 0.0, heading: 0.0, speed: 0.0, speedAccuracy: 0.0, altitudeAccuracy: 0.0, headingAccuracy: 0.0, ); _gpsAccuracy = 100.0; _isGpsEnabled = false; _connectivityResult = [ConnectivityResult.wifi]; _isLoading = false; _locationPermissionGranted = false; _statusMessage = statusMessage; }); _updateNearbyPassages(); } } void _initializeAnimations() { // Animation pour GPS faible/perdu _gpsBlinkController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); _gpsBlinkAnimation = Tween( begin: 1.0, end: 0.3, ).animate(CurvedAnimation( parent: _gpsBlinkController, curve: Curves.easeInOut, )); // Animation pour réseau faible/perdu _networkBlinkController = AnimationController( duration: const Duration(milliseconds: 500), vsync: this, ); _networkBlinkAnimation = Tween( begin: 1.0, end: 0.3, ).animate(CurvedAnimation( parent: _networkBlinkController, curve: Curves.easeInOut, )); } Future _checkPermissionsAndStartTracking() async { // Les permissions GPS sont déjà vérifiées obligatoirement dans splash_page.dart // On peut donc directement commencer le tracking setState(() { _locationPermissionGranted = true; }); _startLocationTracking(); } void _startLocationTracking() { const locationSettings = LocationSettings( accuracy: LocationAccuracy.high, distanceFilter: 5, // Mise à jour tous les 5 mètres ); _positionStreamSubscription = Geolocator.getPositionStream( locationSettings: locationSettings, ).listen((Position position) { setState(() { _currentPosition = position; _gpsAccuracy = position.accuracy; _isGpsEnabled = true; _isLoading = false; }); _updateNearbyPassages(); _updateBlinkAnimations(); // Centrer la carte sur la nouvelle position _mapController.move(LatLng(position.latitude, position.longitude), 17); }, onError: (error) { setState(() { _isGpsEnabled = false; }); }); } void _startQualityMonitoring() { // Mise à jour toutes les 5 secondes _qualityUpdateTimer = Timer.periodic(const Duration(seconds: 5), (timer) async { // Vérifier la connexion réseau final connectivityResult = await Connectivity().checkConnectivity(); setState(() { _connectivityResult = connectivityResult; }); // Vérifier si le GPS est activé final isLocationServiceEnabled = await Geolocator.isLocationServiceEnabled(); setState(() { _isGpsEnabled = isLocationServiceEnabled; }); _updateBlinkAnimations(); }); } void _updateBlinkAnimations() { // GPS: clignoter si précision > 30m ou GPS désactivé if (!_isGpsEnabled || _gpsAccuracy > 30) { _gpsBlinkController.repeat(reverse: true); } else { _gpsBlinkController.stop(); _gpsBlinkController.value = 1.0; } // Réseau: clignoter si connexion faible ou absente if (_connectivityResult.contains(ConnectivityResult.none) || _connectivityResult.contains(ConnectivityResult.mobile)) { _networkBlinkController.repeat(reverse: true); } else { _networkBlinkController.stop(); _networkBlinkController.value = 1.0; } } void _updateNearbyPassages() { if (_currentPosition == null) return; final passagesBox = Hive.box(AppKeys.passagesBoxName); final allPassages = passagesBox.values.toList(); // Tous les types de passages // Calculer les distances et trier final passagesWithDistance = allPassages.map((passage) { // Convertir les coordonnées GPS string en double final double lat = double.tryParse(passage.gpsLat) ?? 0; final double lng = double.tryParse(passage.gpsLng) ?? 0; final distance = _calculateDistance( _currentPosition!.latitude, _currentPosition!.longitude, lat, lng, ); return MapEntry(passage, distance); }).toList(); passagesWithDistance.sort((a, b) => a.value.compareTo(b.value)); final newNearbyPassages = passagesWithDistance .where((entry) => entry.value <= 500) // Max 500m .map((entry) => entry.key) .toList(); // Ne setState que si les passages ont vraiment changé if (!_arePassagesEqual(_nearbyPassages, newNearbyPassages)) { setState(() { _nearbyPassages = newNearbyPassages; }); } } // Comparer deux listes de passages pour éviter les setState inutiles bool _arePassagesEqual(List oldPassages, List newPassages) { if (oldPassages.length != newPassages.length) return false; // Créer des clés uniques incluant ID + fkType pour détecter les changements de type // (important pour le gradient des immeubles qui dépend du fkType) final oldKeys = oldPassages.map((p) => '${p.id}_${p.fkType}').toSet(); final newKeys = newPassages.map((p) => '${p.id}_${p.fkType}').toSet(); return oldKeys.length == newKeys.length && oldKeys.containsAll(newKeys); } double _calculateDistance( double lat1, double lon1, double lat2, double lon2) { const distance = Distance(); return distance.as( LengthUnit.Meter, LatLng(lat1, lon1), LatLng(lat2, lon2), ); } void _recenterMap() { if (_currentPosition != null) { _mapController.move( LatLng(_currentPosition!.latitude, _currentPosition!.longitude), 17, ); HapticFeedback.lightImpact(); } } void _openPassageForm(PassageModel passage) { showDialog( context: context, builder: (context) => PassageFormDialog( passage: passage, title: 'Modifier le passage', readOnly: false, passageRepository: passageRepository, userRepository: userRepository, operationRepository: operationRepository, amicaleRepository: amicaleRepository, onSuccess: () { // Rafraîchir les passages après modification _updateNearbyPassages(); }, ), ); } // Afficher la dialog groupée pour les immeubles void _showGroupedPassagesDialog(PassageModel referencePassage) { showDialog( context: context, builder: (context) => GroupedPassagesDialog( referencePassage: referencePassage, isAdmin: false, // Mode terrain = utilisateur simple ), ); } // Vérifier si l'amicale autorise la suppression des passages bool _canDeletePassages() { try { final amicale = CurrentAmicaleService.instance.currentAmicale; if (amicale != null) { return amicale.chkUserDeletePass == true; } } catch (e) { debugPrint( 'Erreur lors de la vérification des permissions de suppression: $e'); } return false; } // Afficher le dialog de confirmation de suppression void _showDeleteConfirmationDialog(PassageModel passage) { final TextEditingController confirmController = TextEditingController(); final String streetNumber = passage.numero; final String fullAddress = '${passage.numero} ${passage.rueBis} ${passage.rue}'.trim(); showDialog( context: context, barrierDismissible: false, builder: (BuildContext dialogContext) { return AlertDialog( title: const Row( children: [ Icon(Icons.warning, color: Colors.red, size: 28), SizedBox(width: 8), Text('Confirmation de suppression'), ], ), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'ATTENTION : Cette action est irréversible !', style: TextStyle( fontWeight: FontWeight.bold, color: Colors.red, fontSize: AppTheme.r(context, 16), ), ), const SizedBox(height: 16), Text( 'Vous êtes sur le point de supprimer définitivement le passage :', style: TextStyle(color: Colors.grey[800]), ), const SizedBox(height: 8), Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.grey[100], borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.grey[300]!), ), child: Text( fullAddress.isEmpty ? 'Adresse inconnue' : fullAddress, style: TextStyle( fontWeight: FontWeight.w600, fontSize: AppTheme.r(context, 14), ), ), ), const SizedBox(height: 20), const Text( 'Pour confirmer la suppression, veuillez saisir le numéro de rue de ce passage :', style: TextStyle(fontWeight: FontWeight.w500), ), const SizedBox(height: 12), TextField( controller: confirmController, decoration: InputDecoration( labelText: 'Numéro de rue', hintText: streetNumber.isNotEmpty ? 'Ex: $streetNumber' : 'Saisir le numéro', border: const OutlineInputBorder(), prefixIcon: const Icon(Icons.home), ), keyboardType: TextInputType.text, textCapitalization: TextCapitalization.characters, ), ], ), ), actions: [ TextButton( onPressed: () { confirmController.dispose(); Navigator.of(dialogContext).pop(); }, child: const Text('Annuler'), ), ElevatedButton( onPressed: () async { // Vérifier que le numéro saisi correspond final enteredNumber = confirmController.text.trim(); if (enteredNumber.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Veuillez saisir le numéro de rue'), backgroundColor: Colors.orange, ), ); return; } if (streetNumber.isNotEmpty && enteredNumber.toUpperCase() != streetNumber.toUpperCase()) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Le numéro de rue ne correspond pas'), backgroundColor: Colors.red, ), ); return; } // Fermer le dialog confirmController.dispose(); Navigator.of(dialogContext).pop(); // Effectuer la suppression await _deletePassage(passage); }, style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, ), child: const Text('Supprimer définitivement'), ), ], ); }, ); } // Supprimer un passage Future _deletePassage(PassageModel passage) async { try { // Appeler le repository pour supprimer via l'API final success = await passageRepository.deletePassageViaApi(passage.id); if (success && mounted) { ApiException.showSuccess(context, 'Passage supprimé avec succès'); // Rafraîchir la liste des passages _updateNearbyPassages(); } else if (mounted) { ApiException.showError( context, Exception('Erreur lors de la suppression')); } } catch (e) { debugPrint('Erreur suppression passage: $e'); if (mounted) { ApiException.showError(context, e); } } } @override void dispose() { _positionStreamSubscription?.cancel(); _qualityUpdateTimer?.cancel(); _gpsBlinkController.dispose(); _networkBlinkController.dispose(); _searchController.dispose(); _passagesBox?.listenable().removeListener(_onPassagesChanged); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.grey[100], appBar: AppBar( title: Row( children: [ const Text('Mode terrain'), if (_currentPosition != null) ...[ const SizedBox(width: 16), Expanded( child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.white.withOpacity(0.2), borderRadius: BorderRadius.circular(12), ), child: Text( kIsWeb ? (_locationPermissionGranted ? 'GPS: ${_currentPosition!.latitude.toStringAsFixed(4)}, ${_currentPosition!.longitude.toStringAsFixed(4)}' : _statusMessage.isNotEmpty ? _statusMessage : 'Position approximative') : '', style: TextStyle( fontSize: AppTheme.r(context, 12), fontWeight: FontWeight.normal, ), overflow: TextOverflow.ellipsis, ), ), ), ], ], ), backgroundColor: Colors.green, foregroundColor: Colors.white, elevation: 0, actions: [ // Indicateur GPS _buildGpsIndicator(), const SizedBox(width: 8), // Indicateur réseau _buildNetworkIndicator(), const SizedBox(width: 16), ], ), body: _isLoading ? const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(), SizedBox(height: 16), Text('Recherche de votre position...'), ], ), ) : Column( children: [ // Carte (40% de la hauteur) SizedBox( height: MediaQuery.of(context).size.height * 0.4, child: _buildMap(), ), // Barre de recherche Container( color: Colors.white, padding: const EdgeInsets.all(12), child: TextField( controller: _searchController, decoration: InputDecoration( hintText: 'Rechercher une rue...', prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( icon: const Icon(Icons.clear), onPressed: () { setState(() { _searchController.clear(); _searchQuery = ''; }); }, ) : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(30), borderSide: BorderSide.none, ), filled: true, fillColor: Colors.grey[100], contentPadding: const EdgeInsets.symmetric(horizontal: 20), ), onChanged: (value) { setState(() { _searchQuery = value.toLowerCase(); }); }, ), ), // En-tête de la liste Container( color: Colors.white, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Row( children: [ Icon(Icons.location_on, color: Colors.green[600], size: 20), const SizedBox(width: 8), Text( '${_getFilteredPassages().length} passage${_getFilteredPassages().length > 1 ? 's' : ''} à proximité', style: TextStyle( fontWeight: FontWeight.w600, color: Colors.grey[800], ), ), ], ), ), // Liste des passages (reste de la hauteur) Expanded( child: _buildPassagesList(), ), ], ), ); } Widget _buildGpsIndicator() { IconData icon; Color color; String tooltip; if (!_isGpsEnabled) { icon = Icons.gps_off; color = Colors.black54; tooltip = 'GPS désactivé'; } else if (_gpsAccuracy <= 5) { icon = Icons.gps_fixed; color = Colors.green; tooltip = 'GPS: Excellent (${_gpsAccuracy.toStringAsFixed(0)}m)'; } else if (_gpsAccuracy <= 15) { icon = Icons.gps_fixed; color = Colors.yellow[700]!; tooltip = 'GPS: Bon (${_gpsAccuracy.toStringAsFixed(0)}m)'; } else if (_gpsAccuracy <= 30) { icon = Icons.gps_not_fixed; color = Colors.orange; tooltip = 'GPS: Moyen (${_gpsAccuracy.toStringAsFixed(0)}m)'; } else { icon = Icons.gps_not_fixed; color = Colors.red; tooltip = 'GPS: Faible (${_gpsAccuracy.toStringAsFixed(0)}m)'; } return AnimatedBuilder( animation: _gpsBlinkAnimation, builder: (context, child) { return Tooltip( message: tooltip, child: Opacity( opacity: _gpsBlinkAnimation.value, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: color.withOpacity(0.2), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: color, size: 20), const SizedBox(width: 4), Text( '${_gpsAccuracy.toStringAsFixed(0)}m', style: TextStyle( color: color, fontWeight: FontWeight.bold, fontSize: AppTheme.r(context, 12)), ), ], ), ), ), ); }, ); } Widget _buildNetworkIndicator() { IconData icon; Color color; String label; String tooltip; // Utiliser le premier élément de la liste pour déterminer le type de connexion final primaryResult = _connectivityResult.firstWhere( (result) => result != ConnectivityResult.none, orElse: () => ConnectivityResult.none ); switch (primaryResult) { case ConnectivityResult.wifi: icon = Icons.wifi; color = Colors.green; label = 'WiFi'; tooltip = 'Connexion WiFi'; break; case ConnectivityResult.ethernet: icon = Icons.cable; color = Colors.green; label = '4G'; tooltip = 'Connexion Ethernet'; break; case ConnectivityResult.mobile: icon = Icons.signal_cellular_alt; color = Colors.yellow[700]!; label = '3G'; tooltip = 'Connexion mobile'; break; case ConnectivityResult.none: default: icon = Icons.signal_cellular_off; color = Colors.red; label = 'Hors ligne'; tooltip = 'Aucune connexion'; break; } return AnimatedBuilder( animation: _networkBlinkAnimation, builder: (context, child) { return Tooltip( message: tooltip, child: Opacity( opacity: _networkBlinkAnimation.value, child: Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: color.withOpacity(0.2), borderRadius: BorderRadius.circular(20), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, color: color, size: 20), const SizedBox(width: 4), Text( label, style: TextStyle( color: color, fontWeight: FontWeight.bold, fontSize: AppTheme.r(context, 12)), ), ], ), ), ), ); }, ); } Widget _buildMap() { if (_currentPosition == null) { return Container( color: Colors.grey[200], child: const Center( child: CircularProgressIndicator(), ), ); } final apiService = ApiService.instance; final mapboxApiKey = AppKeys.getMapboxApiKey(apiService.getCurrentEnvironment()); return Stack( children: [ FlutterMap( mapController: _mapController, options: MapOptions( initialCenter: LatLng( _currentPosition!.latitude, _currentPosition!.longitude), initialZoom: 17, maxZoom: 19, minZoom: 10, interactionOptions: const InteractionOptions( enableMultiFingerGestureRace: true, flags: InteractiveFlag.all & ~InteractiveFlag.rotate, ), ), children: [ TileLayer( // Utiliser l'API v4 de Mapbox sur mobile ou OpenStreetMap en fallback urlTemplate: kIsWeb ? 'https://api.mapbox.com/styles/v1/mapbox/streets-v11/tiles/{z}/{x}/{y}?access_token=$mapboxApiKey' : 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', // OpenStreetMap temporairement sur mobile userAgentPackageName: 'app3.geosector.fr', additionalOptions: const { 'attribution': '© OpenStreetMap contributors', }, ), // Markers des passages MarkerLayer( markers: _buildPassageMarkers(), ), // Position actuelle MarkerLayer( markers: [ Marker( point: LatLng(_currentPosition!.latitude, _currentPosition!.longitude), width: 30, height: 30, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: Colors.blue, border: Border.all(color: Colors.white, width: 3), boxShadow: [ BoxShadow( color: Colors.blue.withOpacity(0.3), blurRadius: 10, spreadRadius: 5, ), ], ), child: const Icon( Icons.person_pin, color: Colors.white, size: 16, ), ), ), ], ), ], ), // Bouton recentrage (bas gauche) Positioned( bottom: 16, left: 16, child: FloatingActionButton.small( backgroundColor: Colors.white, foregroundColor: Colors.green[700], onPressed: _recenterMap, child: const Icon(Icons.my_location), ), ), ], ); } // Assombrir une couleur pour les bordures Color _darkenColor(Color color, [double amount = 0.3]) { assert(amount >= 0 && amount <= 1); final hsl = HSLColor.fromColor(color); final hslDark = hsl.withLightness((hsl.lightness - amount).clamp(0.0, 1.0)); return hslDark.toColor(); } List _buildPassageMarkers() { if (_currentPosition == null) return []; final List markers = []; // 1. Séparer les passages immeubles (fkHabitat=2) des autres final buildingPassages = >>{}; final individualPassages = []; for (final passage in _nearbyPassages) { if (passage.fkHabitat == 2) { // Créer une clé unique basée sur l'adresse complète final addressKey = '${passage.numero}|${passage.rueBis}|${passage.rue}|${passage.ville}'; // Convertir les coordonnées GPS string en double final double lat = double.tryParse(passage.gpsLat) ?? 0; final double lng = double.tryParse(passage.gpsLng) ?? 0; buildingPassages.putIfAbsent(addressKey, () => []); buildingPassages[addressKey]!.add({ 'model': passage, 'position': LatLng(lat, lng), 'id': passage.id, }); } else { individualPassages.add(passage); } } // 2. Créer les markers individuels (fkHabitat != 2) - Cercles for (final passage in individualPassages) { // Déterminer la couleur selon le type de passage Color fillColor = Colors.grey; // Couleur par défaut if (AppKeys.typesPassages.containsKey(passage.fkType)) { final typeInfo = AppKeys.typesPassages[passage.fkType]!; if (passage.fkType == 2) { // Type 2 (À finaliser) : adapter la couleur selon nbPassages if (passage.nbPassages == 0) { fillColor = Color(typeInfo['couleur1'] as int); } else if (passage.nbPassages == 1) { fillColor = Color(typeInfo['couleur2'] as int); } else { fillColor = Color(typeInfo['couleur3'] as int); } } else { // Autres types : utiliser couleur2 par défaut fillColor = Color(typeInfo['couleur2'] as int); } } // Bordure : version assombrie de la couleur de remplissage final borderColor = _darkenColor(fillColor, 0.3); // Convertir les coordonnées GPS string en double final double lat = double.tryParse(passage.gpsLat) ?? 0; final double lng = double.tryParse(passage.gpsLng) ?? 0; markers.add( Marker( point: LatLng(lat, lng), width: 40, height: 40, child: GestureDetector( onTap: () => _openPassageForm(passage), child: Container( decoration: BoxDecoration( shape: BoxShape.circle, color: fillColor, border: Border.all(color: borderColor, width: 3), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.2), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: Center( child: Text( '${passage.numero}${(passage.rueBis.isNotEmpty) ? passage.rueBis.substring(0, 1).toLowerCase() : ''}', style: TextStyle( // Texte noir sur fond clair, blanc sur fond foncé color: fillColor.computeLuminance() > 0.5 ? Colors.black : Colors.white, fontWeight: FontWeight.bold, fontSize: AppTheme.r(context, 12), ), textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ), ), ), ); } // 3. Créer les markers groupés (carrés avec dégradé blanc→vert selon avancement) for (final entry in buildingPassages.entries) { final passages = entry.value; if (passages.isEmpty) continue; // Utiliser la position du premier passage du groupe final position = passages.first['position'] as LatLng; final count = passages.length; final displayCount = count >= 99 ? '99' : count.toString(); // Calculer le pourcentage de passages réalisés (fkType != 2) final models = passages.map((p) => p['model'] as PassageModel).toList(); final realizedCount = models.where((p) => p.fkType != 2).length; final percentage = realizedCount / models.length; // Déterminer la couleur de remplissage selon le palier (5 niveaux) Color fillColor; if (percentage == 0) { // 0% : Blanc pur fillColor = Colors.white; } else if (percentage <= 0.25) { // 1-25% : Blanc cassé → Vert très clair fillColor = Color.lerp(Colors.white, const Color(0xFFB3F5E0), (percentage / 0.25))!; } else if (percentage <= 0.50) { // 26-50% : Vert très clair → Vert clair fillColor = Color.lerp(const Color(0xFFB3F5E0), const Color(0xFF66EBBB), ((percentage - 0.25) / 0.25))!; } else if (percentage <= 0.75) { // 51-75% : Vert clair → Vert moyen fillColor = Color.lerp(const Color(0xFF66EBBB), const Color(0xFF33E1A0), ((percentage - 0.50) / 0.25))!; } else if (percentage < 1.0) { // 76-99% : Vert moyen → Vert foncé fillColor = Color.lerp(const Color(0xFF33E1A0), const Color(0xFF00E09D), ((percentage - 0.75) / 0.25))!; } else { // 100% : Vert foncé (couleur "Effectué") fillColor = const Color(0xFF00E09D); } markers.add( Marker( point: position, width: 24.0, height: 24.0, child: GestureDetector( onTap: () { _showGroupedPassagesDialog(passages.first['model'] as PassageModel); }, child: Container( decoration: BoxDecoration( color: fillColor, shape: BoxShape.rectangle, borderRadius: BorderRadius.circular(4), border: Border.all( color: Colors.blue, // Bordure bleue toujours width: 2, ), ), child: Center( child: Text( displayCount, style: TextStyle( color: percentage < 0.5 ? Colors.black87 : Colors.white, // Texte noir sur fond clair, blanc sur fond foncé fontSize: 12, fontWeight: FontWeight.bold, ), ), ), ), ), ), ); } return markers; } List> _getFilteredPassages() { // Filtrer d'abord par recherche si nécessaire List filtered = _searchQuery.isEmpty ? _nearbyPassages : _nearbyPassages.where((passage) { final address = '${passage.numero} ${passage.rueBis} ${passage.rue}' .trim() .toLowerCase(); return address.contains(_searchQuery); }).toList(); // Convertir au format attendu par PassagesListWidget avec distance return filtered.map((passage) { // Calculer la distance final double lat = double.tryParse(passage.gpsLat) ?? 0; final double lng = double.tryParse(passage.gpsLng) ?? 0; final distance = _currentPosition != null ? _calculateDistance( _currentPosition!.latitude, _currentPosition!.longitude, lat, lng, ) : 0.0; // Construire l'adresse complète final String address = '${passage.numero} ${passage.rueBis} ${passage.rue}'.trim(); // Convertir le montant double amount = 0.0; try { if (passage.montant.isNotEmpty) { String montantStr = passage.montant.replaceAll(',', '.'); amount = double.tryParse(montantStr) ?? 0.0; } } catch (e) { // Ignorer les erreurs de conversion } return { 'id': passage.id, 'address': address.isEmpty ? 'Adresse inconnue' : address, 'amount': amount, 'date': passage.passedAt ?? DateTime.now(), 'type': passage.fkType, 'payment': passage.fkTypeReglement, 'name': passage.name, 'notes': passage.remarque, 'hasReceipt': passage.nomRecu.isNotEmpty, 'hasError': passage.emailErreur.isNotEmpty, 'fkUser': passage.fkUser, 'distance': distance, // Ajouter la distance pour le tri et l'affichage 'nbPassages': passage.nbPassages, // Pour la couleur de l'indicateur 'isOwnedByCurrentUser': passage.fkUser == userRepository .getCurrentUser() ?.opeUserId, // Comparer avec ope_users.id // Garder les données originales pour l'édition 'numero': passage.numero, 'rueBis': passage.rueBis, 'rue': passage.rue, 'ville': passage.ville, }; }).toList(); } Widget _buildPassagesList() { final filteredPassages = _getFilteredPassages(); return Container( color: Colors.white, child: PassagesListWidget( passages: filteredPassages, showActions: true, showAddButton: true, // Activer le bouton de création onPassageSelected: null, onPassageEdit: (passage) { // Retrouver le PassageModel original pour l'édition final passageId = passage['id'] as int; final originalPassage = _nearbyPassages.firstWhere( (p) => p.id == passageId, orElse: () => _nearbyPassages.first, ); _openPassageForm(originalPassage); }, onAddPassage: () async { // Ouvrir le dialogue de création de passage await showDialog( context: context, barrierDismissible: false, builder: (BuildContext context) { return PassageFormDialog( title: 'Nouveau passage', passageRepository: passageRepository, userRepository: userRepository, operationRepository: operationRepository, amicaleRepository: amicaleRepository, onSuccess: () { // Le widget se rafraîchira automatiquement via ValueListenableBuilder }, ); }, ); }, onPassageDelete: _canDeletePassages() ? (passage) { // Retrouver le PassageModel original pour la suppression final passageId = passage['id'] as int; final originalPassage = _nearbyPassages.firstWhere( (p) => p.id == passageId, orElse: () => _nearbyPassages.first, ); _showDeleteConfirmationDialog(originalPassage); } : null, ), ); } }