import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:travel_mate/blocs/trip/trip_bloc.dart'; import 'package:travel_mate/blocs/trip/trip_event.dart'; import 'package:travel_mate/blocs/activity/activity_bloc.dart'; import 'package:travel_mate/blocs/activity/activity_event.dart'; import 'package:travel_mate/components/home/create_trip_content.dart'; import 'package:travel_mate/models/trip.dart'; import 'package:travel_mate/components/map/map_content.dart'; import 'package:travel_mate/services/error_service.dart'; import 'package:travel_mate/services/logger_service.dart'; import 'package:travel_mate/repositories/group_repository.dart'; import 'package:travel_mate/repositories/user_repository.dart'; import 'package:travel_mate/repositories/account_repository.dart'; import 'package:travel_mate/models/group_member.dart'; import 'package:travel_mate/components/activities/activities_page.dart'; import 'package:travel_mate/components/activities/activity_detail_dialog.dart'; import 'package:travel_mate/components/home/calendar/calendar_page.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:intl/intl.dart'; import 'package:travel_mate/models/activity.dart'; import 'package:travel_mate/blocs/activity/activity_state.dart'; import 'package:travel_mate/blocs/balance/balance_bloc.dart'; import 'package:travel_mate/blocs/balance/balance_event.dart'; import 'package:travel_mate/blocs/balance/balance_state.dart'; import 'package:travel_mate/blocs/group/group_bloc.dart'; import 'package:travel_mate/blocs/group/group_event.dart'; import 'package:travel_mate/blocs/user/user_bloc.dart'; import 'package:travel_mate/blocs/user/user_state.dart' as user_state; import 'package:travel_mate/components/account/group_expenses_page.dart'; import 'package:travel_mate/models/group.dart'; import 'package:travel_mate/models/account.dart'; import 'package:travel_mate/models/user_balance.dart'; import 'package:travel_mate/models/user.dart'; import 'package:travel_mate/repositories/trip_invitation_repository.dart'; import 'package:travel_mate/models/checklist_item.dart'; import 'package:travel_mate/services/trip_checklist_service.dart'; import 'package:travel_mate/models/trip_document.dart'; import 'package:travel_mate/services/trip_document_service.dart'; import 'package:travel_mate/models/transport_segment.dart'; import 'package:travel_mate/services/transport_service.dart'; import 'package:travel_mate/models/packing_item.dart'; import 'package:travel_mate/models/budget_category.dart'; import 'package:travel_mate/services/packing_service.dart'; import 'package:travel_mate/services/budget_service.dart'; import 'package:travel_mate/services/offline_flag_service.dart'; import 'package:travel_mate/models/emergency_contact.dart'; import 'package:travel_mate/services/emergency_service.dart'; import 'package:travel_mate/models/album_photo.dart'; import 'package:travel_mate/services/album_service.dart'; import 'package:travel_mate/services/guest_flag_service.dart'; import 'package:travel_mate/models/reminder_item.dart'; import 'package:travel_mate/services/reminder_service.dart'; import 'package:travel_mate/services/activity_suggestion_service.dart'; import 'package:travel_mate/services/storage_service.dart'; import 'package:travel_mate/services/ai_activity_service.dart'; import 'package:travel_mate/services/notification_service.dart'; import 'package:travel_mate/services/sos_service.dart'; import 'package:geolocator/geolocator.dart'; import 'package:image_picker/image_picker.dart'; import 'dart:io'; class ShowTripDetailsContent extends StatefulWidget { final Trip trip; const ShowTripDetailsContent({super.key, required this.trip}); @override State createState() => _ShowTripDetailsContentState(); } class _ShowTripDetailsContentState extends State { final ErrorService _errorService = ErrorService(); final GroupRepository _groupRepository = GroupRepository(); final UserRepository _userRepository = UserRepository(); final AccountRepository _accountRepository = AccountRepository(); final TripInvitationRepository _tripInvitationRepository = TripInvitationRepository(); final TripChecklistService _checklistService = TripChecklistService(); final TripDocumentService _documentService = TripDocumentService(); final TransportService _transportService = TransportService(); final PackingService _packingService = PackingService(); final BudgetService _budgetService = BudgetService(); final OfflineFlagService _offlineFlagService = OfflineFlagService(); final EmergencyService _emergencyService = EmergencyService(); final AlbumService _albumService = AlbumService(); final GuestFlagService _guestFlagService = GuestFlagService(); final ReminderService _reminderService = ReminderService(); final ActivitySuggestionService _activitySuggestionService = ActivitySuggestionService(); final StorageService _storageService = StorageService(); final AiActivityService _aiActivityService = AiActivityService( baseUrl: 'https://api.example.com', // TODO: set real backend ); final SosService _sosService = SosService( baseUrl: 'https://api.example.com', // TODO: set real backend ); List _checklistItems = []; bool _isLoadingChecklist = false; List _documents = []; bool _isLoadingDocuments = false; List _segments = []; bool _isLoadingSegments = false; List _packingItems = []; bool _isLoadingPacking = false; List _budgets = []; bool _isLoadingBudgets = false; bool _offlineEnabled = false; List _emergencyContacts = []; bool _isLoadingEmergency = false; bool _shareLocation = false; List _albumPhotos = []; bool _isLoadingAlbum = false; bool _guestEnabled = false; List _reminders = []; bool _isLoadingReminders = false; List _suggestedActivities = []; Group? _group; Account? _account; @override void initState() { super.initState(); // Charger les activités du voyage depuis la DB if (widget.trip.id != null) { context.read().add(LoadActivities(widget.trip.id!)); _loadGroupAndAccount(); _loadChecklist(); _loadDocuments(); _loadTransportSegments(); _loadPacking(); _loadBudgets(); _loadOfflineFlag(); _loadEmergency(); _loadAlbum(); _loadGuestFlag(); _loadReminders(); _buildSuggestions(); } } Future _loadGroupAndAccount() async { if (widget.trip.id == null) return; try { final group = await _groupRepository.getGroupByTripId(widget.trip.id!); final account = await _accountRepository.getAccountByTripId( widget.trip.id!, ); if (mounted) { setState(() { _group = group; _account = account; }); if (group != null) { context.read().add(LoadGroupBalances(group.id)); } } } catch (e) { _errorService.logError( 'ShowTripDetailsContent', 'Error loading group/account: $e', ); } } /// Loads checklist items from local storage for the current trip. /// /// The method keeps a lightweight spinner state and tolerates missing /// storage entries to avoid blocking the screen if preferences are empty. Future _loadChecklist() async { if (widget.trip.id == null) { return; } setState(() { _isLoadingChecklist = true; }); final items = await _checklistService.loadChecklist(widget.trip.id!); if (!mounted) { return; } setState(() { _checklistItems = items; _isLoadingChecklist = false; }); } /// Loads stored documents for the current trip from local preferences. Future _loadDocuments() async { if (widget.trip.id == null) { return; } setState(() { _isLoadingDocuments = true; }); final docs = await _documentService.loadDocuments(widget.trip.id!); if (!mounted) return; setState(() { _documents = docs; _isLoadingDocuments = false; }); } /// Loads transport segments stored for this trip. Future _loadTransportSegments() async { if (widget.trip.id == null) return; setState(() { _isLoadingSegments = true; }); final loaded = await _transportService.loadSegments(widget.trip.id!); if (!mounted) return; setState(() { _segments = loaded; _isLoadingSegments = false; }); } /// Loads packing list for this trip. Future _loadPacking() async { if (widget.trip.id == null) return; setState(() => _isLoadingPacking = true); final items = await _packingService.loadItems(widget.trip.id!); if (!mounted) return; setState(() { _packingItems = items; _isLoadingPacking = false; }); } /// Loads budget envelopes for this trip. Future _loadBudgets() async { if (widget.trip.id == null) return; setState(() => _isLoadingBudgets = true); final items = await _budgetService.loadBudgets(widget.trip.id!); if (!mounted) return; setState(() { _budgets = items; _isLoadingBudgets = false; }); } /// Loads offline toggle for this trip. Future _loadOfflineFlag() async { if (widget.trip.id == null) return; final enabled = await _offlineFlagService.isOfflineEnabled(widget.trip.id!); if (!mounted) return; setState(() { _offlineEnabled = enabled; }); } /// Loads emergency contacts and location sharing toggle. Future _loadEmergency() async { if (widget.trip.id == null) return; setState(() => _isLoadingEmergency = true); final contacts = await _emergencyService.loadContacts(widget.trip.id!); final shareLoc = await _offlineFlagService.isOfflineEnabled( widget.trip.id!, ); // reuse flag if needed if (!mounted) return; setState(() { _emergencyContacts = contacts; _shareLocation = shareLoc; _isLoadingEmergency = false; }); } /// Loads album photos. Future _loadAlbum() async { if (widget.trip.id == null) return; setState(() => _isLoadingAlbum = true); final photos = await _albumService.loadPhotos(widget.trip.id!); if (!mounted) return; setState(() { _albumPhotos = photos; _isLoadingAlbum = false; }); } /// Loads guest mode flag. Future _loadGuestFlag() async { if (widget.trip.id == null) return; final enabled = await _guestFlagService.isGuestEnabled(widget.trip.id!); if (!mounted) return; setState(() => _guestEnabled = enabled); } /// Loads reminders list. Future _loadReminders() async { if (widget.trip.id == null) return; setState(() => _isLoadingReminders = true); final items = await _reminderService.loadReminders(widget.trip.id!); if (!mounted) return; setState(() { _reminders = items; _isLoadingReminders = false; }); } /// Builds cached suggestions for activities (light heuristic). void _buildSuggestions() { final city = widget.trip.location; _suggestedActivities = _activitySuggestionService.suggestions( city: city, weatherCode: 'default', ); _fetchAiSuggestions(city); } /// Fetches AI suggestions asynchronously and merges with local ones. Future _fetchAiSuggestions(String city) async { final aiList = await _aiActivityService.fetchSuggestions( city: city, interests: const ['food', 'culture'], budget: 'mid', ); if (!mounted || aiList.isEmpty) return; setState(() { _suggestedActivities = {..._suggestedActivities, ...aiList}.toList(); }); } // Calculer les jours restants avant le voyage int get daysUntilTrip { final now = DateTime.now(); final tripStart = widget.trip.startDate; final difference = tripStart.difference(now).inDays; return difference > 0 ? difference : 0; } // Méthode pour ouvrir la carte interne void _openInternalMap() { Navigator.push( context, MaterialPageRoute( builder: (context) => MapContent(initialSearchQuery: widget.trip.location), ), ); } // Méthode pour afficher le dialogue de sélection de carte void _showMapOptions() { final theme = Theme.of(context); showDialog( context: context, builder: (BuildContext context) { return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Ouvrir la carte', style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onSurface, ), ), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Choisissez comment vous souhaitez ouvrir la carte :', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, ), ), const SizedBox(height: 20), // Options centrées verticalement Column( children: [ // Carte de l'application SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () { Navigator.pop(context); _openInternalMap(); }, icon: const Icon(Icons.map), label: const Text('Carte de l\'app'), style: ElevatedButton.styleFrom( backgroundColor: Colors.blue, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 12), ), ), ), const SizedBox(height: 12), // Google Maps SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () { Navigator.pop(context); _openGoogleMaps(); }, icon: const Icon(Icons.directions), label: const Text('Google Maps'), style: ElevatedButton.styleFrom( backgroundColor: Colors.green, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 12), ), ), ), const SizedBox(height: 12), // Waze SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () { Navigator.pop(context); _openWaze(); }, icon: const Icon(Icons.navigation), label: const Text('Waze'), style: ElevatedButton.styleFrom( backgroundColor: Colors.orange, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 12), ), ), ), ], ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text( 'Annuler', style: TextStyle(color: theme.colorScheme.primary), ), ), ], ); }, ); } // Méthode pour ouvrir Google Maps Future _openGoogleMaps() async { final location = Uri.encodeComponent(widget.trip.location); try { // Essayer d'abord l'URL scheme pour l'app mobile final appUrl = 'comgooglemaps://?q=$location'; final appUri = Uri.parse(appUrl); if (await canLaunchUrl(appUri)) { await launchUrl(appUri); return; } // Fallback vers l'URL web final webUrl = 'https://www.google.com/maps/search/?api=1&query=$location'; final webUri = Uri.parse(webUrl); if (await canLaunchUrl(webUri)) { await launchUrl(webUri, mode: LaunchMode.externalApplication); return; } _errorService.showError( message: 'Impossible d\'ouvrir Google Maps. Vérifiez que l\'application est installée.', ); } catch (e) { _errorService.showError( message: 'Erreur lors de l\'ouverture de Google Maps', ); } } // Méthode pour ouvrir Waze Future _openWaze() async { try { String wazeUrl; // Utiliser les coordonnées si disponibles (plus précis) if (widget.trip.latitude != null && widget.trip.longitude != null) { final lat = widget.trip.latitude; final lng = widget.trip.longitude; // Format: https://www.waze.com/ul?ll=lat%2Clng&navigate=yes wazeUrl = 'https://www.waze.com/ul?ll=$lat%2C$lng&navigate=yes'; LoggerService.info('Opening Waze with coordinates: $lat, $lng'); } else { // Fallback sur l'adresse/nom final location = Uri.encodeComponent(widget.trip.location); wazeUrl = 'https://www.waze.com/ul?q=$location&navigate=yes'; LoggerService.info( 'Opening Waze with location query: ${widget.trip.location}', ); } final uri = Uri.parse(wazeUrl); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } else { LoggerService.warning('Could not launch Waze URL: $wazeUrl'); _errorService.showError( message: 'Impossible d\'ouvrir Waze. Vérifiez que l\'application est installée.', ); } } catch (e) { LoggerService.error('Error opening Waze', error: e); _errorService.showError(message: 'Erreur lors de l\'ouverture de Waze'); } } /// Opens a dialog to create a new checklist entry and persists it. Future _openAddChecklistDialog() async { if (widget.trip.id == null) { return; } final controller = TextEditingController(); final theme = Theme.of(context); final label = await showDialog( context: context, builder: (context) { return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Nouvelle tâche', style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onSurface, ), ), content: TextField( controller: controller, autofocus: true, decoration: const InputDecoration( labelText: 'Que devez-vous préparer ?', hintText: 'Ex: Scanner les passeports', ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Annuler'), ), ElevatedButton( onPressed: () { Navigator.pop(context, controller.text.trim()); }, child: const Text('Ajouter'), ), ], ); }, ); if (label == null || label.isEmpty || !mounted) { return; } final newItem = ChecklistItem.newItem( id: DateTime.now().microsecondsSinceEpoch.toString(), label: label, ); final updated = await _checklistService.addItem(widget.trip.id!, newItem); if (!mounted) { return; } setState(() { _checklistItems = updated; }); } /// Toggles the completion status for a checklist item. Future _toggleChecklistItem(String itemId) async { if (widget.trip.id == null) { return; } final updated = await _checklistService.toggleItem(widget.trip.id!, itemId); if (!mounted) { return; } setState(() { _checklistItems = updated; }); } /// Removes a checklist item and refreshes the local state. Future _deleteChecklistItem(String itemId) async { if (widget.trip.id == null) { return; } final updated = await _checklistService.deleteItem(widget.trip.id!, itemId); if (!mounted) { return; } setState(() { _checklistItems = updated; }); } /// Opens a dialog to create a new trip document entry. Future _openAddDocumentDialog() async { if (widget.trip.id == null) return; final titleController = TextEditingController(); final urlController = TextEditingController(); String selectedCategory = 'billet'; DateTime? expiry; final theme = Theme.of(context); final result = await showDialog( context: context, builder: (context) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Ajouter un document', style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onSurface, ), ), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: titleController, decoration: const InputDecoration( labelText: 'Titre', hintText: 'Billet AF763', ), ), const SizedBox(height: 12), DropdownButtonFormField( value: selectedCategory, decoration: const InputDecoration(labelText: 'Catégorie'), items: const [ DropdownMenuItem( value: 'billet', child: Text('Billet'), ), DropdownMenuItem( value: 'passeport', child: Text('Passeport'), ), DropdownMenuItem( value: 'assurance', child: Text('Assurance'), ), DropdownMenuItem( value: 'hebergement', child: Text('Hébergement'), ), DropdownMenuItem(value: 'autre', child: Text('Autre')), ], onChanged: (value) { if (value != null) { setDialogState(() => selectedCategory = value); } }, ), const SizedBox(height: 12), TextField( controller: urlController, decoration: const InputDecoration( labelText: 'Lien (PDF, Drive, etc.)', hintText: 'https://.../document.pdf', ), ), const SizedBox(height: 12), Row( children: [ Expanded( child: Text( expiry == null ? 'Pas d\'échéance' : 'Expire le ${DateFormat.yMd().format(expiry!)}', style: theme.textTheme.bodyMedium, ), ), TextButton( onPressed: () async { final now = DateTime.now(); final picked = await showDatePicker( context: context, initialDate: now, firstDate: now.subtract(const Duration(days: 1)), lastDate: now.add(const Duration(days: 365 * 5)), ); if (picked != null) { setDialogState(() => expiry = picked); } }, child: const Text('Échéance'), ), ], ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () { Navigator.pop(context, true); }, child: const Text('Ajouter'), ), ], ); }, ); }, ); if (result != true || !mounted) return; final title = titleController.text.trim(); if (title.isEmpty) return; final newDoc = TripDocument.newEntry( id: DateTime.now().microsecondsSinceEpoch.toString(), title: title, category: selectedCategory, downloadUrl: urlController.text.trim().isEmpty ? null : urlController.text.trim(), expiresAt: expiry, ); final updated = await _documentService.addDocument(widget.trip.id!, newDoc); if (!mounted) return; setState(() { _documents = updated; }); } /// Deletes a document and updates state. Future _deleteDocument(String docId) async { if (widget.trip.id == null) return; final updated = await _documentService.deleteDocument( widget.trip.id!, docId, ); if (!mounted) return; setState(() { _documents = updated; }); } /// Opens dialog to add a transport segment (vol/train/bus) with PNR and horaires. Future _openAddSegmentDialog() async { if (widget.trip.id == null) return; final numberController = TextEditingController(); final carrierController = TextEditingController(); final pnrController = TextEditingController(); final depCodeController = TextEditingController(); final arrCodeController = TextEditingController(); final seatController = TextEditingController(); String type = 'flight'; DateTime departure = DateTime.now().toUtc(); DateTime arrival = DateTime.now().toUtc().add(const Duration(hours: 2)); final theme = Theme.of(context); final confirmed = await showDialog( context: context, builder: (context) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Ajouter un trajet', style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onSurface, ), ), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( value: type, decoration: const InputDecoration(labelText: 'Type'), items: const [ DropdownMenuItem(value: 'flight', child: Text('Vol')), DropdownMenuItem(value: 'train', child: Text('Train')), DropdownMenuItem(value: 'bus', child: Text('Bus')), ], onChanged: (value) { if (value != null) { setDialogState(() => type = value); } }, ), TextField( controller: carrierController, decoration: const InputDecoration( labelText: 'Compagnie', hintText: 'AF / SN / TGV', ), ), TextField( controller: numberController, decoration: const InputDecoration( labelText: 'Numéro', hintText: '763 / 8401', ), ), TextField( controller: pnrController, decoration: const InputDecoration( labelText: 'PNR (optionnel)', hintText: 'ABC123', ), ), Row( children: [ Expanded( child: TextField( controller: depCodeController, decoration: const InputDecoration( labelText: 'Départ', hintText: 'CDG / BRU', ), ), ), const SizedBox(width: 8), Expanded( child: TextField( controller: arrCodeController, decoration: const InputDecoration( labelText: 'Arrivée', hintText: 'JFK / AMS', ), ), ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: Text( 'Départ: ${DateFormat.yMd().add_Hm().format(departure.toLocal())}', ), ), TextButton( onPressed: () async { final picked = await showDatePicker( context: context, initialDate: departure.toLocal(), firstDate: DateTime.now().subtract( const Duration(days: 1), ), lastDate: DateTime.now().add( const Duration(days: 365), ), ); if (picked != null) { final time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime( departure.toLocal(), ), ); if (time != null) { final local = DateTime( picked.year, picked.month, picked.day, time.hour, time.minute, ); setDialogState(() => departure = local.toUtc()); } } }, child: const Text('Choisir'), ), ], ), Row( children: [ Expanded( child: Text( 'Arrivée: ${DateFormat.yMd().add_Hm().format(arrival.toLocal())}', ), ), TextButton( onPressed: () async { final picked = await showDatePicker( context: context, initialDate: arrival.toLocal(), firstDate: DateTime.now().subtract( const Duration(days: 1), ), lastDate: DateTime.now().add( const Duration(days: 365), ), ); if (picked != null) { final time = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime( arrival.toLocal(), ), ); if (time != null) { final local = DateTime( picked.year, picked.month, picked.day, time.hour, time.minute, ); setDialogState(() => arrival = local.toUtc()); } } }, child: const Text('Choisir'), ), ], ), TextField( controller: seatController, decoration: const InputDecoration( labelText: 'Siège (optionnel)', hintText: '12A / Voiture 6 place 42', ), ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter'), ), ], ); }, ); }, ); if (confirmed != true || !mounted) return; if (carrierController.text.trim().isEmpty || numberController.text.trim().isEmpty) { return; } final segment = TransportSegment.newSegment( id: DateTime.now().microsecondsSinceEpoch.toString(), type: type, carrier: carrierController.text.trim(), number: numberController.text.trim(), pnr: pnrController.text.trim().isEmpty ? null : pnrController.text.trim(), departureCode: depCodeController.text.trim().isEmpty ? '---' : depCodeController.text.trim(), arrivalCode: arrCodeController.text.trim().isEmpty ? '---' : arrCodeController.text.trim(), departureUtc: departure, arrivalUtc: arrival, gate: null, seat: seatController.text.trim().isEmpty ? null : seatController.text.trim(), ); final updated = await _transportService.addSegment( widget.trip.id!, segment, ); if (!mounted) return; setState(() { _segments = updated; }); } /// Deletes a transport segment. Future _deleteSegment(String segmentId) async { if (widget.trip.id == null) return; final updated = await _transportService.deleteSegment( widget.trip.id!, segmentId, ); if (!mounted) return; setState(() { _segments = updated; }); } /// Adds packing item from dialog, with optional templates. Future _openAddPackingDialog() async { if (widget.trip.id == null) return; final theme = Theme.of(context); final labelController = TextEditingController(); bool useTemplate = false; int nights = widget.trip.endDate.difference(widget.trip.startDate).inDays; bool cold = false; final confirmed = await showDialog( context: context, builder: (context) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: const Text('Ajouter à la valise'), content: Column( mainAxisSize: MainAxisSize.min, children: [ CheckboxListTile( value: useTemplate, onChanged: (v) => setDialogState(() => useTemplate = v ?? false), title: const Text('Utiliser une suggestion auto'), ), if (!useTemplate) TextField( controller: labelController, decoration: const InputDecoration( hintText: 'Adaptateur, médocs...', ), ), if (useTemplate) ...[ Row( children: [ Expanded(child: Text('Nuits estimées: $nights')), IconButton( icon: const Icon(Icons.remove), onPressed: () => setDialogState(() { nights = (nights - 1).clamp(1, 30); }), ), IconButton( icon: const Icon(Icons.add), onPressed: () => setDialogState(() { nights = (nights + 1).clamp(1, 30); }), ), ], ), SwitchListTile( value: cold, onChanged: (v) => setDialogState(() => cold = v), title: const Text('Destination froide'), ), ], ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter'), ), ], ); }, ); }, ); if (confirmed != true || !mounted) return; List labels; if (useTemplate) { labels = _packingService.suggestedItems(nights: nights, cold: cold); } else { if (labelController.text.trim().isEmpty) return; labels = [labelController.text.trim()]; } final newItems = labels .map( (l) => PackingItem.newItem( id: '${DateTime.now().microsecondsSinceEpoch}-${l.hashCode}', label: l, ), ) .toList(); var current = await _packingService.loadItems(widget.trip.id!); current = [...current, ...newItems]; await _packingService.saveItems(widget.trip.id!, current); if (!mounted) return; setState(() { _packingItems = current; }); } /// Toggles packing item. Future _togglePacking(String itemId) async { if (widget.trip.id == null) return; final updated = await _packingService.toggleItem(widget.trip.id!, itemId); if (!mounted) return; setState(() { _packingItems = updated; }); } /// Deletes packing item. Future _deletePacking(String itemId) async { if (widget.trip.id == null) return; final updated = await _packingService.deleteItem(widget.trip.id!, itemId); if (!mounted) return; setState(() { _packingItems = updated; }); } /// Adds a budget envelope. Future _openAddBudgetDialog() async { if (widget.trip.id == null) return; final theme = Theme.of(context); final nameController = TextEditingController(); final amountController = TextEditingController(); String currency = 'EUR'; final confirmed = await showDialog( context: context, builder: (context) { return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: const Text('Budget par poste'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameController, decoration: const InputDecoration(labelText: 'Catégorie'), ), TextField( controller: amountController, keyboardType: TextInputType.number, decoration: const InputDecoration(labelText: 'Montant prévu'), ), DropdownButtonFormField( value: currency, decoration: const InputDecoration(labelText: 'Devise'), items: const [ DropdownMenuItem(value: 'EUR', child: Text('EUR')), DropdownMenuItem(value: 'USD', child: Text('USD')), DropdownMenuItem(value: 'GBP', child: Text('GBP')), DropdownMenuItem(value: 'CHF', child: Text('CHF')), ], onChanged: (v) { if (v != null) currency = v; }, ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter'), ), ], ); }, ); if (confirmed != true || !mounted) return; final name = nameController.text.trim(); final planned = double.tryParse(amountController.text.trim()) ?? 0; if (name.isEmpty || planned <= 0) return; final cat = BudgetCategory.newCategory( id: DateTime.now().microsecondsSinceEpoch.toString(), name: name, planned: planned, currency: currency, ); final updated = await _budgetService.addBudget(widget.trip.id!, cat); if (!mounted) return; setState(() => _budgets = updated); } /// Deletes a budget category. Future _deleteBudget(String categoryId) async { if (widget.trip.id == null) return; final updated = await _budgetService.deleteBudget( widget.trip.id!, categoryId, ); if (!mounted) return; setState(() => _budgets = updated); } /// Toggles offline flag for this trip. Future _toggleOffline(bool value) async { if (widget.trip.id == null) return; await _offlineFlagService.setOfflineEnabled(widget.trip.id!, value); if (!mounted) return; setState(() => _offlineEnabled = value); } /// Adds an emergency contact. Future _openAddEmergencyDialog() async { if (widget.trip.id == null) return; final theme = Theme.of(context); final nameCtrl = TextEditingController(); final phoneCtrl = TextEditingController(); final noteCtrl = TextEditingController(); final confirmed = await showDialog( context: context, builder: (context) { return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: const Text('Ajouter un contact d\'urgence'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameCtrl, decoration: const InputDecoration(labelText: 'Nom/Service'), ), TextField( controller: phoneCtrl, decoration: const InputDecoration(labelText: 'Téléphone'), keyboardType: TextInputType.phone, ), TextField( controller: noteCtrl, decoration: const InputDecoration( labelText: 'Note (optionnel)', ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter'), ), ], ); }, ); if (confirmed != true || !mounted) return; if (nameCtrl.text.trim().isEmpty || phoneCtrl.text.trim().isEmpty) return; final contact = EmergencyContact.newContact( id: DateTime.now().microsecondsSinceEpoch.toString(), name: nameCtrl.text.trim(), phone: phoneCtrl.text.trim(), note: noteCtrl.text.trim().isEmpty ? null : noteCtrl.text.trim(), ); final updated = await _emergencyService.addContact( widget.trip.id!, contact, ); if (!mounted) return; setState(() => _emergencyContacts = updated); } /// Deletes an emergency contact. Future _deleteEmergency(String contactId) async { if (widget.trip.id == null) return; final updated = await _emergencyService.deleteContact( widget.trip.id!, contactId, ); if (!mounted) return; setState(() => _emergencyContacts = updated); } /// Toggles location sharing flag (local only for now). Future _toggleShareLocation(bool value) async { if (widget.trip.id == null) return; await _offlineFlagService.setOfflineEnabled(widget.trip.id!, value); if (!mounted) return; setState(() => _shareLocation = value); } /// Simulates SOS action (placeholder for backend integration). void _triggerSos() { _sendSos(); } /// Sends SOS to backend with best-effort location. Future _sendSos() async { if (widget.trip.id == null) return; try { // Try to get location; if fails, send without coordinates. double? lat; double? lng; try { final position = await Geolocator.getCurrentPosition(); lat = position.latitude; lng = position.longitude; } catch (_) {} final ok = await _sosService.sendSos( tripId: widget.trip.id!, lat: lat ?? 0, lng: lng ?? 0, message: 'SOS depuis ${widget.trip.title}', ); if (ok) { _errorService.showError(message: 'SOS envoyé au backend'); } else { _errorService.showError( message: 'SOS non envoyé (backend injoignable)', ); } } catch (e) { _errorService.showError(message: 'SOS en échec: $e'); } } /// Adds a photo to the shared album. /// Adds a photo link to the shared album without uploading. Future _openAddPhotoDialog() async { if (widget.trip.id == null) return; final theme = Theme.of(context); final urlCtrl = TextEditingController(); final captionCtrl = TextEditingController(); final confirmed = await showDialog( context: context, builder: (context) { return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: const Text('Ajouter une photo (URL)'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: urlCtrl, decoration: const InputDecoration( labelText: 'URL', hintText: 'https://...', ), ), TextField( controller: captionCtrl, decoration: const InputDecoration( labelText: 'Légende (optionnel)', ), ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter'), ), ], ); }, ); if (confirmed != true || !mounted) return; if (urlCtrl.text.trim().isEmpty) return; final photo = AlbumPhoto.newPhoto( id: DateTime.now().microsecondsSinceEpoch.toString(), url: urlCtrl.text.trim(), caption: captionCtrl.text.trim().isEmpty ? null : captionCtrl.text.trim(), uploadedBy: 'Moi', ); final updated = await _albumService.addPhoto(widget.trip.id!, photo); if (!mounted) return; setState(() => _albumPhotos = updated); } /// Picks an image from gallery, uploads to storage, and adds to album. Future _pickAndUploadPhoto() async { if (widget.trip.id == null || _guestEnabled) return; final picker = ImagePicker(); final picked = await picker.pickImage(source: ImageSource.gallery); if (picked == null) return; setState(() => _isLoadingAlbum = true); try { final file = File(picked.path); final url = await _storageService.uploadAlbumImage(widget.trip.id!, file); final photo = AlbumPhoto.newPhoto( id: DateTime.now().microsecondsSinceEpoch.toString(), url: url, caption: picked.name, uploadedBy: 'Moi', ); final updated = await _albumService.addPhoto(widget.trip.id!, photo); if (!mounted) return; setState(() => _albumPhotos = updated); } catch (e) { _errorService.showError(message: 'Échec upload photo: $e'); } finally { if (mounted) { setState(() => _isLoadingAlbum = false); } } } /// Deletes a photo. Future _deletePhoto(String photoId) async { if (widget.trip.id == null) return; final updated = await _albumService.deletePhoto(widget.trip.id!, photoId); if (!mounted) return; setState(() => _albumPhotos = updated); } /// Toggles guest mode flag. Future _toggleGuest(bool value) async { if (widget.trip.id == null) return; await _guestFlagService.setGuestEnabled(widget.trip.id!, value); if (!mounted) return; setState(() => _guestEnabled = value); } /// Adds a reminder item. /// Opens a dialog to add a dated reminder and schedules it locally. Future _openAddReminderDialog() async { if (widget.trip.id == null) return; final theme = Theme.of(context); final titleCtrl = TextEditingController(); final noteCtrl = TextEditingController(); DateTime dueAt = DateTime.now().add(const Duration(hours: 6)); final confirmed = await showDialog( context: context, builder: (context) { return StatefulBuilder( builder: (context, setDialogState) { return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: const Text('Ajouter un rappel'), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: titleCtrl, decoration: const InputDecoration(labelText: 'Titre'), ), TextField( controller: noteCtrl, decoration: const InputDecoration(labelText: 'Note'), ), const SizedBox(height: 8), Row( children: [ Expanded( child: Text( 'Échéance: ${DateFormat.yMd().add_Hm().format(dueAt.toLocal())}', ), ), TextButton( onPressed: () async { final pickedDate = await showDatePicker( context: context, initialDate: dueAt.toLocal(), firstDate: DateTime.now().subtract( const Duration(days: 1), ), lastDate: DateTime.now().add( const Duration(days: 365), ), ); if (pickedDate != null) { final pickedTime = await showTimePicker( context: context, initialTime: TimeOfDay.fromDateTime( dueAt.toLocal(), ), ); if (pickedTime != null) { final local = DateTime( pickedDate.year, pickedDate.month, pickedDate.day, pickedTime.hour, pickedTime.minute, ); setDialogState(() => dueAt = local.toUtc()); } } }, child: const Text('Choisir'), ), ], ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context, false), child: const Text('Annuler'), ), ElevatedButton( onPressed: () => Navigator.pop(context, true), child: const Text('Ajouter'), ), ], ); }, ); }, ); if (confirmed != true || !mounted) return; if (titleCtrl.text.trim().isEmpty) return; final reminder = ReminderItem.newItem( id: DateTime.now().microsecondsSinceEpoch.toString(), title: titleCtrl.text.trim(), note: noteCtrl.text.trim().isEmpty ? null : noteCtrl.text.trim(), dueAt: dueAt, ); final updated = await _reminderService.addReminder( widget.trip.id!, reminder, ); await NotificationService().scheduleReminder( id: reminder.id, title: reminder.title, body: reminder.note ?? 'Rappel', dueAt: reminder.dueAt, ); if (!mounted) return; setState(() => _reminders = updated); } /// Toggles a reminder. /// Toggles a reminder's completion status and persists the change. Future _toggleReminder(String reminderId) async { if (widget.trip.id == null) return; final updated = await _reminderService.toggleReminder( widget.trip.id!, reminderId, ); final current = updated.firstWhere((r) => r.id == reminderId); if (current.isDone) { await NotificationService().cancelReminder(reminderId); } else { await NotificationService().scheduleReminder( id: current.id, title: current.title, body: current.note ?? 'Rappel', dueAt: current.dueAt, ); } if (!mounted) return; setState(() => _reminders = updated); } /// Deletes a reminder. /// Deletes a reminder and updates local state. Future _deleteReminder(String reminderId) async { if (widget.trip.id == null) return; final updated = await _reminderService.deleteReminder( widget.trip.id!, reminderId, ); await NotificationService().cancelReminder(reminderId); if (!mounted) return; setState(() => _reminders = updated); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; return Scaffold( backgroundColor: isDarkMode ? theme.scaffoldBackgroundColor : Colors.grey[50], appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( icon: Icon(Icons.arrow_back, color: theme.colorScheme.onSurface), onPressed: () => Navigator.pop(context), ), title: Text( widget.trip.title, style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onSurface, fontWeight: FontWeight.w600, ), ), actions: [ IconButton( icon: Icon(Icons.more_vert, color: theme.colorScheme.onSurface), onPressed: () => _showOptionsMenu(), ), ], ), body: SingleChildScrollView( child: Column( children: [ // Image du voyage Container( height: 250, width: double.infinity, margin: const EdgeInsets.all(16), decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 10, offset: const Offset(0, 5), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(16), child: widget.trip.imageUrl != null && widget.trip.imageUrl!.isNotEmpty ? Image.network( widget.trip.imageUrl!, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => _buildPlaceholderImage(), ) : _buildPlaceholderImage(), ), ), // Contenu principal Padding( padding: const EdgeInsets.all(16), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Section "Départ dans X jours" Container( padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDarkMode ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), width: 1, ), boxShadow: [ BoxShadow( color: isDarkMode ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.1), blurRadius: isDarkMode ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Row( children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Colors.teal.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon( Icons.flight_takeoff, color: Colors.teal, size: 20, ), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Départ dans', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues( alpha: 0.6, ), ), ), Text( daysUntilTrip > 0 ? '$daysUntilTrip Jours' : 'Voyage terminé', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), Text( widget.trip.formattedDates, style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.onSurface.withValues( alpha: 0.6, ), ), ), ], ), ], ), ), const SizedBox(height: 24), // Section Participants Text( 'Participants', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), const SizedBox(height: 12), // Afficher les participants avec leurs images _buildParticipantsSection(), const SizedBox(height: 32), // Grille d'actions GridView.count( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), crossAxisCount: 2, childAspectRatio: 1.5, crossAxisSpacing: 16, mainAxisSpacing: 16, children: [ _buildActionButton( icon: Icons.calendar_today, title: 'Calendrier', color: Colors.blue, onTap: () => Navigator.push( context, MaterialPageRoute( builder: (context) => CalendarPage(trip: widget.trip), ), ), ), _buildActionButton( icon: Icons.local_activity, title: 'Activités', color: Colors.green, onTap: () => _navigateToActivities(), ), _buildActionButton( icon: Icons.account_balance_wallet, title: 'Dépenses', color: Colors.orange, onTap: () { if (_group != null && _account != null) { Navigator.push( context, MaterialPageRoute( builder: (context) => GroupExpensesPage( group: _group!, account: _account!, ), ), ); } }, ), _buildActionButton( icon: Icons.map, title: 'Ouvrir la carte', color: Colors.purple, onTap: _showMapOptions, ), ], ), const SizedBox(height: 32), _buildDocumentsSection(), _buildTransportSection(), _buildSafetySection(), _buildAlbumSection(), _buildPackingSection(), _buildBudgetSection(), _buildGuestToggle(), _buildOfflineToggle(), _buildRecapSection(), _buildRemindersSection(), _buildSuggestionsSection(), _buildChecklistSection(), _buildNextActivitiesSection(), _buildExpensesCard(), ], ), ), ], ), ), ); } Widget _buildPlaceholderImage() { return Container( color: Colors.grey[200], child: const Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.location_city, size: 48, color: Colors.grey), SizedBox(height: 8), Text( 'Aucune image', style: TextStyle(color: Colors.grey, fontSize: 14), ), ], ), ), ); } Widget _buildActionButton({ required IconData icon, required String title, required Color color, required VoidCallback onTap, }) { final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDarkMode ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), width: 1, ), boxShadow: [ BoxShadow( color: isDarkMode ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.1), blurRadius: isDarkMode ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: color.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Icon(icon, color: color, size: 24), ), const SizedBox(height: 8), Text( title, style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface, ), textAlign: TextAlign.center, ), ], ), ), ); } /// Builds the checklist section to help users prepare the trip. Widget _buildChecklistSection() { if (widget.trip.id == null) { return const SizedBox.shrink(); } final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDarkMode ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), width: 1, ), boxShadow: [ BoxShadow( color: isDarkMode ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.08), blurRadius: isDarkMode ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.checklist_rtl, color: theme.colorScheme.primary), const SizedBox(width: 8), Expanded( child: Text( 'Check-list', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ), TextButton.icon( onPressed: _openAddChecklistDialog, icon: const Icon(Icons.add), label: const Text('Ajouter'), ), ], ), const SizedBox(height: 12), if (_isLoadingChecklist) const Center(child: CircularProgressIndicator()), if (!_isLoadingChecklist && _checklistItems.isEmpty) Text( 'Ajoutez vos tâches (billets, bagages, documents) pour ne rien oublier.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), if (!_isLoadingChecklist && _checklistItems.isNotEmpty) ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _checklistItems.length, itemBuilder: (context, index) { final item = _checklistItems[index]; return _buildChecklistTile(item, theme); }, ), ], ), ); } /// Builds the document vault section so travellers centralize tickets & IDs. Widget _buildDocumentsSection() { if (widget.trip.id == null) { return const SizedBox.shrink(); } final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDarkMode ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), width: 1, ), boxShadow: [ BoxShadow( color: isDarkMode ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.08), blurRadius: isDarkMode ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.folder_shared, color: theme.colorScheme.primary), const SizedBox(width: 8), Expanded( child: Text( 'Coffre de documents', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ), TextButton.icon( onPressed: _openAddDocumentDialog, icon: const Icon(Icons.add), label: const Text('Ajouter'), ), ], ), const SizedBox(height: 12), if (_isLoadingDocuments) const Center(child: CircularProgressIndicator()), if (!_isLoadingDocuments && _documents.isEmpty) Text( 'Stocke billets, passeports, assurances, QR et liens pour les retrouver hors ligne.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), if (!_isLoadingDocuments && _documents.isNotEmpty) ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _documents.length, itemBuilder: (context, index) { final doc = _documents[index]; return _buildDocumentTile(doc, theme); }, ), ], ), ); } /// Builds the transport section (vol/train/bus) with PNR, horaires, statut. Widget _buildTransportSection() { if (widget.trip.id == null) return const SizedBox.shrink(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), width: 1, ), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.08), blurRadius: isDark ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.flight_takeoff, color: theme.colorScheme.primary), const SizedBox(width: 8), Expanded( child: Text( 'Transports', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ), TextButton.icon( onPressed: _openAddSegmentDialog, icon: const Icon(Icons.add), label: const Text('Ajouter'), ), ], ), const SizedBox(height: 12), if (_isLoadingSegments) const Center(child: CircularProgressIndicator()), if (!_isLoadingSegments && _segments.isEmpty) Text( 'Ajoutez vos vols/trains avec PNR, heures et sièges. Le statut pourra être mis à jour plus tard.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), if (!_isLoadingSegments && _segments.isNotEmpty) ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _segments.length, itemBuilder: (context, index) { final seg = _segments[index]; return _buildSegmentTile(seg, theme); }, ), ], ), ); } /// Builds packing list section with suggestions and assignments. Widget _buildPackingSection() { if (widget.trip.id == null) return const SizedBox.shrink(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), ), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.08), blurRadius: isDark ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.luggage, color: theme.colorScheme.primary), const SizedBox(width: 8), Expanded( child: Text( 'Bagages', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ), TextButton.icon( onPressed: _openAddPackingDialog, icon: const Icon(Icons.add), label: const Text('Ajouter'), ), ], ), const SizedBox(height: 12), if (_isLoadingPacking) const Center(child: CircularProgressIndicator()), if (!_isLoadingPacking && _packingItems.isEmpty) Text( 'Crée ou génère une liste selon la durée/météo pour que chacun coche ses items.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), if (!_isLoadingPacking && _packingItems.isNotEmpty) ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _packingItems.length, itemBuilder: (context, index) { final item = _packingItems[index]; return Dismissible( key: ValueKey(item.id), direction: DismissDirection.endToStart, background: Container( padding: const EdgeInsets.symmetric(horizontal: 20), alignment: Alignment.centerRight, decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.delete, color: Colors.red), ), onDismissed: (_) => _deletePacking(item.id), child: CheckboxListTile( value: item.isPacked, onChanged: (_) => _togglePacking(item.id), title: Text( item.label, style: theme.textTheme.bodyMedium?.copyWith( decoration: item.isPacked ? TextDecoration.lineThrough : TextDecoration.none, ), ), subtitle: item.assignee != null ? Text('Par: ${item.assignee}') : null, controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), ); }, ), ], ), ); } /// Builds budget envelopes list with multi-currency display. Widget _buildBudgetSection() { if (widget.trip.id == null) return const SizedBox.shrink(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), ), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.08), blurRadius: isDark ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.account_balance_wallet, color: theme.colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Text( 'Budgets par poste', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ), TextButton.icon( onPressed: _openAddBudgetDialog, icon: const Icon(Icons.add), label: const Text('Ajouter'), ), ], ), const SizedBox(height: 12), if (_isLoadingBudgets) const Center(child: CircularProgressIndicator()), if (!_isLoadingBudgets && _budgets.isEmpty) Text( 'Planifie tes enveloppes (multi-devise) pour suivre le réalisé plus tard.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), if (!_isLoadingBudgets && _budgets.isNotEmpty) ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _budgets.length, itemBuilder: (context, index) { final b = _budgets[index]; final progress = b.planned == 0 ? 0.0 : (b.spent / b.planned).clamp(0, 2); return Dismissible( key: ValueKey(b.id), direction: DismissDirection.endToStart, background: Container( padding: const EdgeInsets.symmetric(horizontal: 20), alignment: Alignment.centerRight, decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.delete, color: Colors.red), ), onDismissed: (_) => _deleteBudget(b.id), child: ListTile( contentPadding: EdgeInsets.zero, title: Text(b.name), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Prévu: ${b.planned.toStringAsFixed(0)} ${b.currency} · Dépensé: ${b.spent.toStringAsFixed(0)}', ), LinearProgressIndicator( value: (progress > 1 ? 1 : progress).toDouble(), backgroundColor: theme.colorScheme.surfaceVariant, ), ], ), ), ); }, ), ], ), ); } /// Builds safety/emergency section with contacts, location toggle, SOS button. Widget _buildSafetySection() { if (widget.trip.id == null) return const SizedBox.shrink(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), ), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.08), blurRadius: isDark ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.health_and_safety, color: theme.colorScheme.primary), const SizedBox(width: 8), Expanded( child: Text( 'Urgence & sécurité', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ), TextButton.icon( onPressed: _openAddEmergencyDialog, icon: const Icon(Icons.add), label: const Text('Contact'), ), ], ), const SizedBox(height: 8), if (_isLoadingEmergency) const Center(child: CircularProgressIndicator()) else ...[ if (_emergencyContacts.isEmpty) Text( 'Ajoute ambassade, assurance, proches pour les joindre vite.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ) else ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _emergencyContacts.length, itemBuilder: (context, index) { final c = _emergencyContacts[index]; return Dismissible( key: ValueKey(c.id), direction: DismissDirection.endToStart, background: Container( padding: const EdgeInsets.symmetric(horizontal: 20), alignment: Alignment.centerRight, decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.delete, color: Colors.red), ), onDismissed: (_) => _deleteEmergency(c.id), child: ListTile( contentPadding: EdgeInsets.zero, title: Text(c.name), subtitle: Text(c.note ?? 'Tel: ${c.phone}'), trailing: IconButton( icon: const Icon(Icons.call), onPressed: () async { final uri = Uri.parse('tel:${c.phone}'); if (await canLaunchUrl(uri)) { await launchUrl(uri); } else { _errorService.showError( message: 'Impossible d\'appeler ce numéro', ); } }, ), ), ); }, ), const Divider(), SwitchListTile( value: _shareLocation, onChanged: _toggleShareLocation, title: const Text('Partage position (local flag)'), subtitle: Text( 'Active pour préparer le partage de localisation en cas de SOS.', style: theme.textTheme.bodySmall, ), ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, ), onPressed: _triggerSos, icon: const Icon(Icons.sos), label: const Text('SOS (notifie le groupe)'), ), ), ], ], ), ); } /// Builds offline toggle row. Widget _buildOfflineToggle() { if (widget.trip.id == null) return const SizedBox.shrink(); final theme = Theme.of(context); return SwitchListTile( value: _offlineEnabled, onChanged: _toggleOffline, title: const Text('Mode hors ligne'), subtitle: Text( 'Télécharger docs + trajets pour les consulter sans réseau (flag local).', style: theme.textTheme.bodySmall, ), ); } /// Single segment tile with basic status and times. Widget _buildSegmentTile(TransportSegment seg, ThemeData theme) { final depLocal = seg.departureUtc.toLocal(); final arrLocal = seg.arrivalUtc.toLocal(); final statusColor = _statusColor(seg.status, theme); return Dismissible( key: ValueKey(seg.id), direction: DismissDirection.endToStart, background: Container( padding: const EdgeInsets.symmetric(horizontal: 20), alignment: Alignment.centerRight, decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.delete, color: Colors.red), ), onDismissed: (_) => _deleteSegment(seg.id), child: ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.12), child: Icon( seg.type == 'flight' ? Icons.flight_takeoff : seg.type == 'train' ? Icons.train : Icons.directions_bus, color: theme.colorScheme.primary, ), ), title: Text( '${seg.carrier}${seg.number} · ${seg.departureCode} → ${seg.arrivalCode}', ), subtitle: Text( '${DateFormat.Hm().format(depLocal)} - ${DateFormat.Hm().format(arrLocal)} · ${DateFormat.yMd().format(depLocal)}', style: theme.textTheme.bodySmall, ), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( seg.status, style: theme.textTheme.bodyMedium?.copyWith(color: statusColor), ), if (seg.pnr != null) Text('PNR ${seg.pnr}', style: theme.textTheme.labelSmall), ], ), ), ); } Color _statusColor(String status, ThemeData theme) { switch (status) { case 'delayed': return Colors.orange; case 'cancelled': return Colors.red; case 'in_air': case 'boarding': return Colors.green; default: return theme.colorScheme.onSurface.withValues(alpha: 0.7); } } /// Builds a single document tile with category, expiry and quick open. Widget _buildDocumentTile(TripDocument doc, ThemeData theme) { final hasExpiry = doc.expiresAt != null; final isExpired = hasExpiry && doc.expiresAt!.isBefore(DateTime.now()); final expiryText = hasExpiry ? 'Expire le ${DateFormat.yMd().format(doc.expiresAt!)}' : 'Aucune échéance'; return Dismissible( key: ValueKey(doc.id), direction: DismissDirection.endToStart, background: Container( padding: const EdgeInsets.symmetric(horizontal: 20), alignment: Alignment.centerRight, decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.delete, color: Colors.red), ), onDismissed: (_) => _deleteDocument(doc.id), child: ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.12), child: Icon(Icons.description, color: theme.colorScheme.primary), ), title: Text(doc.title), subtitle: Text( '${doc.category} · $expiryText', style: theme.textTheme.bodySmall?.copyWith( color: isExpired ? Colors.red : theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ), trailing: doc.downloadUrl != null ? IconButton( icon: const Icon(Icons.open_in_new), onPressed: () async { final uri = Uri.tryParse(doc.downloadUrl!); if (uri != null && await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } else { _errorService.showError( message: 'Lien invalide ou impossible à ouvrir', ); } }, ) : null, ), ); } /// Builds a single row for a checklist item with toggle and delete. Widget _buildChecklistTile(ChecklistItem item, ThemeData theme) { return Dismissible( key: ValueKey(item.id), direction: DismissDirection.endToStart, background: Container( padding: const EdgeInsets.symmetric(horizontal: 20), alignment: Alignment.centerRight, decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.delete, color: Colors.red), ), onDismissed: (_) => _deleteChecklistItem(item.id), child: CheckboxListTile( value: item.isDone, onChanged: (_) => _toggleChecklistItem(item.id), title: Text( item.label, style: theme.textTheme.bodyMedium?.copyWith( decoration: item.isDone ? TextDecoration.lineThrough : TextDecoration.none, color: item.isDone ? theme.colorScheme.onSurface.withValues(alpha: 0.6) : theme.colorScheme.onSurface, ), ), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), ); } void _showOptionsMenu() { final theme = Theme.of(context); showModalBottomSheet( context: context, backgroundColor: theme.bottomSheetTheme.backgroundColor, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (context) { return BlocBuilder( builder: (context, state) { final currentUser = state is user_state.UserLoaded ? state.user : null; final isCreator = currentUser?.id == widget.trip.createdBy; return Container( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (isCreator) ...[ ListTile( leading: Icon( Icons.edit, color: theme.colorScheme.primary, ), title: Text( 'Modifier le voyage', style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.onSurface, ), ), onTap: () { Navigator.pop(context); Navigator.push( context, MaterialPageRoute( builder: (context) => CreateTripContent(tripToEdit: widget.trip), ), ); }, ), ListTile( leading: const Icon(Icons.delete, color: Colors.red), title: Text( 'Supprimer le voyage', style: theme.textTheme.bodyLarge?.copyWith( color: Colors.red, ), ), onTap: () { Navigator.pop(context); _confirmDeleteTrip(); }, ), const Divider(), ], if (!isCreator) ListTile( leading: Icon(Icons.exit_to_app, color: Colors.red[400]), title: Text( 'Quitter le voyage', style: theme.textTheme.bodyLarge?.copyWith( color: Colors.red[400], ), ), onTap: () { Navigator.pop(context); _handleLeaveTrip(currentUser); }, ), ], ), ); }, ); }, ); } void _handleLeaveTrip(user_state.UserModel? currentUser) { if (currentUser == null || _group == null) return; // Vérifier les dettes final balanceState = context.read().state; if (balanceState is GroupBalancesLoaded) { final myBalance = balanceState.balances.firstWhere( (b) => b.userId == currentUser.id, orElse: () => const UserBalance( userId: '', userName: '', totalPaid: 0, totalOwed: 0, balance: 0, ), ); // Tolérance pour les arrondis (0.01€) if (myBalance.balance.abs() > 0.01) { _errorService.showError( message: 'Vous devez régler vos dettes (ou récupérer votre argent) avant de quitter le voyage. Solde: ${myBalance.formattedBalance}', ); return; } _confirmLeaveTrip(currentUser.id); } else { // Si les balances ne sont pas chargées, on essaie de les charger et on demande de rééssayer context.read().add(LoadGroupBalances(_group!.id)); _errorService.showError( message: 'Impossible de vérifier votre solde. Veuillez réessayer dans un instant.', ); } } void _confirmLeaveTrip(String userId) { final theme = Theme.of(context); showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Quitter le voyage', style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onSurface, ), ), content: Text( 'Êtes-vous sûr de vouloir quitter ce voyage ? Vous ne pourrez plus voir les détails ni les dépenses.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text( 'Annuler', style: TextStyle(color: theme.colorScheme.primary), ), ), TextButton( onPressed: () { Navigator.pop(context); // Fermer le dialog if (_group != null) { context.read().add( RemoveMemberFromGroup(_group!.id, userId), ); // Retourner à l'écran d'accueil Navigator.pop(context); } }, child: const Text('Quitter', style: TextStyle(color: Colors.red)), ), ], ), ); } void _confirmDeleteTrip() { final theme = Theme.of(context); showDialog( context: context, builder: (context) => AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Confirmer la suppression', style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onSurface, ), ), content: Text( 'Êtes-vous sûr de vouloir supprimer ce voyage ? Cette action est irréversible.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, ), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text( 'Annuler', style: TextStyle(color: theme.colorScheme.primary), ), ), TextButton( onPressed: () { context.read().add( TripDeleteRequested(tripId: widget.trip.id!), ); Navigator.pop(context); Navigator.pop(context, true); }, child: const Text('Supprimer', style: TextStyle(color: Colors.red)), ), ], ), ); } /// Construire la section des participants avec leurs images de profil Widget _buildParticipantsSection() { // Vérifier que le trip a un ID if (widget.trip.id == null || widget.trip.id!.isEmpty) { return const Center(child: Text('Aucun participant')); } return FutureBuilder( future: _groupRepository.getGroupByTripId(widget.trip.id!), builder: (context, groupSnapshot) { if (groupSnapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } if (groupSnapshot.hasError || !groupSnapshot.hasData || groupSnapshot.data == null) { return const Center(child: Text('Aucun participant')); } final groupId = groupSnapshot.data!.id; return StreamBuilder>( stream: _groupRepository.watchGroupMembers(groupId), builder: (context, snapshot) { // En attente if (snapshot.connectionState == ConnectionState.waiting) { return const Center(child: CircularProgressIndicator()); } // Erreur if (snapshot.hasError) { return Center( child: Text( 'Erreur: ${snapshot.error}', style: TextStyle(color: Colors.red), ), ); } final members = snapshot.data ?? []; if (members.isEmpty) { return const Center(child: Text('Aucun participant')); } return SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( children: [ ...List.generate(members.length, (index) { final member = members[index]; return Padding( padding: const EdgeInsets.only(right: 12), child: _buildParticipantAvatar(member), ); }), // Bouton "+" pour ajouter un participant Padding( padding: const EdgeInsets.only(right: 12), child: _buildAddParticipantButton(), ), ], ), ); }, ); }, ); } /// Construire un avatar pour un participant Widget _buildParticipantAvatar(dynamic member) { final theme = Theme.of(context); final initials = member.pseudo.isNotEmpty ? member.pseudo[0].toUpperCase() : (member.firstName.isNotEmpty ? member.firstName[0].toUpperCase() : '?'); final name = member.pseudo.isNotEmpty ? member.pseudo : member.firstName; return Tooltip( message: name, child: Container( decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: theme.colorScheme.primary.withValues(alpha: 0.3), width: 2, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: CircleAvatar( radius: 28, backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.2), backgroundImage: (member.profilePictureUrl != null && member.profilePictureUrl!.isNotEmpty) ? NetworkImage(member.profilePictureUrl!) : null, child: (member.profilePictureUrl == null || member.profilePictureUrl!.isEmpty) ? Text( initials, style: TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: theme.colorScheme.primary, ), ) : null, ), ), ); } /// Construire le bouton pour ajouter un participant Widget _buildAddParticipantButton() { final theme = Theme.of(context); return Tooltip( message: 'Ajouter un participant', child: Container( decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: theme.colorScheme.primary.withValues(alpha: 0.3), width: 2, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 4, offset: const Offset(0, 2), ), ], ), child: GestureDetector( onTap: _showAddParticipantDialog, child: CircleAvatar( radius: 28, backgroundColor: theme.colorScheme.primary.withValues(alpha: 0.1), child: Icon(Icons.add, color: theme.colorScheme.primary, size: 28), ), ), ), ); } /// Afficher le dialogue pour ajouter un participant void _showAddParticipantDialog() { final theme = Theme.of(context); final TextEditingController emailController = TextEditingController(); List suggestions = []; User? selectedUser; bool isSearching = false; showDialog( context: context, builder: (BuildContext context) { return StatefulBuilder( builder: (BuildContext context, StateSetter setDialogState) { /// Recherche des utilisateurs inscrits pour les suggestions. /// /// Les participants déjà dans le voyage et l'utilisateur courant /// sont exclus pour éviter les invitations invalides. Future searchSuggestions(String query) async { final normalizedQuery = query.trim(); if (normalizedQuery.length < 2) { setDialogState(() { suggestions = []; selectedUser = null; isSearching = false; }); return; } setDialogState(() { isSearching = true; }); final users = await _userRepository.searchUsers(normalizedQuery); final participantIds = { ...widget.trip.participants, widget.trip.createdBy, }; final filteredUsers = users .where((user) { if (user.id == null) { return false; } return !participantIds.contains(user.id); }) .toList(growable: false); if (!mounted) { return; } setDialogState(() { suggestions = filteredUsers; isSearching = false; }); } return AlertDialog( backgroundColor: theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, title: Text( 'Ajouter un participant', style: theme.textTheme.titleLarge?.copyWith( color: theme.colorScheme.onSurface, ), ), content: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'Recherchez un utilisateur déjà inscrit (email, prénom ou nom).', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface, ), ), const SizedBox(height: 16), TextField( controller: emailController, keyboardType: TextInputType.emailAddress, onChanged: searchSuggestions, decoration: InputDecoration( hintText: 'participant@example.com', hintStyle: TextStyle( color: theme.colorScheme.onSurface.withValues( alpha: 0.5, ), ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(8), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, ), ), style: TextStyle(color: theme.colorScheme.onSurface), ), if (isSearching) ...[ const SizedBox(height: 12), const SizedBox( width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2), ), ], if (!isSearching && suggestions.isNotEmpty) ...[ const SizedBox(height: 12), ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: ListView.builder( shrinkWrap: true, itemCount: suggestions.length, itemBuilder: (context, index) { final user = suggestions[index]; return ListTile( dense: true, contentPadding: EdgeInsets.zero, title: Text('${user.prenom} ${user.nom}'), subtitle: Text(user.email), onTap: () { setDialogState(() { selectedUser = user; emailController.text = user.email; suggestions = []; }); }, ); }, ), ), ], ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text( 'Annuler', style: TextStyle(color: theme.colorScheme.primary), ), ), TextButton( onPressed: () { if (emailController.text.trim().isEmpty) { _errorService.showError( message: 'Veuillez entrer un email valide', ); return; } _inviteParticipantByEmail( email: emailController.text.trim(), selectedUser: selectedUser, ); Navigator.pop(context); }, child: Text( 'Inviter', style: TextStyle(color: theme.colorScheme.primary), ), ), ], ); }, ); }, ); } /// Envoie une invitation de participation à partir d'un email. /// /// Si [selectedUser] est fourni, il est utilisé directement. Sinon, la méthode /// recherche un compte via l'email. L'invitation est refusée si l'utilisateur /// est déjà membre du voyage, s'invite lui-même, ou si une invitation est déjà /// en attente. Future _inviteParticipantByEmail({ required String email, User? selectedUser, }) async { try { final currentUserState = context.read().state; if (currentUserState is! user_state.UserLoaded) { _errorService.showError(message: 'Utilisateur courant introuvable'); return; } final user = selectedUser ?? await _userRepository.getUserByEmail(email); if (user == null) { _errorService.showError( message: 'Aucun compte inscrit trouvé avec cet email', ); return; } if (user.id == null) { _errorService.showError(message: 'ID utilisateur invalide'); return; } if (user.id == currentUserState.user.id) { _errorService.showError(message: 'Vous êtes déjà dans ce voyage'); return; } final participantIds = { ...widget.trip.participants, widget.trip.createdBy, }; if (participantIds.contains(user.id)) { _errorService.showError( message: '${user.prenom} participe déjà à ce voyage', ); return; } final tripId = widget.trip.id; if (tripId == null) { _errorService.showError(message: 'Voyage introuvable'); return; } final existingInvite = await _tripInvitationRepository .getPendingInvitation(tripId: tripId, inviteeId: user.id!); if (existingInvite != null) { _errorService.showError( message: 'Une invitation est déjà en attente pour cet utilisateur', ); return; } await _tripInvitationRepository.createInvitation( tripId: tripId, tripTitle: widget.trip.title, inviterId: currentUserState.user.id, inviterName: currentUserState.user.prenom, inviteeId: user.id!, inviteeEmail: user.email, ); if (!mounted) { return; } _errorService.showSnackbar( message: 'Invitation envoyée à ${user.prenom}', isError: false, ); } catch (e) { _errorService.showError(message: 'Erreur lors de l\'invitation: $e'); } } void _navigateToActivities() { Navigator.push( context, MaterialPageRoute( builder: (context) => ActivitiesPage(trip: widget.trip), ), ); } Widget _buildNextActivitiesSection() { final theme = Theme.of(context); return BlocBuilder( builder: (context, state) { List activities = []; if (state is ActivityLoaded) { activities = state.activities; } // Filter scheduled activities and sort by date final scheduledActivities = activities .where((a) => a.date != null && a.date!.isAfter(DateTime.now())) .toList(); scheduledActivities.sort((a, b) => a.date!.compareTo(b.date!)); // Take next 3 activities final nextActivities = scheduledActivities.take(3).toList(); if (nextActivities.isEmpty) { return const SizedBox.shrink(); } return Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text( 'Prochaines activités', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), TextButton( onPressed: () => Navigator.push( context, MaterialPageRoute( builder: (context) => CalendarPage(trip: widget.trip), ), ), child: Text( 'Voir calendrier', style: TextStyle( color: Colors.teal, fontWeight: FontWeight.w600, ), ), ), ], ), const SizedBox(height: 8), ...nextActivities.map((activity) { if (activity.date == null) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(bottom: 12), child: _buildActivityCard(activity: activity), ); }), ], ); }, ); } IconData _getCategoryIcon(String category) { if (category.toLowerCase().contains('musée')) return Icons.museum; if (category.toLowerCase().contains('restaurant')) return Icons.restaurant; if (category.toLowerCase().contains('nature')) return Icons.nature; if (category.toLowerCase().contains('photo')) return Icons.camera_alt; if (category.toLowerCase().contains('détente')) return Icons.icecream; return Icons.place; } Widget _buildActivityCard({required Activity activity}) { final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; final date = activity.date != null ? DateFormat('d MMM, HH:mm', 'fr_FR').format(activity.date!) : 'Date inconnue'; final icon = _getCategoryIcon(activity.category); return GestureDetector( onTap: () { showDialog( context: context, builder: (context) => ActivityDetailDialog(activity: activity), ); }, child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(16), border: Border.all( color: isDarkMode ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.05), width: 1, ), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.05), blurRadius: 10, offset: const Offset(0, 2), ), ], ), child: Row( children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.teal.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Icon(icon, color: Colors.teal, size: 24), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( activity.name, style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), const SizedBox(height: 4), Text( date, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ), ), ], ), ), Icon( Icons.chevron_right, color: theme.colorScheme.onSurface.withValues(alpha: 0.4), ), ], ), ), ); } /// Builds shared album section (URL-based for now). /// Builds the shared album section with link and upload actions. Widget _buildAlbumSection() { if (widget.trip.id == null) return const SizedBox.shrink(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), ), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.08), blurRadius: isDark ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.photo_library, color: theme.colorScheme.primary), const SizedBox(width: 8), Expanded( child: Text( 'Album partagé', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ), Row( children: [ TextButton.icon( onPressed: _guestEnabled ? null : _openAddPhotoDialog, icon: const Icon(Icons.link), label: const Text('Lien'), ), TextButton.icon( onPressed: _guestEnabled ? null : _pickAndUploadPhoto, icon: const Icon(Icons.file_upload), label: const Text('Upload'), ), ], ), ], ), const SizedBox(height: 12), if (_isLoadingAlbum) const Center(child: CircularProgressIndicator()) else if (_albumPhotos.isEmpty) Text( 'Ajoute des liens d’images (Drive/Cloud) pour les partager rapidement.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ) else ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _albumPhotos.length, itemBuilder: (context, index) { final p = _albumPhotos[index]; return Dismissible( key: ValueKey(p.id), direction: DismissDirection.endToStart, background: Container( padding: const EdgeInsets.symmetric(horizontal: 20), alignment: Alignment.centerRight, decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.delete, color: Colors.red), ), onDismissed: (_) => _deletePhoto(p.id), child: ListTile( contentPadding: EdgeInsets.zero, leading: CircleAvatar( backgroundColor: theme.colorScheme.primary.withValues( alpha: 0.12, ), child: const Icon(Icons.photo), ), title: Text(p.caption ?? 'Photo'), subtitle: Text(p.uploadedBy ?? 'Lien externe'), trailing: IconButton( icon: const Icon(Icons.open_in_new), onPressed: () async { final uri = Uri.tryParse(p.url); if (uri != null && await canLaunchUrl(uri)) { await launchUrl( uri, mode: LaunchMode.externalApplication, ); } else { _errorService.showError(message: 'Lien invalide'); } }, ), ), ); }, ), ], ), ); } /// Guest mode toggle (read-only placeholder). Widget _buildGuestToggle() { if (widget.trip.id == null) return const SizedBox.shrink(); return SwitchListTile( value: _guestEnabled, onChanged: _toggleGuest, title: const Text('Mode invité (lecture seule locale)'), subtitle: const Text( 'Prépare le mode invité; désactive les actions locales.', ), ); } /// Builds reminders/to-dos section. Widget _buildRemindersSection() { if (widget.trip.id == null) return const SizedBox.shrink(); final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.1) : Colors.black.withValues(alpha: 0.1), ), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.08), blurRadius: isDark ? 8 : 5, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon( Icons.notifications_active, color: theme.colorScheme.primary, ), const SizedBox(width: 8), Expanded( child: Text( 'Rappels & to-dos', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ), TextButton.icon( onPressed: _guestEnabled ? null : _openAddReminderDialog, icon: const Icon(Icons.add), label: const Text('Ajouter'), ), ], ), const SizedBox(height: 12), if (_isLoadingReminders) const Center(child: CircularProgressIndicator()) else if (_reminders.isEmpty) Text( 'Ajoute check-in, pass métro, retrait cash… avec une date.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.7), ), ) else ListView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), itemCount: _reminders.length, itemBuilder: (context, index) { final r = _reminders[index]; return Dismissible( key: ValueKey(r.id), direction: DismissDirection.endToStart, background: Container( padding: const EdgeInsets.symmetric(horizontal: 20), alignment: Alignment.centerRight, decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: const Icon(Icons.delete, color: Colors.red), ), onDismissed: (_) => _deleteReminder(r.id), child: CheckboxListTile( value: r.isDone, onChanged: (_) => _toggleReminder(r.id), title: Text(r.title), subtitle: Text( '${DateFormat.yMd().add_Hm().format(r.dueAt.toLocal())}${r.note != null ? ' · ${r.note}' : ''}', ), controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, ), ); }, ), ], ), ); } /// Displays simple activity suggestions (local heuristic). Widget _buildSuggestionsSection() { final theme = Theme.of(context); return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.onSurface.withValues(alpha: 0.08), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.lightbulb, color: theme.colorScheme.primary), const SizedBox(width: 8), Text( 'Suggestions d’activités', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ], ), const SizedBox(height: 8), ..._suggestedActivities.map( (s) => ListTile( dense: true, contentPadding: EdgeInsets.zero, leading: const Icon(Icons.arrow_right), title: Text(s), ), ), ], ), ); } /// Provides a concise recap card aggregating key counts. Widget _buildRecapSection() { final theme = Theme.of(context); final today = DateTime.now(); final todayCount = _reminders .where( (r) => r.dueAt.toLocal().year == today.year && r.dueAt.toLocal().month == today.month && r.dueAt.toLocal().day == today.day && !r.isDone, ) .length; return Container( margin: const EdgeInsets.only(bottom: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: theme.cardColor, borderRadius: BorderRadius.circular(12), border: Border.all( color: theme.colorScheme.onSurface.withValues(alpha: 0.08), ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(Icons.today, color: theme.colorScheme.primary), const SizedBox(width: 8), Text( 'Récap du voyage', style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: theme.colorScheme.onSurface, ), ), ], ), const SizedBox(height: 12), Wrap( spacing: 12, runSpacing: 8, children: [ _recapChip( theme, label: 'Docs', value: _documents.length, icon: Icons.description, ), _recapChip( theme, label: 'Transports', value: _segments.length, icon: Icons.flight_takeoff, ), _recapChip( theme, label: 'Photos', value: _albumPhotos.length, icon: Icons.photo_library, ), _recapChip( theme, label: 'Rappels ajd', value: todayCount, icon: Icons.alarm, ), _recapChip( theme, label: 'Bagages', value: _packingItems.length, icon: Icons.luggage, ), ], ), const SizedBox(height: 12), SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: () async { final title = 'Récap ${widget.trip.title}'; final body = '${_documents.length} docs • ${_segments.length} transports • ${_albumPhotos.length} photos • ${todayCount} rappels ajd'; await NotificationService().showLocalRecap( title: title, body: body, ); }, icon: const Icon(Icons.notifications_active), label: const Text('Envoyer un récap local'), ), ), ], ), ); } /// Builds a small chip used in the recap card. Widget _recapChip( ThemeData theme, { required String label, required int value, required IconData icon, }) { return Chip( avatar: Icon(icon, size: 16, color: theme.colorScheme.primary), label: Text('$label: $value'), backgroundColor: theme.colorScheme.surfaceVariant, ); } Widget _buildExpensesCard() { final theme = Theme.of(context); return BlocBuilder( builder: (context, state) { String balanceText = 'Chargement...'; bool isLoading = state is BalanceLoading; bool isPositive = true; if (state is GroupBalancesLoaded) { final userState = context.read().state; if (userState is user_state.UserLoaded) { final currentUserId = userState.user.id; // Filter settlements involving the current user final mySettlements = state.settlements .where( (s) => !s.isCompleted && (s.fromUserId == currentUserId || s.toUserId == currentUserId), ) .toList(); if (mySettlements.isEmpty) { // Check if user has a balance of 0 final myBalanceObj = state.balances.firstWhere( (b) => b.userId == currentUserId, orElse: () => const UserBalance( userId: '', userName: '', totalPaid: 0, totalOwed: 0, balance: 0, ), ); if (myBalanceObj.balance.abs() < 0.01) { balanceText = 'Vous êtes à jour'; } else { // Fallback to total balance if no settlements found but balance exists isPositive = myBalanceObj.balance >= 0; final amountStr = '${myBalanceObj.balance.abs().toStringAsFixed(2)} €'; balanceText = isPositive ? 'On vous doit $amountStr' : 'Vous devez $amountStr'; } } else { // Construct detailed string final debtsToPay = mySettlements .where((s) => s.fromUserId == currentUserId) .toList(); final debtsToReceive = mySettlements .where((s) => s.toUserId == currentUserId) .toList(); if (debtsToPay.isNotEmpty) { isPositive = false; final details = debtsToPay .map( (s) => '${s.amount.toStringAsFixed(2)}€ à ${s.toUserName}', ) .join(' et '); balanceText = 'Vous devez $details'; } else if (debtsToReceive.isNotEmpty) { isPositive = true; final details = debtsToReceive .map( (s) => '${s.amount.toStringAsFixed(2)}€ de ${s.fromUserName}', ) .join(' et '); balanceText = 'On vous doit $details'; // Or "X owes you..." but "On vous doit" is generic enough or we can be specific // Let's be specific as requested: "X doit vous payer..." or similar? // The user asked: "vous devez 21 euros à John..." (active voice for user paying). // For receiving, "John vous doit 21 euros..." would be symmetric. // Let's try to match the requested format for paying first. if (debtsToReceive.length == 1) { balanceText = '${debtsToReceive.first.fromUserName} vous doit ${debtsToReceive.first.amount.toStringAsFixed(2)}€'; } else { balanceText = '${debtsToReceive.map((s) => '${s.fromUserName} (${s.amount.toStringAsFixed(2)}€)').join(' et ')} vous doivent de l\'argent'; } } } } } return GestureDetector( onTap: () { if (_group != null && _account != null) { Navigator.push( context, MaterialPageRoute( builder: (context) => GroupExpensesPage(group: _group!, account: _account!), ), ); } }, child: Container( margin: const EdgeInsets.only(top: 24), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: const Color(0xFFFDF4E3), // Light beige background borderRadius: BorderRadius.circular(16), ), child: Row( children: [ Container( padding: const EdgeInsets.all(10), decoration: const BoxDecoration( color: Colors.orange, shape: BoxShape.circle, ), child: const Icon( Icons.warning_amber_rounded, color: Colors.white, size: 24, ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Dépenses', style: theme.textTheme.bodyLarge?.copyWith( fontWeight: FontWeight.bold, color: const Color(0xFF5D4037), // Brown text ), ), const SizedBox(height: 4), if (isLoading) const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) else Text( balanceText, style: theme.textTheme.bodyMedium?.copyWith( color: const Color(0xFF8D6E63), // Lighter brown ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ), ), TextButton( onPressed: () { if (_group != null && _account != null) { Navigator.push( context, MaterialPageRoute( builder: (context) => GroupExpensesPage( group: _group!, account: _account!, ), ), ); } }, child: Text( 'Régler', style: TextStyle( color: const Color(0xFF5D4037), fontWeight: FontWeight.bold, ), ), ), ], ), ), ); }, ); } }