diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index c7d3028..51f8c36 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -77,5 +77,23 @@ + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8e24ffb..d9a4a3e 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -32,6 +32,14 @@ com.googleusercontent.apps.521527250907-3i1qe2656eojs8k9hjdi573j09i9p41m + + CFBundleURLName + Apple Sign-In + CFBundleURLSchemes + + $(PRODUCT_BUNDLE_IDENTIFIER) + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) @@ -67,17 +75,12 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CFBundleURLTypes - - - CFBundleURLName - Apple Sign-In - CFBundleURLSchemes - - $(PRODUCT_BUNDLE_IDENTIFIER) - - - + + LSApplicationQueriesSchemes + + comgooglemaps + waze + NSCameraUsageDescription L'application a besoin d'accéder à votre caméra pour prendre des photos de profil. diff --git a/lib/components/activities/activities_page.dart b/lib/components/activities/activities_page.dart index 41441a0..152379e 100644 --- a/lib/components/activities/activities_page.dart +++ b/lib/components/activities/activities_page.dart @@ -12,6 +12,7 @@ import '../loading/laoding_content.dart'; import '../../blocs/user/user_bloc.dart'; import '../../blocs/user/user_state.dart'; import '../../services/error_service.dart'; +import 'activity_detail_dialog.dart'; class ActivitiesPage extends StatefulWidget { final Trip trip; @@ -636,315 +637,330 @@ class _ActivitiesPageState extends State activity.name.toLowerCase().trim(), ); - return Card( - margin: const EdgeInsets.only(bottom: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Image de l'activité - if (activity.imageUrl != null && activity.imageUrl!.isNotEmpty) - ClipRRect( - borderRadius: const BorderRadius.vertical( - top: Radius.circular(12), - ), - child: SizedBox( - height: 200, - width: double.infinity, - child: Image.network( - activity.imageUrl!, - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) { - return Container( - height: 200, - color: theme.colorScheme.surfaceContainerHighest, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.image_not_supported, - size: 48, - color: theme.colorScheme.onSurfaceVariant, - ), - const SizedBox(height: 8), - Text( - 'Image non disponible', - style: theme.textTheme.bodySmall?.copyWith( + return GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => ActivityDetailDialog(activity: activity), + ); + }, + child: Card( + margin: const EdgeInsets.only(bottom: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image de l'activité + if (activity.imageUrl != null && activity.imageUrl!.isNotEmpty) + ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(12), + ), + child: SizedBox( + height: 200, + width: double.infinity, + child: Image.network( + activity.imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + height: 200, + color: theme.colorScheme.surfaceContainerHighest, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.image_not_supported, + size: 48, color: theme.colorScheme.onSurfaceVariant, ), - ), - ], - ), - ); - }, - loadingBuilder: (context, child, loadingProgress) { - if (loadingProgress == null) return child; - return Container( - height: 200, - color: theme.colorScheme.surfaceContainerHighest, - child: Center( - child: CircularProgressIndicator( - value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! - : null, + const SizedBox(height: 8), + Text( + 'Image non disponible', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], ), - ), - ); - }, + ); + }, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) return child; + return Container( + height: 200, + color: theme.colorScheme.surfaceContainerHighest, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + ), ), ), - ), - // Contenu de la carte - Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - // Icône de catégorie - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: theme.colorScheme.primary.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - _getCategoryIcon(activity.category), - color: theme.colorScheme.primary, - size: 20, - ), - ), - const SizedBox(width: 12), - // Nom et catégorie - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - activity.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - Text( - activity.category, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.primary, - ), - ), - ], - ), - ), - // Note - if (activity.rating != null) + // Contenu de la carte + Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + // Icône de catégorie Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), + padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: Colors.amber.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), + color: theme.colorScheme.primary.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular(8), ), - child: Row( - mainAxisSize: MainAxisSize.min, + child: Icon( + _getCategoryIcon(activity.category), + color: theme.colorScheme.primary, + size: 20, + ), + ), + const SizedBox(width: 12), + // Nom et catégorie + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Icon( - Icons.star, - color: Colors.amber, - size: 16, - ), - const SizedBox(width: 4), Text( - activity.rating!.toStringAsFixed(1), - style: theme.textTheme.bodySmall?.copyWith( + activity.name, + style: theme.textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, ), ), + Text( + activity.category, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.primary, + ), + ), ], ), ), - ], - ), - if (activity.description.isNotEmpty) ...[ - const SizedBox(height: 12), - Text( - activity.description, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withValues(alpha: 0.8), - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - ], - if (activity.address != null) ...[ - const SizedBox(height: 8), - Row( - children: [ - Icon( - Icons.location_on, - size: 16, - color: theme.colorScheme.onSurface.withValues( - alpha: 0.6, - ), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - activity.address!, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withValues( - alpha: 0.6, - ), - ), - ), - ), - ], - ), - ], - const SizedBox(height: 12), - // Boutons d'action et votes (différents selon le contexte) - if (isGoogleSuggestion) ...[ - // Pour les suggestions Google : bouton d'ajout ou indication si déjà ajoutée - Row( - children: [ - if (activityAlreadyExists) ...[ - // Activité déjà dans le voyage + // Note + if (activity.rating != null) Container( padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 8, + horizontal: 8, + vertical: 4, ), decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.1), + color: Colors.amber.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.orange.withValues(alpha: 0.3), - ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.check_circle, + const Icon( + Icons.star, + color: Colors.amber, size: 16, - color: Colors.orange.shade700, ), - const SizedBox(width: 6), + const SizedBox(width: 4), Text( - 'Déjà dans le voyage', + activity.rating!.toStringAsFixed(1), style: theme.textTheme.bodySmall?.copyWith( - color: Colors.orange.shade700, fontWeight: FontWeight.w600, ), ), ], ), ), - ] else ...[ - // Bouton pour ajouter l'activité + ], + ), + if (activity.description.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + activity.description, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.8, + ), + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + if (activity.address != null) ...[ + const SizedBox(height: 8), + Row( + children: [ + Icon( + Icons.location_on, + size: 16, + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, + ), + ), + const SizedBox(width: 4), Expanded( - child: ElevatedButton.icon( - onPressed: () => _addGoogleActivityToTrip(activity), - icon: const Icon(Icons.add, size: 18), - label: const Text('Ajouter au voyage'), - style: ElevatedButton.styleFrom( - backgroundColor: theme.colorScheme.primary, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + child: Text( + activity.address!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withValues( + alpha: 0.6, ), ), ), ), ], - ], - ), - ] else ...[ - // Pour les activités du voyage : système de votes - Row( - children: [ - // Votes positifs (pouces verts) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${activity.positiveVotes}', - style: theme.textTheme.bodySmall?.copyWith( + ), + ], + const SizedBox(height: 12), + // Boutons d'action et votes (différents selon le contexte) + if (isGoogleSuggestion) ...[ + // Pour les suggestions Google : bouton d'ajout ou indication si déjà ajoutée + Row( + children: [ + if (activityAlreadyExists) ...[ + // Activité déjà dans le voyage + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.orange.withValues(alpha: 0.3), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.check_circle, + size: 16, + color: Colors.orange.shade700, + ), + const SizedBox(width: 6), + Text( + 'Déjà dans le voyage', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.orange.shade700, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ] else ...[ + // Bouton pour ajouter l'activité + Expanded( + child: ElevatedButton.icon( + onPressed: () => + _addGoogleActivityToTrip(activity), + icon: const Icon(Icons.add, size: 18), + label: const Text('Ajouter au voyage'), + style: ElevatedButton.styleFrom( + backgroundColor: theme.colorScheme.primary, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric( + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ], + ], + ), + ] else ...[ + // Pour les activités du voyage : système de votes + Row( + children: [ + // Votes positifs (pouces verts) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${activity.positiveVotes}', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.green.shade700, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.thumb_up, + size: 16, color: Colors.green.shade700, - fontWeight: FontWeight.w600, ), - ), - const SizedBox(width: 4), - Icon( - Icons.thumb_up, - size: 16, - color: Colors.green.shade700, - ), - ], + ], + ), ), - ), - const SizedBox(width: 8), - // Votes négatifs (pouces rouges) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: Colors.red.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${activity.negativeVotes}', - style: theme.textTheme.bodySmall?.copyWith( + const SizedBox(width: 8), + // Votes négatifs (pouces rouges) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: Colors.red.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${activity.negativeVotes}', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.red.shade700, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.thumb_down, + size: 16, color: Colors.red.shade700, - fontWeight: FontWeight.w600, ), - ), - const SizedBox(width: 4), - Icon( - Icons.thumb_down, - size: 16, - color: Colors.red.shade700, - ), - ], + ], + ), ), - ), - const Spacer(), - // Bouton J'aime/J'aime pas - IconButton( - onPressed: () => _voteForActivity(activity.id, 1), - icon: const Icon(Icons.thumb_up), - iconSize: 20, - ), - IconButton( - onPressed: () => _voteForActivity(activity.id, -1), - icon: const Icon(Icons.thumb_down), - iconSize: 20, - ), - ], - ), + const Spacer(), + // Bouton J'aime/J'aime pas + IconButton( + onPressed: () => _voteForActivity(activity.id, 1), + icon: const Icon(Icons.thumb_up), + iconSize: 20, + ), + IconButton( + onPressed: () => _voteForActivity(activity.id, -1), + icon: const Icon(Icons.thumb_down), + iconSize: 20, + ), + ], + ), + ], ], - ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/lib/components/activities/activity_detail_dialog.dart b/lib/components/activities/activity_detail_dialog.dart new file mode 100644 index 0000000..653d0b6 --- /dev/null +++ b/lib/components/activities/activity_detail_dialog.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import '../../services/map_navigation_service.dart'; +import '../../models/activity.dart'; + +class ActivityDetailDialog extends StatelessWidget { + final Activity activity; + + const ActivityDetailDialog({super.key, required this.activity}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + // final isDarkMode = theme.brightness == Brightness.dark; + + // Traduction de la catégorie + String categoryDisplay = activity.category; + final categoryEnum = ActivityCategory.values.firstWhere( + (e) => e.name == activity.category, + orElse: () => ActivityCategory.attraction, // Fallback + ); + categoryDisplay = categoryEnum.displayName; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + backgroundColor: theme.scaffoldBackgroundColor, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Image header + if (activity.imageUrl != null && activity.imageUrl!.isNotEmpty) + ClipRRect( + borderRadius: const BorderRadius.vertical( + top: Radius.circular(16), + ), + child: Image.network( + activity.imageUrl!, + height: 200, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => Container( + height: 100, + color: Colors.grey[300], + child: const Icon(Icons.image_not_supported, size: 50), + ), + ), + ), + + Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Titre et Catégorie + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + activity.name, + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 5, + ), + decoration: BoxDecoration( + color: theme.colorScheme.primary.withValues( + alpha: 0.1, + ), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + categoryDisplay, + style: TextStyle( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ), + const SizedBox(height: 16), + + // Date + if (activity.date != null) ...[ + Row( + children: [ + Icon( + Icons.calendar_today, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 8), + Text( + DateFormat( + 'EEEE d MMMM yyyy', + 'fr_FR', + ).format(activity.date!), + style: theme.textTheme.bodyMedium, + ), + ], + ), + const SizedBox(height: 16), + ], + + // Heures d'ouverture + if (activity.openingHours.isNotEmpty) ...[ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.access_time, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: activity.openingHours + .map( + (hour) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + hour, + style: theme.textTheme.bodyMedium, + ), + ), + ) + .toList(), + ), + ), + ], + ), + const SizedBox(height: 16), + ], + + // Adresse + if (activity.address != null) ...[ + Row( + children: [ + Icon( + Icons.location_on, + size: 20, + color: Colors.grey[600], + ), + const SizedBox(width: 8), + Expanded( + child: Text( + activity.address!, + style: theme.textTheme.bodyMedium, + ), + ), + ], + ), + const SizedBox(height: 16), + ], + + // Description + if (activity.description.isNotEmpty) ...[ + Text( + 'Description', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + activity.description, + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + ], + + // Votes + if (activity.votes.isNotEmpty) ...[ + const Divider(), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildVoteStat( + Icons.thumb_up, + Colors.green, + activity.positiveVotes, + 'Pour', + ), + _buildVoteStat( + Icons.thumb_down, + Colors.red, + activity.negativeVotes, + 'Contre', + ), + ], + ), + ], + ], + ), + ), + + // Boutons + Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + if (activity.latitude != null && activity.longitude != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ElevatedButton.icon( + onPressed: () { + // Déclencher la navigation + context + .read() + .navigateToLocation( + activity.latitude!, + activity.longitude!, + name: activity.name, + ); + + // Revenir à la page d'accueil (fermer le dialog et les pages empilées comme ActivitiesPage) + Navigator.of( + context, + ).popUntil((route) => route.isFirst); + }, + icon: const Icon(Icons.map_outlined), + label: const Text('Voir sur la carte de l\'app'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 24, + ), + backgroundColor: + theme.colorScheme.secondaryContainer, + foregroundColor: + theme.colorScheme.onSecondaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + const SizedBox(height: 8), + ElevatedButton.icon( + onPressed: () async { + final url = Uri.parse( + 'https://www.google.com/maps/search/?api=1&query=${activity.latitude},${activity.longitude}', + ); + if (await canLaunchUrl(url)) { + await launchUrl( + url, + mode: LaunchMode.externalApplication, + ); + } + }, + icon: const Icon(Icons.map), + label: const Text('Voir sur Google Maps'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + vertical: 12, + horizontal: 24, + ), + backgroundColor: + theme.colorScheme.primaryContainer, + foregroundColor: + theme.colorScheme.onPrimaryContainer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + ], + ), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + minimumSize: const Size(double.infinity, 45), + ), + child: const Text('Fermer'), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildVoteStat(IconData icon, Color color, int count, String label) { + return Column( + children: [ + Icon(icon, color: color), + const SizedBox(height: 4), + Text( + '$count', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: color, + ), + ), + Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)), + ], + ); + } +} diff --git a/lib/components/group/chat_group_content.dart b/lib/components/group/chat_group_content.dart index 307df71..13e1707 100644 --- a/lib/components/group/chat_group_content.dart +++ b/lib/components/group/chat_group_content.dart @@ -406,12 +406,10 @@ class _ChatGroupContentState extends State { } // Trouver le membre qui a envoyé le message pour récupérer son pseudo actuel - final senderMember = - widget.group.members.firstWhere( - (m) => m.userId == message.senderId, - orElse: () => null as dynamic, - ) - as dynamic; + final senderMember = widget.group.members.cast().firstWhere( + (m) => m?.userId == message.senderId, + orElse: () => null, + ); // Utiliser le pseudo actuel du membre, ou le senderName en fallback final displayName = senderMember != null diff --git a/lib/components/home/calendar/calendar_page.dart b/lib/components/home/calendar/calendar_page.dart index 5c23c3e..75cdc27 100644 --- a/lib/components/home/calendar/calendar_page.dart +++ b/lib/components/home/calendar/calendar_page.dart @@ -20,7 +20,7 @@ class CalendarPage extends StatefulWidget { class _CalendarPageState extends State { late DateTime _focusedDay; DateTime? _selectedDay; - CalendarFormat _calendarFormat = CalendarFormat.week; + final CalendarFormat _calendarFormat = CalendarFormat.week; @override void initState() { @@ -387,20 +387,25 @@ class _CalendarPageState extends State { // Simple mapping based on category name // You might want to use the enum if possible, but category is String in Activity model if (category.toLowerCase().contains('musée') || - category.toLowerCase().contains('museum')) + category.toLowerCase().contains('museum')) { return Colors.blue; + } if (category.toLowerCase().contains('restaurant') || - category.toLowerCase().contains('food')) + category.toLowerCase().contains('food')) { return Colors.orange; + } if (category.toLowerCase().contains('nature') || - category.toLowerCase().contains('park')) + category.toLowerCase().contains('park')) { return Colors.green; + } if (category.toLowerCase().contains('photo') || - category.toLowerCase().contains('attraction')) + category.toLowerCase().contains('attraction')) { return Colors.purple; + } if (category.toLowerCase().contains('détente') || - category.toLowerCase().contains('relax')) + category.toLowerCase().contains('relax')) { return Colors.pink; + } return Colors.teal; } @@ -409,8 +414,9 @@ class _CalendarPageState extends State { 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')) + if (category.toLowerCase().contains('détente')) { return Icons.icecream; // Gelato icon :) + } return Icons.place; } } diff --git a/lib/components/home/show_trip_details_content.dart b/lib/components/home/show_trip_details_content.dart index 1aaca20..86d4938 100644 --- a/lib/components/home/show_trip_details_content.dart +++ b/lib/components/home/show_trip_details_content.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.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'; @@ -9,11 +9,14 @@ 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'; @@ -22,7 +25,7 @@ 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/models/settlement.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'; @@ -238,30 +241,38 @@ class _ShowTripDetailsContentState extends State { // Méthode pour ouvrir Waze Future _openWaze() async { - final location = Uri.encodeComponent(widget.trip.location); - try { - // Essayer d'abord l'URL scheme pour l'app mobile - final appUrl = 'waze://?q=$location'; - final appUri = Uri.parse(appUrl); - if (await canLaunchUrl(appUri)) { - await launchUrl(appUri); - return; + 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}', + ); } - // Fallback vers l'URL web - final webUrl = 'https://waze.com/ul?q=$location'; - final webUri = Uri.parse(webUrl); - if (await canLaunchUrl(webUri)) { - await launchUrl(webUri, mode: LaunchMode.externalApplication); - return; - } + final uri = Uri.parse(wazeUrl); - _errorService.showError( - message: - 'Impossible d\'ouvrir Waze. Vérifiez que l\'application est installée.', - ); + 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'); } } @@ -1035,14 +1046,7 @@ class _ShowTripDetailsContentState extends State { if (activity.date == null) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(bottom: 12), - child: _buildActivityCard( - title: activity.name, - date: DateFormat( - 'd MMM, HH:mm', - 'fr_FR', - ).format(activity.date!), - icon: _getCategoryIcon(activity.category), - ), + child: _buildActivityCard(activity: activity), ); }), ], @@ -1060,70 +1064,79 @@ class _ShowTripDetailsContentState extends State { return Icons.place; } - Widget _buildActivityCard({ - required String title, - required String date, - required IconData icon, - }) { + Widget _buildActivityCard({required Activity activity}) { final theme = Theme.of(context); final isDarkMode = theme.brightness == Brightness.dark; - return 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, + 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), + ), + ], ), - 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: 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), ), - child: Icon(icon, color: Colors.teal, size: 24), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.bold, - color: theme.colorScheme.onSurface, + 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), + 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), - ), - ], + Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurface.withValues(alpha: 0.4), + ), + ], + ), ), ); } @@ -1214,13 +1227,7 @@ class _ShowTripDetailsContentState extends State { '${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'; + '${debtsToReceive.map((s) => '${s.fromUserName} (${s.amount.toStringAsFixed(2)}€)').join(' et ')} vous doivent de l\'argent'; } } } diff --git a/lib/components/map/map_content.dart b/lib/components/map/map_content.dart index 11f87d1..c3d033a 100644 --- a/lib/components/map/map_content.dart +++ b/lib/components/map/map_content.dart @@ -6,6 +6,9 @@ import 'package:http/http.dart' as http; import 'dart:ui' as ui; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../services/error_service.dart'; +import '../../services/map_navigation_service.dart'; +import '../../services/logger_service.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; class MapContent extends StatefulWidget { final String? initialSearchQuery; @@ -33,8 +36,37 @@ class _MapContentState extends State { @override void initState() { super.initState(); - // Si une recherche initiale est fournie, la pré-remplir et lancer la recherche - if (widget.initialSearchQuery != null && + + final mapService = context.read(); + + // Écouter les nouvelles demandes + mapService.requestStream.listen((request) { + LoggerService.info( + 'MapContent: Received navigation request: ${request.name}', + ); + _handleNavigationRequest(request); + }); + + // Vérifier s'il y a une demande de navigation en attente + if (mapService.lastRequest != null) { + LoggerService.info( + 'MapContent: Found pending navigation request: ${mapService.lastRequest!.name}', + ); + // Handle synchronously for initial build + final request = mapService.lastRequest!; + final position = LatLng(request.latitude, request.longitude); + _initialPosition = position; + _markers.add( + Marker( + markerId: MarkerId( + 'nav_request_${request.timestamp.millisecondsSinceEpoch}', + ), + position: position, + infoWindow: InfoWindow(title: request.name ?? 'Lieu sélectionné'), + ), + ); + // Ne pas lancer _getCurrentLocation() ici pour ne pas écraser la position + } else if (widget.initialSearchQuery != null && widget.initialSearchQuery!.isNotEmpty) { _searchController.text = widget.initialSearchQuery!; // Lancer la recherche automatiquement après un court délai pour laisser l'interface se charger @@ -47,6 +79,54 @@ class _MapContentState extends State { } } + void _handleNavigationRequest(MapLocationRequest request) { + if (!mounted) return; + + LoggerService.info( + 'MapContent: Handling navigation request to ${request.latitude}, ${request.longitude}', + ); + final position = LatLng(request.latitude, request.longitude); + + setState(() { + // Garder le marqueur de position utilisateur + _markers.removeWhere((m) => m.markerId.value != 'user_location'); + + // Ajouter le marqueur pour le lieu demandé + _markers.add( + Marker( + markerId: MarkerId( + 'nav_request_${request.timestamp.millisecondsSinceEpoch}', + ), + position: position, + infoWindow: InfoWindow(title: request.name ?? 'Lieu sélectionné'), + ), + ); + _isSearching = false; + }); + + // Animer la caméra si le contrôleur est prêt + if (_mapController != null) { + LoggerService.info( + 'MapContent: Waiting for map to be visible before animating', + ); + // Attendre un peu que l'onglet change et que la carte soit visible + Future.delayed(const Duration(milliseconds: 500), () { + if (mounted && _mapController != null) { + LoggerService.info('MapContent: Animating camera to position'); + _mapController!.animateCamera( + CameraUpdate.newLatLngZoom(position, 15), + ); + } + }); + } else { + LoggerService.info( + 'MapContent: MapController not ready, setting initial position', + ); + // Si le contrôleur n'est pas encore prêt, définir la position initiale + _initialPosition = position; + } + } + @override void dispose() { _searchController.dispose(); diff --git a/lib/main.dart b/lib/main.dart index 1ea6c56..5f348bf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -13,6 +13,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:travel_mate/services/expense_service.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:travel_mate/services/notification_service.dart'; +import 'package:travel_mate/services/map_navigation_service.dart'; import 'blocs/auth/auth_bloc.dart'; import 'blocs/auth/auth_event.dart'; import 'blocs/theme/theme_bloc.dart'; @@ -127,6 +128,10 @@ class MyApp extends StatelessWidget { expenseRepository: context.read(), ), ), + // Map navigation service + RepositoryProvider( + create: (context) => MapNavigationService(), + ), ], child: MultiBlocProvider( providers: [ diff --git a/lib/pages/home.dart b/lib/pages/home.dart index 988ac98..9592c27 100644 --- a/lib/pages/home.dart +++ b/lib/pages/home.dart @@ -12,6 +12,8 @@ import '../blocs/user/user_event.dart'; import '../blocs/auth/auth_bloc.dart'; import '../blocs/auth/auth_event.dart'; import '../services/error_service.dart'; +import '../services/notification_service.dart'; +import '../services/map_navigation_service.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -37,6 +39,24 @@ class _HomePageState extends State { super.initState(); // Initialiser les données utilisateur context.read().add(UserInitialized()); + + // Setup notifications listener and check for initial message + final notificationService = NotificationService(); + notificationService.startListening(); + + // Check for initial message after a slight delay to ensure the widget tree is fully built + WidgetsBinding.instance.addPostFrameCallback((_) { + notificationService.handleInitialMessage(); + }); + + // Écouter les demandes de navigation vers la carte + context.read().requestStream.listen((request) { + if (_currentIndex != 2) { + setState(() { + _currentIndex = 2; + }); + } + }); } Widget _buildPage(int index) { diff --git a/lib/services/activity_places_service.dart b/lib/services/activity_places_service.dart index 3f0b785..aeaa64a 100644 --- a/lib/services/activity_places_service.dart +++ b/lib/services/activity_places_service.dart @@ -116,7 +116,7 @@ class ActivityPlacesService { final encodedDestination = Uri.encodeComponent(destination); final url = - 'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey'; + 'https://maps.googleapis.com/maps/api/geocode/json?address=$encodedDestination&key=$_apiKey&language=fr'; LoggerService.info('ActivityPlacesService: Géocodage de "$destination"'); LoggerService.info('ActivityPlacesService: URL = $url'); @@ -184,7 +184,8 @@ class ActivityPlacesService { '?location=$lat,$lng' '&radius=$radius' '&type=${category.googlePlaceType}' - '&key=$_apiKey'; + '&key=$_apiKey' + '&language=fr'; final response = await http.get(Uri.parse(url)); @@ -287,7 +288,8 @@ class ActivityPlacesService { 'https://maps.googleapis.com/maps/api/place/details/json' '?place_id=$placeId' '&fields=formatted_address,formatted_phone_number,website,opening_hours,editorial_summary' - '&key=$_apiKey'; + '&key=$_apiKey' + '&language=fr'; final response = await http.get(Uri.parse(url)); @@ -356,7 +358,8 @@ class ActivityPlacesService { '?query=$encodedQuery in $destination' '&location=${coordinates['lat']},${coordinates['lng']}' '&radius=$radius' - '&key=$_apiKey'; + '&key=$_apiKey' + '&language=fr'; final response = await http.get(Uri.parse(url)); @@ -513,7 +516,8 @@ class ActivityPlacesService { '?location=$lat,$lng' '&radius=$radius' '&type=${category.googlePlaceType}' - '&key=$_apiKey'; + '&key=$_apiKey' + '&language=fr'; if (nextPageToken != null) { url += '&pagetoken=$nextPageToken'; @@ -589,7 +593,8 @@ class ActivityPlacesService { '?location=$lat,$lng' '&radius=$radius' '&type=tourist_attraction' - '&key=$_apiKey'; + '&key=$_apiKey' + '&language=fr'; if (nextPageToken != null) { url += '&pagetoken=$nextPageToken'; diff --git a/lib/services/map_navigation_service.dart b/lib/services/map_navigation_service.dart new file mode 100644 index 0000000..1ee7b51 --- /dev/null +++ b/lib/services/map_navigation_service.dart @@ -0,0 +1,36 @@ +import 'dart:async'; + +class MapLocationRequest { + final double latitude; + final double longitude; + final String? name; + final DateTime timestamp; + + MapLocationRequest({ + required this.latitude, + required this.longitude, + this.name, + }) : timestamp = DateTime.now(); +} + +class MapNavigationService { + final _requestController = StreamController.broadcast(); + MapLocationRequest? _lastRequest; + + Stream get requestStream => _requestController.stream; + MapLocationRequest? get lastRequest => _lastRequest; + + void navigateToLocation(double lat, double lng, {String? name}) { + final request = MapLocationRequest( + latitude: lat, + longitude: lng, + name: name, + ); + _lastRequest = request; + _requestController.add(request); + } + + void dispose() { + _requestController.close(); + } +} diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 87ef771..a8c8a85 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -4,6 +4,12 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:travel_mate/services/logger_service.dart'; +import 'package:flutter/material.dart'; +import 'package:travel_mate/services/error_service.dart'; +import 'package:travel_mate/repositories/group_repository.dart'; +import 'package:travel_mate/repositories/account_repository.dart'; +import 'package:travel_mate/components/group/chat_group_content.dart'; +import 'package:travel_mate/components/account/group_expenses_page.dart'; @pragma('vm:entry-point') Future firebaseMessagingBackgroundHandler(RemoteMessage message) async { @@ -43,6 +49,7 @@ class NotificationService { onDidReceiveNotificationResponse: (details) { // Handle notification tap LoggerService.info('Notification tapped: ${details.payload}'); + // TODO: Handle local notification tap if needed, usually we rely on FCM callbacks }, ); @@ -52,6 +59,10 @@ class NotificationService { // Handle token refresh FirebaseMessaging.instance.onTokenRefresh.listen(_onTokenRefresh); + // Setup interacted message (Deep Linking) + // We don't call this here anymore, it will be called from HomePage + // await setupInteractedMessage(); + _isInitialized = true; LoggerService.info('NotificationService initialized'); @@ -60,6 +71,87 @@ class NotificationService { LoggerService.info('Current FCM Token: $token'); } + /// Sets up the background message listener. + /// Should be called when the app is ready to handle navigation. + void startListening() { + // Handle any interaction when the app is in the background via a + // Stream listener + FirebaseMessaging.onMessageOpenedApp.listen((RemoteMessage message) { + _handleNotificationTap(message.data); + }); + } + + /// Checks for an initial message (app opened from terminated state) + /// and handles it if present. + Future handleInitialMessage() async { + // Get any messages which caused the application to open from + // a terminated state. + RemoteMessage? initialMessage = await _firebaseMessaging + .getInitialMessage(); + + if (initialMessage != null) { + LoggerService.info('Found initial message: ${initialMessage.data}'); + _handleNotificationTap(initialMessage.data); + } + } + + Future _handleNotificationTap(Map data) async { + LoggerService.info('Handling notification tap with data: $data'); + // DEBUG: Show snackbar to verify payload + // ErrorService().showSnackbar(message: 'Debug: Payload $data', isError: false); + + final type = data['type']; + + try { + if (type == 'message') { + final groupId = data['groupId']; + if (groupId != null) { + final groupRepository = GroupRepository(); + final group = await groupRepository.getGroupById(groupId); + if (group != null) { + ErrorService.navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (context) => ChatGroupContent(group: group), + ), + ); + } else { + LoggerService.error('Group not found: $groupId'); + // ErrorService().showError(message: 'Groupe introuvable: $groupId'); + } + } else { + LoggerService.error('Missing groupId in payload'); + // ErrorService().showError(message: 'Payload invalide: groupId manquant'); + } + } else if (type == 'expense') { + final tripId = data['tripId']; + if (tripId != null) { + final accountRepository = AccountRepository(); + final groupRepository = GroupRepository(); + + final account = await accountRepository.getAccountByTripId(tripId); + final group = await groupRepository.getGroupByTripId(tripId); + + if (account != null && group != null) { + ErrorService.navigatorKey.currentState?.push( + MaterialPageRoute( + builder: (context) => + GroupExpensesPage(account: account, group: group), + ), + ); + } else { + LoggerService.error('Account or Group not found for trip: $tripId'); + // ErrorService().showError(message: 'Compte ou Groupe introuvable'); + } + } + } else { + LoggerService.info('Unknown notification type: $type'); + } + } catch (e) { + LoggerService.error('Error handling notification tap: $e'); + ErrorService().showError(message: 'Erreur navigation: $e'); + } + } + Future _onTokenRefresh(String newToken) async { LoggerService.info('FCM Token refreshed: $newToken'); // We need the user ID to save the token.