1126 lines
38 KiB
Dart
1126 lines
38 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/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(
|
|
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();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
// 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: [
|
|
const Text(
|
|
'ATTENTION : Cette action est irréversible !',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.red,
|
|
fontSize: 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: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 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) {
|
|
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<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') ?? 0;
|
|
final double lng = double.tryParse(passage.gpsLng ?? '0') ?? 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,
|
|
),
|
|
);
|
|
}
|
|
} |