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:flutter/material.dart';
import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart';
import 'package:geolocator/geolocator.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 { class MapContent extends StatefulWidget {
const MapContent({super.key}); const MapContent({super.key});
@@ -11,10 +14,18 @@ class MapContent extends StatefulWidget {
class _MapContentState extends State<MapContent> { class _MapContentState extends State<MapContent> {
GoogleMapController? _mapController; 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(); final TextEditingController _searchController = TextEditingController();
bool _isLoadingLocation = false; bool _isLoadingLocation = false;
bool _isSearching = false;
Position? _currentPosition; Position? _currentPosition;
final Set<Marker> _markers = {};
final Set<Circle> _circles = {};
List<PlaceSuggestion> _suggestions = [];
static const String _apiKey = 'AIzaSyBPxanjGyrWVjI4-hZmi086VdQFSEYT_2U';
@override @override
void initState() { void initState() {
@@ -29,14 +40,12 @@ class _MapContentState extends State<MapContent> {
super.dispose(); super.dispose();
} }
// Obtenir la position actuelle
Future<void> _getCurrentLocation() async { Future<void> _getCurrentLocation() async {
setState(() { setState(() {
_isLoadingLocation = true; _isLoadingLocation = true;
}); });
try { try {
// Vérifier si la localisation est activée
bool serviceEnabled = await Geolocator.isLocationServiceEnabled(); bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!serviceEnabled) { if (!serviceEnabled) {
_showError('Veuillez activer les services de localisation'); _showError('Veuillez activer les services de localisation');
@@ -46,7 +55,6 @@ class _MapContentState extends State<MapContent> {
return; return;
} }
// Vérifier les permissions
LocationPermission permission = await Geolocator.checkPermission(); LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) { if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission(); permission = await Geolocator.requestPermission();
@@ -67,21 +75,26 @@ class _MapContentState extends State<MapContent> {
return; return;
} }
// Obtenir la position actuelle
Position position = await Geolocator.getCurrentPosition( Position position = await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.high, locationSettings: const LocationSettings(
accuracy: LocationAccuracy.high,
),
); );
final newPosition = LatLng(position.latitude, position.longitude);
setState(() { setState(() {
_currentPosition = position; _currentPosition = position;
_initialPosition = LatLng(position.latitude, position.longitude); _initialPosition = newPosition;
_isLoadingLocation = false; _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) { if (_mapController != null) {
_mapController!.animateCamera( _mapController!.animateCamera(
CameraUpdate.newLatLngZoom(_initialPosition, 14), CameraUpdate.newLatLngZoom(_initialPosition, 15),
); );
} }
} catch (e) { } catch (e) {
@@ -92,18 +105,197 @@ class _MapContentState extends State<MapContent> {
} }
} }
void _showError(String message) { // Créer une icône personnalisée à partir de l'icône Material
ScaffoldMessenger.of(context).showSnackBar( Future<BitmapDescriptor> _createCustomMarkerIcon() async {
SnackBar(content: Text(message), backgroundColor: Colors.red), 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() { // Ajouter le marqueur avec l'icône personnalisée
final searchQuery = _searchController.text.trim(); Future<void> _addUserLocationMarker(LatLng position) async {
if (searchQuery.isNotEmpty) { _markers.clear();
ScaffoldMessenger.of( _circles.clear();
context,
).showSnackBar(SnackBar(content: Text('Recherche de: $searchQuery'))); // 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) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: SafeArea( body: SafeArea(
child: Padding( child: Stack(
padding: const EdgeInsets.all(12.0), children: [
child: Column( // Google Maps en arrière-plan
children: [ Positioned.fill(
// Champ de recherche child: Padding(
TextField( padding: const EdgeInsets.all(12.0),
controller: _searchController, child: ClipRRect(
decoration: InputDecoration( borderRadius: BorderRadius.circular(16),
hintText: 'Rechercher un lieu...', child: GoogleMap(
prefixIcon: const Icon(Icons.search), initialCameraPosition: CameraPosition(
border: OutlineInputBorder( target: _initialPosition,
borderRadius: BorderRadius.circular(12.0), 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), // Barre de recherche et suggestions
Positioned(
// Google Maps top: 0,
Expanded( left: 0,
child: Stack( right: 0,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [ children: [
ClipRRect( // Champ de recherche
borderRadius: BorderRadius.circular(16), Container(
child: GoogleMap( decoration: BoxDecoration(
initialCameraPosition: CameraPosition( borderRadius: BorderRadius.circular(12),
target: _initialPosition, boxShadow: [
zoom: 14, 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) { decoration: InputDecoration(
_mapController = controller; 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 // Liste des suggestions
if (_isLoadingLocation) if (_suggestions.isNotEmpty)
Center( Container(
child: Container( margin: const EdgeInsets.only(top: 8),
padding: EdgeInsets.all(16), constraints: BoxConstraints(
decoration: BoxDecoration( maxHeight: MediaQuery.of(context).size.height * 0.4,
color: Colors.white, ),
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
), color: Colors.white,
child: Column( borderRadius: BorderRadius.circular(12),
mainAxisSize: MainAxisSize.min, boxShadow: [
children: [ BoxShadow(
CircularProgressIndicator(), color: Colors.black.withValues(alpha: 0.15),
SizedBox(height: 8), blurRadius: 10,
Text('Localisation en cours...'), 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( floatingActionButton: FloatingActionButton(
onPressed: _getCurrentLocation, onPressed: _getCurrentLocation,
tooltip: 'Ma position', 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" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" 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: equatable:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -376,6 +392,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.14+2" 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: google_sign_in:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -433,7 +457,7 @@ packages:
source: hosted source: hosted
version: "0.15.6" version: "0.15.6"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007 sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
@@ -528,6 +552,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.16.0" version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -640,6 +672,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.5+1" 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: sanitize_html:
dependency: transitive dependency: transitive
description: description:

View File

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