From d41e6ff6dbe678c9582e9bbd5a67c6180440ba9c Mon Sep 17 00:00:00 2001 From: Dayron Date: Mon, 20 Oct 2025 01:35:48 +0200 Subject: [PATCH] feat: Enhance map functionality with user location markers and place search integration --- lib/components/map/map_content.dart | 497 ++++++++++++++++++++++++---- pubspec.lock | 42 ++- pubspec.yaml | 2 + 3 files changed, 471 insertions(+), 70 deletions(-) diff --git a/lib/components/map/map_content.dart b/lib/components/map/map_content.dart index eca342f..093a88e 100644 --- a/lib/components/map/map_content.dart +++ b/lib/components/map/map_content.dart @@ -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,10 +14,18 @@ class MapContent extends StatefulWidget { class _MapContentState extends State { 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 _markers = {}; + final Set _circles = {}; + + List _suggestions = []; + + static const String _apiKey = 'AIzaSyBPxanjGyrWVjI4-hZmi086VdQFSEYT_2U'; @override void initState() { @@ -29,14 +40,12 @@ class _MapContentState extends State { super.dispose(); } - // Obtenir la position actuelle Future _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 { 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 { 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 { } } - 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 _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 _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 _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 _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 { 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, + }); +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 392c30f..bac8123 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 8847e19..01827bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: