Files
geo/app/lib/presentation/user/user_field_mode_page.dart
Pierre 43d4cd66e1 feat: Mise à jour des interfaces mobiles v3.2.3
- Amélioration des interfaces utilisateur sur mobile
- Optimisation de la responsivité des composants Flutter
- Mise à jour des widgets de chat et communication
- Amélioration des formulaires et tableaux
- Ajout de nouveaux composants pour l'administration
- Optimisation des thèmes et styles visuels

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 20:35:40 +02:00

1164 lines
38 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
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/app.dart';
import 'package:sensors_plus/sensors_plus.dart';
import 'package:geosector_app/core/utils/api_exception.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(
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<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 (!_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;
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));
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 =
magnetometerEventStream().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();
},
),
);
}
// 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<void> _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();
_magnetometerSubscription?.cancel();
_gpsBlinkController.dispose();
_networkBlinkController.dispose();
_searchController.dispose();
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.withValues(alpha: 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.withValues(alpha: 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;
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.withValues(alpha: 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: [
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.withValues(alpha: 0.1),
borderColor: Colors.blue.withValues(alpha: 0.3),
borderStrokeWidth: 1,
),
CircleMarker(
point: LatLng(_currentPosition!.latitude,
_currentPosition!.longitude),
radius: 100,
color: Colors.transparent,
borderColor: Colors.blue.withValues(alpha: 0.2),
borderStrokeWidth: 1,
),
CircleMarker(
point: LatLng(_currentPosition!.latitude,
_currentPosition!.longitude),
radius: 250,
color: Colors.transparent,
borderColor: Colors.blue.withValues(alpha: 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.withValues(alpha: 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: TextStyle(
color: Colors.white,
fontSize: AppTheme.r(context, 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;
final double lng = double.tryParse(passage.gpsLng) ?? 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.withValues(alpha: 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(
color:
fillColor == Colors.white ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
fontSize: AppTheme.r(context, 12),
),
textAlign: TextAlign.center,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
),
);
}).toList();
}
List<Map<String, dynamic>> _getFilteredPassages() {
// Filtrer d'abord par recherche si nécessaire
List<PassageModel> 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()
?.id, // Ajout du champ pour le widget
// 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,
showFilters: false, // Pas de filtres, juste la liste
showSearch: false, // La recherche est déjà dans l'interface
showActions: true,
sortBy: 'distance', // Tri par distance pour le mode terrain
excludePassageTypes: const [], // Afficher tous les types (notamment le type 2)
showAddButton: true, // Activer le bouton de création
// Le widget gère maintenant le flux conditionnel par défaut
onPassageSelected: null,
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,
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,
),
);
}
}