feat: Add logger service and improve expense dialog with enhanced receipt management and calculation logic.

This commit is contained in:
Van Leemput Dayron
2025-11-28 12:54:54 +01:00
parent cad9d42128
commit fd710b8cb8
35 changed files with 2148 additions and 1296 deletions

View File

@@ -22,9 +22,10 @@ import 'package:http/http.dart' as http;
import 'package:flutter_dotenv/flutter_dotenv.dart';
import '../../services/place_image_service.dart';
import '../../services/trip_geocoding_service.dart';
import '../../services/logger_service.dart';
/// Create trip content widget for trip creation and editing functionality.
///
///
/// This widget provides a comprehensive form interface for creating new trips
/// or editing existing ones. Key features include:
/// - Trip creation with validation
@@ -34,22 +35,19 @@ import '../../services/trip_geocoding_service.dart';
/// - Group creation and member management
/// - Account setup for expense tracking
/// - Integration with mapping services for location selection
///
///
/// The widget handles both creation and editing modes based on the
/// provided tripToEdit parameter.
class CreateTripContent extends StatefulWidget {
/// Optional trip to edit. If null, creates a new trip
final Trip? tripToEdit;
/// Creates a create trip content widget.
///
///
/// Args:
/// [tripToEdit]: Optional trip to edit. If provided, the form will
/// be populated with existing trip data for editing
const CreateTripContent({
super.key,
this.tripToEdit,
});
const CreateTripContent({super.key, this.tripToEdit});
@override
State<CreateTripContent> createState() => _CreateTripContentState();
@@ -58,17 +56,17 @@ class CreateTripContent extends StatefulWidget {
class _CreateTripContentState extends State<CreateTripContent> {
/// Service for handling and displaying errors
final _errorService = ErrorService();
/// Form validation key
final _formKey = GlobalKey<FormState>();
/// Text controllers for form fields
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
final _locationController = TextEditingController();
final _budgetController = TextEditingController();
final _participantController = TextEditingController();
/// Services for user and group operations
final _userService = UserService();
final _groupRepository = GroupRepository();
@@ -79,7 +77,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
/// Trip date variables
DateTime? _startDate;
DateTime? _endDate;
/// Loading and state management variables
bool _isLoading = false;
String? _createdTripId;
@@ -127,7 +125,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
void _onLocationChanged() {
final query = _locationController.text.trim();
if (query.length < 2) {
_hideSuggestions();
return;
@@ -151,14 +149,14 @@ class _CreateTripContentState extends State<CreateTripContent> {
'?input=${Uri.encodeComponent(query)}'
'&types=(cities)'
'&language=fr'
'&key=$_apiKey'
'&key=$_apiKey',
);
final response = await http.get(url);
if (response.statusCode == 200) {
final data = json.decode(response.body);
if (data['status'] == 'OK') {
final predictions = data['predictions'] as List;
setState(() {
@@ -170,7 +168,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
}).toList();
_isLoadingSuggestions = false;
});
if (_placeSuggestions.isNotEmpty) {
_showSuggestions();
} else {
@@ -202,12 +200,14 @@ class _CreateTripContentState extends State<CreateTripContent> {
// Nouvelle méthode pour afficher les suggestions
void _showSuggestions() {
_hideSuggestions(); // Masquer d'abord les suggestions existantes
if (_placeSuggestions.isEmpty) return;
_suggestionsOverlay = OverlayEntry(
builder: (context) => Positioned(
width: MediaQuery.of(context).size.width - 32, // Largeur du champ avec padding
width:
MediaQuery.of(context).size.width -
32, // Largeur du champ avec padding
child: CompositedTransformFollower(
link: _layerLink,
showWhenUnlinked: false,
@@ -258,26 +258,32 @@ class _CreateTripContentState extends State<CreateTripContent> {
setState(() {
_placeSuggestions = [];
});
// Charger l'image du lieu sélectionné
_loadPlaceImage(suggestion.description);
}
/// Charge l'image du lieu depuis Google Places API
Future<void> _loadPlaceImage(String location) async {
print('CreateTripContent: Chargement de l\'image pour: $location');
LoggerService.info(
'CreateTripContent: Chargement de l\'image pour: $location',
);
try {
final imageUrl = await _placeImageService.getPlaceImageUrl(location);
print('CreateTripContent: Image URL reçue: $imageUrl');
LoggerService.info('CreateTripContent: Image URL reçue: $imageUrl');
if (mounted) {
setState(() {
_selectedImageUrl = imageUrl;
});
print('CreateTripContent: État mis à jour avec imageUrl: $_selectedImageUrl');
LoggerService.info(
'CreateTripContent: État mis à jour avec imageUrl: $_selectedImageUrl',
);
}
} catch (e) {
print('CreateTripContent: Erreur lors du chargement de l\'image: $e');
LoggerService.error(
'CreateTripContent: Erreur lors du chargement de l\'image: $e',
);
if (mounted) {
_errorService.logError(
'create_trip_content.dart',
@@ -337,7 +343,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
}) {
final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark;
return TextFormField(
controller: controller,
validator: validator,
@@ -349,42 +355,36 @@ class _CreateTripContentState extends State<CreateTripContent> {
decoration: InputDecoration(
hintText: label,
hintStyle: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.5),
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
prefixIcon: Icon(
icon,
color: theme.colorScheme.onSurface.withOpacity(0.5),
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
suffixIcon: suffixIcon,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: isDarkMode
? Colors.white.withOpacity(0.2)
: Colors.black.withOpacity(0.2),
color: isDarkMode
? Colors.white.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.2),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: isDarkMode
? Colors.white.withOpacity(0.2)
: Colors.black.withOpacity(0.2),
color: isDarkMode
? Colors.white.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.teal,
width: 2,
),
borderSide: BorderSide(color: Colors.teal, width: 2),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Colors.red,
width: 2,
),
borderSide: const BorderSide(color: Colors.red, width: 2),
),
filled: true,
fillColor: theme.cardColor,
@@ -403,7 +403,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
}) {
final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
@@ -413,27 +413,27 @@ class _CreateTripContentState extends State<CreateTripContent> {
color: theme.cardColor,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isDarkMode
? Colors.white.withOpacity(0.2)
: Colors.black.withOpacity(0.2),
color: isDarkMode
? Colors.white.withValues(alpha: 0.2)
: Colors.black.withValues(alpha: 0.2),
),
),
child: Row(
children: [
Icon(
Icons.calendar_today,
color: theme.colorScheme.onSurface.withOpacity(0.5),
color: theme.colorScheme.onSurface.withValues(alpha: 0.5),
size: 20,
),
const SizedBox(width: 12),
Text(
date != null
? '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'
: 'mm/dd/yyyy',
date != null
? '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}'
: 'mm/dd/yyyy',
style: theme.textTheme.bodyLarge?.copyWith(
color: date != null
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withOpacity(0.5),
color: date != null
? theme.colorScheme.onSurface
: theme.colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
@@ -442,11 +442,11 @@ class _CreateTripContentState extends State<CreateTripContent> {
);
}
@override
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDarkMode = theme.brightness == Brightness.dark;
return BlocListener<TripBloc, TripState>(
listener: (context, tripState) {
if (tripState is TripCreated) {
@@ -454,7 +454,10 @@ class _CreateTripContentState extends State<CreateTripContent> {
_createGroupAndAccountForTrip(_createdTripId!);
} else if (tripState is TripOperationSuccess) {
if (mounted) {
_errorService.showSnackbar(message: tripState.message, isError: false);
_errorService.showSnackbar(
message: tripState.message,
isError: false,
);
setState(() {
_isLoading = false;
});
@@ -465,7 +468,10 @@ class _CreateTripContentState extends State<CreateTripContent> {
}
} else if (tripState is TripError) {
if (mounted) {
_errorService.showSnackbar(message: tripState.message, isError: true);
_errorService.showSnackbar(
message: tripState.message,
isError: true,
);
setState(() {
_isLoading = false;
});
@@ -478,7 +484,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
return Scaffold(
backgroundColor: theme.scaffoldBackgroundColor,
appBar: AppBar(
title: Text(isEditing ? 'Modifier le voyage' : 'Créer un voyage'),
title: Text(
isEditing ? 'Modifier le voyage' : 'Créer un voyage',
),
backgroundColor: theme.appBarTheme.backgroundColor,
foregroundColor: theme.appBarTheme.foregroundColor,
),
@@ -506,7 +514,10 @@ class _CreateTripContentState extends State<CreateTripContent> {
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: theme.colorScheme.onSurface),
icon: Icon(
Icons.arrow_back,
color: theme.colorScheme.onSurface,
),
onPressed: () => Navigator.pop(context),
),
),
@@ -519,7 +530,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(isDarkMode ? 0.3 : 0.1),
color: Colors.black.withValues(
alpha: isDarkMode ? 0.3 : 0.1,
),
blurRadius: 10,
offset: const Offset(0, 5),
),
@@ -544,7 +557,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
Text(
'Donne un nom à ton voyage',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurface.withOpacity(0.7),
color: theme.colorScheme.onSurface.withValues(
alpha: 0.7,
),
),
),
const SizedBox(height: 24),
@@ -588,7 +603,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: null,
),
@@ -667,7 +684,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
controller: _budgetController,
label: 'Ex : 500',
icon: Icons.euro,
keyboardType: TextInputType.numberWithOptions(decimal: true),
keyboardType: TextInputType.numberWithOptions(
decimal: true,
),
),
const SizedBox(height: 20),
@@ -701,7 +720,10 @@ class _CreateTripContentState extends State<CreateTripContent> {
),
child: IconButton(
onPressed: _addParticipant,
icon: const Icon(Icons.add, color: Colors.white),
icon: const Icon(
Icons.add,
color: Colors.white,
),
),
),
],
@@ -720,7 +742,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
vertical: 8,
),
decoration: BoxDecoration(
color: Colors.teal.withOpacity(0.1),
color: Colors.teal.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Row(
@@ -728,10 +750,11 @@ class _CreateTripContentState extends State<CreateTripContent> {
children: [
Text(
email,
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.teal,
fontWeight: FontWeight.w500,
),
style: theme.textTheme.bodySmall
?.copyWith(
color: Colors.teal,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 8),
GestureDetector(
@@ -758,7 +781,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
width: double.infinity,
height: 56,
child: ElevatedButton(
onPressed: _isLoading ? null : () => _saveTrip(userState.user),
onPressed: _isLoading
? null
: () => _saveTrip(userState.user),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.teal,
foregroundColor: Colors.white,
@@ -773,15 +798,20 @@ class _CreateTripContentState extends State<CreateTripContent> {
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white,
),
),
)
: Text(
isEditing ? 'Modifier le voyage' : 'Créer le voyage',
style: theme.textTheme.titleMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
isEditing
? 'Modifier le voyage'
: 'Créer le voyage',
style: theme.textTheme.titleMedium
?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
),
@@ -846,15 +876,18 @@ class _CreateTripContentState extends State<CreateTripContent> {
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(email)) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Email invalide')));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Email invalide')));
}
return;
}
if (_participants.contains(email)) {
if (mounted) {
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Ce participant est déjà ajouté')));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Ce participant est déjà ajouté')),
);
}
return;
}
@@ -879,7 +912,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
) async {
final groupBloc = context.read<GroupBloc>();
final accountBloc = context.read<AccountBloc>();
try {
final group = await _groupRepository.getGroupByTripId(tripId);
@@ -900,7 +933,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
final currentMemberIds = currentMembers.map((m) => m.userId).toSet();
final newMemberIds = newMembers.map((m) => m.userId).toSet();
final membersToAdd = newMembers.where((m) => !currentMemberIds.contains(m.userId)).toList();
final membersToAdd = newMembers
.where((m) => !currentMemberIds.contains(m.userId))
.toList();
final membersToRemove = currentMembers
.where((m) => !newMemberIds.contains(m.userId) && m.role != 'admin')
@@ -961,14 +996,16 @@ class _CreateTripContentState extends State<CreateTripContent> {
role: 'admin',
profilePictureUrl: currentUser.profilePictureUrl,
),
...participantsData.map((p) => GroupMember(
userId: p['id'] as String,
firstName: p['firstName'] as String,
lastName: p['lastName'] as String? ?? '',
pseudo: p['firstName'] as String,
role: 'member',
profilePictureUrl: p['profilePictureUrl'] as String?,
)),
...participantsData.map(
(p) => GroupMember(
userId: p['id'] as String,
firstName: p['firstName'] as String,
lastName: p['lastName'] as String? ?? '',
pseudo: p['firstName'] as String,
role: 'member',
profilePictureUrl: p['profilePictureUrl'] as String?,
),
),
];
return groupMembers;
}
@@ -976,9 +1013,8 @@ class _CreateTripContentState extends State<CreateTripContent> {
Future<void> _createGroupAndAccountForTrip(String tripId) async {
final groupBloc = context.read<GroupBloc>();
final accountBloc = context.read<AccountBloc>();
try {
final userState = context.read<UserBloc>().state;
if (userState is! user_state.UserLoaded) {
throw Exception('Utilisateur non connecté');
@@ -998,21 +1034,19 @@ class _CreateTripContentState extends State<CreateTripContent> {
throw Exception('Erreur lors de la création des membres du groupe');
}
groupBloc.add(CreateGroupWithMembers(
group: group,
members: groupMembers,
));
groupBloc.add(
CreateGroupWithMembers(group: group, members: groupMembers),
);
final account = Account(
id: '',
tripId: tripId,
name: _titleController.text.trim(),
);
accountBloc.add(CreateAccountWithMembers(
account: account,
members: groupMembers,
));
accountBloc.add(
CreateAccountWithMembers(account: account, members: groupMembers),
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -1025,7 +1059,6 @@ class _CreateTripContentState extends State<CreateTripContent> {
});
Navigator.pop(context);
}
} catch (e) {
_errorService.logError(
'create_trip_content.dart',
@@ -1034,10 +1067,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: Colors.red,
),
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
);
setState(() {
_isLoading = false;
@@ -1046,8 +1076,6 @@ class _CreateTripContentState extends State<CreateTripContent> {
}
}
Future<void> _saveTrip(user_state.UserModel currentUser) async {
if (!_formKey.currentState!.validate()) {
return;
@@ -1070,7 +1098,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
try {
final participantsData = await _getParticipantsData(_participants);
List<String> participantIds = participantsData.map((p) => p['id'] as String).toList();
List<String> participantIds = participantsData
.map((p) => p['id'] as String)
.toList();
if (!participantIds.contains(currentUser.id)) {
participantIds.insert(0, currentUser.id);
@@ -1101,7 +1131,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Voyage créé sans géolocalisation (pas d\'impact sur les fonctionnalités)'),
content: Text(
'Voyage créé sans géolocalisation (pas d\'impact sur les fonctionnalités)',
),
backgroundColor: Colors.orange,
duration: Duration(seconds: 2),
),
@@ -1114,16 +1146,21 @@ class _CreateTripContentState extends State<CreateTripContent> {
tripBloc.add(TripUpdateRequested(trip: tripWithCoordinates));
// Mettre à jour le groupe ET les comptes avec les nouveaux participants
if (widget.tripToEdit != null && widget.tripToEdit!.id != null && widget.tripToEdit!.id!.isNotEmpty) {
print('🔄 [CreateTrip] Mise à jour du groupe et du compte pour le voyage ID: ${widget.tripToEdit!.id}');
print('👥 Participants: ${participantsData.map((p) => p['id']).toList()}');
if (widget.tripToEdit != null &&
widget.tripToEdit!.id != null &&
widget.tripToEdit!.id!.isNotEmpty) {
LoggerService.info(
'🔄 [CreateTrip] Mise à jour du groupe et du compte pour le voyage ID: ${widget.tripToEdit!.id}',
);
LoggerService.info(
'👥 Participants: ${participantsData.map((p) => p['id']).toList()}',
);
await _updateGroupAndAccountMembers(
widget.tripToEdit!.id!,
currentUser,
participantsData,
);
}
} else {
// Mode création - Le groupe sera créé dans le listener TripCreated
tripBloc.add(TripCreateRequested(trip: tripWithCoordinates));
@@ -1131,10 +1168,7 @@ class _CreateTripContentState extends State<CreateTripContent> {
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur: $e'),
backgroundColor: Colors.red,
),
SnackBar(content: Text('Erreur: $e'), backgroundColor: Colors.red),
);
setState(() {
@@ -1144,7 +1178,9 @@ class _CreateTripContentState extends State<CreateTripContent> {
}
}
Future<List<Map<String, dynamic>>> _getParticipantsData(List<String> emails) async {
Future<List<Map<String, dynamic>>> _getParticipantsData(
List<String> emails,
) async {
List<Map<String, dynamic>> participantsData = [];
for (String email in emails) {
@@ -1188,8 +1224,5 @@ class PlaceSuggestion {
final String placeId;
final String description;
PlaceSuggestion({
required this.placeId,
required this.description,
});
}
PlaceSuggestion({required this.placeId, required this.description});
}