Files
geo/app/lib/presentation/user/user_field_mode_page.dart
pierre 2f5946a184 feat: Version 3.5.2 - Configuration Stripe et gestion des immeubles
- 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>
2025-11-09 18:26:27 +01:00

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,
),
);
}
}