Files
TravelMate/lib/components/home/show_trip_details_content.dart
Van Leemput Dayron 9b08b2896c feat: Add services for managing trip-related data
- Implement EmergencyService for handling emergency contacts per trip.
- Create GuestFlagService to manage guest mode flags for trips.
- Introduce NotificationService with local notification capabilities.
- Add OfflineFlagService for managing offline caching flags.
- Develop PackingService for shared packing lists per trip.
- Implement ReminderService for managing reminders/to-dos per trip.
- Create SosService for dispatching SOS events to a backend.
- Add StorageService with album image upload functionality.
- Implement TransportService for managing transport segments per trip.
- Create TripChecklistService for storing and retrieving trip checklists.
- Add TripDocumentService for persisting trip documents metadata.

test: Add unit tests for new services

- Implement tests for AlbumService, BudgetService, EmergencyService, GuestFlagService, PackingService, ReminderService, SosService, TransportService, TripChecklistService, and TripDocumentService.
- Ensure tests cover adding, loading, deleting, and handling corrupted payloads for each service.
2026-03-13 15:02:23 +01:00

4072 lines
139 KiB
Dart
Raw Blame History

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