diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index 90c0e21..10b54f0 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -36,6 +36,32 @@ 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; @@ -52,6 +78,46 @@ class _ShowTripDetailsContentState extends State { final AccountRepository _accountRepository = AccountRepository(); final TripInvitationRepository _tripInvitationRepository = TripInvitationRepository(); + final TripChecklistService _checklistService = TripChecklistService(); + final TripDocumentService _documentService = TripDocumentService(); + final TransportService _transportService = TransportService(); + final PackingService _packingService = PackingService(); + final BudgetService _budgetService = BudgetService(); + final OfflineFlagService _offlineFlagService = OfflineFlagService(); + final EmergencyService _emergencyService = EmergencyService(); + final AlbumService _albumService = AlbumService(); + final GuestFlagService _guestFlagService = GuestFlagService(); + final ReminderService _reminderService = ReminderService(); + final ActivitySuggestionService _activitySuggestionService = + ActivitySuggestionService(); + final StorageService _storageService = StorageService(); + final AiActivityService _aiActivityService = AiActivityService( + baseUrl: 'https://api.example.com', // TODO: set real backend + ); + final SosService _sosService = SosService( + baseUrl: 'https://api.example.com', // TODO: set real backend + ); + + List _checklistItems = []; + bool _isLoadingChecklist = false; + List _documents = []; + bool _isLoadingDocuments = false; + List _segments = []; + bool _isLoadingSegments = false; + List _packingItems = []; + bool _isLoadingPacking = false; + List _budgets = []; + bool _isLoadingBudgets = false; + bool _offlineEnabled = false; + List _emergencyContacts = []; + bool _isLoadingEmergency = false; + bool _shareLocation = false; + List _albumPhotos = []; + bool _isLoadingAlbum = false; + bool _guestEnabled = false; + List _reminders = []; + bool _isLoadingReminders = false; + List _suggestedActivities = []; Group? _group; Account? _account; @@ -63,6 +129,17 @@ class _ShowTripDetailsContentState extends State { if (widget.trip.id != null) { context.read().add(LoadActivities(widget.trip.id!)); _loadGroupAndAccount(); + _loadChecklist(); + _loadDocuments(); + _loadTransportSegments(); + _loadPacking(); + _loadBudgets(); + _loadOfflineFlag(); + _loadEmergency(); + _loadAlbum(); + _loadGuestFlag(); + _loadReminders(); + _buildSuggestions(); } } @@ -93,6 +170,172 @@ class _ShowTripDetailsContentState extends State { } } + /// Loads checklist items from local storage for the current trip. + /// + /// The method keeps a lightweight spinner state and tolerates missing + /// storage entries to avoid blocking the screen if preferences are empty. + Future _loadChecklist() async { + if (widget.trip.id == null) { + return; + } + + setState(() { + _isLoadingChecklist = true; + }); + + final items = await _checklistService.loadChecklist(widget.trip.id!); + + if (!mounted) { + return; + } + + setState(() { + _checklistItems = items; + _isLoadingChecklist = false; + }); + } + + /// Loads stored documents for the current trip from local preferences. + Future _loadDocuments() async { + if (widget.trip.id == null) { + return; + } + + setState(() { + _isLoadingDocuments = true; + }); + + final docs = await _documentService.loadDocuments(widget.trip.id!); + + if (!mounted) return; + + setState(() { + _documents = docs; + _isLoadingDocuments = false; + }); + } + + /// Loads transport segments stored for this trip. + Future _loadTransportSegments() async { + if (widget.trip.id == null) return; + setState(() { + _isLoadingSegments = true; + }); + + final loaded = await _transportService.loadSegments(widget.trip.id!); + + if (!mounted) return; + setState(() { + _segments = loaded; + _isLoadingSegments = false; + }); + } + + /// Loads packing list for this trip. + Future _loadPacking() async { + if (widget.trip.id == null) return; + setState(() => _isLoadingPacking = true); + final items = await _packingService.loadItems(widget.trip.id!); + if (!mounted) return; + setState(() { + _packingItems = items; + _isLoadingPacking = false; + }); + } + + /// Loads budget envelopes for this trip. + Future _loadBudgets() async { + if (widget.trip.id == null) return; + setState(() => _isLoadingBudgets = true); + final items = await _budgetService.loadBudgets(widget.trip.id!); + if (!mounted) return; + setState(() { + _budgets = items; + _isLoadingBudgets = false; + }); + } + + /// Loads offline toggle for this trip. + Future _loadOfflineFlag() async { + if (widget.trip.id == null) return; + final enabled = await _offlineFlagService.isOfflineEnabled(widget.trip.id!); + if (!mounted) return; + setState(() { + _offlineEnabled = enabled; + }); + } + + /// Loads emergency contacts and location sharing toggle. + Future _loadEmergency() async { + if (widget.trip.id == null) return; + setState(() => _isLoadingEmergency = true); + final contacts = await _emergencyService.loadContacts(widget.trip.id!); + final shareLoc = await _offlineFlagService.isOfflineEnabled( + widget.trip.id!, + ); // reuse flag if needed + if (!mounted) return; + setState(() { + _emergencyContacts = contacts; + _shareLocation = shareLoc; + _isLoadingEmergency = false; + }); + } + + /// Loads album photos. + Future _loadAlbum() async { + if (widget.trip.id == null) return; + setState(() => _isLoadingAlbum = true); + final photos = await _albumService.loadPhotos(widget.trip.id!); + if (!mounted) return; + setState(() { + _albumPhotos = photos; + _isLoadingAlbum = false; + }); + } + + /// Loads guest mode flag. + Future _loadGuestFlag() async { + if (widget.trip.id == null) return; + final enabled = await _guestFlagService.isGuestEnabled(widget.trip.id!); + if (!mounted) return; + setState(() => _guestEnabled = enabled); + } + + /// Loads reminders list. + Future _loadReminders() async { + if (widget.trip.id == null) return; + setState(() => _isLoadingReminders = true); + final items = await _reminderService.loadReminders(widget.trip.id!); + if (!mounted) return; + setState(() { + _reminders = items; + _isLoadingReminders = false; + }); + } + + /// Builds cached suggestions for activities (light heuristic). + void _buildSuggestions() { + final city = widget.trip.location; + _suggestedActivities = _activitySuggestionService.suggestions( + city: city, + weatherCode: 'default', + ); + _fetchAiSuggestions(city); + } + + /// Fetches AI suggestions asynchronously and merges with local ones. + Future _fetchAiSuggestions(String city) async { + final aiList = await _aiActivityService.fetchSuggestions( + city: city, + interests: const ['food', 'culture'], + budget: 'mid', + ); + if (!mounted || aiList.isEmpty) return; + setState(() { + _suggestedActivities = {..._suggestedActivities, ...aiList}.toList(); + }); + } + // Calculer les jours restants avant le voyage int get daysUntilTrip { final now = DateTime.now(); @@ -283,6 +526,1117 @@ class _ShowTripDetailsContentState extends State { } } + /// Opens a dialog to create a new checklist entry and persists it. + Future _openAddChecklistDialog() async { + if (widget.trip.id == null) { + return; + } + + final controller = TextEditingController(); + final theme = Theme.of(context); + + final label = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: + theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, + title: Text( + 'Nouvelle tâche', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Que devez-vous préparer ?', + hintText: 'Ex: Scanner les passeports', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context, controller.text.trim()); + }, + child: const Text('Ajouter'), + ), + ], + ); + }, + ); + + if (label == null || label.isEmpty || !mounted) { + return; + } + + final newItem = ChecklistItem.newItem( + id: DateTime.now().microsecondsSinceEpoch.toString(), + label: label, + ); + + final updated = await _checklistService.addItem(widget.trip.id!, newItem); + + if (!mounted) { + return; + } + + setState(() { + _checklistItems = updated; + }); + } + + /// Toggles the completion status for a checklist item. + Future _toggleChecklistItem(String itemId) async { + if (widget.trip.id == null) { + return; + } + + final updated = await _checklistService.toggleItem(widget.trip.id!, itemId); + + if (!mounted) { + return; + } + + setState(() { + _checklistItems = updated; + }); + } + + /// Removes a checklist item and refreshes the local state. + Future _deleteChecklistItem(String itemId) async { + if (widget.trip.id == null) { + return; + } + + final updated = await _checklistService.deleteItem(widget.trip.id!, itemId); + + if (!mounted) { + return; + } + + setState(() { + _checklistItems = updated; + }); + } + + /// Opens a dialog to create a new trip document entry. + Future _openAddDocumentDialog() async { + if (widget.trip.id == null) return; + + final titleController = TextEditingController(); + final urlController = TextEditingController(); + String selectedCategory = 'billet'; + DateTime? expiry; + + final theme = Theme.of(context); + + final result = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + backgroundColor: + theme.dialogTheme.backgroundColor ?? + theme.colorScheme.surface, + title: Text( + 'Ajouter un document', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: titleController, + decoration: const InputDecoration( + labelText: 'Titre', + hintText: 'Billet AF763', + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: selectedCategory, + decoration: const InputDecoration(labelText: 'Catégorie'), + items: const [ + DropdownMenuItem( + value: 'billet', + child: Text('Billet'), + ), + DropdownMenuItem( + value: 'passeport', + child: Text('Passeport'), + ), + DropdownMenuItem( + value: 'assurance', + child: Text('Assurance'), + ), + DropdownMenuItem( + value: 'hebergement', + child: Text('Hébergement'), + ), + DropdownMenuItem(value: 'autre', child: Text('Autre')), + ], + onChanged: (value) { + if (value != null) { + setDialogState(() => selectedCategory = value); + } + }, + ), + const SizedBox(height: 12), + TextField( + controller: urlController, + decoration: const InputDecoration( + labelText: 'Lien (PDF, Drive, etc.)', + hintText: 'https://.../document.pdf', + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + expiry == null + ? 'Pas d\'échéance' + : 'Expire le ${DateFormat.yMd().format(expiry!)}', + style: theme.textTheme.bodyMedium, + ), + ), + TextButton( + onPressed: () async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: now, + firstDate: now.subtract(const Duration(days: 1)), + lastDate: now.add(const Duration(days: 365 * 5)), + ); + if (picked != null) { + setDialogState(() => expiry = picked); + } + }, + child: const Text('Échéance'), + ), + ], + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Ajouter'), + ), + ], + ); + }, + ); + }, + ); + + if (result != true || !mounted) return; + + final title = titleController.text.trim(); + if (title.isEmpty) return; + + final newDoc = TripDocument.newEntry( + id: DateTime.now().microsecondsSinceEpoch.toString(), + title: title, + category: selectedCategory, + downloadUrl: urlController.text.trim().isEmpty + ? null + : urlController.text.trim(), + expiresAt: expiry, + ); + + final updated = await _documentService.addDocument(widget.trip.id!, newDoc); + + if (!mounted) return; + setState(() { + _documents = updated; + }); + } + + /// Deletes a document and updates state. + Future _deleteDocument(String docId) async { + if (widget.trip.id == null) return; + final updated = await _documentService.deleteDocument( + widget.trip.id!, + docId, + ); + if (!mounted) return; + setState(() { + _documents = updated; + }); + } + + /// Opens dialog to add a transport segment (vol/train/bus) with PNR and horaires. + Future _openAddSegmentDialog() async { + if (widget.trip.id == null) return; + + final numberController = TextEditingController(); + final carrierController = TextEditingController(); + final pnrController = TextEditingController(); + final depCodeController = TextEditingController(); + final arrCodeController = TextEditingController(); + final seatController = TextEditingController(); + String type = 'flight'; + DateTime departure = DateTime.now().toUtc(); + DateTime arrival = DateTime.now().toUtc().add(const Duration(hours: 2)); + + final theme = Theme.of(context); + + final confirmed = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + backgroundColor: + theme.dialogTheme.backgroundColor ?? + theme.colorScheme.surface, + title: Text( + 'Ajouter un trajet', + style: theme.textTheme.titleLarge?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DropdownButtonFormField( + value: type, + decoration: const InputDecoration(labelText: 'Type'), + items: const [ + DropdownMenuItem(value: 'flight', child: Text('Vol')), + DropdownMenuItem(value: 'train', child: Text('Train')), + DropdownMenuItem(value: 'bus', child: Text('Bus')), + ], + onChanged: (value) { + if (value != null) { + setDialogState(() => type = value); + } + }, + ), + TextField( + controller: carrierController, + decoration: const InputDecoration( + labelText: 'Compagnie', + hintText: 'AF / SN / TGV', + ), + ), + TextField( + controller: numberController, + decoration: const InputDecoration( + labelText: 'Numéro', + hintText: '763 / 8401', + ), + ), + TextField( + controller: pnrController, + decoration: const InputDecoration( + labelText: 'PNR (optionnel)', + hintText: 'ABC123', + ), + ), + Row( + children: [ + Expanded( + child: TextField( + controller: depCodeController, + decoration: const InputDecoration( + labelText: 'Départ', + hintText: 'CDG / BRU', + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: arrCodeController, + decoration: const InputDecoration( + labelText: 'Arrivée', + hintText: 'JFK / AMS', + ), + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + 'Départ: ${DateFormat.yMd().add_Hm().format(departure.toLocal())}', + ), + ), + TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: departure.toLocal(), + firstDate: DateTime.now().subtract( + const Duration(days: 1), + ), + lastDate: DateTime.now().add( + const Duration(days: 365), + ), + ); + if (picked != null) { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime( + departure.toLocal(), + ), + ); + if (time != null) { + final local = DateTime( + picked.year, + picked.month, + picked.day, + time.hour, + time.minute, + ); + setDialogState(() => departure = local.toUtc()); + } + } + }, + child: const Text('Choisir'), + ), + ], + ), + Row( + children: [ + Expanded( + child: Text( + 'Arrivée: ${DateFormat.yMd().add_Hm().format(arrival.toLocal())}', + ), + ), + TextButton( + onPressed: () async { + final picked = await showDatePicker( + context: context, + initialDate: arrival.toLocal(), + firstDate: DateTime.now().subtract( + const Duration(days: 1), + ), + lastDate: DateTime.now().add( + const Duration(days: 365), + ), + ); + if (picked != null) { + final time = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime( + arrival.toLocal(), + ), + ); + if (time != null) { + final local = DateTime( + picked.year, + picked.month, + picked.day, + time.hour, + time.minute, + ); + setDialogState(() => arrival = local.toUtc()); + } + } + }, + child: const Text('Choisir'), + ), + ], + ), + TextField( + controller: seatController, + decoration: const InputDecoration( + labelText: 'Siège (optionnel)', + hintText: '12A / Voiture 6 place 42', + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Ajouter'), + ), + ], + ); + }, + ); + }, + ); + + if (confirmed != true || !mounted) return; + if (carrierController.text.trim().isEmpty || + numberController.text.trim().isEmpty) { + return; + } + + final segment = TransportSegment.newSegment( + id: DateTime.now().microsecondsSinceEpoch.toString(), + type: type, + carrier: carrierController.text.trim(), + number: numberController.text.trim(), + pnr: pnrController.text.trim().isEmpty ? null : pnrController.text.trim(), + departureCode: depCodeController.text.trim().isEmpty + ? '---' + : depCodeController.text.trim(), + arrivalCode: arrCodeController.text.trim().isEmpty + ? '---' + : arrCodeController.text.trim(), + departureUtc: departure, + arrivalUtc: arrival, + gate: null, + seat: seatController.text.trim().isEmpty + ? null + : seatController.text.trim(), + ); + + final updated = await _transportService.addSegment( + widget.trip.id!, + segment, + ); + + if (!mounted) return; + setState(() { + _segments = updated; + }); + } + + /// Deletes a transport segment. + Future _deleteSegment(String segmentId) async { + if (widget.trip.id == null) return; + final updated = await _transportService.deleteSegment( + widget.trip.id!, + segmentId, + ); + if (!mounted) return; + setState(() { + _segments = updated; + }); + } + + /// Adds packing item from dialog, with optional templates. + Future _openAddPackingDialog() async { + if (widget.trip.id == null) return; + final theme = Theme.of(context); + final labelController = TextEditingController(); + bool useTemplate = false; + int nights = widget.trip.endDate.difference(widget.trip.startDate).inDays; + bool cold = false; + + final confirmed = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + backgroundColor: + theme.dialogTheme.backgroundColor ?? + theme.colorScheme.surface, + title: const Text('Ajouter à la valise'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CheckboxListTile( + value: useTemplate, + onChanged: (v) => + setDialogState(() => useTemplate = v ?? false), + title: const Text('Utiliser une suggestion auto'), + ), + if (!useTemplate) + TextField( + controller: labelController, + decoration: const InputDecoration( + hintText: 'Adaptateur, médocs...', + ), + ), + if (useTemplate) ...[ + Row( + children: [ + Expanded(child: Text('Nuits estimées: $nights')), + IconButton( + icon: const Icon(Icons.remove), + onPressed: () => setDialogState(() { + nights = (nights - 1).clamp(1, 30); + }), + ), + IconButton( + icon: const Icon(Icons.add), + onPressed: () => setDialogState(() { + nights = (nights + 1).clamp(1, 30); + }), + ), + ], + ), + SwitchListTile( + value: cold, + onChanged: (v) => setDialogState(() => cold = v), + title: const Text('Destination froide'), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Ajouter'), + ), + ], + ); + }, + ); + }, + ); + + if (confirmed != true || !mounted) return; + + List labels; + if (useTemplate) { + labels = _packingService.suggestedItems(nights: nights, cold: cold); + } else { + if (labelController.text.trim().isEmpty) return; + labels = [labelController.text.trim()]; + } + + final newItems = labels + .map( + (l) => PackingItem.newItem( + id: '${DateTime.now().microsecondsSinceEpoch}-${l.hashCode}', + label: l, + ), + ) + .toList(); + + var current = await _packingService.loadItems(widget.trip.id!); + current = [...current, ...newItems]; + await _packingService.saveItems(widget.trip.id!, current); + + if (!mounted) return; + setState(() { + _packingItems = current; + }); + } + + /// Toggles packing item. + Future _togglePacking(String itemId) async { + if (widget.trip.id == null) return; + final updated = await _packingService.toggleItem(widget.trip.id!, itemId); + if (!mounted) return; + setState(() { + _packingItems = updated; + }); + } + + /// Deletes packing item. + Future _deletePacking(String itemId) async { + if (widget.trip.id == null) return; + final updated = await _packingService.deleteItem(widget.trip.id!, itemId); + if (!mounted) return; + setState(() { + _packingItems = updated; + }); + } + + /// Adds a budget envelope. + Future _openAddBudgetDialog() async { + if (widget.trip.id == null) return; + final theme = Theme.of(context); + final nameController = TextEditingController(); + final amountController = TextEditingController(); + String currency = 'EUR'; + + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: + theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, + title: const Text('Budget par poste'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration(labelText: 'Catégorie'), + ), + TextField( + controller: amountController, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Montant prévu'), + ), + DropdownButtonFormField( + value: currency, + decoration: const InputDecoration(labelText: 'Devise'), + items: const [ + DropdownMenuItem(value: 'EUR', child: Text('EUR')), + DropdownMenuItem(value: 'USD', child: Text('USD')), + DropdownMenuItem(value: 'GBP', child: Text('GBP')), + DropdownMenuItem(value: 'CHF', child: Text('CHF')), + ], + onChanged: (v) { + if (v != null) currency = v; + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Ajouter'), + ), + ], + ); + }, + ); + + if (confirmed != true || !mounted) return; + final name = nameController.text.trim(); + final planned = double.tryParse(amountController.text.trim()) ?? 0; + if (name.isEmpty || planned <= 0) return; + + final cat = BudgetCategory.newCategory( + id: DateTime.now().microsecondsSinceEpoch.toString(), + name: name, + planned: planned, + currency: currency, + ); + + final updated = await _budgetService.addBudget(widget.trip.id!, cat); + if (!mounted) return; + setState(() => _budgets = updated); + } + + /// Deletes a budget category. + Future _deleteBudget(String categoryId) async { + if (widget.trip.id == null) return; + final updated = await _budgetService.deleteBudget( + widget.trip.id!, + categoryId, + ); + if (!mounted) return; + setState(() => _budgets = updated); + } + + /// Toggles offline flag for this trip. + Future _toggleOffline(bool value) async { + if (widget.trip.id == null) return; + await _offlineFlagService.setOfflineEnabled(widget.trip.id!, value); + if (!mounted) return; + setState(() => _offlineEnabled = value); + } + + /// Adds an emergency contact. + Future _openAddEmergencyDialog() async { + if (widget.trip.id == null) return; + final theme = Theme.of(context); + final nameCtrl = TextEditingController(); + final phoneCtrl = TextEditingController(); + final noteCtrl = TextEditingController(); + + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: + theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, + title: const Text('Ajouter un contact d\'urgence'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameCtrl, + decoration: const InputDecoration(labelText: 'Nom/Service'), + ), + TextField( + controller: phoneCtrl, + decoration: const InputDecoration(labelText: 'Téléphone'), + keyboardType: TextInputType.phone, + ), + TextField( + controller: noteCtrl, + decoration: const InputDecoration( + labelText: 'Note (optionnel)', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Ajouter'), + ), + ], + ); + }, + ); + + if (confirmed != true || !mounted) return; + if (nameCtrl.text.trim().isEmpty || phoneCtrl.text.trim().isEmpty) return; + + final contact = EmergencyContact.newContact( + id: DateTime.now().microsecondsSinceEpoch.toString(), + name: nameCtrl.text.trim(), + phone: phoneCtrl.text.trim(), + note: noteCtrl.text.trim().isEmpty ? null : noteCtrl.text.trim(), + ); + + final updated = await _emergencyService.addContact( + widget.trip.id!, + contact, + ); + if (!mounted) return; + setState(() => _emergencyContacts = updated); + } + + /// Deletes an emergency contact. + Future _deleteEmergency(String contactId) async { + if (widget.trip.id == null) return; + final updated = await _emergencyService.deleteContact( + widget.trip.id!, + contactId, + ); + if (!mounted) return; + setState(() => _emergencyContacts = updated); + } + + /// Toggles location sharing flag (local only for now). + Future _toggleShareLocation(bool value) async { + if (widget.trip.id == null) return; + await _offlineFlagService.setOfflineEnabled(widget.trip.id!, value); + if (!mounted) return; + setState(() => _shareLocation = value); + } + + /// Simulates SOS action (placeholder for backend integration). + void _triggerSos() { + _sendSos(); + } + + /// Sends SOS to backend with best-effort location. + Future _sendSos() async { + if (widget.trip.id == null) return; + try { + // Try to get location; if fails, send without coordinates. + double? lat; + double? lng; + try { + final position = await Geolocator.getCurrentPosition(); + lat = position.latitude; + lng = position.longitude; + } catch (_) {} + + final ok = await _sosService.sendSos( + tripId: widget.trip.id!, + lat: lat ?? 0, + lng: lng ?? 0, + message: 'SOS depuis ${widget.trip.title}', + ); + + if (ok) { + _errorService.showError(message: 'SOS envoyé au backend'); + } else { + _errorService.showError( + message: 'SOS non envoyé (backend injoignable)', + ); + } + } catch (e) { + _errorService.showError(message: 'SOS en échec: $e'); + } + } + + /// Adds a photo to the shared album. + /// Adds a photo link to the shared album without uploading. + Future _openAddPhotoDialog() async { + if (widget.trip.id == null) return; + final theme = Theme.of(context); + final urlCtrl = TextEditingController(); + final captionCtrl = TextEditingController(); + + final confirmed = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + backgroundColor: + theme.dialogTheme.backgroundColor ?? theme.colorScheme.surface, + title: const Text('Ajouter une photo (URL)'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: urlCtrl, + decoration: const InputDecoration( + labelText: 'URL', + hintText: 'https://...', + ), + ), + TextField( + controller: captionCtrl, + decoration: const InputDecoration( + labelText: 'Légende (optionnel)', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Ajouter'), + ), + ], + ); + }, + ); + + if (confirmed != true || !mounted) return; + if (urlCtrl.text.trim().isEmpty) return; + + final photo = AlbumPhoto.newPhoto( + id: DateTime.now().microsecondsSinceEpoch.toString(), + url: urlCtrl.text.trim(), + caption: captionCtrl.text.trim().isEmpty ? null : captionCtrl.text.trim(), + uploadedBy: 'Moi', + ); + + final updated = await _albumService.addPhoto(widget.trip.id!, photo); + if (!mounted) return; + setState(() => _albumPhotos = updated); + } + + /// Picks an image from gallery, uploads to storage, and adds to album. + Future _pickAndUploadPhoto() async { + if (widget.trip.id == null || _guestEnabled) return; + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.gallery); + if (picked == null) return; + + setState(() => _isLoadingAlbum = true); + try { + final file = File(picked.path); + final url = await _storageService.uploadAlbumImage(widget.trip.id!, file); + final photo = AlbumPhoto.newPhoto( + id: DateTime.now().microsecondsSinceEpoch.toString(), + url: url, + caption: picked.name, + uploadedBy: 'Moi', + ); + final updated = await _albumService.addPhoto(widget.trip.id!, photo); + if (!mounted) return; + setState(() => _albumPhotos = updated); + } catch (e) { + _errorService.showError(message: 'Échec upload photo: $e'); + } finally { + if (mounted) { + setState(() => _isLoadingAlbum = false); + } + } + } + + /// Deletes a photo. + Future _deletePhoto(String photoId) async { + if (widget.trip.id == null) return; + final updated = await _albumService.deletePhoto(widget.trip.id!, photoId); + if (!mounted) return; + setState(() => _albumPhotos = updated); + } + + /// Toggles guest mode flag. + Future _toggleGuest(bool value) async { + if (widget.trip.id == null) return; + await _guestFlagService.setGuestEnabled(widget.trip.id!, value); + if (!mounted) return; + setState(() => _guestEnabled = value); + } + + /// Adds a reminder item. + /// Opens a dialog to add a dated reminder and schedules it locally. + Future _openAddReminderDialog() async { + if (widget.trip.id == null) return; + final theme = Theme.of(context); + final titleCtrl = TextEditingController(); + final noteCtrl = TextEditingController(); + DateTime dueAt = DateTime.now().add(const Duration(hours: 6)); + + final confirmed = await showDialog( + context: context, + builder: (context) { + return StatefulBuilder( + builder: (context, setDialogState) { + return AlertDialog( + backgroundColor: + theme.dialogTheme.backgroundColor ?? + theme.colorScheme.surface, + title: const Text('Ajouter un rappel'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: titleCtrl, + decoration: const InputDecoration(labelText: 'Titre'), + ), + TextField( + controller: noteCtrl, + decoration: const InputDecoration(labelText: 'Note'), + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: Text( + 'Échéance: ${DateFormat.yMd().add_Hm().format(dueAt.toLocal())}', + ), + ), + TextButton( + onPressed: () async { + final pickedDate = await showDatePicker( + context: context, + initialDate: dueAt.toLocal(), + firstDate: DateTime.now().subtract( + const Duration(days: 1), + ), + lastDate: DateTime.now().add( + const Duration(days: 365), + ), + ); + if (pickedDate != null) { + final pickedTime = await showTimePicker( + context: context, + initialTime: TimeOfDay.fromDateTime( + dueAt.toLocal(), + ), + ); + if (pickedTime != null) { + final local = DateTime( + pickedDate.year, + pickedDate.month, + pickedDate.day, + pickedTime.hour, + pickedTime.minute, + ); + setDialogState(() => dueAt = local.toUtc()); + } + } + }, + child: const Text('Choisir'), + ), + ], + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Ajouter'), + ), + ], + ); + }, + ); + }, + ); + + if (confirmed != true || !mounted) return; + if (titleCtrl.text.trim().isEmpty) return; + + final reminder = ReminderItem.newItem( + id: DateTime.now().microsecondsSinceEpoch.toString(), + title: titleCtrl.text.trim(), + note: noteCtrl.text.trim().isEmpty ? null : noteCtrl.text.trim(), + dueAt: dueAt, + ); + + final updated = await _reminderService.addReminder( + widget.trip.id!, + reminder, + ); + await NotificationService().scheduleReminder( + id: reminder.id, + title: reminder.title, + body: reminder.note ?? 'Rappel', + dueAt: reminder.dueAt, + ); + if (!mounted) return; + setState(() => _reminders = updated); + } + + /// Toggles a reminder. + /// Toggles a reminder's completion status and persists the change. + Future _toggleReminder(String reminderId) async { + if (widget.trip.id == null) return; + final updated = await _reminderService.toggleReminder( + widget.trip.id!, + reminderId, + ); + final current = updated.firstWhere((r) => r.id == reminderId); + if (current.isDone) { + await NotificationService().cancelReminder(reminderId); + } else { + await NotificationService().scheduleReminder( + id: current.id, + title: current.title, + body: current.note ?? 'Rappel', + dueAt: current.dueAt, + ); + } + if (!mounted) return; + setState(() => _reminders = updated); + } + + /// Deletes a reminder. + /// Deletes a reminder and updates local state. + Future _deleteReminder(String reminderId) async { + if (widget.trip.id == null) return; + final updated = await _reminderService.deleteReminder( + widget.trip.id!, + reminderId, + ); + await NotificationService().cancelReminder(reminderId); + if (!mounted) return; + setState(() => _reminders = updated); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -497,6 +1851,18 @@ class _ShowTripDetailsContentState extends State { ], ), const SizedBox(height: 32), + _buildDocumentsSection(), + _buildTransportSection(), + _buildSafetySection(), + _buildAlbumSection(), + _buildPackingSection(), + _buildBudgetSection(), + _buildGuestToggle(), + _buildOfflineToggle(), + _buildRecapSection(), + _buildRemindersSection(), + _buildSuggestionsSection(), + _buildChecklistSection(), _buildNextActivitiesSection(), _buildExpensesCard(), ], @@ -586,6 +1952,760 @@ class _ShowTripDetailsContentState extends State { ); } + /// 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); @@ -1369,6 +3489,399 @@ class _ShowTripDetailsContentState extends State { ); } + /// 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); diff --git a/lib/models/album_photo.dart b/lib/models/album_photo.dart new file mode 100644 index 0000000..2365245 --- /dev/null +++ b/lib/models/album_photo.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +/// Represents a shared photo entry in the trip album. +/// +/// Stores a remote [url], optional [caption], and the uploader identifier for +/// basic attribution. Persistence is local/offline via JSON helpers. +class AlbumPhoto { + /// Unique identifier for the photo entry. + final String id; + + /// Public or signed URL of the photo. + final String url; + + /// Optional caption provided by the user. + final String? caption; + + /// Name or ID of the uploader for display. + final String? uploadedBy; + + /// Creation timestamp. + final DateTime createdAt; + + /// Creates an album photo. + const AlbumPhoto({ + required this.id, + required this.url, + required this.createdAt, + this.caption, + this.uploadedBy, + }); + + /// Convenience builder for a new entry. + factory AlbumPhoto.newPhoto({ + required String id, + required String url, + String? caption, + String? uploadedBy, + }) { + return AlbumPhoto( + id: id, + url: url, + caption: caption, + uploadedBy: uploadedBy, + createdAt: DateTime.now().toUtc(), + ); + } + + /// Copy with updates. + AlbumPhoto copyWith({ + String? id, + String? url, + String? caption, + String? uploadedBy, + DateTime? createdAt, + }) { + return AlbumPhoto( + id: id ?? this.id, + url: url ?? this.url, + caption: caption ?? this.caption, + uploadedBy: uploadedBy ?? this.uploadedBy, + createdAt: createdAt ?? this.createdAt, + ); + } + + /// Serialize to JSON. + Map toJson() { + return { + 'id': id, + 'url': url, + 'caption': caption, + 'uploadedBy': uploadedBy, + 'createdAt': createdAt.toIso8601String(), + }; + } + + /// Deserialize from JSON. + factory AlbumPhoto.fromJson(Map json) { + return AlbumPhoto( + id: json['id'] as String, + url: json['url'] as String, + caption: json['caption'] as String?, + uploadedBy: json['uploadedBy'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + /// Encode list to string. + static String encodeList(List photos) { + return json.encode(photos.map((p) => p.toJson()).toList()); + } + + /// Decode list from string. + static List decodeList(String raw) { + final decoded = json.decode(raw) as List; + return decoded + .cast>() + .map(AlbumPhoto.fromJson) + .toList(); + } +} diff --git a/lib/models/budget_category.dart b/lib/models/budget_category.dart new file mode 100644 index 0000000..86b6d3b --- /dev/null +++ b/lib/models/budget_category.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +/// Represents a budget envelope per category with currency awareness. +class BudgetCategory { + /// Unique identifier of the category entry. + final String id; + + /// Category name (hébergement, transport, food, activités...). + final String name; + + /// Planned amount. + final double planned; + + /// Currency code (ISO 4217) used for the amount. + final String currency; + + /// Amount actually spent (to be filled by expenses sync later). + final double spent; + + /// Creation timestamp for ordering. + final DateTime createdAt; + + /// Creates a budget category entry. + const BudgetCategory({ + required this.id, + required this.name, + required this.planned, + required this.currency, + required this.spent, + required this.createdAt, + }); + + /// Convenience constructor for new envelope. + factory BudgetCategory.newCategory({ + required String id, + required String name, + required double planned, + required String currency, + }) { + return BudgetCategory( + id: id, + name: name, + planned: planned, + currency: currency, + spent: 0, + createdAt: DateTime.now().toUtc(), + ); + } + + /// Returns a copy with updated fields. + BudgetCategory copyWith({ + String? id, + String? name, + double? planned, + String? currency, + double? spent, + DateTime? createdAt, + }) { + return BudgetCategory( + id: id ?? this.id, + name: name ?? this.name, + planned: planned ?? this.planned, + currency: currency ?? this.currency, + spent: spent ?? this.spent, + createdAt: createdAt ?? this.createdAt, + ); + } + + /// JSON serialization. + Map toJson() { + return { + 'id': id, + 'name': name, + 'planned': planned, + 'currency': currency, + 'spent': spent, + 'createdAt': createdAt.toIso8601String(), + }; + } + + /// JSON deserialization. + factory BudgetCategory.fromJson(Map json) { + return BudgetCategory( + id: json['id'] as String, + name: json['name'] as String, + planned: (json['planned'] as num).toDouble(), + currency: json['currency'] as String, + spent: (json['spent'] as num).toDouble(), + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + /// Encodes a list to JSON string. + static String encodeList(List categories) { + return json.encode(categories.map((c) => c.toJson()).toList()); + } + + /// Decodes a list from JSON string. + static List decodeList(String raw) { + final decoded = json.decode(raw) as List; + return decoded + .cast>() + .map(BudgetCategory.fromJson) + .toList(); + } +} diff --git a/lib/models/checklist_item.dart b/lib/models/checklist_item.dart new file mode 100644 index 0000000..fba3107 --- /dev/null +++ b/lib/models/checklist_item.dart @@ -0,0 +1,104 @@ +import 'dart:convert'; + +/// Data model representing a single checklist item for a trip. +/// +/// Each item stores a unique [id], the [label] to display, its completion +/// status via [isDone], and optional timestamps to support ordering or +/// future reminders. The model includes JSON helpers to simplify +/// persistence with `SharedPreferences`. +class ChecklistItem { + /// Unique identifier of the checklist item. + final String id; + + /// Human‑readable text describing the task to complete. + final String label; + + /// Indicates whether the task has been completed. + final bool isDone; + + /// Creation timestamp used to keep a stable order in the list UI. + final DateTime createdAt; + + /// Optional due date for the task; can be leveraged by reminders later. + final DateTime? dueDate; + + /// Creates a checklist item. + const ChecklistItem({ + required this.id, + required this.label, + required this.isDone, + required this.createdAt, + this.dueDate, + }); + + /// Builds a new item in the pending state with the current timestamp. + factory ChecklistItem.newItem({ + required String id, + required String label, + DateTime? dueDate, + }) { + return ChecklistItem( + id: id, + label: label, + isDone: false, + createdAt: DateTime.now().toUtc(), + dueDate: dueDate, + ); + } + + /// Creates a copy with updated fields while keeping immutability. + ChecklistItem copyWith({ + String? id, + String? label, + bool? isDone, + DateTime? createdAt, + DateTime? dueDate, + }) { + return ChecklistItem( + id: id ?? this.id, + label: label ?? this.label, + isDone: isDone ?? this.isDone, + createdAt: createdAt ?? this.createdAt, + dueDate: dueDate ?? this.dueDate, + ); + } + + /// Serializes the item to JSON for storage. + Map toJson() { + return { + 'id': id, + 'label': label, + 'isDone': isDone, + 'createdAt': createdAt.toIso8601String(), + 'dueDate': dueDate?.toIso8601String(), + }; + } + + /// Deserializes a checklist item from JSON. + factory ChecklistItem.fromJson(Map json) { + return ChecklistItem( + id: json['id'] as String, + label: json['label'] as String, + isDone: json['isDone'] as bool? ?? false, + createdAt: DateTime.parse(json['createdAt'] as String), + dueDate: json['dueDate'] != null + ? DateTime.tryParse(json['dueDate'] as String) + : null, + ); + } + + /// Encodes a list of checklist items to a JSON string. + static String encodeList(List items) { + final jsonList = items.map((item) => item.toJson()).toList(); + return json.encode(jsonList); + } + + /// Decodes a list of checklist items from a JSON string. + static List decodeList(String jsonString) { + final decoded = json.decode(jsonString) as List; + return decoded + .cast>() + .map(ChecklistItem.fromJson) + .toList(); + } +} diff --git a/lib/models/emergency_contact.dart b/lib/models/emergency_contact.dart new file mode 100644 index 0000000..492d3d6 --- /dev/null +++ b/lib/models/emergency_contact.dart @@ -0,0 +1,100 @@ +import 'dart:convert'; + +/// Represents an emergency contact for a trip (person or service). +/// +/// Stores basic contact details and optional notes for quick access during +/// critical situations. +class EmergencyContact { + /// Unique identifier for the contact entry. + final String id; + + /// Display name (ex: "Ambassade", "Marie", "Assurance Europ"). + final String name; + + /// Phone number in international format when possible. + final String phone; + + /// Optional description or role (ex: "Assistance médicale", "Famille"). + final String? note; + + /// Creation timestamp for stable ordering. + final DateTime createdAt; + + /// Creates an emergency contact entry. + const EmergencyContact({ + required this.id, + required this.name, + required this.phone, + required this.createdAt, + this.note, + }); + + /// Builds a new contact with current timestamp. + factory EmergencyContact.newContact({ + required String id, + required String name, + required String phone, + String? note, + }) { + return EmergencyContact( + id: id, + name: name, + phone: phone, + note: note, + createdAt: DateTime.now().toUtc(), + ); + } + + /// Returns a copy with updated fields. + EmergencyContact copyWith({ + String? id, + String? name, + String? phone, + String? note, + DateTime? createdAt, + }) { + return EmergencyContact( + id: id ?? this.id, + name: name ?? this.name, + phone: phone ?? this.phone, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + ); + } + + /// Serializes contact to JSON. + Map toJson() { + return { + 'id': id, + 'name': name, + 'phone': phone, + 'note': note, + 'createdAt': createdAt.toIso8601String(), + }; + } + + /// Deserializes contact from JSON. + factory EmergencyContact.fromJson(Map json) { + return EmergencyContact( + id: json['id'] as String, + name: json['name'] as String, + phone: json['phone'] as String, + note: json['note'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + /// Encodes list to JSON string. + static String encodeList(List contacts) { + return json.encode(contacts.map((c) => c.toJson()).toList()); + } + + /// Decodes list from JSON string. + static List decodeList(String raw) { + final decoded = json.decode(raw) as List; + return decoded + .cast>() + .map(EmergencyContact.fromJson) + .toList(); + } +} diff --git a/lib/models/packing_item.dart b/lib/models/packing_item.dart new file mode 100644 index 0000000..b343ef0 --- /dev/null +++ b/lib/models/packing_item.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +/// Represents an item in the shared packing list for a trip. +/// +/// Each item stores a [label], completion flag [isPacked], and an optional +/// [assignee] to indicate who prend en charge l'item. +class PackingItem { + /// Unique identifier for the packing entry. + final String id; + + /// Text displayed in the list (ex: "Adaptateur US", "Pharmacie"). + final String label; + + /// Whether the item is already packed. + final bool isPacked; + + /// Optional assignee (user id or name) for accountability. + final String? assignee; + + /// Creation timestamp for ordering. + final DateTime createdAt; + + /// Creates a packing item. + const PackingItem({ + required this.id, + required this.label, + required this.isPacked, + required this.createdAt, + this.assignee, + }); + + /// Factory to create a new item in not-packed state. + factory PackingItem.newItem({ + required String id, + required String label, + String? assignee, + }) { + return PackingItem( + id: id, + label: label, + assignee: assignee, + isPacked: false, + createdAt: DateTime.now().toUtc(), + ); + } + + /// Returns a copy with modifications. + PackingItem copyWith({ + String? id, + String? label, + bool? isPacked, + String? assignee, + DateTime? createdAt, + }) { + return PackingItem( + id: id ?? this.id, + label: label ?? this.label, + isPacked: isPacked ?? this.isPacked, + assignee: assignee ?? this.assignee, + createdAt: createdAt ?? this.createdAt, + ); + } + + /// JSON serialization helper. + Map toJson() { + return { + 'id': id, + 'label': label, + 'isPacked': isPacked, + 'assignee': assignee, + 'createdAt': createdAt.toIso8601String(), + }; + } + + /// JSON deserialization helper. + factory PackingItem.fromJson(Map json) { + return PackingItem( + id: json['id'] as String, + label: json['label'] as String, + isPacked: json['isPacked'] as bool? ?? false, + assignee: json['assignee'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + /// Encodes list to JSON string. + static String encodeList(List items) { + return json.encode(items.map((i) => i.toJson()).toList()); + } + + /// Decodes list from JSON string. + static List decodeList(String raw) { + final decoded = json.decode(raw) as List; + return decoded + .cast>() + .map(PackingItem.fromJson) + .toList(); + } +} diff --git a/lib/models/reminder_item.dart b/lib/models/reminder_item.dart new file mode 100644 index 0000000..ce6362c --- /dev/null +++ b/lib/models/reminder_item.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +/// Represents a dated reminder or to-do for the trip. +class ReminderItem { + /// Unique identifier. + final String id; + + /// Text to display. + final String title; + + /// Optional detailed note. + final String? note; + + /// Due date/time (UTC) for the reminder. + final DateTime dueAt; + + /// Completion flag. + final bool isDone; + + /// Creation timestamp. + final DateTime createdAt; + + /// Creates a reminder item. + const ReminderItem({ + required this.id, + required this.title, + required this.dueAt, + required this.isDone, + required this.createdAt, + this.note, + }); + + /// Convenience builder for new pending reminder. + factory ReminderItem.newItem({ + required String id, + required String title, + required DateTime dueAt, + String? note, + }) { + return ReminderItem( + id: id, + title: title, + note: note, + dueAt: dueAt, + isDone: false, + createdAt: DateTime.now().toUtc(), + ); + } + + /// Copy with changes. + ReminderItem copyWith({ + String? id, + String? title, + String? note, + DateTime? dueAt, + bool? isDone, + DateTime? createdAt, + }) { + return ReminderItem( + id: id ?? this.id, + title: title ?? this.title, + note: note ?? this.note, + dueAt: dueAt ?? this.dueAt, + isDone: isDone ?? this.isDone, + createdAt: createdAt ?? this.createdAt, + ); + } + + /// JSON serialization. + Map toJson() { + return { + 'id': id, + 'title': title, + 'note': note, + 'dueAt': dueAt.toIso8601String(), + 'isDone': isDone, + 'createdAt': createdAt.toIso8601String(), + }; + } + + /// JSON deserialization. + factory ReminderItem.fromJson(Map json) { + return ReminderItem( + id: json['id'] as String, + title: json['title'] as String, + note: json['note'] as String?, + dueAt: DateTime.parse(json['dueAt'] as String), + isDone: json['isDone'] as bool? ?? false, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + /// Encodes list. + static String encodeList(List reminders) { + return json.encode(reminders.map((r) => r.toJson()).toList()); + } + + /// Decodes list. + static List decodeList(String raw) { + final decoded = json.decode(raw) as List; + return decoded + .cast>() + .map(ReminderItem.fromJson) + .toList(); + } +} diff --git a/lib/models/transport_segment.dart b/lib/models/transport_segment.dart new file mode 100644 index 0000000..6f5be36 --- /dev/null +++ b/lib/models/transport_segment.dart @@ -0,0 +1,179 @@ +import 'dart:convert'; + +/// Represents a transport segment (vol/train/bus) tied to a trip. +/// +/// Includes identifiers (PNR/train number), schedule times, status, carrier +/// and station/airport codes for display and potential real-time tracking. +class TransportSegment { + /// Unique identifier for this segment entry. + final String id; + + /// Segment type: `flight`, `train`, `bus` (extendable). + final String type; + + /// Carrier code (e.g., AF, SN, TGV, OUIGO). + final String carrier; + + /// Public number (e.g., AF763, TGV 8401). + final String number; + + /// Booking reference / PNR if available. + final String? pnr; + + /// Departure code (IATA/CRS) or station name. + final String departureCode; + + /// Arrival code (IATA/CRS) or station name. + final String arrivalCode; + + /// Planned departure time (UTC). + final DateTime departureUtc; + + /// Planned arrival time (UTC). + final DateTime arrivalUtc; + + /// Current status string (scheduled/delayed/cancelled/boarding/in_air etc.). + final String status; + + /// Gate/platform when known. + final String? gate; + + /// Seat assignment if provided. + final String? seat; + + /// Created-at timestamp for ordering. + final DateTime createdAt; + + /// Creates a transport segment entry. + const TransportSegment({ + required this.id, + required this.type, + required this.carrier, + required this.number, + required this.departureCode, + required this.arrivalCode, + required this.departureUtc, + required this.arrivalUtc, + required this.status, + required this.createdAt, + this.pnr, + this.gate, + this.seat, + }); + + /// Helper to instantiate a new scheduled segment with defaults. + factory TransportSegment.newSegment({ + required String id, + required String type, + required String carrier, + required String number, + required String departureCode, + required String arrivalCode, + required DateTime departureUtc, + required DateTime arrivalUtc, + String? pnr, + String? gate, + String? seat, + }) { + return TransportSegment( + id: id, + type: type, + carrier: carrier, + number: number, + pnr: pnr, + departureCode: departureCode, + arrivalCode: arrivalCode, + departureUtc: departureUtc, + arrivalUtc: arrivalUtc, + gate: gate, + seat: seat, + status: 'scheduled', + createdAt: DateTime.now().toUtc(), + ); + } + + /// Returns a copy with updated fields. + TransportSegment copyWith({ + String? id, + String? type, + String? carrier, + String? number, + String? pnr, + String? departureCode, + String? arrivalCode, + DateTime? departureUtc, + DateTime? arrivalUtc, + String? status, + String? gate, + String? seat, + DateTime? createdAt, + }) { + return TransportSegment( + id: id ?? this.id, + type: type ?? this.type, + carrier: carrier ?? this.carrier, + number: number ?? this.number, + pnr: pnr ?? this.pnr, + departureCode: departureCode ?? this.departureCode, + arrivalCode: arrivalCode ?? this.arrivalCode, + departureUtc: departureUtc ?? this.departureUtc, + arrivalUtc: arrivalUtc ?? this.arrivalUtc, + status: status ?? this.status, + gate: gate ?? this.gate, + seat: seat ?? this.seat, + createdAt: createdAt ?? this.createdAt, + ); + } + + /// Serializes the segment to JSON for persistence. + Map toJson() { + return { + 'id': id, + 'type': type, + 'carrier': carrier, + 'number': number, + 'pnr': pnr, + 'departureCode': departureCode, + 'arrivalCode': arrivalCode, + 'departureUtc': departureUtc.toIso8601String(), + 'arrivalUtc': arrivalUtc.toIso8601String(), + 'status': status, + 'gate': gate, + 'seat': seat, + 'createdAt': createdAt.toIso8601String(), + }; + } + + /// Deserializes a segment from JSON. + factory TransportSegment.fromJson(Map json) { + return TransportSegment( + id: json['id'] as String, + type: json['type'] as String, + carrier: json['carrier'] as String, + number: json['number'] as String, + pnr: json['pnr'] as String?, + departureCode: json['departureCode'] as String, + arrivalCode: json['arrivalCode'] as String, + departureUtc: DateTime.parse(json['departureUtc'] as String), + arrivalUtc: DateTime.parse(json['arrivalUtc'] as String), + status: json['status'] as String, + gate: json['gate'] as String?, + seat: json['seat'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + /// Encodes a list of segments to JSON string. + static String encodeList(List segments) { + return json.encode(segments.map((s) => s.toJson()).toList()); + } + + /// Decodes a list of segments from JSON string. + static List decodeList(String raw) { + final decoded = json.decode(raw) as List; + return decoded + .cast>() + .map(TransportSegment.fromJson) + .toList(); + } +} diff --git a/lib/models/trip_document.dart b/lib/models/trip_document.dart new file mode 100644 index 0000000..c3699e4 --- /dev/null +++ b/lib/models/trip_document.dart @@ -0,0 +1,123 @@ +import 'dart:convert'; + +/// Represents a document attached to a trip (billet, passeport, assurance, etc.). +/// +/// The model stores a human-friendly [title], a [category] to filter in the UI, +/// an optional [downloadUrl] when the file is hosted remotely, and an optional +/// [expiresAt] date for reminders (ex: passeport ou ESTA). +class TripDocument { + /// Unique identifier for the document entry. + final String id; + + /// Display name chosen by the user (ex: « Billet retour AF763 »). + final String title; + + /// Type/category (ex: `billet`, `passeport`, `assurance`, `hebergement`). + final String category; + + /// Optional URL to open/download the document (cloud storage or external). + final String? downloadUrl; + + /// Optional local file path when offline-only; kept for future sync. + final String? localPath; + + /// Optional expiration date to trigger reminders. + final DateTime? expiresAt; + + /// Creation timestamp used for stable ordering. + final DateTime createdAt; + + /// Creates a trip document entry. + const TripDocument({ + required this.id, + required this.title, + required this.category, + required this.createdAt, + this.downloadUrl, + this.localPath, + this.expiresAt, + }); + + /// Builds a new entry with defaults. + factory TripDocument.newEntry({ + required String id, + required String title, + required String category, + String? downloadUrl, + String? localPath, + DateTime? expiresAt, + }) { + return TripDocument( + id: id, + title: title, + category: category, + downloadUrl: downloadUrl, + localPath: localPath, + expiresAt: expiresAt, + createdAt: DateTime.now().toUtc(), + ); + } + + /// Returns a copy with updated fields. + TripDocument copyWith({ + String? id, + String? title, + String? category, + String? downloadUrl, + String? localPath, + DateTime? expiresAt, + DateTime? createdAt, + }) { + return TripDocument( + id: id ?? this.id, + title: title ?? this.title, + category: category ?? this.category, + downloadUrl: downloadUrl ?? this.downloadUrl, + localPath: localPath ?? this.localPath, + expiresAt: expiresAt ?? this.expiresAt, + createdAt: createdAt ?? this.createdAt, + ); + } + + /// Serializes the entry to JSON for persistence. + Map toJson() { + return { + 'id': id, + 'title': title, + 'category': category, + 'downloadUrl': downloadUrl, + 'localPath': localPath, + 'expiresAt': expiresAt?.toIso8601String(), + 'createdAt': createdAt.toIso8601String(), + }; + } + + /// Deserializes a trip document from JSON. + factory TripDocument.fromJson(Map json) { + return TripDocument( + id: json['id'] as String, + title: json['title'] as String, + category: json['category'] as String, + downloadUrl: json['downloadUrl'] as String?, + localPath: json['localPath'] as String?, + expiresAt: json['expiresAt'] != null + ? DateTime.tryParse(json['expiresAt'] as String) + : null, + createdAt: DateTime.parse(json['createdAt'] as String), + ); + } + + /// Encodes a list of documents to a JSON string. + static String encodeList(List docs) { + return json.encode(docs.map((d) => d.toJson()).toList()); + } + + /// Decodes a list of documents from a JSON string. + static List decodeList(String raw) { + final decoded = json.decode(raw) as List; + return decoded + .cast>() + .map(TripDocument.fromJson) + .toList(); + } +} diff --git a/lib/services/activity_suggestion_service.dart b/lib/services/activity_suggestion_service.dart new file mode 100644 index 0000000..ea81f21 --- /dev/null +++ b/lib/services/activity_suggestion_service.dart @@ -0,0 +1,39 @@ +import 'dart:math'; + +/// Provides lightweight, offline activity suggestions using heuristics. +class ActivitySuggestionService { + /// Returns a list of suggestion strings based on [city] and [weatherCode]. + /// + /// [weatherCode] is a simple tag: `sunny`, `rain`, `cold`, `default`. + List suggestions({ + required String city, + String weatherCode = 'default', + }) { + final base = [ + 'Free walking tour de $city', + 'Spot photo au coucher du soleil', + 'Café local pour travailler/charger', + 'Parc ou rooftop tranquille', + ]; + + if (weatherCode == 'rain') { + base.addAll([ + 'Musée immanquable de $city', + 'Escape game ou activité indoor', + 'Food court couvert pour goûter local', + ]); + } else if (weatherCode == 'cold') { + base.addAll(['Spa / bains chauds', 'Visite guidée en intérieur']); + } else { + base.addAll([ + 'Balade vélo ou trottinette', + 'Pique-nique au parc central', + 'Vue panoramique / rooftop', + ]); + } + + // Shuffle slightly for variation. + base.shuffle(Random(city.hashCode)); + return base.take(6).toList(); + } +} diff --git a/lib/services/ai_activity_service.dart b/lib/services/ai_activity_service.dart new file mode 100644 index 0000000..7e87088 --- /dev/null +++ b/lib/services/ai_activity_service.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// Provides AI-powered activity suggestions using an external endpoint. +/// +/// The endpoint is expected to accept POST JSON: +/// { "city": "...", "interests": ["food","art"], "budget": "low|mid|high" } +/// and return { "suggestions": [ { "title": "...", "detail": "..." }, ... ] } +/// +/// This class is a thin client and can be wired to a custom backend that +/// proxies LLM calls (to avoid shipping secrets in the app). +class AiActivityService { + /// Base URL of the AI suggestion endpoint. + final String baseUrl; + + /// Optional API key if your backend requires it. + final String? apiKey; + + /// Creates an AI activity service client. + const AiActivityService({required this.baseUrl, this.apiKey}); + + /// Fetches suggestions for the given [city] with optional [interests] and [budget]. + /// + /// Returns a list of string suggestions. In case of error, returns an empty list + /// to keep the UI responsive. + Future> fetchSuggestions({ + required String city, + List interests = const [], + String budget = 'mid', + }) async { + final uri = Uri.parse('$baseUrl/ai/suggestions'); + try { + final response = await http.post( + uri, + headers: { + 'Content-Type': 'application/json', + if (apiKey != null) 'Authorization': 'Bearer $apiKey', + }, + body: json.encode({ + 'city': city, + 'interests': interests, + 'budget': budget, + }), + ); + + if (response.statusCode == 200) { + final data = json.decode(response.body) as Map; + final list = (data['suggestions'] as List? ?? []) + .cast>(); + return list + .map((item) => item['title'] as String? ?? 'Suggestion') + .toList(); + } + return const []; + } catch (_) { + return const []; + } + } +} diff --git a/lib/services/album_service.dart b/lib/services/album_service.dart new file mode 100644 index 0000000..dcad140 --- /dev/null +++ b/lib/services/album_service.dart @@ -0,0 +1,42 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/album_photo.dart'; + +/// Stores shared album photos per trip locally for offline access. +class AlbumService { + /// Loads photos for the given trip. + Future> loadPhotos(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key(tripId)); + if (raw == null) return const []; + try { + return AlbumPhoto.decodeList(raw); + } catch (_) { + await prefs.remove(_key(tripId)); + return const []; + } + } + + /// Saves photo list. + Future savePhotos(String tripId, List photos) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key(tripId), AlbumPhoto.encodeList(photos)); + } + + /// Adds one photo. + Future> addPhoto(String tripId, AlbumPhoto photo) async { + final current = await loadPhotos(tripId); + final updated = [...current, photo]; + await savePhotos(tripId, updated); + return updated; + } + + /// Deletes a photo. + Future> deletePhoto(String tripId, String photoId) async { + final current = await loadPhotos(tripId); + final updated = current.where((p) => p.id != photoId).toList(); + await savePhotos(tripId, updated); + return updated; + } + + String _key(String tripId) => 'album_$tripId'; +} diff --git a/lib/services/budget_service.dart b/lib/services/budget_service.dart new file mode 100644 index 0000000..22113b1 --- /dev/null +++ b/lib/services/budget_service.dart @@ -0,0 +1,72 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/budget_category.dart'; + +/// Service to manage per-trip budget envelopes (multi-devise) locally. +/// +/// Stores envelopes in `SharedPreferences` under `budget_` so they +/// remain available offline. Integration with expense data can later update +/// the [spent] field. +class BudgetService { + /// Loads budget categories for the trip. + Future> loadBudgets(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key(tripId)); + if (raw == null) return const []; + try { + return BudgetCategory.decodeList(raw); + } catch (_) { + await prefs.remove(_key(tripId)); + return const []; + } + } + + /// Persists full list. + Future saveBudgets( + String tripId, + List categories, + ) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key(tripId), BudgetCategory.encodeList(categories)); + } + + /// Adds an envelope. + Future> addBudget( + String tripId, + BudgetCategory category, + ) async { + final current = await loadBudgets(tripId); + final updated = [...current, category]; + await saveBudgets(tripId, updated); + return updated; + } + + /// Deletes an envelope. + Future> deleteBudget( + String tripId, + String categoryId, + ) async { + final current = await loadBudgets(tripId); + final updated = current.where((c) => c.id != categoryId).toList(); + await saveBudgets(tripId, updated); + return updated; + } + + /// Updates spent amount for a category (used later by expense sync). + Future> updateSpent( + String tripId, + String categoryId, + double spent, + ) async { + final current = await loadBudgets(tripId); + final updated = current + .map((c) { + if (c.id != categoryId) return c; + return c.copyWith(spent: spent); + }) + .toList(growable: false); + await saveBudgets(tripId, updated); + return updated; + } + + String _key(String tripId) => 'budget_$tripId'; +} diff --git a/lib/services/emergency_service.dart b/lib/services/emergency_service.dart new file mode 100644 index 0000000..1059ead --- /dev/null +++ b/lib/services/emergency_service.dart @@ -0,0 +1,55 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/emergency_contact.dart'; + +/// Stores emergency contacts per trip for offline access. +/// +/// Data is persisted locally in `SharedPreferences` under the key +/// `emergency_`. Corrupted payloads are cleaned up automatically to +/// avoid crashing the UI during critical usage. +class EmergencyService { + /// Loads contacts for [tripId]. Returns an empty list if none or corrupted. + Future> loadContacts(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key(tripId)); + if (raw == null) return const []; + try { + return EmergencyContact.decodeList(raw); + } catch (_) { + await prefs.remove(_key(tripId)); + return const []; + } + } + + /// Saves the complete contact list. + Future saveContacts( + String tripId, + List contacts, + ) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key(tripId), EmergencyContact.encodeList(contacts)); + } + + /// Adds a contact and returns updated list. + Future> addContact( + String tripId, + EmergencyContact contact, + ) async { + final current = await loadContacts(tripId); + final updated = [...current, contact]; + await saveContacts(tripId, updated); + return updated; + } + + /// Deletes a contact. + Future> deleteContact( + String tripId, + String contactId, + ) async { + final current = await loadContacts(tripId); + final updated = current.where((c) => c.id != contactId).toList(); + await saveContacts(tripId, updated); + return updated; + } + + String _key(String tripId) => 'emergency_$tripId'; +} diff --git a/lib/services/guest_flag_service.dart b/lib/services/guest_flag_service.dart new file mode 100644 index 0000000..adecbbb --- /dev/null +++ b/lib/services/guest_flag_service.dart @@ -0,0 +1,18 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Stores a simple read-only guest mode flag per trip. +class GuestFlagService { + /// Returns whether guest mode is enabled for [tripId]. + Future isGuestEnabled(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_key(tripId)) ?? false; + } + + /// Sets guest mode flag for [tripId]. + Future setGuestEnabled(String tripId, bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key(tripId), enabled); + } + + String _key(String tripId) => 'guest_$tripId'; +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index ffd71c8..bd77b74 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -6,6 +6,8 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:timezone/data/latest_all.dart' as tz; +import 'package:timezone/timezone.dart' as tz; import 'package:travel_mate/components/account/group_expenses_page.dart'; import 'package:travel_mate/components/activities/activities_page.dart'; import 'package:travel_mate/components/group/chat_group_content.dart'; @@ -50,6 +52,8 @@ class NotificationService { if (_isInitialized) return; await _requestPermissions(); + tz.initializeTimeZones(); + tz.setLocalLocation(tz.getLocation('UTC')); const androidSettings = AndroidInitializationSettings( '@mipmap/ic_launcher', @@ -411,4 +415,75 @@ class NotificationService { payload: json.encode(message.data), ); } + + /// Shows a local push notification with a custom [title] and [body]. + /// + /// Used for recap/reminder notifications when on-device scheduling is desired. + Future showLocalRecap({ + required String title, + required String body, + }) async { + const androidDetails = AndroidNotificationDetails( + 'recap_channel', + 'Recap quotidien', + channelDescription: 'Notifications de récap voyage', + importance: Importance.max, + priority: Priority.high, + ); + const iosDetails = DarwinNotificationDetails(); + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _localNotifications.show( + DateTime.now().millisecondsSinceEpoch ~/ 1000, + title, + body, + details, + ); + } + + /// Schedules a local reminder notification at [dueAt] with [title]/[body]. + Future scheduleReminder({ + required String id, + required String title, + required String body, + required DateTime dueAt, + }) async { + try { + final int notifId = id.hashCode & 0x7fffffff; + final scheduled = tz.TZDateTime.from(dueAt, tz.local); + + const androidDetails = AndroidNotificationDetails( + 'reminder_channel', + 'Rappels voyage', + channelDescription: 'Notifications des rappels/to-dos', + importance: Importance.high, + priority: Priority.high, + ); + const iosDetails = DarwinNotificationDetails(); + const details = NotificationDetails( + android: androidDetails, + iOS: iosDetails, + ); + + await _localNotifications.zonedSchedule( + notifId, + title, + body, + scheduled, + details, + androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle, + ); + } catch (e) { + LoggerService.error('Failed to schedule reminder', error: e); + } + } + + /// Cancels a scheduled reminder notification by [id]. + Future cancelReminder(String id) async { + final notifId = id.hashCode & 0x7fffffff; + await _localNotifications.cancel(notifId); + } } diff --git a/lib/services/offline_flag_service.dart b/lib/services/offline_flag_service.dart new file mode 100644 index 0000000..ecd159c --- /dev/null +++ b/lib/services/offline_flag_service.dart @@ -0,0 +1,18 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +/// Stores a per-trip offline toggle to trigger background caching later. +class OfflineFlagService { + /// Returns whether offline caching is enabled for [tripId]. + Future isOfflineEnabled(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getBool(_key(tripId)) ?? false; + } + + /// Persists the offline toggle. + Future setOfflineEnabled(String tripId, bool enabled) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setBool(_key(tripId), enabled); + } + + String _key(String tripId) => 'offline_trip_$tripId'; +} diff --git a/lib/services/packing_service.dart b/lib/services/packing_service.dart new file mode 100644 index 0000000..9025f20 --- /dev/null +++ b/lib/services/packing_service.dart @@ -0,0 +1,78 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/packing_item.dart'; + +/// Service handling shared packing lists per trip. +/// +/// Uses local `SharedPreferences` for fast offline access. The list can later +/// be synced remotely without changing the calling code. +class PackingService { + /// Loads packing items for a trip. Returns empty list if none/corrupted. + Future> loadItems(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key(tripId)); + if (raw == null) return const []; + try { + return PackingItem.decodeList(raw); + } catch (_) { + await prefs.remove(_key(tripId)); + return const []; + } + } + + /// Saves complete list. + Future saveItems(String tripId, List items) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key(tripId), PackingItem.encodeList(items)); + } + + /// Adds one item. + Future> addItem(String tripId, PackingItem item) async { + final current = await loadItems(tripId); + final updated = [...current, item]; + await saveItems(tripId, updated); + return updated; + } + + /// Toggles packed flag. + Future> toggleItem(String tripId, String itemId) async { + final current = await loadItems(tripId); + final updated = current + .map((i) { + if (i.id != itemId) return i; + return i.copyWith(isPacked: !i.isPacked); + }) + .toList(growable: false); + await saveItems(tripId, updated); + return updated; + } + + /// Deletes an item. + Future> deleteItem(String tripId, String itemId) async { + final current = await loadItems(tripId); + final updated = current.where((i) => i.id != itemId).toList(); + await saveItems(tripId, updated); + return updated; + } + + /// Suggests a starter template based on duration/weather (simplified here). + List suggestedItems({required int nights, required bool cold}) { + final base = [ + 'Passeport/ID', + 'Billets / PNR', + 'Chargeurs et adaptateurs', + 'Trousse de secours', + 'Assurance voyage', + ]; + if (cold) { + base.addAll(['Veste chaude', 'Gants', 'Bonnet', 'Chaussettes chaudes']); + } else { + base.addAll(['Crème solaire', 'Lunettes de soleil', 'Maillot de bain']); + } + if (nights > 4) { + base.add('Lessive/ziplock'); + } + return base; + } + + String _key(String tripId) => 'packing_$tripId'; +} diff --git a/lib/services/reminder_service.dart b/lib/services/reminder_service.dart new file mode 100644 index 0000000..69965ed --- /dev/null +++ b/lib/services/reminder_service.dart @@ -0,0 +1,64 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/reminder_item.dart'; + +/// Persists dated reminders/to-dos per trip locally for offline use. +class ReminderService { + /// Loads reminders for [tripId]. + Future> loadReminders(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key(tripId)); + if (raw == null) return const []; + try { + return ReminderItem.decodeList(raw); + } catch (_) { + await prefs.remove(_key(tripId)); + return const []; + } + } + + /// Saves full list. + Future saveReminders( + String tripId, + List reminders, + ) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key(tripId), ReminderItem.encodeList(reminders)); + } + + /// Adds a reminder. + Future> addReminder( + String tripId, + ReminderItem reminder, + ) async { + final current = await loadReminders(tripId); + final updated = [...current, reminder]; + await saveReminders(tripId, updated); + return updated; + } + + /// Toggles done state. + Future> toggleReminder( + String tripId, + String reminderId, + ) async { + final current = await loadReminders(tripId); + final updated = current + .map((r) => r.id == reminderId ? r.copyWith(isDone: !r.isDone) : r) + .toList(growable: false); + await saveReminders(tripId, updated); + return updated; + } + + /// Deletes a reminder. + Future> deleteReminder( + String tripId, + String reminderId, + ) async { + final current = await loadReminders(tripId); + final updated = current.where((r) => r.id != reminderId).toList(); + await saveReminders(tripId, updated); + return updated; + } + + String _key(String tripId) => 'reminders_$tripId'; +} diff --git a/lib/services/sos_service.dart b/lib/services/sos_service.dart new file mode 100644 index 0000000..771fc58 --- /dev/null +++ b/lib/services/sos_service.dart @@ -0,0 +1,54 @@ +import 'dart:convert'; +import 'package:http/http.dart' as http; + +/// Service in charge of dispatching SOS events to a backend endpoint. +/// +/// The backend is expected to accept POST JSON payloads like: +/// { +/// "tripId": "...", +/// "lat": 0.0, +/// "lng": 0.0, +/// "message": "...", +/// } +class SosService { + /// Base URL of the backend (e.g. https://api.example.com/sos). + final String baseUrl; + + /// Optional API key header. + final String? apiKey; + + /// Optional injected HTTP client (useful for testing). + final http.Client _client; + + /// Creates a new SOS service. + SosService({required this.baseUrl, this.apiKey, http.Client? client}) + : _client = client ?? http.Client(); + + /// Sends an SOS event. Returns true on HTTP 200. + Future sendSos({ + required String tripId, + required double lat, + required double lng, + String message = 'SOS déclenché', + }) async { + final uri = Uri.parse('$baseUrl/sos'); + try { + final response = await _client.post( + uri, + headers: { + 'Content-Type': 'application/json', + if (apiKey != null) 'Authorization': 'Bearer $apiKey', + }, + body: json.encode({ + 'tripId': tripId, + 'lat': lat, + 'lng': lng, + 'message': message, + }), + ); + return response.statusCode == 200; + } catch (_) { + return false; + } + } +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index e691c62..77fa7f7 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -50,6 +50,43 @@ class StorageService { : _storage = storage ?? FirebaseStorage.instance, _errorService = errorService ?? ErrorService(); + /// Uploads an album image for a trip with compression. + /// + /// Saves the file under `album//` with a unique name, and returns + /// the download URL. Uses the same compression pipeline as receipts. + Future uploadAlbumImage(String tripId, File imageFile) async { + try { + _validateImageFile(imageFile); + final compressedImage = await _compressImage(imageFile); + final fileName = + 'album_${DateTime.now().millisecondsSinceEpoch}_${path.basename(imageFile.path)}'; + final storageRef = _storage.ref().child('album/$tripId/$fileName'); + + final metadata = SettableMetadata( + contentType: 'image/jpeg', + customMetadata: { + 'tripId': tripId, + 'uploadedAt': DateTime.now().toIso8601String(), + 'compressed': 'true', + }, + ); + + final uploadTask = storageRef.putData(compressedImage, metadata); + + final snapshot = await uploadTask; + final downloadUrl = await snapshot.ref.getDownloadURL(); + + _errorService.logSuccess( + 'StorageService', + 'Album image uploaded: $fileName', + ); + return downloadUrl; + } catch (e) { + _errorService.logError('StorageService', 'Error uploading album image: $e'); + rethrow; + } + } + /// Uploads a receipt image for an expense with automatic compression. /// /// Validates the image file, compresses it to JPEG format with 85% quality, diff --git a/lib/services/transport_service.dart b/lib/services/transport_service.dart new file mode 100644 index 0000000..624bdab --- /dev/null +++ b/lib/services/transport_service.dart @@ -0,0 +1,72 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/transport_segment.dart'; + +/// Service that stores per-trip transport segments locally for offline access. +/// +/// Uses `SharedPreferences` keyed by `trip_transport_` to keep +/// creation/edit quick without round-trips. Real-time status can later be +/// updated by a background job hitting external APIs. +class TransportService { + /// Loads stored transport segments for [tripId]. + Future> loadSegments(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key(tripId)); + if (raw == null) return const []; + try { + return TransportSegment.decodeList(raw); + } catch (_) { + await prefs.remove(_key(tripId)); + return const []; + } + } + + /// Persists the full list of segments for [tripId]. + Future saveSegments( + String tripId, + List segments, + ) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key(tripId), TransportSegment.encodeList(segments)); + } + + /// Adds a segment entry. + Future> addSegment( + String tripId, + TransportSegment segment, + ) async { + final current = await loadSegments(tripId); + final updated = [...current, segment]; + await saveSegments(tripId, updated); + return updated; + } + + /// Deletes a segment by [segmentId]. + Future> deleteSegment( + String tripId, + String segmentId, + ) async { + final current = await loadSegments(tripId); + final updated = current.where((s) => s.id != segmentId).toList(); + await saveSegments(tripId, updated); + return updated; + } + + /// Updates the status of a segment (e.g., delayed/boarding/in_air). + Future> updateStatus( + String tripId, + String segmentId, + String status, + ) async { + final current = await loadSegments(tripId); + final updated = current + .map((s) { + if (s.id != segmentId) return s; + return s.copyWith(status: status); + }) + .toList(growable: false); + await saveSegments(tripId, updated); + return updated; + } + + String _key(String tripId) => 'trip_transport_$tripId'; +} diff --git a/lib/services/trip_checklist_service.dart b/lib/services/trip_checklist_service.dart new file mode 100644 index 0000000..9311a83 --- /dev/null +++ b/lib/services/trip_checklist_service.dart @@ -0,0 +1,77 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/checklist_item.dart'; + +/// Service responsible for storing and retrieving per-trip checklists. +/// +/// Persistence relies on `SharedPreferences` with one JSON string per trip +/// key. All methods are resilient to corrupted payloads and return empty +/// lists rather than throwing to keep the UI responsive. +class TripChecklistService { + /// Loads the checklist items for the given [tripId]. + /// + /// Returns an empty list if no data exists or if deserialization fails. + Future> loadChecklist(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key(tripId)); + + if (raw == null) { + return const []; + } + + try { + return ChecklistItem.decodeList(raw); + } catch (_) { + // Corrupted payload: clear it to avoid persisting errors. + await prefs.remove(_key(tripId)); + return const []; + } + } + + /// Persists the provided [items] list for [tripId]. + /// + /// This method overrides the previously stored list; use helpers like + /// [addItem], [toggleItem], or [deleteItem] for incremental updates. + Future saveChecklist(String tripId, List items) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key(tripId), ChecklistItem.encodeList(items)); + } + + /// Adds a new [item] to the checklist for [tripId]. + /// + /// Items are appended in creation order; the updated list is persisted. + Future> addItem(String tripId, ChecklistItem item) async { + final current = await loadChecklist(tripId); + final updated = [...current, item]; + await saveChecklist(tripId, updated); + return updated; + } + + /// Toggles the completion state of the item matching [itemId]. + /// + /// Returns the updated list. If the item is not found, the list is + /// returned unchanged to keep UI state consistent. + Future> toggleItem(String tripId, String itemId) async { + final current = await loadChecklist(tripId); + final updated = current + .map((item) { + if (item.id != itemId) { + return item; + } + return item.copyWith(isDone: !item.isDone); + }) + .toList(growable: false); + + await saveChecklist(tripId, updated); + return updated; + } + + /// Deletes the item matching [itemId] and persists the change. + Future> deleteItem(String tripId, String itemId) async { + final current = await loadChecklist(tripId); + final updated = current.where((item) => item.id != itemId).toList(); + await saveChecklist(tripId, updated); + return updated; + } + + String _key(String tripId) => 'checklist_$tripId'; +} diff --git a/lib/services/trip_document_service.dart b/lib/services/trip_document_service.dart new file mode 100644 index 0000000..7c8e8f6 --- /dev/null +++ b/lib/services/trip_document_service.dart @@ -0,0 +1,49 @@ +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/trip_document.dart'; + +/// Service that persists per-trip documents metadata locally. +/// +/// Documents are stored as JSON in `SharedPreferences` to keep the UI +/// responsive offline. Each trip key is `trip_docs_`. The service is +/// tolerant to corrupted payloads and resets gracefully to an empty list. +class TripDocumentService { + /// Loads documents for the given [tripId]. Returns an empty list when none. + Future> loadDocuments(String tripId) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key(tripId)); + if (raw == null) return const []; + try { + return TripDocument.decodeList(raw); + } catch (_) { + await prefs.remove(_key(tripId)); + return const []; + } + } + + /// Saves the full document list for [tripId]. + Future saveDocuments(String tripId, List docs) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key(tripId), TripDocument.encodeList(docs)); + } + + /// Adds a new document entry and persists the updated list. + Future> addDocument( + String tripId, + TripDocument doc, + ) async { + final current = await loadDocuments(tripId); + final updated = [...current, doc]; + await saveDocuments(tripId, updated); + return updated; + } + + /// Deletes a document by [docId] and persists the change. + Future> deleteDocument(String tripId, String docId) async { + final current = await loadDocuments(tripId); + final updated = current.where((d) => d.id != docId).toList(); + await saveDocuments(tripId, updated); + return updated; + } + + String _key(String tripId) => 'trip_docs_$tripId'; +} diff --git a/pubspec.lock b/pubspec.lock index 957490b..b4cd72b 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1558,7 +1558,7 @@ packages: source: hosted version: "0.6.11" timezone: - dependency: transitive + dependency: "direct main" description: name: timezone sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1 diff --git a/pubspec.yaml b/pubspec.yaml index c975f67..6f6bee9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2026.3.2+3 +version: 2026.3.14 environment: sdk: ^3.9.2 @@ -30,6 +30,7 @@ environment: dependencies: flutter: sdk: flutter + timezone: ^0.10.1 provider: ^6.1.1 shared_preferences: ^2.2.2 path_provider: ^2.1.1 diff --git a/test/services/album_service_test.dart b/test/services/album_service_test.dart new file mode 100644 index 0000000..bbd7eb9 --- /dev/null +++ b/test/services/album_service_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/album_photo.dart'; +import 'package:travel_mate/services/album_service.dart'; + +void main() { + const tripId = 'trip-album-1'; + late AlbumService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = AlbumService(); + }); + + test('adds and loads photos', () async { + final photo = AlbumPhoto.newPhoto( + id: 'p1', + url: 'https://example.com/img.jpg', + caption: 'Coucher de soleil', + uploadedBy: 'Alice', + ); + await service.addPhoto(tripId, photo); + final loaded = await service.loadPhotos(tripId); + expect(loaded.single.url, contains('example.com')); + }); + + test('deletes photo and clears corrupted payload', () async { + final p = AlbumPhoto.newPhoto(id: 'p1', url: 'u', caption: null); + await service.addPhoto(tripId, p); + var updated = await service.deletePhoto(tripId, 'p1'); + expect(updated, isEmpty); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('album_$tripId', 'oops'); + updated = await service.loadPhotos(tripId); + expect(updated, isEmpty); + expect(prefs.getString('album_$tripId'), isNull); + }); +} diff --git a/test/services/budget_service_test.dart b/test/services/budget_service_test.dart new file mode 100644 index 0000000..68b329b --- /dev/null +++ b/test/services/budget_service_test.dart @@ -0,0 +1,51 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/budget_category.dart'; +import 'package:travel_mate/services/budget_service.dart'; + +void main() { + const tripId = 'trip-budget-1'; + late BudgetService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = BudgetService(); + }); + + test('adds and deletes budget envelopes', () async { + final cat = BudgetCategory.newCategory( + id: 'food', + name: 'Food', + planned: 300, + currency: 'EUR', + ); + await service.addBudget(tripId, cat); + + var loaded = await service.loadBudgets(tripId); + expect(loaded.single.name, 'Food'); + + loaded = await service.deleteBudget(tripId, 'food'); + expect(loaded, isEmpty); + }); + + test('updates spent amount', () async { + final cat = BudgetCategory.newCategory( + id: 'transport', + name: 'Transport', + planned: 200, + currency: 'USD', + ); + await service.addBudget(tripId, cat); + + final updated = await service.updateSpent(tripId, 'transport', 55.5); + expect(updated.first.spent, 55.5); + }); + + test('corrupted payload cleared', () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('budget_$tripId', 'oops'); + final loaded = await service.loadBudgets(tripId); + expect(loaded, isEmpty); + expect(prefs.getString('budget_$tripId'), isNull); + }); +} diff --git a/test/services/emergency_service_test.dart b/test/services/emergency_service_test.dart new file mode 100644 index 0000000..431d75c --- /dev/null +++ b/test/services/emergency_service_test.dart @@ -0,0 +1,54 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/emergency_contact.dart'; +import 'package:travel_mate/services/emergency_service.dart'; + +void main() { + const tripId = 'trip-emergency-1'; + late EmergencyService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = EmergencyService(); + }); + + test('adds and loads contacts', () async { + final contact = EmergencyContact.newContact( + id: 'c1', + name: 'Assistance', + phone: '+33123456789', + note: 'Assurance', + ); + await service.addContact(tripId, contact); + + final loaded = await service.loadContacts(tripId); + expect(loaded.single.phone, '+33123456789'); + }); + + test('deletes contact', () async { + final a = EmergencyContact.newContact( + id: 'a', + name: 'Ambassade', + phone: '+321234', + ); + final b = EmergencyContact.newContact( + id: 'b', + name: 'Marie', + phone: '+33999', + ); + await service.addContact(tripId, a); + await service.addContact(tripId, b); + + final updated = await service.deleteContact(tripId, 'a'); + expect(updated.map((c) => c.id), contains('b')); + expect(updated.length, 1); + }); + + test('corrupted payload cleared', () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('emergency_$tripId', 'oops'); + final loaded = await service.loadContacts(tripId); + expect(loaded, isEmpty); + expect(prefs.getString('emergency_$tripId'), isNull); + }); +} diff --git a/test/services/guest_flag_service_test.dart b/test/services/guest_flag_service_test.dart new file mode 100644 index 0000000..15f930c --- /dev/null +++ b/test/services/guest_flag_service_test.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/services/guest_flag_service.dart'; + +void main() { + const tripId = 'trip-guest-1'; + late GuestFlagService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = GuestFlagService(); + }); + + test('sets and reads guest mode flag', () async { + expect(await service.isGuestEnabled(tripId), isFalse); + await service.setGuestEnabled(tripId, true); + expect(await service.isGuestEnabled(tripId), isTrue); + }); +} diff --git a/test/services/packing_service_test.dart b/test/services/packing_service_test.dart new file mode 100644 index 0000000..cf3cd05 --- /dev/null +++ b/test/services/packing_service_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/packing_item.dart'; +import 'package:travel_mate/services/packing_service.dart'; + +void main() { + const tripId = 'trip-pack-1'; + late PackingService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = PackingService(); + }); + + test('adds, toggles and deletes packing items', () async { + final item = PackingItem.newItem(id: '1', label: 'Adaptateur US'); + await service.addItem(tripId, item); + + var loaded = await service.loadItems(tripId); + expect(loaded.first.isPacked, isFalse); + + loaded = await service.toggleItem(tripId, '1'); + expect(loaded.first.isPacked, isTrue); + + loaded = await service.deleteItem(tripId, '1'); + expect(loaded, isEmpty); + }); + + test('suggested items react to cold and duration', () { + final cold = service.suggestedItems(nights: 5, cold: true); + expect(cold, contains('Veste chaude')); + expect(cold, contains('Lessive/ziplock')); + + final warm = service.suggestedItems(nights: 2, cold: false); + expect(warm, contains('Crème solaire')); + expect(warm, isNot(contains('Lessive/ziplock'))); + }); + + test('handles corrupted payload', () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('packing_$tripId', 'oops'); + final items = await service.loadItems(tripId); + expect(items, isEmpty); + expect(prefs.getString('packing_$tripId'), isNull); + }); +} diff --git a/test/services/reminder_service_test.dart b/test/services/reminder_service_test.dart new file mode 100644 index 0000000..d22ae64 --- /dev/null +++ b/test/services/reminder_service_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/reminder_item.dart'; +import 'package:travel_mate/services/reminder_service.dart'; + +void main() { + const tripId = 'trip-reminders-1'; + late ReminderService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = ReminderService(); + }); + + test('adds and toggles reminders', () async { + final r = ReminderItem.newItem( + id: 'r1', + title: 'Check-in en ligne', + dueAt: DateTime.utc(2026, 4, 10, 7), + ); + await service.addReminder(tripId, r); + var list = await service.loadReminders(tripId); + expect(list.single.isDone, isFalse); + + list = await service.toggleReminder(tripId, 'r1'); + expect(list.single.isDone, isTrue); + }); + + test('deletes and clears corrupted payload', () async { + final r = ReminderItem.newItem( + id: 'r1', + title: 'Acheter métro pass', + dueAt: DateTime.utc(2026, 4, 1), + ); + await service.addReminder(tripId, r); + var list = await service.deleteReminder(tripId, 'r1'); + expect(list, isEmpty); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('reminders_$tripId', 'oops'); + list = await service.loadReminders(tripId); + expect(list, isEmpty); + expect(prefs.getString('reminders_$tripId'), isNull); + }); +} diff --git a/test/services/sos_service_test.dart b/test/services/sos_service_test.dart new file mode 100644 index 0000000..74da963 --- /dev/null +++ b/test/services/sos_service_test.dart @@ -0,0 +1,37 @@ +import 'dart:convert'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:travel_mate/services/sos_service.dart'; + +void main() { + test('sendSos returns true on 200', () async { + final mockClient = MockClient((request) async { + expect(request.url.toString(), 'https://api.example.com/sos'); + final body = json.decode(request.body) as Map; + expect(body['tripId'], 't1'); + return http.Response('{}', 200); + }); + + final service = SosService( + baseUrl: 'https://api.example.com', + client: mockClient, + ); + + final result = await service.sendSos(tripId: 't1', lat: 1, lng: 2); + expect(result, isTrue); + }); + + test('sendSos returns false on error', () async { + final mockClient = MockClient((request) async { + return http.Response('fail', 500); + }); + final service = SosService( + baseUrl: 'https://api.example.com', + client: mockClient, + ); + + final result = await service.sendSos(tripId: 't1', lat: 0, lng: 0); + expect(result, isFalse); + }); +} diff --git a/test/services/transport_service_test.dart b/test/services/transport_service_test.dart new file mode 100644 index 0000000..b7f24fe --- /dev/null +++ b/test/services/transport_service_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/transport_segment.dart'; +import 'package:travel_mate/services/transport_service.dart'; + +void main() { + const tripId = 'trip-transport-1'; + late TransportService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = TransportService(); + }); + + test('adds and loads a segment', () async { + final seg = TransportSegment.newSegment( + id: 'seg1', + type: 'flight', + carrier: 'AF', + number: '763', + departureCode: 'CDG', + arrivalCode: 'JFK', + departureUtc: DateTime.utc(2026, 4, 10, 7, 0), + arrivalUtc: DateTime.utc(2026, 4, 10, 11, 0), + pnr: 'ABC123', + ); + + await service.addSegment(tripId, seg); + final loaded = await service.loadSegments(tripId); + + expect(loaded, hasLength(1)); + expect(loaded.first.number, '763'); + expect(loaded.first.status, 'scheduled'); + }); + + test('updates status', () async { + final seg = TransportSegment.newSegment( + id: 'seg1', + type: 'train', + carrier: 'TGV', + number: '8401', + departureCode: 'PAR', + arrivalCode: 'BRU', + departureUtc: DateTime.utc(2026, 5, 1, 8, 30), + arrivalUtc: DateTime.utc(2026, 5, 1, 10, 30), + ); + await service.addSegment(tripId, seg); + + final updated = await service.updateStatus(tripId, 'seg1', 'delayed'); + + expect(updated.first.status, 'delayed'); + }); + + test('deletes segment and handles corrupted payload', () async { + final seg = TransportSegment.newSegment( + id: 'seg1', + type: 'bus', + carrier: 'FLX', + number: '12', + departureCode: 'AMS', + arrivalCode: 'BRU', + departureUtc: DateTime.utc(2026, 6, 1, 9, 0), + arrivalUtc: DateTime.utc(2026, 6, 1, 11, 30), + ); + await service.addSegment(tripId, seg); + await service.deleteSegment(tripId, 'seg1'); + + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('trip_transport_$tripId', 'bad-json'); + + final loaded = await service.loadSegments(tripId); + expect(loaded, isEmpty); + expect(prefs.getString('trip_transport_$tripId'), isNull); + }); +} diff --git a/test/services/trip_checklist_service_test.dart b/test/services/trip_checklist_service_test.dart new file mode 100644 index 0000000..0be3ffa --- /dev/null +++ b/test/services/trip_checklist_service_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/checklist_item.dart'; +import 'package:travel_mate/services/trip_checklist_service.dart'; + +void main() { + late TripChecklistService service; + const tripId = 'trip-123'; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = TripChecklistService(); + }); + + test('adds and loads checklist items', () async { + final created = ChecklistItem.newItem(id: '1', label: 'Préparer passeport'); + await service.addItem(tripId, created); + + final result = await service.loadChecklist(tripId); + + expect(result, hasLength(1)); + expect(result.first.label, 'Préparer passeport'); + expect(result.first.isDone, isFalse); + }); + + test('toggles completion state', () async { + final item = ChecklistItem.newItem(id: '1', label: 'Acheter billets'); + await service.addItem(tripId, item); + + final toggled = await service.toggleItem(tripId, '1'); + + expect(toggled.first.isDone, isTrue); + + final toggledBack = await service.toggleItem(tripId, '1'); + expect(toggledBack.first.isDone, isFalse); + }); + + test('deletes items and keeps list consistent', () async { + final itemA = ChecklistItem.newItem(id: '1', label: 'Adapter prise'); + final itemB = ChecklistItem.newItem(id: '2', label: 'Chargeur'); + await service.addItem(tripId, itemA); + await service.addItem(tripId, itemB); + + final afterDelete = await service.deleteItem(tripId, '1'); + + expect(afterDelete, hasLength(1)); + expect(afterDelete.first.id, '2'); + + final persisted = await service.loadChecklist(tripId); + expect(persisted, hasLength(1)); + expect(persisted.first.id, '2'); + }); + + test('handles corrupted payload gracefully', () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('checklist_$tripId', 'not-json'); + + final items = await service.loadChecklist(tripId); + + expect(items, isEmpty); + expect(prefs.getString('checklist_$tripId'), isNull); + }); +} diff --git a/test/services/trip_document_service_test.dart b/test/services/trip_document_service_test.dart new file mode 100644 index 0000000..9c3b42b --- /dev/null +++ b/test/services/trip_document_service_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:travel_mate/models/trip_document.dart'; +import 'package:travel_mate/services/trip_document_service.dart'; + +void main() { + const tripId = 'trip-docs-1'; + late TripDocumentService service; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + service = TripDocumentService(); + }); + + test('adds and loads documents', () async { + final doc = TripDocument.newEntry( + id: 'doc1', + title: 'Billet Aller', + category: 'billet', + downloadUrl: 'https://example.com/billet.pdf', + ); + + await service.addDocument(tripId, doc); + final loaded = await service.loadDocuments(tripId); + + expect(loaded, hasLength(1)); + expect(loaded.first.title, 'Billet Aller'); + expect(loaded.first.category, 'billet'); + }); + + test('deletes a document', () async { + final a = TripDocument.newEntry( + id: 'a', + title: 'Passeport', + category: 'passeport', + ); + final b = TripDocument.newEntry( + id: 'b', + title: 'Assurance', + category: 'assurance', + ); + await service.addDocument(tripId, a); + await service.addDocument(tripId, b); + + final afterDelete = await service.deleteDocument(tripId, 'a'); + + expect(afterDelete, hasLength(1)); + expect(afterDelete.first.id, 'b'); + }); + + test('handles corrupted payload gracefully', () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('trip_docs_$tripId', 'oops'); + + final docs = await service.loadDocuments(tripId); + + expect(docs, isEmpty); + expect(prefs.getString('trip_docs_$tripId'), isNull); + }); +}