feat: Add map navigation, enhance FCM deep linking, localize Google Places API, and refine activity display.

This commit is contained in:
Van Leemput Dayron
2025-12-04 11:24:30 +01:00
parent cf4c6447dd
commit e174c1274d
13 changed files with 991 additions and 383 deletions

View File

@@ -77,5 +77,23 @@
<action android:name="android.intent.action.PROCESS_TEXT"/> <action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/> <data android:mimeType="text/plain"/>
</intent> </intent>
<!-- Waze -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="waze" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" android:host="www.waze.com" />
</intent>
<!-- Google Maps -->
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="google.navigation" />
</intent>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="https" android:host="www.google.com" />
</intent>
</queries> </queries>
</manifest> </manifest>

View File

@@ -32,6 +32,14 @@
<string>com.googleusercontent.apps.521527250907-3i1qe2656eojs8k9hjdi573j09i9p41m</string> <string>com.googleusercontent.apps.521527250907-3i1qe2656eojs8k9hjdi573j09i9p41m</string>
</array> </array>
</dict> </dict>
<dict>
<key>CFBundleURLName</key>
<string>Apple Sign-In</string>
<key>CFBundleURLSchemes</key>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
@@ -67,17 +75,12 @@
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>CFBundleURLTypes</key>
<array> <key>LSApplicationQueriesSchemes</key>
<dict> <array>
<key>CFBundleURLName</key> <string>comgooglemaps</string>
<string>Apple Sign-In</string> <string>waze</string>
<key>CFBundleURLSchemes</key> </array>
<array>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
</array>
</dict>
</array>
<!-- Permission Caméra --> <!-- Permission Caméra -->
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
<string>L'application a besoin d'accéder à votre caméra pour prendre des photos de profil.</string> <string>L'application a besoin d'accéder à votre caméra pour prendre des photos de profil.</string>

View File

@@ -12,6 +12,7 @@ import '../loading/laoding_content.dart';
import '../../blocs/user/user_bloc.dart'; import '../../blocs/user/user_bloc.dart';
import '../../blocs/user/user_state.dart'; import '../../blocs/user/user_state.dart';
import '../../services/error_service.dart'; import '../../services/error_service.dart';
import 'activity_detail_dialog.dart';
class ActivitiesPage extends StatefulWidget { class ActivitiesPage extends StatefulWidget {
final Trip trip; final Trip trip;
@@ -636,315 +637,330 @@ class _ActivitiesPageState extends State<ActivitiesPage>
activity.name.toLowerCase().trim(), activity.name.toLowerCase().trim(),
); );
return Card( return GestureDetector(
margin: const EdgeInsets.only(bottom: 12), onTap: () {
child: Column( showDialog(
crossAxisAlignment: CrossAxisAlignment.start, context: context,
children: [ builder: (context) => ActivityDetailDialog(activity: activity),
// Image de l'activité );
if (activity.imageUrl != null && activity.imageUrl!.isNotEmpty) },
ClipRRect( child: Card(
borderRadius: const BorderRadius.vertical( margin: const EdgeInsets.only(bottom: 12),
top: Radius.circular(12), child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
child: SizedBox( children: [
height: 200, // Image de l'activité
width: double.infinity, if (activity.imageUrl != null && activity.imageUrl!.isNotEmpty)
child: Image.network( ClipRRect(
activity.imageUrl!, borderRadius: const BorderRadius.vertical(
fit: BoxFit.cover, top: Radius.circular(12),
errorBuilder: (context, error, stackTrace) { ),
return Container( child: SizedBox(
height: 200, height: 200,
color: theme.colorScheme.surfaceContainerHighest, width: double.infinity,
child: Column( child: Image.network(
mainAxisAlignment: MainAxisAlignment.center, activity.imageUrl!,
children: [ fit: BoxFit.cover,
Icon( errorBuilder: (context, error, stackTrace) {
Icons.image_not_supported, return Container(
size: 48, height: 200,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.surfaceContainerHighest,
), child: Column(
const SizedBox(height: 8), mainAxisAlignment: MainAxisAlignment.center,
Text( children: [
'Image non disponible', Icon(
style: theme.textTheme.bodySmall?.copyWith( Icons.image_not_supported,
size: 48,
color: theme.colorScheme.onSurfaceVariant, color: theme.colorScheme.onSurfaceVariant,
), ),
), 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,
), ),
), );
); },
}, 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 // Contenu de la carte
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
children: [ children: [
// Icône de catégorie // 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)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.all(8),
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.amber.withValues(alpha: 0.1), color: theme.colorScheme.primary.withValues(
borderRadius: BorderRadius.circular(12), alpha: 0.1,
),
borderRadius: BorderRadius.circular(8),
), ),
child: Row( child: Icon(
mainAxisSize: MainAxisSize.min, _getCategoryIcon(activity.category),
color: theme.colorScheme.primary,
size: 20,
),
),
const SizedBox(width: 12),
// Nom et catégorie
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const Icon(
Icons.star,
color: Colors.amber,
size: 16,
),
const SizedBox(width: 4),
Text( Text(
activity.rating!.toStringAsFixed(1), activity.name,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
), ),
Text(
activity.category,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.primary,
),
),
], ],
), ),
), ),
], // Note
), if (activity.rating != null)
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
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 12, horizontal: 8,
vertical: 8, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1), color: Colors.amber.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.orange.withValues(alpha: 0.3),
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( const Icon(
Icons.check_circle, Icons.star,
color: Colors.amber,
size: 16, size: 16,
color: Colors.orange.shade700,
), ),
const SizedBox(width: 6), const SizedBox(width: 4),
Text( Text(
'Déjà dans le voyage', activity.rating!.toStringAsFixed(1),
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: Colors.orange.shade700,
fontWeight: FontWeight.w600, 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( Expanded(
child: ElevatedButton.icon( child: Text(
onPressed: () => _addGoogleActivityToTrip(activity), activity.address!,
icon: const Icon(Icons.add, size: 18), style: theme.textTheme.bodySmall?.copyWith(
label: const Text('Ajouter au voyage'), color: theme.colorScheme.onSurface.withValues(
style: ElevatedButton.styleFrom( alpha: 0.6,
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
), ),
), ),
), ),
], ],
], ),
), ],
] else ...[ const SizedBox(height: 12),
// Pour les activités du voyage : système de votes // Boutons d'action et votes (différents selon le contexte)
Row( if (isGoogleSuggestion) ...[
children: [ // Pour les suggestions Google : bouton d'ajout ou indication si déjà ajoutée
// Votes positifs (pouces verts) Row(
Container( children: [
padding: const EdgeInsets.symmetric( if (activityAlreadyExists) ...[
horizontal: 8, // Activité déjà dans le voyage
vertical: 4, Container(
), padding: const EdgeInsets.symmetric(
decoration: BoxDecoration( horizontal: 12,
color: Colors.green.withValues(alpha: 0.1), vertical: 8,
borderRadius: BorderRadius.circular(12), ),
), decoration: BoxDecoration(
child: Row( color: Colors.orange.withValues(alpha: 0.1),
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.circular(12),
children: [ border: Border.all(
Text( color: Colors.orange.withValues(alpha: 0.3),
'${activity.positiveVotes}', ),
style: theme.textTheme.bodySmall?.copyWith( ),
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, 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),
const SizedBox(width: 8), // Votes négatifs (pouces rouges)
// Votes négatifs (pouces rouges) Container(
Container( padding: const EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric( horizontal: 8,
horizontal: 8, vertical: 4,
vertical: 4, ),
), decoration: BoxDecoration(
decoration: BoxDecoration( color: Colors.red.withValues(alpha: 0.1),
color: Colors.red.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(12), ),
), child: Row(
child: Row( mainAxisSize: MainAxisSize.min,
mainAxisSize: MainAxisSize.min, children: [
children: [ Text(
Text( '${activity.negativeVotes}',
'${activity.negativeVotes}', style: theme.textTheme.bodySmall?.copyWith(
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, color: Colors.red.shade700,
fontWeight: FontWeight.w600,
), ),
), ],
const SizedBox(width: 4), ),
Icon(
Icons.thumb_down,
size: 16,
color: Colors.red.shade700,
),
],
), ),
), const Spacer(),
const Spacer(), // Bouton J'aime/J'aime pas
// Bouton J'aime/J'aime pas IconButton(
IconButton( onPressed: () => _voteForActivity(activity.id, 1),
onPressed: () => _voteForActivity(activity.id, 1), icon: const Icon(Icons.thumb_up),
icon: const Icon(Icons.thumb_up), iconSize: 20,
iconSize: 20, ),
), IconButton(
IconButton( onPressed: () => _voteForActivity(activity.id, -1),
onPressed: () => _voteForActivity(activity.id, -1), icon: const Icon(Icons.thumb_down),
icon: const Icon(Icons.thumb_down), iconSize: 20,
iconSize: 20, ),
), ],
], ),
), ],
], ],
], ),
), ),
), ],
], ),
), ),
); );
} }

View File

@@ -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<MapNavigationService>()
.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)),
],
);
}
}

View File

@@ -406,12 +406,10 @@ class _ChatGroupContentState extends State<ChatGroupContent> {
} }
// Trouver le membre qui a envoyé le message pour récupérer son pseudo actuel // Trouver le membre qui a envoyé le message pour récupérer son pseudo actuel
final senderMember = final senderMember = widget.group.members.cast<GroupMember?>().firstWhere(
widget.group.members.firstWhere( (m) => m?.userId == message.senderId,
(m) => m.userId == message.senderId, orElse: () => null,
orElse: () => null as dynamic, );
)
as dynamic;
// Utiliser le pseudo actuel du membre, ou le senderName en fallback // Utiliser le pseudo actuel du membre, ou le senderName en fallback
final displayName = senderMember != null final displayName = senderMember != null

View File

@@ -20,7 +20,7 @@ class CalendarPage extends StatefulWidget {
class _CalendarPageState extends State<CalendarPage> { class _CalendarPageState extends State<CalendarPage> {
late DateTime _focusedDay; late DateTime _focusedDay;
DateTime? _selectedDay; DateTime? _selectedDay;
CalendarFormat _calendarFormat = CalendarFormat.week; final CalendarFormat _calendarFormat = CalendarFormat.week;
@override @override
void initState() { void initState() {
@@ -387,20 +387,25 @@ class _CalendarPageState extends State<CalendarPage> {
// Simple mapping based on category name // Simple mapping based on category name
// You might want to use the enum if possible, but category is String in Activity model // You might want to use the enum if possible, but category is String in Activity model
if (category.toLowerCase().contains('musée') || if (category.toLowerCase().contains('musée') ||
category.toLowerCase().contains('museum')) category.toLowerCase().contains('museum')) {
return Colors.blue; return Colors.blue;
}
if (category.toLowerCase().contains('restaurant') || if (category.toLowerCase().contains('restaurant') ||
category.toLowerCase().contains('food')) category.toLowerCase().contains('food')) {
return Colors.orange; return Colors.orange;
}
if (category.toLowerCase().contains('nature') || if (category.toLowerCase().contains('nature') ||
category.toLowerCase().contains('park')) category.toLowerCase().contains('park')) {
return Colors.green; return Colors.green;
}
if (category.toLowerCase().contains('photo') || if (category.toLowerCase().contains('photo') ||
category.toLowerCase().contains('attraction')) category.toLowerCase().contains('attraction')) {
return Colors.purple; return Colors.purple;
}
if (category.toLowerCase().contains('détente') || if (category.toLowerCase().contains('détente') ||
category.toLowerCase().contains('relax')) category.toLowerCase().contains('relax')) {
return Colors.pink; return Colors.pink;
}
return Colors.teal; return Colors.teal;
} }
@@ -409,8 +414,9 @@ class _CalendarPageState extends State<CalendarPage> {
if (category.toLowerCase().contains('restaurant')) return Icons.restaurant; if (category.toLowerCase().contains('restaurant')) return Icons.restaurant;
if (category.toLowerCase().contains('nature')) return Icons.nature; if (category.toLowerCase().contains('nature')) return Icons.nature;
if (category.toLowerCase().contains('photo')) return Icons.camera_alt; 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.icecream; // Gelato icon :)
}
return Icons.place; return Icons.place;
} }
} }

View File

@@ -1,5 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:travel_mate/blocs/trip/trip_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/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/models/trip.dart';
import 'package:travel_mate/components/map/map_content.dart'; import 'package:travel_mate/components/map/map_content.dart';
import 'package:travel_mate/services/error_service.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/group_repository.dart';
import 'package:travel_mate/repositories/user_repository.dart'; import 'package:travel_mate/repositories/user_repository.dart';
import 'package:travel_mate/repositories/account_repository.dart'; import 'package:travel_mate/repositories/account_repository.dart';
import 'package:travel_mate/models/group_member.dart'; import 'package:travel_mate/models/group_member.dart';
import 'package:travel_mate/components/activities/activities_page.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:travel_mate/components/home/calendar/calendar_page.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:intl/intl.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_bloc.dart';
import 'package:travel_mate/blocs/balance/balance_event.dart'; import 'package:travel_mate/blocs/balance/balance_event.dart';
import 'package:travel_mate/blocs/balance/balance_state.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_bloc.dart';
import 'package:travel_mate/blocs/user/user_state.dart' as user_state; 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/components/account/group_expenses_page.dart';
@@ -238,30 +241,38 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
// Méthode pour ouvrir Waze // Méthode pour ouvrir Waze
Future<void> _openWaze() async { Future<void> _openWaze() async {
final location = Uri.encodeComponent(widget.trip.location);
try { try {
// Essayer d'abord l'URL scheme pour l'app mobile String wazeUrl;
final appUrl = 'waze://?q=$location';
final appUri = Uri.parse(appUrl); // Utiliser les coordonnées si disponibles (plus précis)
if (await canLaunchUrl(appUri)) { if (widget.trip.latitude != null && widget.trip.longitude != null) {
await launchUrl(appUri); final lat = widget.trip.latitude;
return; 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 uri = Uri.parse(wazeUrl);
final webUrl = 'https://waze.com/ul?q=$location';
final webUri = Uri.parse(webUrl);
if (await canLaunchUrl(webUri)) {
await launchUrl(webUri, mode: LaunchMode.externalApplication);
return;
}
_errorService.showError( if (await canLaunchUrl(uri)) {
message: await launchUrl(uri, mode: LaunchMode.externalApplication);
'Impossible d\'ouvrir Waze. Vérifiez que l\'application est installée.', } 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) { } catch (e) {
LoggerService.error('Error opening Waze', error: e);
_errorService.showError(message: 'Erreur lors de l\'ouverture de Waze'); _errorService.showError(message: 'Erreur lors de l\'ouverture de Waze');
} }
} }
@@ -1035,14 +1046,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
if (activity.date == null) return const SizedBox.shrink(); if (activity.date == null) return const SizedBox.shrink();
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 12),
child: _buildActivityCard( child: _buildActivityCard(activity: activity),
title: activity.name,
date: DateFormat(
'd MMM, HH:mm',
'fr_FR',
).format(activity.date!),
icon: _getCategoryIcon(activity.category),
),
); );
}), }),
], ],
@@ -1060,70 +1064,79 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
return Icons.place; return Icons.place;
} }
Widget _buildActivityCard({ Widget _buildActivityCard({required Activity activity}) {
required String title,
required String date,
required IconData icon,
}) {
final theme = Theme.of(context); final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark; final isDarkMode = theme.brightness == Brightness.dark;
return Container( final date = activity.date != null
padding: const EdgeInsets.all(16), ? DateFormat('d MMM, HH:mm', 'fr_FR').format(activity.date!)
decoration: BoxDecoration( : 'Date inconnue';
color: theme.cardColor, final icon = _getCategoryIcon(activity.category);
borderRadius: BorderRadius.circular(16),
border: Border.all( return GestureDetector(
color: isDarkMode onTap: () {
? Colors.white.withValues(alpha: 0.1) showDialog(
: Colors.black.withValues(alpha: 0.05), context: context,
width: 1, 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: [ child: Row(
BoxShadow( children: [
color: Colors.black.withValues(alpha: 0.05), Container(
blurRadius: 10, padding: const EdgeInsets.all(12),
offset: const Offset(0, 2), decoration: BoxDecoration(
), color: Colors.teal.withValues(alpha: 0.1),
], borderRadius: BorderRadius.circular(12),
), ),
child: Row( child: Icon(icon, color: Colors.teal, size: 24),
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(
const SizedBox(width: 16), child: Column(
Expanded( crossAxisAlignment: CrossAxisAlignment.start,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, Text(
children: [ activity.name,
Text( style: theme.textTheme.bodyLarge?.copyWith(
title, fontWeight: FontWeight.bold,
style: theme.textTheme.bodyLarge?.copyWith( color: theme.colorScheme.onSurface,
fontWeight: FontWeight.bold, ),
color: theme.colorScheme.onSurface,
), ),
), const SizedBox(height: 4),
const SizedBox(height: 4), Text(
Text( date,
date, style: theme.textTheme.bodyMedium?.copyWith(
style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
color: theme.colorScheme.onSurface.withValues(alpha: 0.6), ),
), ),
), ],
], ),
), ),
), Icon(
Icon( Icons.chevron_right,
Icons.chevron_right, color: theme.colorScheme.onSurface.withValues(alpha: 0.4),
color: theme.colorScheme.onSurface.withValues(alpha: 0.4), ),
), ],
], ),
), ),
); );
} }
@@ -1214,13 +1227,7 @@ class _ShowTripDetailsContentState extends State<ShowTripDetailsContent> {
'${debtsToReceive.first.fromUserName} vous doit ${debtsToReceive.first.amount.toStringAsFixed(2)}'; '${debtsToReceive.first.fromUserName} vous doit ${debtsToReceive.first.amount.toStringAsFixed(2)}';
} else { } else {
balanceText = balanceText =
debtsToReceive '${debtsToReceive.map((s) => '${s.fromUserName} (${s.amount.toStringAsFixed(2)}€)').join(' et ')} vous doivent de l\'argent';
.map(
(s) =>
'${s.fromUserName} (${s.amount.toStringAsFixed(2)}€)',
)
.join(' et ') +
' vous doivent de l\'argent';
} }
} }
} }

View File

@@ -6,6 +6,9 @@ import 'package:http/http.dart' as http;
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../services/error_service.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 { class MapContent extends StatefulWidget {
final String? initialSearchQuery; final String? initialSearchQuery;
@@ -33,8 +36,37 @@ class _MapContentState extends State<MapContent> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Si une recherche initiale est fournie, la pré-remplir et lancer la recherche
if (widget.initialSearchQuery != null && final mapService = context.read<MapNavigationService>();
// É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) { widget.initialSearchQuery!.isNotEmpty) {
_searchController.text = widget.initialSearchQuery!; _searchController.text = widget.initialSearchQuery!;
// Lancer la recherche automatiquement après un court délai pour laisser l'interface se charger // Lancer la recherche automatiquement après un court délai pour laisser l'interface se charger
@@ -47,6 +79,54 @@ class _MapContentState extends State<MapContent> {
} }
} }
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 @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();

View File

@@ -13,6 +13,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:travel_mate/services/expense_service.dart'; import 'package:travel_mate/services/expense_service.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:travel_mate/services/notification_service.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_bloc.dart';
import 'blocs/auth/auth_event.dart'; import 'blocs/auth/auth_event.dart';
import 'blocs/theme/theme_bloc.dart'; import 'blocs/theme/theme_bloc.dart';
@@ -127,6 +128,10 @@ class MyApp extends StatelessWidget {
expenseRepository: context.read<ExpenseRepository>(), expenseRepository: context.read<ExpenseRepository>(),
), ),
), ),
// Map navigation service
RepositoryProvider<MapNavigationService>(
create: (context) => MapNavigationService(),
),
], ],
child: MultiBlocProvider( child: MultiBlocProvider(
providers: [ providers: [

View File

@@ -12,6 +12,8 @@ import '../blocs/user/user_event.dart';
import '../blocs/auth/auth_bloc.dart'; import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart'; import '../blocs/auth/auth_event.dart';
import '../services/error_service.dart'; import '../services/error_service.dart';
import '../services/notification_service.dart';
import '../services/map_navigation_service.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({super.key}); const HomePage({super.key});
@@ -37,6 +39,24 @@ class _HomePageState extends State<HomePage> {
super.initState(); super.initState();
// Initialiser les données utilisateur // Initialiser les données utilisateur
context.read<UserBloc>().add(UserInitialized()); context.read<UserBloc>().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<MapNavigationService>().requestStream.listen((request) {
if (_currentIndex != 2) {
setState(() {
_currentIndex = 2;
});
}
});
} }
Widget _buildPage(int index) { Widget _buildPage(int index) {

View File

@@ -116,7 +116,7 @@ class ActivityPlacesService {
final encodedDestination = Uri.encodeComponent(destination); final encodedDestination = Uri.encodeComponent(destination);
final url = 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: Géocodage de "$destination"');
LoggerService.info('ActivityPlacesService: URL = $url'); LoggerService.info('ActivityPlacesService: URL = $url');
@@ -184,7 +184,8 @@ class ActivityPlacesService {
'?location=$lat,$lng' '?location=$lat,$lng'
'&radius=$radius' '&radius=$radius'
'&type=${category.googlePlaceType}' '&type=${category.googlePlaceType}'
'&key=$_apiKey'; '&key=$_apiKey'
'&language=fr';
final response = await http.get(Uri.parse(url)); final response = await http.get(Uri.parse(url));
@@ -287,7 +288,8 @@ class ActivityPlacesService {
'https://maps.googleapis.com/maps/api/place/details/json' 'https://maps.googleapis.com/maps/api/place/details/json'
'?place_id=$placeId' '?place_id=$placeId'
'&fields=formatted_address,formatted_phone_number,website,opening_hours,editorial_summary' '&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)); final response = await http.get(Uri.parse(url));
@@ -356,7 +358,8 @@ class ActivityPlacesService {
'?query=$encodedQuery in $destination' '?query=$encodedQuery in $destination'
'&location=${coordinates['lat']},${coordinates['lng']}' '&location=${coordinates['lat']},${coordinates['lng']}'
'&radius=$radius' '&radius=$radius'
'&key=$_apiKey'; '&key=$_apiKey'
'&language=fr';
final response = await http.get(Uri.parse(url)); final response = await http.get(Uri.parse(url));
@@ -513,7 +516,8 @@ class ActivityPlacesService {
'?location=$lat,$lng' '?location=$lat,$lng'
'&radius=$radius' '&radius=$radius'
'&type=${category.googlePlaceType}' '&type=${category.googlePlaceType}'
'&key=$_apiKey'; '&key=$_apiKey'
'&language=fr';
if (nextPageToken != null) { if (nextPageToken != null) {
url += '&pagetoken=$nextPageToken'; url += '&pagetoken=$nextPageToken';
@@ -589,7 +593,8 @@ class ActivityPlacesService {
'?location=$lat,$lng' '?location=$lat,$lng'
'&radius=$radius' '&radius=$radius'
'&type=tourist_attraction' '&type=tourist_attraction'
'&key=$_apiKey'; '&key=$_apiKey'
'&language=fr';
if (nextPageToken != null) { if (nextPageToken != null) {
url += '&pagetoken=$nextPageToken'; url += '&pagetoken=$nextPageToken';

View File

@@ -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<MapLocationRequest>.broadcast();
MapLocationRequest? _lastRequest;
Stream<MapLocationRequest> 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();
}
}

View File

@@ -4,6 +4,12 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:travel_mate/services/logger_service.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') @pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
@@ -43,6 +49,7 @@ class NotificationService {
onDidReceiveNotificationResponse: (details) { onDidReceiveNotificationResponse: (details) {
// Handle notification tap // Handle notification tap
LoggerService.info('Notification tapped: ${details.payload}'); 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 // Handle token refresh
FirebaseMessaging.instance.onTokenRefresh.listen(_onTokenRefresh); 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; _isInitialized = true;
LoggerService.info('NotificationService initialized'); LoggerService.info('NotificationService initialized');
@@ -60,6 +71,87 @@ class NotificationService {
LoggerService.info('Current FCM Token: $token'); 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<void> 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<void> _handleNotificationTap(Map<String, dynamic> 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<void> _onTokenRefresh(String newToken) async { Future<void> _onTokenRefresh(String newToken) async {
LoggerService.info('FCM Token refreshed: $newToken'); LoggerService.info('FCM Token refreshed: $newToken');
// We need the user ID to save the token. // We need the user ID to save the token.