Files
geo/app/lib/presentation/user/user_field_mode_page.dart
Pierre 5ab03751e1 feat: Release version 3.1.4 - Mode terrain et génération PDF
 Nouvelles fonctionnalités:
- Ajout du mode terrain pour utilisation mobile hors connexion
- Génération automatique de reçus PDF avec template personnalisé
- Révision complète du système de cartes avec amélioration des performances

🔧 Améliorations techniques:
- Refactoring du module chat avec architecture simplifiée
- Optimisation du système de sécurité NIST SP 800-63B
- Amélioration de la gestion des secteurs géographiques
- Support UTF-8 étendu pour les noms d'utilisateurs

📱 Application mobile:
- Nouveau mode terrain dans user_field_mode_page
- Interface utilisateur adaptative pour conditions difficiles
- Synchronisation offline améliorée

🗺️ Cartographie:
- Optimisation des performances MapBox
- Meilleure gestion des tuiles hors ligne
- Amélioration de l'affichage des secteurs

📄 Documentation:
- Ajout guide Android (ANDROID-GUIDE.md)
- Documentation sécurité API (API-SECURITY.md)
- Guide module chat (CHAT_MODULE.md)

🐛 Corrections:
- Résolution des erreurs 400 lors de la création d'utilisateurs
- Correction de la validation des noms d'utilisateurs
- Fix des problèmes de synchronisation chat

🤖 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-19 19:38:03 +02:00

1003 lines
33 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.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/data/models/amicale_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/app.dart';
import 'package:sensors_plus/sensors_plus.dart';
class UserFieldModePage extends StatefulWidget {
const UserFieldModePage({super.key});
@override
State<UserFieldModePage> createState() => _UserFieldModePageState();
}
class _UserFieldModePageState extends State<UserFieldModePage> with TickerProviderStateMixin {
// Controllers
final MapController _mapController = MapController();
final TextEditingController _searchController = TextEditingController();
// Animation controllers pour le clignotement
late AnimationController _gpsBlinkController;
late AnimationController _networkBlinkController;
late Animation<double> _gpsBlinkAnimation;
late Animation<double> _networkBlinkAnimation;
// Position et tracking
Position? _currentPosition;
StreamSubscription<Position>? _positionStreamSubscription;
Timer? _qualityUpdateTimer;
// Qualité des signaux
double _gpsAccuracy = 999;
ConnectivityResult _connectivityResult = ConnectivityResult.none;
bool _isGpsEnabled = false;
// Mode boussole
bool _compassMode = false;
double _heading = 0;
StreamSubscription<MagnetometerEvent>? _magnetometerSubscription;
// Filtrage et recherche
String _searchQuery = '';
List<PassageModel> _nearbyPassages = [];
// État de chargement
bool _isLoading = true;
bool _locationPermissionGranted = false;
String _statusMessage = '';
@override
void initState() {
super.initState();
_initializeAnimations();
if (kIsWeb) {
// Sur web, utiliser une position simulée pour éviter le blocage
_initializeWebMode();
} else {
// Sur mobile, utiliser le GPS réel
_checkPermissionsAndStartTracking();
_startQualityMonitoring();
}
}
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(
desiredAccuracy: 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<double>(
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<double>(
begin: 1.0,
end: 0.3,
).animate(CurvedAnimation(
parent: _networkBlinkController,
curve: Curves.easeInOut,
));
}
Future<void> _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
if (_mapController.mapEventStream != null && !_compassMode) {
_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 connectivityResults = await Connectivity().checkConnectivity();
setState(() {
// Prendre le premier résultat de la liste
_connectivityResult = connectivityResults.isNotEmpty
? connectivityResults.first
: ConnectivityResult.none;
});
// 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 == ConnectivityResult.none ||
_connectivityResult == ConnectivityResult.mobile) {
_networkBlinkController.repeat(reverse: true);
} else {
_networkBlinkController.stop();
_networkBlinkController.value = 1.0;
}
}
void _updateNearbyPassages() {
if (_currentPosition == null) return;
final passagesBox = Hive.box<PassageModel>(AppKeys.passagesBoxName);
final allPassages = passagesBox.values.where((p) => p.fkType == 2).toList();
// 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') ?? 0;
final double lng = double.tryParse(passage.gpsLng ?? '0') ?? 0;
final distance = _calculateDistance(
_currentPosition!.latitude,
_currentPosition!.longitude,
lat,
lng,
);
return MapEntry(passage, distance);
}).toList();
passagesWithDistance.sort((a, b) => a.value.compareTo(b.value));
setState(() {
_nearbyPassages = passagesWithDistance
.take(50) // Limiter à 50 passages
.where((entry) => entry.value <= 2000) // Max 2km
.map((entry) => entry.key)
.toList();
});
}
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 _toggleCompassMode() {
// Mode boussole désactivé sur web
if (kIsWeb) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le mode boussole nécessite un appareil mobile'),
duration: Duration(seconds: 2),
),
);
return;
}
setState(() {
_compassMode = !_compassMode;
});
if (_compassMode) {
_startCompass();
// Vibration légère pour feedback
HapticFeedback.lightImpact();
} else {
_stopCompass();
}
}
void _startCompass() {
_magnetometerSubscription = magnetometerEvents.listen((MagnetometerEvent event) {
setState(() {
// Calculer l'orientation à partir du magnétomètre
_heading = math.atan2(event.y, event.x) * (180 / math.pi);
});
});
}
void _stopCompass() {
_magnetometerSubscription?.cancel();
_magnetometerSubscription = null;
setState(() {
_heading = 0;
});
}
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,
onSuccess: () {
// Rafraîchir les passages après modification
_updateNearbyPassages();
},
),
);
}
@override
void dispose() {
_positionStreamSubscription?.cancel();
_qualityUpdateTimer?.cancel();
_magnetometerSubscription?.cancel();
_gpsBlinkController.dispose();
_networkBlinkController.dispose();
_searchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(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: const TextStyle(
fontSize: 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: 12),
),
],
),
),
),
);
},
);
}
Widget _buildNetworkIndicator() {
IconData icon;
Color color;
String label;
String tooltip;
switch (_connectivityResult) {
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: 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: [
Transform.rotate(
angle: _compassMode ? _heading * (math.pi / 180) : 0,
child: 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: 'app.geosector.fr',
additionalOptions: const {
'attribution': '© OpenStreetMap contributors',
},
),
// Cercles de distance en mode boussole
if (_compassMode)
CircleLayer(
circles: [
CircleMarker(
point: LatLng(_currentPosition!.latitude, _currentPosition!.longitude),
radius: 50,
color: Colors.blue.withOpacity(0.1),
borderColor: Colors.blue.withOpacity(0.3),
borderStrokeWidth: 1,
),
CircleMarker(
point: LatLng(_currentPosition!.latitude, _currentPosition!.longitude),
radius: 100,
color: Colors.transparent,
borderColor: Colors.blue.withOpacity(0.2),
borderStrokeWidth: 1,
),
CircleMarker(
point: LatLng(_currentPosition!.latitude, _currentPosition!.longitude),
radius: 250,
color: Colors.transparent,
borderColor: Colors.blue.withOpacity(0.15),
borderStrokeWidth: 1,
),
],
),
// 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),
),
),
// Bouton boussole (bas droite)
Positioned(
bottom: 16,
right: 16,
child: FloatingActionButton.small(
backgroundColor: _compassMode ? Colors.green : Colors.white,
foregroundColor: _compassMode ? Colors.white : Colors.grey[700],
onPressed: _toggleCompassMode,
child: Transform.rotate(
angle: _compassMode ? _heading * (math.pi / 180) : 0,
child: const Icon(Icons.explore),
),
),
),
// Indicateur de mode boussole
if (_compassMode)
Positioned(
top: 16,
right: 16,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.green,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.explore, color: Colors.white, size: 16),
const SizedBox(width: 4),
Text(
'Mode boussole',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
);
}
List<Marker> _buildPassageMarkers() {
if (_currentPosition == null) return [];
return _nearbyPassages.map((passage) {
// Déterminer la couleur selon nbPassages
Color fillColor;
if (passage.nbPassages == 0) {
fillColor = const Color(0xFFFFFFFF); // couleur1: Blanc
} else if (passage.nbPassages == 1) {
fillColor = const Color(0xFFF7A278); // couleur2: Orange
} else {
fillColor = const Color(0xFFE65100); // couleur3: Orange foncé
}
// Bordure toujours orange (couleur2)
const borderColor = Color(0xFFF7A278);
// Convertir les coordonnées GPS string en double
final double lat = double.tryParse(passage.gpsLat ?? '0') ?? 0;
final double lng = double.tryParse(passage.gpsLng ?? '0') ?? 0;
return 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 != null && passage.rueBis!.isNotEmpty) ? passage.rueBis!.substring(0, 1).toLowerCase() : ''}',
style: TextStyle(
color: fillColor == Colors.white ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
fontSize: 12,
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
);
}).toList();
}
List<PassageModel> _getFilteredPassages() {
if (_searchQuery.isEmpty) {
return _nearbyPassages;
}
return _nearbyPassages.where((passage) {
// Construire l'adresse à partir des champs disponibles
final address = '${passage.numero ?? ''} ${passage.rueBis ?? ''} ${passage.rue ?? ''}'.trim().toLowerCase();
return address.contains(_searchQuery);
}).toList();
}
Widget _buildPassagesList() {
final filteredPassages = _getFilteredPassages();
if (filteredPassages.isEmpty) {
return Container(
color: Colors.white,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.search_off, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
_searchQuery.isNotEmpty
? 'Aucun passage trouvé pour "$_searchQuery"'
: 'Aucun passage à proximité',
style: TextStyle(color: Colors.grey[600]),
),
],
),
),
);
}
return Container(
color: Colors.white,
child: ListView.separated(
padding: const EdgeInsets.only(bottom: 20),
itemCount: filteredPassages.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final passage = filteredPassages[index];
// Convertir les coordonnées GPS string en double
final double lat = double.tryParse(passage.gpsLat ?? '0') ?? 0;
final double lng = double.tryParse(passage.gpsLng ?? '0') ?? 0;
final distance = _currentPosition != null
? _calculateDistance(
_currentPosition!.latitude,
_currentPosition!.longitude,
lat,
lng,
)
: 0.0;
// Formater la distance
String distanceText;
if (distance < 1000) {
distanceText = '${distance.toStringAsFixed(0)} m';
} else {
distanceText = '${(distance / 1000).toStringAsFixed(1)} km';
}
// Couleur selon nbPassages
Color indicatorColor;
if (passage.nbPassages == 0) {
indicatorColor = Colors.grey[400]!;
} else if (passage.nbPassages == 1) {
indicatorColor = const Color(0xFFF7A278);
} else {
indicatorColor = const Color(0xFFE65100);
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: indicatorColor,
border: Border.all(color: const Color(0xFFF7A278), width: 2), // couleur2: Orange
),
),
title: Text(
'${passage.numero ?? ''} ${passage.rueBis ?? ''} ${passage.rue ?? ''}'.trim().isEmpty
? 'Adresse inconnue'
: '${passage.numero ?? ''} ${passage.rueBis ?? ''} ${passage.rue ?? ''}'.trim(),
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 15,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Row(
children: [
Icon(Icons.navigation, size: 14, color: Colors.green[600]),
const SizedBox(width: 4),
Text(
distanceText,
style: TextStyle(
color: Colors.green[700],
fontWeight: FontWeight.w500,
fontSize: 13,
),
),
if (passage.name != null && passage.name!.isNotEmpty) ...[
const SizedBox(width: 12),
Expanded(
child: Text(
passage.name!,
style: TextStyle(
color: Colors.grey[600],
fontSize: 13,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
],
),
trailing: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.green[50],
shape: BoxShape.circle,
),
child: Icon(
Icons.arrow_forward_ios,
size: 16,
color: Colors.green[700],
),
),
onTap: () {
HapticFeedback.lightImpact();
_openPassageForm(passage);
},
);
},
),
);
}
}