feat: enhance global search and map experience
- 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:
@@ -13,6 +13,7 @@ import '../../blocs/user/user_bloc.dart';
|
||||
import '../../blocs/user/user_state.dart';
|
||||
import '../../services/error_service.dart';
|
||||
import 'activity_detail_dialog.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
class ActivitiesPage extends StatefulWidget {
|
||||
final Trip trip;
|
||||
@@ -637,6 +638,23 @@ class _ActivitiesPageState extends State<ActivitiesPage>
|
||||
activity.name.toLowerCase().trim(),
|
||||
);
|
||||
|
||||
// Calculer la distance si c'est une suggestion Google et que le voyage a des coordonnées
|
||||
double? distanceInKm;
|
||||
if (isGoogleSuggestion &&
|
||||
widget.trip.hasCoordinates &&
|
||||
activity.latitude != null &&
|
||||
activity.longitude != null) {
|
||||
final distanceInMeters = Geolocator.distanceBetween(
|
||||
widget.trip.latitude!,
|
||||
widget.trip.longitude!,
|
||||
activity.latitude!,
|
||||
activity.longitude!,
|
||||
);
|
||||
distanceInKm = distanceInMeters / 1000;
|
||||
}
|
||||
|
||||
final isFar = distanceInKm != null && distanceInKm > 50;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
showDialog(
|
||||
@@ -709,6 +727,40 @@ class _ActivitiesPageState extends State<ActivitiesPage>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (isFar)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: theme.colorScheme.error,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Activité éloignée : ${distanceInKm!.toStringAsFixed(0)} km du lieu du voyage',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// Icône de catégorie
|
||||
|
||||
@@ -135,7 +135,11 @@ class _CreateTripContentState extends State<CreateTripContent> {
|
||||
}
|
||||
}
|
||||
|
||||
bool _isProgrammaticUpdate = false;
|
||||
|
||||
void _onLocationChanged() {
|
||||
if (_isProgrammaticUpdate) return;
|
||||
|
||||
final query = _locationController.text.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
@@ -215,15 +219,18 @@ class _CreateTripContentState extends State<CreateTripContent> {
|
||||
|
||||
if (_placeSuggestions.isEmpty) return;
|
||||
|
||||
final overlay = Overlay.of(context);
|
||||
|
||||
// Calculer la largeur correcte en fonction du padding parent (16 margin + 24 padding = 40 de chaque côté)
|
||||
final width = MediaQuery.of(context).size.width - 80;
|
||||
|
||||
_suggestionsOverlay = OverlayEntry(
|
||||
builder: (context) => Positioned(
|
||||
width:
|
||||
MediaQuery.of(context).size.width -
|
||||
32, // Largeur du champ avec padding
|
||||
width: width,
|
||||
child: CompositedTransformFollower(
|
||||
link: _layerLink,
|
||||
showWhenUnlinked: false,
|
||||
offset: const Offset(0, 60), // Position sous le champ
|
||||
targetAnchor: Alignment.bottomLeft,
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -236,6 +243,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
|
||||
),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.zero,
|
||||
itemCount: _placeSuggestions.length,
|
||||
itemBuilder: (context, index) {
|
||||
final suggestion = _placeSuggestions[index];
|
||||
@@ -256,7 +264,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_suggestionsOverlay!);
|
||||
overlay.insert(_suggestionsOverlay!);
|
||||
}
|
||||
|
||||
void _hideSuggestions() {
|
||||
@@ -265,7 +273,10 @@ class _CreateTripContentState extends State<CreateTripContent> {
|
||||
}
|
||||
|
||||
void _selectSuggestion(PlaceSuggestion suggestion) {
|
||||
_isProgrammaticUpdate = true;
|
||||
_locationController.text = suggestion.description;
|
||||
_isProgrammaticUpdate = false;
|
||||
|
||||
_hideSuggestions();
|
||||
setState(() {
|
||||
_placeSuggestions = [];
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -365,7 +365,7 @@ class ActivityPlacesService {
|
||||
final encodedQuery = Uri.encodeComponent(query);
|
||||
final url =
|
||||
'https://maps.googleapis.com/maps/api/place/textsearch/json'
|
||||
'?query=$encodedQuery in $destination'
|
||||
'?query=$encodedQuery'
|
||||
'&location=${coordinates['lat']},${coordinates['lng']}'
|
||||
'&radius=$radius'
|
||||
'&key=$_apiKey'
|
||||
|
||||
Reference in New Issue
Block a user