feat: enhance global search and map experience
All checks were successful
Deploy TravelMate (Full Mobile) / deploy-android (push) Successful in 2m10s
Deploy TravelMate (Full Mobile) / deploy-ios (push) Successful in 4m19s

- Global Activity Search:
  - Allow searching activities globally (not just in destination).
  - Add distance warning for activities > 50km away.
- Create Trip UI:
  - Fix destination suggestion list overflow.
  - Prevent suggestion list from reappearing after selection.
- Map:
  - Add generic text search support (e.g., "Restaurants") on 'Enter'.
  - Display multiple results for generic searches.
  - Resize markers (User 60.0, Places 50.0).
  - Standardize place markers to red pin.
This commit is contained in:
Van Leemput Dayron
2026-01-13 16:59:04 +01:00
parent 4fc7abc5b4
commit c0e53cd3f6
63 changed files with 273 additions and 6764 deletions

View File

@@ -22,6 +22,7 @@ class MapContent extends StatefulWidget {
class _MapContentState extends State<MapContent> {
GoogleMapController? _mapController;
LatLng _initialPosition = const LatLng(48.8566, 2.3522);
LatLng? _currentMapCenter;
final TextEditingController _searchController = TextEditingController();
bool _isLoadingLocation = false;
bool _isSearching = false;
@@ -321,20 +322,30 @@ class _MapContentState extends State<MapContent> {
}
}
// Créer une icône personnalisée à partir de l'icône Material
Future<BitmapDescriptor> _createCustomMarkerIcon() async {
// Créer une icône personnalisée
Future<BitmapDescriptor> _createMarkerIcon(
IconData iconData,
Color color, {
double size = 60.0,
}) async {
final pictureRecorder = ui.PictureRecorder();
final canvas = Canvas(pictureRecorder);
const size = 80.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),
text: String.fromCharCode(iconData.codePoint),
style: TextStyle(
fontSize: 70,
fontFamily: Icons.person_pin_circle.fontFamily,
color: Colors.blue[700],
fontSize: size,
fontFamily: iconData.fontFamily,
package: iconData.fontPackage,
color: color,
shadows: [
Shadow(
offset: const Offset(1, 1),
blurRadius: 2,
color: Colors.black.withValues(alpha: 0.3),
),
],
),
);
iconPainter.layout();
@@ -364,8 +375,12 @@ class _MapContentState extends State<MapContent> {
),
);
// Créer l'icône personnalisée
final icon = await _createCustomMarkerIcon();
// Créer l'icône personnalisée (plus petite: 60 au lieu de 80)
final icon = await _createMarkerIcon(
Icons.person_pin_circle,
Colors.blue[700]!,
size: 60.0,
);
// Ajouter le marqueur avec l'icône
setState(() {
@@ -374,10 +389,7 @@ class _MapContentState extends State<MapContent> {
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)
anchor: const Offset(0.5, 0.85), // Ancrer au bas de l'icône
infoWindow: InfoWindow(
title: 'Ma position',
snippet:
@@ -401,10 +413,21 @@ class _MapContentState extends State<MapContent> {
});
try {
final apiKey = _apiKey;
LoggerService.info('MapContent: Searching places with query "$query"');
if (apiKey.isEmpty) {
LoggerService.error('MapContent: API Key is empty!');
} else {
// Log first few chars to verify correct key is loaded without leaking full key
LoggerService.info(
'MapContent: Using API Key starting with ${apiKey.substring(0, 5)}...',
);
}
final url = Uri.parse(
'https://maps.googleapis.com/maps/api/place/autocomplete/json'
'?input=${Uri.encodeComponent(query)}'
'&key=$_apiKey'
'&key=$apiKey'
'&language=fr',
);
@@ -412,11 +435,21 @@ class _MapContentState extends State<MapContent> {
if (!mounted) return;
LoggerService.info(
'MapContent: Response status code: ${response.statusCode}',
);
if (response.statusCode == 200) {
final data = json.decode(response.body);
final status = data['status'];
if (data['status'] == 'OK') {
LoggerService.info('MapContent: API Status: $status');
if (status == 'OK') {
final predictions = data['predictions'] as List;
LoggerService.info(
'MapContent: Found ${predictions.length} predictions',
);
setState(() {
_suggestions = predictions
@@ -430,13 +463,19 @@ class _MapContentState extends State<MapContent> {
_isSearching = false;
});
} else {
LoggerService.error(
'MapContent: API Error: $status - ${data['error_message'] ?? "No error message"}',
);
setState(() {
_suggestions = [];
_isSearching = false;
});
}
} else {
LoggerService.error('MapContent: HTTP Error ${response.statusCode}');
}
} catch (e) {
LoggerService.error('MapContent: Exception during search: $e');
_showError('Erreur lors de la recherche de lieux: $e');
setState(() {
_isSearching = false;
@@ -444,6 +483,128 @@ class _MapContentState extends State<MapContent> {
}
}
Future<void> _performTextSearch(String query) async {
if (query.isEmpty) return;
setState(() {
_isSearching = true;
_suggestions = []; // Hide suggestions
});
try {
final apiKey = _apiKey;
// Utiliser le centre actuel de la carte ou la position initiale
final center = _currentMapCenter ?? _initialPosition;
final url = Uri.parse(
'https://maps.googleapis.com/maps/api/place/textsearch/json'
'?query=${Uri.encodeComponent(query)}'
'&location=${center.latitude},${center.longitude}'
'&radius=5000' // Rechercher dans un rayon de 5km autour du centre
'&key=$apiKey'
'&language=fr',
);
final response = await http.get(url);
if (!mounted) return;
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK') {
final results = data['results'] as List;
final List<Marker> newMarkers = [];
// Garder le marqueur de position utilisateur
final userMarker = _markers
.where((m) => m.markerId.value == 'user_location')
.toList();
double minLat = center.latitude;
double maxLat = center.latitude;
double minLng = center.longitude;
double maxLng = center.longitude;
// Créer le marqueur rouge standard personnalisé
final markerIcon = await _createMarkerIcon(
Icons.location_on,
Colors.red,
size: 50.0,
);
for (final place in results) {
final geometry = place['geometry']['location'];
final lat = geometry['lat'];
final lng = geometry['lng'];
final name = place['name'];
final placeId = place['place_id'];
final position = LatLng(lat, lng);
// Mettre à jour les bornes
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lng < minLng) minLng = lng;
if (lng > maxLng) maxLng = lng;
newMarkers.add(
Marker(
markerId: MarkerId(placeId),
position: position,
icon: markerIcon,
anchor: const Offset(0.5, 0.85), // Standard anchor for pins
infoWindow: InfoWindow(
title: name,
snippet: place['formatted_address'] ?? 'Lieu trouvé',
),
),
);
}
setState(() {
_markers.clear();
if (userMarker.isNotEmpty) {
_markers.add(userMarker.first);
}
_markers.addAll(newMarkers);
_isSearching = false;
});
// Ajuster la caméra pour montrer tous les résultats
if (newMarkers.isNotEmpty) {
_mapController?.animateCamera(
CameraUpdate.newLatLngBounds(
LatLngBounds(
southwest: LatLng(minLat, minLng),
northeast: LatLng(maxLat, maxLng),
),
50.0, // padding
),
);
}
FocusScope.of(context).unfocus();
} else {
_showError('Aucun résultat trouvé pour "$query"');
setState(() {
_isSearching = false;
});
}
} else {
_showError('Erreur lors de la recherche: ${response.statusCode}');
setState(() {
_isSearching = false;
});
}
} catch (e) {
_showError('Erreur lors de la recherche: $e');
setState(() {
_isSearching = false;
});
}
}
Future<void> _selectPlace(PlaceSuggestion suggestion) async {
setState(() {
_isSearching = true;
@@ -467,13 +628,21 @@ class _MapContentState extends State<MapContent> {
final data = json.decode(response.body);
if (data['status'] == 'OK') {
final location = data['result']['geometry']['location'];
final result = data['result'];
final location = result['geometry']['location'];
final lat = location['lat'];
final lng = location['lng'];
final name = data['result']['name'];
final name = result['name'];
final newPosition = LatLng(lat, lng);
// Utiliser le marqueur rouge standard personnalisé
final markerIcon = await _createMarkerIcon(
Icons.location_on,
Colors.red,
size: 50.0,
);
// Ajouter un marqueur pour le lieu recherché (ne pas supprimer le marqueur de position)
setState(() {
// Garder le marqueur de position utilisateur
@@ -484,6 +653,8 @@ class _MapContentState extends State<MapContent> {
Marker(
markerId: MarkerId(suggestion.placeId),
position: newPosition,
icon: markerIcon,
anchor: const Offset(0.5, 0.85),
infoWindow: InfoWindow(title: name),
),
);
@@ -533,6 +704,9 @@ class _MapContentState extends State<MapContent> {
onMapCreated: (GoogleMapController controller) {
_mapController = controller;
},
onCameraMove: (CameraPosition position) {
_currentMapCenter = position.target;
},
markers: _markers,
circles: _circles,
myLocationEnabled: false,
@@ -664,6 +838,10 @@ class _MapContentState extends State<MapContent> {
vertical: 14,
),
),
textInputAction: TextInputAction.search,
onSubmitted: (value) {
_performTextSearch(value);
},
onChanged: (value) {
// Ne pas rechercher si c'est juste le remplissage initial
if (widget.initialSearchQuery != null &&