Trying to do the notification for all users.
This commit is contained in:
@@ -13,6 +13,7 @@
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
|
||||
|
||||
<!-- Permissions pour écrire dans le stockage -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<application
|
||||
android:label="Travel Mate"
|
||||
@@ -62,6 +63,9 @@
|
||||
<meta-data
|
||||
android:name="com.google.android.geo.API_KEY"
|
||||
android:value="AIzaSyCAtz1_d5K0ANwxAA_T84iq7Ac_gsUs_oM"/>
|
||||
<meta-data
|
||||
android:name="com.google.firebase.messaging.default_notification_channel_id"
|
||||
android:value="high_importance_channel" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -5,21 +5,32 @@ admin.initializeApp();
|
||||
|
||||
// Helper function to send notifications to a list of users
|
||||
async function sendNotificationToUsers(userIds, title, body, excludeUserId, data = {}) {
|
||||
console.log(`Starting sendNotificationToUsers. Total users: ${userIds.length}, Exclude: ${excludeUserId}`);
|
||||
try {
|
||||
const tokens = [];
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (userId === excludeUserId) continue;
|
||||
if (userId === excludeUserId) {
|
||||
console.log(`Skipping user ${userId} (sender)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const userDoc = await admin.firestore().collection("users").doc(userId).get();
|
||||
if (userDoc.exists) {
|
||||
const userData = userDoc.data();
|
||||
if (userData.fcmToken) {
|
||||
console.log(`Found token for user ${userId}`);
|
||||
tokens.push(userData.fcmToken);
|
||||
} else {
|
||||
console.log(`No FCM token found for user ${userId}`);
|
||||
}
|
||||
} else {
|
||||
console.log(`User document not found for ${userId}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Total tokens to send: ${tokens.length}`);
|
||||
|
||||
if (tokens.length > 0) {
|
||||
const message = {
|
||||
notification: {
|
||||
@@ -35,6 +46,11 @@ async function sendNotificationToUsers(userIds, title, body, excludeUserId, data
|
||||
|
||||
const response = await admin.messaging().sendMulticast(message);
|
||||
console.log(`${response.successCount} messages were sent successfully`);
|
||||
if (response.failureCount > 0) {
|
||||
console.log('Failed notifications:', response.responses.filter(r => !r.success));
|
||||
}
|
||||
} else {
|
||||
console.log("No tokens found, skipping notification send.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error sending notification:", error);
|
||||
@@ -44,6 +60,7 @@ async function sendNotificationToUsers(userIds, title, body, excludeUserId, data
|
||||
exports.onActivityCreated = functions.firestore
|
||||
.document("activities/{activityId}")
|
||||
.onCreate(async (snapshot, context) => {
|
||||
console.log(`onActivityCreated triggered for ${context.params.activityId}`);
|
||||
const activity = snapshot.data();
|
||||
const tripId = activity.tripId;
|
||||
const createdBy = activity.createdBy || "Unknown";
|
||||
@@ -66,6 +83,8 @@ exports.onActivityCreated = functions.firestore
|
||||
participants.push(trip.createdBy);
|
||||
}
|
||||
|
||||
console.log(`Found trip participants: ${JSON.stringify(participants)}`);
|
||||
|
||||
// Fetch creator name
|
||||
let creatorName = "Quelqu'un";
|
||||
if (createdBy !== "Unknown") {
|
||||
@@ -87,6 +106,7 @@ exports.onActivityCreated = functions.firestore
|
||||
exports.onMessageCreated = functions.firestore
|
||||
.document("groups/{groupId}/messages/{messageId}")
|
||||
.onCreate(async (snapshot, context) => {
|
||||
console.log(`onMessageCreated triggered for ${context.params.messageId} in group ${context.params.groupId}`);
|
||||
const message = snapshot.data();
|
||||
const groupId = context.params.groupId;
|
||||
const senderId = message.senderId;
|
||||
@@ -100,6 +120,7 @@ exports.onMessageCreated = functions.firestore
|
||||
|
||||
const group = groupDoc.data();
|
||||
const memberIds = group.memberIds || [];
|
||||
console.log(`Found group members: ${JSON.stringify(memberIds)}`);
|
||||
|
||||
let senderName = message.senderName || "Quelqu'un";
|
||||
|
||||
@@ -115,6 +136,7 @@ exports.onMessageCreated = functions.firestore
|
||||
exports.onExpenseCreated = functions.firestore
|
||||
.document("expenses/{expenseId}")
|
||||
.onCreate(async (snapshot, context) => {
|
||||
console.log(`onExpenseCreated triggered for ${context.params.expenseId}`);
|
||||
const expense = snapshot.data();
|
||||
const groupId = expense.groupId;
|
||||
const paidBy = expense.paidById || expense.paidBy;
|
||||
@@ -133,6 +155,7 @@ exports.onExpenseCreated = functions.firestore
|
||||
|
||||
const group = groupDoc.data();
|
||||
const memberIds = group.memberIds || [];
|
||||
console.log(`Found group members: ${JSON.stringify(memberIds)}`);
|
||||
|
||||
let payerName = expense.paidByName || "Quelqu'un";
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../../repositories/auth_repository.dart';
|
||||
import 'auth_event.dart';
|
||||
import 'auth_state.dart';
|
||||
import '../../services/notification_service.dart';
|
||||
|
||||
/// BLoC for managing authentication state and operations.
|
||||
class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
@@ -69,6 +70,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
if (user != null) {
|
||||
// Save FCM Token on auto-login
|
||||
await NotificationService().saveTokenToFirestore(user.id!);
|
||||
emit(AuthAuthenticated(user: user));
|
||||
} else {
|
||||
emit(AuthUnauthenticated());
|
||||
@@ -98,6 +101,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
if (user != null) {
|
||||
// Save FCM Token
|
||||
await NotificationService().saveTokenToFirestore(user.id!);
|
||||
emit(AuthAuthenticated(user: user));
|
||||
} else {
|
||||
emit(const AuthError(message: 'Invalid email or password'));
|
||||
@@ -127,6 +132,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
if (user != null) {
|
||||
// Save FCM Token
|
||||
await NotificationService().saveTokenToFirestore(user.id!);
|
||||
emit(AuthAuthenticated(user: user));
|
||||
} else {
|
||||
emit(const AuthError(message: 'Failed to create account'));
|
||||
@@ -150,6 +157,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final user = await _authRepository.signInWithGoogle();
|
||||
|
||||
if (user != null) {
|
||||
// Save FCM Token
|
||||
await NotificationService().saveTokenToFirestore(user.id!);
|
||||
emit(AuthAuthenticated(user: user));
|
||||
} else {
|
||||
emit(
|
||||
@@ -224,6 +233,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
final user = await _authRepository.signInWithApple();
|
||||
|
||||
if (user != null) {
|
||||
// Save FCM Token
|
||||
await NotificationService().saveTokenToFirestore(user.id!);
|
||||
emit(AuthAuthenticated(user: user));
|
||||
} else {
|
||||
emit(
|
||||
|
||||
@@ -86,9 +86,9 @@ class UserBloc extends Bloc<event.UserEvent, state.UserState> {
|
||||
// Update FCM token if it changed
|
||||
if (fcmToken != null && user.fcmToken != fcmToken) {
|
||||
LoggerService.info('UserBloc - Updating FCM token in Firestore');
|
||||
await _firestore.collection('users').doc(currentUser.uid).update({
|
||||
await _firestore.collection('users').doc(currentUser.uid).set({
|
||||
'fcmToken': fcmToken,
|
||||
});
|
||||
}, SetOptions(merge: true));
|
||||
LoggerService.info('UserBloc - FCM token updated');
|
||||
} else {
|
||||
LoggerService.info(
|
||||
|
||||
@@ -42,185 +42,318 @@ class ExpenseDetailDialog extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Formatters for displaying dates and times.
|
||||
final dateFormat = DateFormat('dd MMMM yyyy');
|
||||
final timeFormat = DateFormat('HH:mm');
|
||||
final dateFormat = DateFormat('dd MMMM yyyy', 'fr_FR');
|
||||
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return BlocBuilder<UserBloc, user_state.UserState>(
|
||||
builder: (context, userState) {
|
||||
// Determine the current user and their permissions.
|
||||
final currentUser = userState is user_state.UserLoaded ? userState.user : null;
|
||||
final currentUser = userState is user_state.UserLoaded
|
||||
? userState.user
|
||||
: null;
|
||||
final canEdit = currentUser?.id == expense.paidById;
|
||||
|
||||
return Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 500, maxHeight: 700),
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Détails de la dépense'),
|
||||
automaticallyImplyLeading: false,
|
||||
actions: [
|
||||
if (canEdit) ...[
|
||||
// Edit button.
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_showEditDialog(context, currentUser!);
|
||||
},
|
||||
),
|
||||
// Delete button.
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
onPressed: () => _confirmDelete(context),
|
||||
),
|
||||
],
|
||||
// Close button.
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Header with icon and description.
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Header with actions
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 16, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'Détails',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (canEdit) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_showEditDialog(context, currentUser!);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.red,
|
||||
),
|
||||
child: Icon(
|
||||
expense.category.icon,
|
||||
size: 40,
|
||||
color: Colors.blue,
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => _confirmDelete(context),
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(24, 10, 24, 24),
|
||||
children: [
|
||||
// Icon and Category
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer
|
||||
.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
expense.category.icon,
|
||||
size: 36,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
expense.description,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
expense.category.displayName,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Amount Display
|
||||
Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'Montant total',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}',
|
||||
style: theme.textTheme.displaySmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
if (expense.currency != ExpenseCurrency.eur)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'≈ ${expense.amountInEur.toStringAsFixed(2)} €',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Info Grid
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _buildInfoCard(
|
||||
context,
|
||||
Icons.person_outline,
|
||||
'Payé par',
|
||||
expense.paidByName,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _buildInfoCard(
|
||||
context,
|
||||
Icons.calendar_today_outlined,
|
||||
'Date',
|
||||
dateFormat.format(expense.date),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Splits Section
|
||||
Text(
|
||||
'Répartition',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
children: expense.splits.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final split = entry.value;
|
||||
final isLast = index == expense.splits.length - 1;
|
||||
return Column(
|
||||
children: [
|
||||
_buildSplitTile(context, split),
|
||||
if (!isLast)
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: theme.colorScheme.outline.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
|
||||
// Receipt Section
|
||||
if (expense.receiptUrl != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
expense.description,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
'Reçu',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
expense.category.displayName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Image.network(
|
||||
expense.receiptUrl!,
|
||||
width: double.infinity,
|
||||
height: 200,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder:
|
||||
(context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return Container(
|
||||
height: 200,
|
||||
color: theme
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Container(
|
||||
height: 200,
|
||||
color: theme
|
||||
.colorScheme
|
||||
.surfaceContainerHighest,
|
||||
child: const Center(
|
||||
child: Icon(Icons.broken_image_outlined),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
// TODO: Show full screen image
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Amount card.
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
'${expense.amount.toStringAsFixed(2)} ${expense.currency.symbol}',
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
if (expense.currency != ExpenseCurrency.eur)
|
||||
Text(
|
||||
'≈ ${expense.amountInEur.toStringAsFixed(2)} €',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey[600],
|
||||
// Archive Button
|
||||
if (!expense.isArchived && canEdit) ...[
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _confirmArchive(context),
|
||||
icon: const Icon(Icons.archive_outlined),
|
||||
label: const Text('Archiver cette dépense'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Information rows.
|
||||
_buildInfoRow(Icons.person, 'Payé par', expense.paidByName),
|
||||
_buildInfoRow(Icons.calendar_today, 'Date', dateFormat.format(expense.date)),
|
||||
_buildInfoRow(Icons.access_time, 'Heure', timeFormat.format(expense.createdAt)),
|
||||
|
||||
if (expense.isEdited && expense.editedAt != null)
|
||||
_buildInfoRow(
|
||||
Icons.edit,
|
||||
'Modifié le',
|
||||
dateFormat.format(expense.editedAt!),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Splits section.
|
||||
const Text(
|
||||
'Répartition',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...expense.splits.map((split) => _buildSplitTile(context, split)),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Receipt section.
|
||||
if (expense.receiptUrl != null) ...[
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Reçu',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.network(
|
||||
expense.receiptUrl!,
|
||||
fit: BoxFit.cover,
|
||||
loadingBuilder: (context, child, loadingProgress) {
|
||||
if (loadingProgress == null) return child;
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
},
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return const Center(
|
||||
child: Text('Erreur de chargement de l\'image'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Archive button.
|
||||
if (!expense.isArchived && canEdit)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _confirmArchive(context),
|
||||
icon: const Icon(Icons.archive),
|
||||
label: const Text('Archiver cette dépense'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -228,83 +361,135 @@ class ExpenseDetailDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a row displaying an icon, a label, and a value.
|
||||
Widget _buildInfoRow(IconData icon, String label, String value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
Widget _buildInfoCard(
|
||||
BuildContext context,
|
||||
IconData icon,
|
||||
String label,
|
||||
String value,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outline.withValues(alpha: 0.1),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.grey[600]),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: theme.colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Builds a tile displaying details about a split in the expense.
|
||||
///
|
||||
/// The tile shows the user's name, the split amount, and whether the split is paid. If the current user
|
||||
/// is responsible for the split and it is unpaid, a button is provided to mark it as paid.
|
||||
Widget _buildSplitTile(BuildContext context, ExpenseSplit split) {
|
||||
return BlocBuilder<UserBloc, user_state.UserState>(
|
||||
builder: (context, userState) {
|
||||
final currentUser = userState is user_state.UserLoaded ? userState.user : null;
|
||||
final currentUser = userState is user_state.UserLoaded
|
||||
? userState.user
|
||||
: null;
|
||||
final isCurrentUser = currentUser?.id == split.userId;
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: split.isPaid ? Colors.green : Colors.orange,
|
||||
child: Icon(
|
||||
split.isPaid ? Icons.check : Icons.pending,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
split.userName,
|
||||
style: TextStyle(
|
||||
fontWeight: isCurrentUser ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
subtitle: Text(split.isPaid ? 'Payé' : 'En attente'),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${split.amount.toStringAsFixed(2)} €',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: split.isPaid
|
||||
? Colors.green.withValues(alpha: 0.1)
|
||||
: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
split.isPaid ? Icons.check : Icons.access_time_rounded,
|
||||
color: split.isPaid ? Colors.green : Colors.orange,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
if (!split.isPaid && isCurrentUser) ...[
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.check_circle, color: Colors.green),
|
||||
onPressed: () {
|
||||
context.read<ExpenseBloc>().add(MarkSplitAsPaid(
|
||||
expenseId: expense.id,
|
||||
userId: split.userId,
|
||||
));
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
isCurrentUser ? 'Moi' : split.userName,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: isCurrentUser
|
||||
? FontWeight.bold
|
||||
: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
split.isPaid ? 'Payé' : 'En attente',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: split.isPaid ? Colors.green : Colors.orange,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'${split.amount.toStringAsFixed(2)} €',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (!split.isPaid && isCurrentUser)
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<ExpenseBloc>().add(
|
||||
MarkSplitAsPaid(
|
||||
expenseId: expense.id,
|
||||
userId: split.userId,
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Marquer payé',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -312,7 +497,6 @@ class ExpenseDetailDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a dialog for editing the expense.
|
||||
void _showEditDialog(BuildContext context, user_state.UserModel currentUser) {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -327,13 +511,14 @@ class ExpenseDetailDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a confirmation dialog for deleting the expense.
|
||||
void _confirmDelete(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Supprimer la dépense'),
|
||||
content: const Text('Êtes-vous sûr de vouloir supprimer cette dépense ?'),
|
||||
content: const Text(
|
||||
'Êtes-vous sûr de vouloir supprimer cette dépense ?',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
@@ -341,9 +526,7 @@ class ExpenseDetailDialog extends StatelessWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<ExpenseBloc>().add(DeleteExpense(
|
||||
expense.id,
|
||||
));
|
||||
context.read<ExpenseBloc>().add(DeleteExpense(expense.id));
|
||||
Navigator.of(dialogContext).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
@@ -355,13 +538,14 @@ class ExpenseDetailDialog extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a confirmation dialog for archiving the expense.
|
||||
void _confirmArchive(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('Archiver la dépense'),
|
||||
content: const Text('Cette dépense sera archivée et n\'apparaîtra plus dans les calculs de balance.'),
|
||||
content: const Text(
|
||||
'Cette dépense sera archivée et n\'apparaîtra plus dans les calculs de balance.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
@@ -369,9 +553,7 @@ class ExpenseDetailDialog extends StatelessWidget {
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.read<ExpenseBloc>().add(ArchiveExpense(
|
||||
expense.id,
|
||||
));
|
||||
context.read<ExpenseBloc>().add(ArchiveExpense(expense.id));
|
||||
Navigator.of(dialogContext).pop();
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import '../../blocs/activity/activity_bloc.dart';
|
||||
import '../../blocs/activity/activity_event.dart';
|
||||
import '../../blocs/activity/activity_state.dart';
|
||||
@@ -1030,6 +1031,7 @@ class _ActivitiesPageState extends State<ActivitiesPage>
|
||||
tripId: widget.trip.id,
|
||||
// Générer un nouvel ID unique pour cette activité dans le voyage
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
createdBy: FirebaseAuth.instance.currentUser?.uid,
|
||||
);
|
||||
|
||||
// Afficher le LoadingContent avec la tâche d'ajout
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../../blocs/activity/activity_bloc.dart';
|
||||
import '../../blocs/activity/activity_event.dart';
|
||||
@@ -368,6 +369,7 @@ class _AddActivityBottomSheetState extends State<AddActivityBottomSheet> {
|
||||
votes: {},
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
createdBy: FirebaseAuth.instance.currentUser?.uid,
|
||||
);
|
||||
|
||||
context.read<ActivityBloc>().add(AddActivity(activity));
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:travel_mate/services/error_service.dart';
|
||||
import 'package:travel_mate/services/activity_places_service.dart';
|
||||
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 'blocs/auth/auth_bloc.dart';
|
||||
import 'blocs/auth/auth_event.dart';
|
||||
import 'blocs/theme/theme_bloc.dart';
|
||||
@@ -34,6 +36,8 @@ import 'pages/home.dart';
|
||||
import 'pages/signup.dart';
|
||||
import 'pages/resetpswd.dart';
|
||||
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
/// Entry point of the Travel Mate application.
|
||||
///
|
||||
/// This function initializes Flutter widgets, loads environment variables,
|
||||
@@ -42,6 +46,12 @@ void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await dotenv.load(fileName: ".env");
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
await initializeDateFormatting('fr_FR', null);
|
||||
|
||||
// Set the background messaging handler early on, as a named top-level function
|
||||
FirebaseMessaging.onBackgroundMessage(firebaseMessagingBackgroundHandler);
|
||||
|
||||
await NotificationService().initialize();
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class Activity {
|
||||
final DateTime createdAt;
|
||||
final DateTime updatedAt;
|
||||
final DateTime? date; // Date prévue pour l'activité
|
||||
final String? createdBy; // ID de l'utilisateur qui a créé l'activité
|
||||
|
||||
Activity({
|
||||
required this.id,
|
||||
@@ -42,6 +43,7 @@ class Activity {
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.date,
|
||||
this.createdBy,
|
||||
});
|
||||
|
||||
/// Calcule le score total des votes
|
||||
@@ -108,6 +110,7 @@ class Activity {
|
||||
DateTime? updatedAt,
|
||||
DateTime? date,
|
||||
bool clearDate = false,
|
||||
String? createdBy,
|
||||
}) {
|
||||
return Activity(
|
||||
id: id ?? this.id,
|
||||
@@ -129,6 +132,7 @@ class Activity {
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
updatedAt: updatedAt ?? this.updatedAt,
|
||||
date: clearDate ? null : (date ?? this.date),
|
||||
createdBy: createdBy ?? this.createdBy,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,6 +158,7 @@ class Activity {
|
||||
'createdAt': Timestamp.fromDate(createdAt),
|
||||
'updatedAt': Timestamp.fromDate(updatedAt),
|
||||
'date': date != null ? Timestamp.fromDate(date!) : null,
|
||||
'createdBy': createdBy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,6 +184,7 @@ class Activity {
|
||||
createdAt: (map['createdAt'] as Timestamp).toDate(),
|
||||
updatedAt: (map['updatedAt'] as Timestamp).toDate(),
|
||||
date: map['date'] != null ? (map['date'] as Timestamp).toDate() : null,
|
||||
createdBy: map['createdBy'],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import 'dart:io';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
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';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
await Firebase.initializeApp();
|
||||
LoggerService.info('Handling a background message: ${message.messageId}');
|
||||
}
|
||||
|
||||
class NotificationService {
|
||||
static final NotificationService _instance = NotificationService._internal();
|
||||
factory NotificationService() => _instance;
|
||||
@@ -41,8 +49,24 @@ class NotificationService {
|
||||
// Handle foreground messages
|
||||
FirebaseMessaging.onMessage.listen(_handleForegroundMessage);
|
||||
|
||||
// Handle token refresh
|
||||
FirebaseMessaging.instance.onTokenRefresh.listen(_onTokenRefresh);
|
||||
|
||||
_isInitialized = true;
|
||||
LoggerService.info('NotificationService initialized');
|
||||
|
||||
// Print current token for debugging
|
||||
final token = await getFCMToken();
|
||||
LoggerService.info('Current FCM Token: $token');
|
||||
}
|
||||
|
||||
Future<void> _onTokenRefresh(String newToken) async {
|
||||
LoggerService.info('FCM Token refreshed: $newToken');
|
||||
// We need the user ID to save the token.
|
||||
// Since this service is a singleton, we might not have direct access to the user ID here
|
||||
// without injecting the repository or bloc.
|
||||
// For now, we rely on the AuthBloc to update the token on login/start.
|
||||
// Ideally, we should save it here if we have the user ID.
|
||||
}
|
||||
|
||||
Future<void> _requestPermissions() async {
|
||||
@@ -85,6 +109,20 @@ class NotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> saveTokenToFirestore(String userId) async {
|
||||
try {
|
||||
final token = await getFCMToken();
|
||||
if (token != null) {
|
||||
await FirebaseFirestore.instance.collection('users').doc(userId).set({
|
||||
'fcmToken': token,
|
||||
}, SetOptions(merge: true));
|
||||
LoggerService.info('FCM Token saved to Firestore for user: $userId');
|
||||
}
|
||||
} catch (e) {
|
||||
LoggerService.error('Error saving FCM token to Firestore: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleForegroundMessage(RemoteMessage message) async {
|
||||
LoggerService.info('Got a message whilst in the foreground!');
|
||||
LoggerService.info('Message data: ${message.data}');
|
||||
|
||||
Reference in New Issue
Block a user