- Configuration complète Stripe pour les 3 environnements (DEV/REC/PROD) * DEV: Clés TEST Pierre (mode test) * REC: Clés TEST Client (mode test) * PROD: Clés LIVE Client (mode live) - Ajout de la gestion des bases de données immeubles/bâtiments * Configuration buildings_database pour DEV/REC/PROD * Service BuildingService pour enrichissement des adresses - Optimisations pages et améliorations ergonomie - Mises à jour des dépendances Composer - Nettoyage des fichiers obsolètes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1214 lines
41 KiB
Dart
1214 lines
41 KiB
Dart
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<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;
|
|
List<ConnectivityResult> _connectivityResult = [ConnectivityResult.none];
|
|
bool _isGpsEnabled = false;
|
|
|
|
// Filtrage et recherche
|
|
String _searchQuery = '';
|
|
List<PassageModel> _nearbyPassages = [];
|
|
|
|
// État de chargement
|
|
bool _isLoading = true;
|
|
bool _locationPermissionGranted = false;
|
|
String _statusMessage = '';
|
|
|
|
// Listener pour les changements de la box passages
|
|
Box<PassageModel>? _passagesBox;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeAnimations();
|
|
|
|
// Écouter les changements de la Hive box passages pour rafraîchir la carte
|
|
_passagesBox = Hive.box<PassageModel>(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<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
|
|
_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<PassageModel>(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<PassageModel> oldPassages, List<PassageModel> 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<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();
|
|
_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<Marker> _buildPassageMarkers() {
|
|
if (_currentPosition == null) return [];
|
|
|
|
final List<Marker> markers = [];
|
|
|
|
// 1. Séparer les passages immeubles (fkHabitat=2) des autres
|
|
final buildingPassages = <String, List<Map<String, dynamic>>>{};
|
|
final individualPassages = <PassageModel>[];
|
|
|
|
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<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()
|
|
?.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,
|
|
),
|
|
);
|
|
}
|
|
}
|