feat: Enhance map functionality with user location markers and place search integration

This commit is contained in:
Dayron
2025-10-20 01:35:48 +02:00
parent 61e42fda3e
commit d41e6ff6db
3 changed files with 471 additions and 70 deletions

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geolocator/geolocator.dart';
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'dart:ui' as ui;
class MapContent extends StatefulWidget {
const MapContent({super.key});
@@ -11,11 +14,19 @@ class MapContent extends StatefulWidget {
class _MapContentState extends State<MapContent> {
GoogleMapController? _mapController;
LatLng _initialPosition = const LatLng(48.8566, 2.3522); // Paris par défaut
LatLng _initialPosition = const LatLng(48.8566, 2.3522);
final TextEditingController _searchController = TextEditingController();
bool _isLoadingLocation = false;
bool _isSearching = false;
Position? _currentPosition;
final Set<Marker> _markers = {};
final Set<Circle> _circles = {};
List<PlaceSuggestion> _suggestions = [];
static const String _apiKey = 'AIzaSyBPxanjGyrWVjI4-hZmi086VdQFSEYT_2U';
@override
void initState() {
super.initState();
@@ -29,14 +40,12 @@ class _MapContentState extends State<MapContent> {
super.dispose();
}
// Obtenir la position actuelle
Future<void> _getCurrentLocation() async {
setState(() {
_isLoadingLocation = true;
});
try {
// Vérifier si la localisation est activée
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) {
_showError('Veuillez activer les services de localisation');
@@ -46,7 +55,6 @@ class _MapContentState extends State<MapContent> {
return;
}
// Vérifier les permissions
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
@@ -67,21 +75,26 @@ class _MapContentState extends State<MapContent> {
return;
}
// Obtenir la position actuelle
Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high,
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
);
final newPosition = LatLng(position.latitude, position.longitude);
setState(() {
_currentPosition = position;
_initialPosition = LatLng(position.latitude, position.longitude);
_initialPosition = newPosition;
_isLoadingLocation = false;
});
// Animer la caméra vers la position actuelle
// Créer l'icône personnalisée et ajouter le marqueur
await _addUserLocationMarker(newPosition);
if (_mapController != null) {
_mapController!.animateCamera(
CameraUpdate.newLatLngZoom(_initialPosition, 14),
CameraUpdate.newLatLngZoom(_initialPosition, 15),
);
}
} catch (e) {
@@ -92,18 +105,197 @@ class _MapContentState extends State<MapContent> {
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.red),
// Créer une icône personnalisée à partir de l'icône Material
Future<BitmapDescriptor> _createCustomMarkerIcon() async {
final pictureRecorder = ui.PictureRecorder();
final canvas = Canvas(pictureRecorder);
const size = 120.0;
// Dessiner l'icône person_pin_circle en bleu
final iconPainter = TextPainter(
textDirection: TextDirection.ltr,
);
iconPainter.text = TextSpan(
text: String.fromCharCode(Icons.person_pin_circle.codePoint),
style: TextStyle(
fontSize: 100,
fontFamily: Icons.person_pin_circle.fontFamily,
color: Colors.blue[700],
),
);
iconPainter.layout();
iconPainter.paint(
canvas,
Offset(
(size - iconPainter.width) / 2,
0,
),
);
final picture = pictureRecorder.endRecording();
final image = await picture.toImage(size.toInt(), size.toInt());
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
return BitmapDescriptor.fromBytes(bytes!.buffer.asUint8List());
}
void _searchLocation() {
final searchQuery = _searchController.text.trim();
if (searchQuery.isNotEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Recherche de: $searchQuery')));
// Ajouter le marqueur avec l'icône personnalisée
Future<void> _addUserLocationMarker(LatLng position) async {
_markers.clear();
_circles.clear();
// Ajouter un cercle de précision
_circles.add(
Circle(
circleId: const CircleId('user_location_accuracy'),
center: position,
radius: _currentPosition?.accuracy ?? 50,
fillColor: Colors.blue.withValues(alpha: 0.08),
strokeColor: Colors.blue.withValues(alpha: 0.3),
strokeWidth: 1,
),
);
// Créer l'icône personnalisée
final icon = await _createCustomMarkerIcon();
// Ajouter le marqueur avec l'icône
setState(() {
_markers.add(
Marker(
markerId: const MarkerId('user_location'),
position: position,
icon: icon,
anchor: const Offset(0.5, 0.85), // Ancrer au bas de l'icône (le point du pin)
infoWindow: InfoWindow(
title: 'Ma position',
snippet: 'Lat: ${position.latitude.toStringAsFixed(4)}, Lng: ${position.longitude.toStringAsFixed(4)}',
),
),
);
});
}
Future<void> _searchPlaces(String query) async {
if (query.isEmpty) {
setState(() {
_suggestions = [];
});
return;
}
setState(() {
_isSearching = true;
});
try {
final url = Uri.parse(
'https://maps.googleapis.com/maps/api/place/autocomplete/json'
'?input=${Uri.encodeComponent(query)}'
'&key=$_apiKey'
'&language=fr'
);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK') {
final predictions = data['predictions'] as List;
setState(() {
_suggestions = predictions
.map((p) => PlaceSuggestion(
placeId: p['place_id'],
description: p['description'],
))
.toList();
_isSearching = false;
});
} else {
setState(() {
_suggestions = [];
_isSearching = false;
});
}
}
} catch (e) {
_showError('Erreur lors de la recherche de lieux: $e');
setState(() {
_isSearching = false;
});
}
}
Future<void> _selectPlace(PlaceSuggestion suggestion) async {
setState(() {
_isSearching = true;
_suggestions = [];
_searchController.text = suggestion.description;
});
try {
final url = Uri.parse(
'https://maps.googleapis.com/maps/api/place/details/json'
'?place_id=${suggestion.placeId}'
'&key=$_apiKey'
'&language=fr',
);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK') {
final location = data['result']['geometry']['location'];
final lat = location['lat'];
final lng = location['lng'];
final name = data['result']['name'];
final newPosition = LatLng(lat, lng);
// Ajouter un marqueur pour le lieu recherché (ne pas supprimer le marqueur de position)
setState(() {
// Garder le marqueur de position utilisateur
_markers.removeWhere((m) => m.markerId.value != 'user_location');
// Ajouter le nouveau marqueur de lieu
_markers.add(
Marker(
markerId: MarkerId(suggestion.placeId),
position: newPosition,
infoWindow: InfoWindow(title: name),
),
);
_isSearching = false;
});
_mapController?.animateCamera(
CameraUpdate.newLatLngZoom(newPosition, 15),
);
FocusScope.of(context).unfocus();
}
}
} catch (e) {
_showError('Erreur lors de la sélection du lieu: $e');
setState(() {
_isSearching = false;
});
}
}
void _showError(String message) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Colors.red,
behavior: SnackBarBehavior.floating,
),
);
}
}
@@ -111,80 +303,247 @@ class _MapContentState extends State<MapContent> {
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
children: [
// Champ de recherche
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Rechercher un lieu...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
child: Stack(
children: [
// Google Maps en arrière-plan
Positioned.fill(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: GoogleMap(
initialCameraPosition: CameraPosition(
target: _initialPosition,
zoom: 14,
),
onMapCreated: (GoogleMapController controller) {
_mapController = controller;
},
markers: _markers,
circles: _circles,
myLocationEnabled: false,
myLocationButtonEnabled: false,
zoomControlsEnabled: false,
mapType: MapType.normal,
compassEnabled: true,
rotateGesturesEnabled: true,
scrollGesturesEnabled: true,
tiltGesturesEnabled: true,
zoomGesturesEnabled: true,
onTap: (_) {
setState(() {
_suggestions = [];
});
FocusScope.of(context).unfocus();
},
),
),
),
),
// Indicateur de chargement
if (_isLoadingLocation)
Positioned.fill(
child: Container(
color: Colors.black.withValues(alpha: 0.3),
child: Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
spreadRadius: 2,
),
],
),
child: const Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 12),
Text(
'Localisation en cours...',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
filled: true,
fillColor: Colors.white,
),
onSubmitted: (_) => _searchLocation(),
),
const SizedBox(height: 12),
// Google Maps
Expanded(
child: Stack(
// Barre de recherche et suggestions
Positioned(
top: 0,
left: 0,
right: 0,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: GoogleMap(
initialCameraPosition: CameraPosition(
target: _initialPosition,
zoom: 14,
// Champ de recherche
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: TextField(
controller: _searchController,
style: const TextStyle(
color: Colors.black87,
fontSize: 16,
),
onMapCreated: (GoogleMapController controller) {
_mapController = controller;
decoration: InputDecoration(
hintText: 'Rechercher un lieu...',
hintStyle: TextStyle(
color: Colors.grey[600],
fontSize: 16,
),
prefixIcon: _isSearching
? const Padding(
padding: EdgeInsets.all(14.0),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
)
: Icon(Icons.search, color: Colors.grey[700]),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: Icon(Icons.clear, color: Colors.grey[700]),
onPressed: () {
_searchController.clear();
setState(() {
_suggestions = [];
});
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12.0),
borderSide: BorderSide.none,
),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
onChanged: (value) {
_searchPlaces(value);
},
myLocationEnabled: true,
myLocationButtonEnabled: false,
zoomControlsEnabled: false,
),
),
// Indicateur de chargement
if (_isLoadingLocation)
Center(
child: Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(),
SizedBox(height: 8),
Text('Localisation en cours...'),
],
// Liste des suggestions
if (_suggestions.isNotEmpty)
Container(
margin: const EdgeInsets.only(top: 8),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.4,
),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Material(
color: Colors.transparent,
child: ListView.separated(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: _suggestions.length,
separatorBuilder: (context, index) => Divider(
height: 1,
color: Colors.grey[300],
),
itemBuilder: (context, index) {
final suggestion = _suggestions[index];
return InkWell(
onTap: () => _selectPlace(suggestion),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Icon(
Icons.place,
color: Colors.grey[600],
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Text(
suggestion.description,
style: const TextStyle(
fontSize: 15,
color: Colors.black87,
fontWeight: FontWeight.w400,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
},
),
),
),
),
],
),
),
],
),
),
],
),
),
// Bouton flottant pour recentrer sur la position actuelle
floatingActionButton: FloatingActionButton(
onPressed: _getCurrentLocation,
tooltip: 'Ma position',
child: Icon(Icons.my_location),
child: const Icon(Icons.my_location),
),
);
}
}
class PlaceSuggestion {
final String placeId;
final String description;
PlaceSuggestion({
required this.placeId,
required this.description,
});
}

View File

@@ -129,6 +129,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.11"
dio:
dependency: transitive
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
url: "https://pub.dev"
source: hosted
version: "5.9.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "7586e476d70caecaf1686d21eee7247ea43ef5c345eab9e0cc3583ff13378d78"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
equatable:
dependency: "direct main"
description:
@@ -376,6 +392,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.14+2"
google_places_flutter:
dependency: "direct main"
description:
name: google_places_flutter
sha256: "37bd64221cf4a5aa97eb3a33dc2d40f6326aa5ae4e2f2a9a7116bdc1a14f5194"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
google_sign_in:
dependency: "direct main"
description:
@@ -433,7 +457,7 @@ packages:
source: hosted
version: "0.15.6"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
@@ -528,6 +552,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested:
dependency: transitive
description:
@@ -640,6 +672,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.5+1"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
source: hosted
version: "0.28.0"
sanitize_html:
dependency: transitive
description:

View File

@@ -48,6 +48,8 @@ dependencies:
google_sign_in: ^7.2.0
google_sign_in_platform_interface: ^3.1.0
geolocator: ^14.0.2
google_places_flutter: ^2.1.1
http: ^1.5.0
dev_dependencies:
flutter_test: