feat: Integrate Google Maps functionality with place suggestions and navigation options

This commit is contained in:
Dayron
2025-10-23 12:32:34 +02:00
parent 5b8d3ec45f
commit 37f5bb1710
5 changed files with 695 additions and 222 deletions

View File

@@ -1,3 +1,4 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/models/trip.dart';
@@ -16,6 +17,8 @@ import '../../models/group.dart';
import '../../models/group_member.dart';
import '../../services/user_service.dart';
import '../../repositories/group_repository.dart';
import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
class CreateTripContent extends StatefulWidget {
final Trip? tripToEdit;
@@ -43,15 +46,23 @@ class _CreateTripContentState extends State<CreateTripContent> {
bool _isLoading = false;
String? _createdTripId;
static final String _apiKey = dotenv.env['GOOGLE_MAPS_API_KEY'] ?? '';
final List<String> _participants = [];
final _participantController = TextEditingController();
List<PlaceSuggestion> _placeSuggestions = [];
bool _isLoadingSuggestions = false;
OverlayEntry? _suggestionsOverlay;
final LayerLink _layerLink = LayerLink();
bool get isEditing => widget.tripToEdit != null;
@override
void initState() {
super.initState();
_initializeFormWithTrip();
_locationController.addListener(_onLocationChanged);
}
Future<void> _initializeFormWithTrip() async {
@@ -71,6 +82,141 @@ class _CreateTripContentState extends State<CreateTripContent> {
}
}
void _onLocationChanged() {
final query = _locationController.text.trim();
if (query.length < 2) {
_hideSuggestions();
return;
}
_fetchPlaceSuggestions(query);
}
Future<void> _fetchPlaceSuggestions(String query) async {
if (_apiKey.isEmpty) {
return;
}
setState(() {
_isLoadingSuggestions = true;
});
try {
final url = Uri.parse(
'https://maps.googleapis.com/maps/api/place/autocomplete/json'
'?input=${Uri.encodeComponent(query)}'
'&types=(cities)'
'&language=fr'
'&key=$_apiKey'
);
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(() {
_placeSuggestions = predictions.map((prediction) {
return PlaceSuggestion(
placeId: prediction['place_id'],
description: prediction['description'],
);
}).toList();
_isLoadingSuggestions = false;
});
if (_placeSuggestions.isNotEmpty) {
_showSuggestions();
} else {
_hideSuggestions();
}
} else {
setState(() {
_placeSuggestions = [];
_isLoadingSuggestions = false;
});
_hideSuggestions();
}
} else {
setState(() {
_placeSuggestions = [];
_isLoadingSuggestions = false;
});
_hideSuggestions();
}
} catch (e) {
setState(() {
_placeSuggestions = [];
_isLoadingSuggestions = false;
});
_hideSuggestions();
}
}
// Nouvelle méthode pour afficher les suggestions
void _showSuggestions() {
_hideSuggestions(); // Masquer d'abord les suggestions existantes
if (_placeSuggestions.isEmpty) return;
_suggestionsOverlay = OverlayEntry(
builder: (context) => Positioned(
width: MediaQuery.of(context).size.width - 32, // Largeur du champ avec padding
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
offset: const Offset(0, 60), // Position sous le champ
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(8),
child: Container(
constraints: const BoxConstraints(maxHeight: 200),
decoration: BoxDecoration(
color: Theme.of(context).scaffoldBackgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: ListView.builder(
shrinkWrap: true,
itemCount: _placeSuggestions.length,
itemBuilder: (context, index) {
final suggestion = _placeSuggestions[index];
return ListTile(
leading: const Icon(Icons.location_on, color: Colors.grey),
title: Text(
suggestion.description,
style: const TextStyle(fontSize: 14),
),
dense: true,
onTap: () => _selectSuggestion(suggestion),
);
},
),
),
),
),
),
);
Overlay.of(context).insert(_suggestionsOverlay!);
}
void _hideSuggestions() {
_suggestionsOverlay?.remove();
_suggestionsOverlay = null;
}
void _selectSuggestion(PlaceSuggestion suggestion) {
_locationController.text = suggestion.description;
_hideSuggestions();
setState(() {
_placeSuggestions = [];
});
}
Future<void> _loadParticipantEmails(List<String> participantIds) async {
final userState = context.read<UserBloc>().state;
String? currentUserId;
@@ -108,45 +254,45 @@ class _CreateTripContentState extends State<CreateTripContent> {
super.dispose();
}
@override
@override
Widget build(BuildContext context) {
return BlocListener<TripBloc, TripState>(
listener: (context, tripState) {
if (tripState is TripCreated) {
// Stocker l'ID du trip et créer le groupe
_createdTripId = tripState.tripId;
_createGroupAndAccountForTrip(_createdTripId!);
} else if (tripState is TripOperationSuccess) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tripState.message),
backgroundColor: Colors.green,
),
);
setState(() {
_isLoading = false;
});
Navigator.pop(context);
if (isEditing) {
Navigator.pop(context);
}
}
} else if (tripState is TripError) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tripState.message),
backgroundColor: Colors.red,
),
);
setState(() {
_isLoading = false;
});
}
listener: (context, tripState) {
if (tripState is TripCreated) {
// Stocker l'ID du trip et créer le groupe
_createdTripId = tripState.tripId;
_createGroupAndAccountForTrip(_createdTripId!);
} else if (tripState is TripOperationSuccess) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tripState.message),
backgroundColor: Colors.green,
),
);
setState(() {
_isLoading = false;
});
Navigator.pop(context);
if (isEditing) {
Navigator.pop(context);
}
},
child: BlocBuilder<UserBloc, user_state.UserState>(
}
} else if (tripState is TripError) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(tripState.message),
backgroundColor: Colors.red,
),
);
setState(() {
_isLoading = false;
});
}
}
},
child: BlocBuilder<UserBloc, user_state.UserState>(
builder: (context, userState) {
if (userState is! user_state.UserLoaded) {
return Scaffold(
@@ -163,216 +309,230 @@ class _CreateTripContentState extends State<CreateTripContent> {
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
),
body: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('Informations générales'),
SizedBox(height: 16),
body: GestureDetector(
onTap: _hideSuggestions, // Masquer les suggestions en tapant ailleurs
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionTitle('Informations générales'),
const SizedBox(height: 16),
TextFormField(
controller: _titleController,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Titre requis';
}
return null;
},
decoration: InputDecoration(
labelText: 'Titre du voyage *',
hintText: 'ex: Voyage à Paris',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
TextFormField(
controller: _titleController,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Titre requis';
}
return null;
},
decoration: InputDecoration(
labelText: 'Titre du voyage *',
hintText: 'ex: Voyage à Paris',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: const Icon(Icons.travel_explore),
),
prefixIcon: Icon(Icons.travel_explore),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Description',
hintText: 'Décrivez votre voyage...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
TextFormField(
controller: _descriptionController,
maxLines: 3,
decoration: InputDecoration(
labelText: 'Description',
hintText: 'Décrivez votre voyage...',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: const Icon(Icons.description),
),
prefixIcon: Icon(Icons.description),
),
),
SizedBox(height: 16),
const SizedBox(height: 16),
TextFormField(
controller: _locationController,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Destination requise';
}
return null;
},
decoration: InputDecoration(
labelText: 'Destination *',
hintText: 'ex: Paris, France',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: Icon(Icons.location_on),
),
),
SizedBox(height: 24),
_buildSectionTitle('Dates du voyage'),
SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildDateField(
label: 'Date de début *',
date: _startDate,
onTap: () => _selectStartDate(context),
// Champ de localisation avec suggestions
CompositedTransformTarget(
link: _layerLink,
child: TextFormField(
controller: _locationController,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Destination requise';
}
return null;
},
decoration: InputDecoration(
labelText: 'Destination *',
hintText: 'ex: Paris, France',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: const Icon(Icons.location_on),
suffixIcon: _isLoadingSuggestions
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: null,
),
),
SizedBox(width: 16),
Expanded(
child: _buildDateField(
label: 'Date de fin *',
date: _endDate,
onTap: () => _selectEndDate(context),
),
const SizedBox(height: 24),
_buildSectionTitle('Dates du voyage'),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: _buildDateField(
label: 'Date de début *',
date: _startDate,
onTap: () => _selectStartDate(context),
),
),
const SizedBox(width: 16),
Expanded(
child: _buildDateField(
label: 'Date de fin *',
date: _endDate,
onTap: () => _selectEndDate(context),
),
),
],
),
const SizedBox(height: 24),
_buildSectionTitle('Budget'),
const SizedBox(height: 16),
TextFormField(
controller: _budgetController,
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Budget estimé',
hintText: 'ex: 1200.50',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: const Icon(Icons.euro),
suffixText: '',
),
),
const SizedBox(height: 24),
_buildSectionTitle('Participants'),
const SizedBox(height: 8),
Text(
'Ajoutez les emails des personnes que vous souhaitez inviter',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _participantController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'Email du participant',
hintText: 'ex: ami@email.com',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: const Icon(Icons.person_add),
),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _addParticipant,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(16),
),
child: const Icon(Icons.add),
),
],
),
const SizedBox(height: 16),
if (_participants.isNotEmpty) ...[
Text(
'Participants ajoutés (${_participants.length})',
style: const TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(12),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _participants
.map(
(email) => Chip(
label: Text(email, style: const TextStyle(fontSize: 12)),
deleteIcon: const Icon(Icons.close, size: 18),
onDeleted: () => _removeParticipant(email),
backgroundColor: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.1),
),
)
.toList(),
),
),
],
),
SizedBox(height: 24),
const SizedBox(height: 32),
_buildSectionTitle('Budget'),
SizedBox(height: 16),
TextFormField(
controller: _budgetController,
keyboardType: TextInputType.numberWithOptions(decimal: true),
decoration: InputDecoration(
labelText: 'Budget estimé',
hintText: 'ex: 1200.50',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: Icon(Icons.euro),
suffixText: '',
),
),
SizedBox(height: 24),
_buildSectionTitle('Participants'),
SizedBox(height: 8),
Text(
'Ajoutez les emails des personnes que vous souhaitez inviter',
style: TextStyle(color: Colors.grey[600], fontSize: 14),
),
SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _participantController,
keyboardType: TextInputType.emailAddress,
decoration: InputDecoration(
labelText: 'Email du participant',
hintText: 'ex: ami@email.com',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
prefixIcon: Icon(Icons.person_add),
),
),
),
SizedBox(width: 8),
ElevatedButton(
onPressed: _addParticipant,
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : () => _saveTrip(userState.user),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.all(16),
),
child: Icon(Icons.add),
),
],
),
SizedBox(height: 16),
if (_participants.isNotEmpty) ...[
Text(
'Participants ajoutés (${_participants.length})',
style: TextStyle(fontWeight: FontWeight.w500),
),
SizedBox(height: 8),
Container(
width: double.infinity,
padding: EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[300]!),
borderRadius: BorderRadius.circular(12),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _participants
.map(
(email) => Chip(
label: Text(email, style: TextStyle(fontSize: 12)),
deleteIcon: Icon(Icons.close, size: 18),
onDeleted: () => _removeParticipant(email),
backgroundColor: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.1),
child: _isLoading
? const CircularProgressIndicator(color: Colors.white)
: Text(
isEditing ? 'Mettre à jour le voyage' : 'Créer le voyage',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
)
.toList(),
),
),
const SizedBox(height: 20),
],
SizedBox(height: 32),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: _isLoading ? null : () => _saveTrip(userState.user),
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: _isLoading
? CircularProgressIndicator(color: Colors.white)
: Text(
isEditing ? 'Mettre à jour le voyage' : 'Créer le voyage',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
SizedBox(height: 20),
],
),
),
),
),
@@ -762,4 +922,14 @@ class _CreateTripContentState extends State<CreateTripContent> {
return participantsData;
}
}
class PlaceSuggestion {
final String placeId;
final String description;
PlaceSuggestion({
required this.placeId,
required this.description,
});
}