- Implement EmergencyService for handling emergency contacts per trip. - Create GuestFlagService to manage guest mode flags for trips. - Introduce NotificationService with local notification capabilities. - Add OfflineFlagService for managing offline caching flags. - Develop PackingService for shared packing lists per trip. - Implement ReminderService for managing reminders/to-dos per trip. - Create SosService for dispatching SOS events to a backend. - Add StorageService with album image upload functionality. - Implement TransportService for managing transport segments per trip. - Create TripChecklistService for storing and retrieving trip checklists. - Add TripDocumentService for persisting trip documents metadata. test: Add unit tests for new services - Implement tests for AlbumService, BudgetService, EmergencyService, GuestFlagService, PackingService, ReminderService, SosService, TransportService, TripChecklistService, and TripDocumentService. - Ensure tests cover adding, loading, deleting, and handling corrupted payloads for each service.
4072 lines
139 KiB
Dart
4072 lines
139 KiB
Dart
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<ShowTripDetailsContent> createState() => _ShowTripDetailsContentState();
|
||
}
|
||
|
||
class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
|
||
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<ChecklistItem> _checklistItems = [];
|
||
bool _isLoadingChecklist = false;
|
||
List<TripDocument> _documents = [];
|
||
bool _isLoadingDocuments = false;
|
||
List<TransportSegment> _segments = [];
|
||
bool _isLoadingSegments = false;
|
||
List<PackingItem> _packingItems = [];
|
||
bool _isLoadingPacking = false;
|
||
List<BudgetCategory> _budgets = [];
|
||
bool _isLoadingBudgets = false;
|
||
bool _offlineEnabled = false;
|
||
List<EmergencyContact> _emergencyContacts = [];
|
||
bool _isLoadingEmergency = false;
|
||
bool _shareLocation = false;
|
||
List<AlbumPhoto> _albumPhotos = [];
|
||
bool _isLoadingAlbum = false;
|
||
bool _guestEnabled = false;
|
||
List<ReminderItem> _reminders = [];
|
||
bool _isLoadingReminders = false;
|
||
List<String> _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<ActivityBloc>().add(LoadActivities(widget.trip.id!));
|
||
_loadGroupAndAccount();
|
||
_loadChecklist();
|
||
_loadDocuments();
|
||
_loadTransportSegments();
|
||
_loadPacking();
|
||
_loadBudgets();
|
||
_loadOfflineFlag();
|
||
_loadEmergency();
|
||
_loadAlbum();
|
||
_loadGuestFlag();
|
||
_loadReminders();
|
||
_buildSuggestions();
|
||
}
|
||
}
|
||
|
||
Future<void> _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<BalanceBloc>().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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _openAddChecklistDialog() async {
|
||
if (widget.trip.id == null) {
|
||
return;
|
||
}
|
||
|
||
final controller = TextEditingController();
|
||
final theme = Theme.of(context);
|
||
|
||
final label = await showDialog<String>(
|
||
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<void> _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<void> _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<void> _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<bool>(
|
||
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<String>(
|
||
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<void> _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<void> _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<bool>(
|
||
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<String>(
|
||
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<void> _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<void> _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<bool>(
|
||
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<String> 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<void> _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<void> _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<void> _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<bool>(
|
||
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<String>(
|
||
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<void> _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<void> _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<void> _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<bool>(
|
||
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<void> _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<void> _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<void> _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<void> _openAddPhotoDialog() async {
|
||
if (widget.trip.id == null) return;
|
||
final theme = Theme.of(context);
|
||
final urlCtrl = TextEditingController();
|
||
final captionCtrl = TextEditingController();
|
||
|
||
final confirmed = await showDialog<bool>(
|
||
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<void> _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<void> _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<void> _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<void> _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<bool>(
|
||
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<void> _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<void> _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<UserBloc, user_state.UserState>(
|
||
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<BalanceBloc>().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<BalanceBloc>().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<GroupBloc>().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<TripBloc>().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<List<GroupMember>>(
|
||
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<User> 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<void> 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<void> _inviteParticipantByEmail({
|
||
required String email,
|
||
User? selectedUser,
|
||
}) async {
|
||
try {
|
||
final currentUserState = context.read<UserBloc>().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<ActivityBloc, ActivityState>(
|
||
builder: (context, state) {
|
||
List<Activity> 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<BalanceBloc, BalanceState>(
|
||
builder: (context, state) {
|
||
String balanceText = 'Chargement...';
|
||
bool isLoading = state is BalanceLoading;
|
||
bool isPositive = true;
|
||
|
||
if (state is GroupBalancesLoaded) {
|
||
final userState = context.read<UserBloc>().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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|