diff --git a/em2rp/lib/views/pages/event_add_page.dart b/em2rp/lib/views/pages/event_add_page.dart index 884befe..05cebe2 100644 --- a/em2rp/lib/views/pages/event_add_page.dart +++ b/em2rp/lib/views/pages/event_add_page.dart @@ -17,6 +17,8 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:em2rp/providers/local_user_provider.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_dropzone/flutter_dropzone.dart'; +import 'package:em2rp/views/widgets/inputs/dropzone_upload_widget.dart'; +import 'package:em2rp/views/widgets/user_management/user_multi_select_widget.dart'; class EventAddPage extends StatefulWidget { const EventAddPage({super.key}); @@ -530,14 +532,13 @@ class _EventAddPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( - child: _isLoadingUsers - ? const CircularProgressIndicator() - : UserMultiSelect( - allUsers: _allUsers, - selectedUserIds: _selectedUserIds, - onChanged: (ids) => - setState(() => _selectedUserIds = ids), - ), + child: UserMultiSelectWidget( + allUsers: _allUsers, + selectedUserIds: _selectedUserIds, + onChanged: (ids) => + setState(() => _selectedUserIds = ids), + isLoading: _isLoadingUsers, + ), ), const SizedBox(width: 16), Expanded( @@ -545,328 +546,14 @@ class _EventAddPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildSectionTitle('Documents'), - if (kIsWeb) - Container( - constraints: const BoxConstraints( - minHeight: 180, minWidth: 200), - decoration: BoxDecoration( - border: Border.all( - color: _isDropzoneHighlighted - ? Colors.blue - : Colors.grey, - width: 2, - ), - borderRadius: BorderRadius.circular(8), - color: _isDropzoneHighlighted - ? Colors.blue.withOpacity(0.1) - : null, - ), - child: Stack( - children: [ - DropzoneView( - onCreated: (controller) => - _dropzoneController = controller, - onDrop: (ev) async { - setState(() => _isLoading = true); - try { - final name = - await _dropzoneController! - .getFilename(ev); - final bytes = - await _dropzoneController! - .getFileData(ev); - if (bytes != null) { - final ref = FirebaseStorage - .instance - .ref() - .child( - 'events/temp/${DateTime.now().millisecondsSinceEpoch}_$name'); - final uploadTask = - await ref.putData(bytes); - final url = await uploadTask.ref - .getDownloadURL(); - setState(() { - _uploadedFiles.add( - {'name': name, 'url': url}); - }); - } - } catch (e) { - setState(() { - _error = - 'Erreur lors de l\'upload : $e'; - }); - } finally { - setState(() { - _isLoading = false; - _isDropzoneHighlighted = false; - }); - } - }, - onHover: () => setState(() => - _isDropzoneHighlighted = true), - onLeave: () => setState(() => - _isDropzoneHighlighted = false), - onDropMultiple: (evs) async { - setState(() => _isLoading = true); - try { - if (evs != null) { - for (final ev in evs) { - final name = - await _dropzoneController! - .getFilename(ev); - final bytes = - await _dropzoneController! - .getFileData(ev); - if (bytes != null) { - final ref = FirebaseStorage - .instance - .ref() - .child( - 'events/temp/${DateTime.now().millisecondsSinceEpoch}_$name'); - final uploadTask = - await ref.putData(bytes); - final url = await uploadTask - .ref - .getDownloadURL(); - setState(() { - _uploadedFiles.add({ - 'name': name, - 'url': url - }); - }); - } - } - } - } catch (e) { - setState(() { - _error = - 'Erreur lors de l\'upload : $e'; - }); - } finally { - setState(() { - _isLoading = false; - _isDropzoneHighlighted = false; - }); - } - }, - ), - Positioned.fill( - child: IgnorePointer( - child: _uploadedFiles.isEmpty - ? Column( - mainAxisAlignment: - MainAxisAlignment.center, - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - const Icon( - Icons.cloud_upload, - size: 48, - color: Colors.grey), - const SizedBox(height: 12), - const Text( - 'Glissez-déposez des fichiers ici ou cliquez sur "Ajouter"', - textAlign: - TextAlign.center, - style: TextStyle( - color: Colors.grey, - fontSize: 16), - ), - const SizedBox(height: 16), - ], - ) - : null, - ), - ), - if (_uploadedFiles.isNotEmpty) - Padding( - padding: const EdgeInsets.all(8.0), - child: _buildFileListAndButton(), - ), - if (_uploadedFiles.isEmpty) - Positioned( - bottom: 16, - left: 0, - right: 0, - child: Center( - child: SizedBox( - width: 160, - child: ElevatedButton.icon( - icon: const Icon( - Icons.attach_file, - size: 18), - label: const Text('Ajouter'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets - .symmetric( - horizontal: 8, - vertical: 8), - minimumSize: - const Size(80, 36), - textStyle: const TextStyle( - fontSize: 14), - ), - onPressed: _isLoading - ? null - : _pickAndUploadFiles, - ), - ), - ), - ), - if (_error != null) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Padding( - padding: - const EdgeInsets.only(top: 8.0), - child: Text(_error!, - style: const TextStyle( - color: Colors.red)), - ), - ), - if (_success != null) - Positioned( - bottom: 0, - left: 0, - right: 0, - child: Padding( - padding: const EdgeInsets.only( - top: 32.0), - child: Text(_success!, - style: const TextStyle( - color: Colors.green)), - ), - ), - ], - ), - ) - else if ([ - TargetPlatform.windows, - TargetPlatform.macOS, - TargetPlatform.linux - ].contains(defaultTargetPlatform)) - DragTarget( - onWillAccept: (data) => true, - onAccept: (file) async { - setState(() => _isLoading = true); - try { - final fileBytes = file.bytes; - final fileName = file.name; - if (fileBytes != null) { - final ref = FirebaseStorage.instance - .ref() - .child( - 'events/temp/${DateTime.now().millisecondsSinceEpoch}_$fileName'); - final uploadTask = - await ref.putData(fileBytes); - final url = await uploadTask.ref - .getDownloadURL(); - setState(() { - _uploadedFiles.add( - {'name': fileName, 'url': url}); - }); - } - } catch (e) { - setState(() { - _error = - 'Erreur lors de l\'upload : $e'; - }); - } finally { - setState(() => _isLoading = false); - } - }, - builder: - (context, candidateData, rejectedData) { - final hasFiles = _uploadedFiles.isNotEmpty; - return Container( - constraints: const BoxConstraints( - minHeight: 180, minWidth: 200), - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - border: Border.all( - color: candidateData.isNotEmpty - ? Colors.blue - : Colors.grey, - width: 2, - ), - borderRadius: BorderRadius.circular(8), - color: candidateData.isNotEmpty - ? Colors.blue.withOpacity(0.1) - : null, - ), - child: hasFiles - ? _buildFileListAndButton() - : Column( - mainAxisAlignment: - MainAxisAlignment.center, - crossAxisAlignment: - CrossAxisAlignment.center, - children: [ - const Icon(Icons.cloud_upload, - size: 48, - color: Colors.grey), - const SizedBox(height: 12), - const Text( - 'Glissez-déposez des fichiers ici ou cliquez sur "Ajouter"', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.grey, - fontSize: 16), - ), - const SizedBox(height: 16), - SizedBox( - width: 160, - child: ElevatedButton.icon( - icon: const Icon( - Icons.attach_file, - size: 18), - label: - const Text('Ajouter'), - style: ElevatedButton - .styleFrom( - padding: const EdgeInsets - .symmetric( - horizontal: 8, - vertical: 8), - minimumSize: - const Size(80, 36), - textStyle: - const TextStyle( - fontSize: 14), - ), - onPressed: _isLoading - ? null - : _pickAndUploadFiles, - ), - ), - if (_error != null) - Padding( - padding: - const EdgeInsets.only( - top: 8.0), - child: Text(_error!, - style: const TextStyle( - color: Colors.red)), - ), - if (_success != null) - Padding( - padding: - const EdgeInsets.only( - top: 8.0), - child: Text(_success!, - style: const TextStyle( - color: - Colors.green)), - ), - ], - ), - ); - }, - ) - else - _buildFileListAndButton(), + DropzoneUploadWidget( + uploadedFiles: _uploadedFiles, + onFilesChanged: (files) => + setState(() => _uploadedFiles = files), + isLoading: _isLoading, + error: _error, + success: _success, + ), ], ), ), @@ -906,219 +593,4 @@ class _EventAddPageState extends State { ), ); } - - Widget _buildFileListAndButton() { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ..._uploadedFiles.map((file) { - final fileName = file['name']!; - final ext = p.extension(fileName).toLowerCase(); - IconData icon; - if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"] - .contains(ext)) { - icon = Icons.image; - } else if (ext == ".pdf") { - icon = Icons.picture_as_pdf; - } else if ([".txt", ".md", ".csv", ".json", ".xml"].contains(ext)) { - icon = Icons.description; - } else { - icon = Icons.attach_file; - } - return ListTile( - leading: Icon(icon, color: Colors.blueGrey), - title: Text(fileName, overflow: TextOverflow.ellipsis), - trailing: IconButton( - icon: const Icon(Icons.close), - onPressed: () { - setState(() { - _uploadedFiles.remove(file); - }); - }, - ), - contentPadding: EdgeInsets.zero, - dense: true, - ); - }).toList(), - SizedBox( - width: 160, - child: ElevatedButton.icon( - icon: const Icon(Icons.attach_file, size: 18), - label: const Text('Ajouter'), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), - minimumSize: const Size(80, 36), - textStyle: const TextStyle(fontSize: 14), - ), - onPressed: _isLoading ? null : _pickAndUploadFiles, - ), - ), - if (_error != null) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text(_error!, style: const TextStyle(color: Colors.red)), - ), - if (_success != null) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Text(_success!, style: const TextStyle(color: Colors.green)), - ), - ], - ); - } -} - -class UserMultiSelect extends StatefulWidget { - final List allUsers; - final List selectedUserIds; - final ValueChanged> onChanged; - - const UserMultiSelect({ - super.key, - required this.allUsers, - required this.selectedUserIds, - required this.onChanged, - }); - - @override - State createState() => _UserMultiSelectState(); -} - -class _UserMultiSelectState extends State { - void _openUserPicker() async { - final result = await showDialog>( - context: context, - builder: (context) => _UserPickerDialog( - allUsers: widget.allUsers, - initiallySelected: widget.selectedUserIds, - ), - ); - if (result != null) { - widget.onChanged(result); - } - } - - @override - Widget build(BuildContext context) { - final selectedUsers = widget.allUsers - .where((u) => widget.selectedUserIds.contains(u.uid)) - .toList(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Wrap( - spacing: 12, - runSpacing: 12, - children: selectedUsers - .map((user) => Chip( - avatar: ProfilePictureWidget(userId: user.uid, radius: 28), - label: Text('${user.firstName} ${user.lastName}', - style: const TextStyle(fontSize: 16)), - labelPadding: const EdgeInsets.symmetric(horizontal: 8), - deleteIcon: const Icon(Icons.close, size: 20), - onDeleted: () { - final newList = List.from(widget.selectedUserIds) - ..remove(user.uid); - widget.onChanged(newList); - }, - backgroundColor: Colors.grey[200], - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - )) - .toList(), - ), - const SizedBox(height: 16), - ElevatedButton.icon( - icon: const Icon(Icons.add), - label: const Text('Ajouter'), - onPressed: _openUserPicker, - ), - ], - ); - } -} - -class _UserPickerDialog extends StatefulWidget { - final List allUsers; - final List initiallySelected; - const _UserPickerDialog( - {required this.allUsers, required this.initiallySelected}); - - @override - State<_UserPickerDialog> createState() => _UserPickerDialogState(); -} - -class _UserPickerDialogState extends State<_UserPickerDialog> { - String _search = ''; - late List _selected; - - @override - void initState() { - super.initState(); - _selected = List.from(widget.initiallySelected); - } - - @override - Widget build(BuildContext context) { - final filteredUsers = widget.allUsers.where((u) { - final query = _search.toLowerCase(); - return ('${u.firstName} ${u.lastName}').toLowerCase().contains(query); - }).toList(); - return AlertDialog( - title: const Text('Ajouter du personnel'), - content: SizedBox( - width: 400, - height: 400, - child: Column( - children: [ - TextField( - decoration: const InputDecoration( - labelText: 'Rechercher', - prefixIcon: Icon(Icons.search), - ), - onChanged: (v) => setState(() => _search = v), - ), - const SizedBox(height: 12), - Expanded( - child: filteredUsers.isEmpty - ? const Center(child: Text('Aucun utilisateur trouvé')) - : ListView.builder( - itemCount: filteredUsers.length, - itemBuilder: (context, i) { - final user = filteredUsers[i]; - final isChecked = _selected.contains(user.uid); - return CheckboxListTile( - value: isChecked, - onChanged: (checked) { - setState(() { - if (checked == true) { - _selected.add(user.uid); - } else { - _selected.remove(user.uid); - } - }); - }, - title: Text('${user.firstName} ${user.lastName}'), - subtitle: Text(user.email), - secondary: ProfilePictureWidget( - userId: user.uid, radius: 20), - ); - }, - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context), - child: const Text('Annuler'), - ), - ElevatedButton( - onPressed: () => Navigator.pop(context, _selected), - child: const Text('Ajouter'), - ), - ], - ); - } } diff --git a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart index 886c91a..472ea26 100644 --- a/em2rp/lib/views/widgets/calendar_widgets/event_details.dart +++ b/em2rp/lib/views/widgets/calendar_widgets/event_details.dart @@ -80,7 +80,7 @@ class EventDetails extends StatelessWidget { ], ), const SizedBox(height: 16), - Text( + SelectableText( event.name, style: Theme.of(context).textTheme.headlineMedium?.copyWith( color: AppColors.noir, @@ -127,7 +127,7 @@ class EventDetails extends StatelessWidget { ), ), const SizedBox(height: 8), - Text( + SelectableText( event.description, style: Theme.of(context).textTheme.bodyLarge, ), @@ -140,13 +140,13 @@ class EventDetails extends StatelessWidget { ), ), const SizedBox(height: 8), - Text( + SelectableText( event.address, style: Theme.of(context).textTheme.bodyLarge, ), if (event.latitude != 0.0 || event.longitude != 0.0) ...[ const SizedBox(height: 4), - Text( + SelectableText( '${event.latitude}° N, ${event.longitude}° E', style: Theme.of(context).textTheme.bodySmall, ), @@ -188,7 +188,12 @@ class EventDetails extends StatelessWidget { } return ListTile( leading: Icon(icon, color: Colors.blueGrey), - title: Text(fileName, overflow: TextOverflow.ellipsis), + title: SelectableText( + fileName, + maxLines: 1, + textAlign: TextAlign.left, + style: Theme.of(context).textTheme.bodyMedium, + ), trailing: IconButton( icon: const Icon(Icons.download), onPressed: () async { diff --git a/em2rp/lib/views/widgets/image/profile_picture.dart b/em2rp/lib/views/widgets/image/profile_picture.dart index fc4daa1..1b8f4a7 100644 --- a/em2rp/lib/views/widgets/image/profile_picture.dart +++ b/em2rp/lib/views/widgets/image/profile_picture.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:cloud_firestore/cloud_firestore.dart'; -class ProfilePictureWidget extends StatelessWidget { - final String? userId; // Modifié pour être nullable +class ProfilePictureWidget extends StatefulWidget { + final String? userId; final double radius; final String? defaultImageUrl; @@ -13,28 +13,58 @@ class ProfilePictureWidget extends StatelessWidget { this.defaultImageUrl, }); + @override + State createState() => _ProfilePictureWidgetState(); +} + +class _ProfilePictureWidgetState extends State { + late Future _userFuture; + + @override + void initState() { + super.initState(); + _userFuture = _getUserFuture(); + } + + @override + void didUpdateWidget(ProfilePictureWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.userId != widget.userId) { + _userFuture = _getUserFuture(); + } + } + + Future _getUserFuture() { + if (widget.userId == null || widget.userId!.isEmpty) { + return Future.value(null); + } + return FirebaseFirestore.instance + .collection('users') + .doc(widget.userId) + .get(); + } + @override Widget build(BuildContext context) { - // Vérifier si userId est null ou vide - if (userId == null || userId!.isEmpty) { - return _buildDefaultAvatar(radius, defaultImageUrl); + if (widget.userId == null || widget.userId!.isEmpty) { + return _buildDefaultAvatar(widget.radius, widget.defaultImageUrl); } - return FutureBuilder( - future: FirebaseFirestore.instance.collection('users').doc(userId).get(), + return FutureBuilder( + future: _userFuture, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { - return _buildLoadingAvatar(radius); + return _buildLoadingAvatar(widget.radius); } else if (snapshot.hasError) { print("Error loading profile: ${snapshot.error}"); - return _buildDefaultAvatar(radius, defaultImageUrl); + return _buildDefaultAvatar(widget.radius, widget.defaultImageUrl); } else if (snapshot.data != null && snapshot.data!.exists) { final userData = snapshot.data!.data() as Map?; final profilePhotoUrl = userData?['profilePhotoUrl'] as String?; if (profilePhotoUrl != null && profilePhotoUrl.isNotEmpty) { return CircleAvatar( - radius: radius, + radius: widget.radius, backgroundImage: NetworkImage(profilePhotoUrl), onBackgroundImageError: (e, stack) { print("Error loading profile image: $e"); @@ -42,7 +72,7 @@ class ProfilePictureWidget extends StatelessWidget { ); } } - return _buildDefaultAvatar(radius, defaultImageUrl); + return _buildDefaultAvatar(widget.radius, widget.defaultImageUrl); }, ); } diff --git a/em2rp/lib/views/widgets/inputs/dropzone_upload_widget.dart b/em2rp/lib/views/widgets/inputs/dropzone_upload_widget.dart new file mode 100644 index 0000000..c14d189 --- /dev/null +++ b/em2rp/lib/views/widgets/inputs/dropzone_upload_widget.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:firebase_storage/firebase_storage.dart'; +import 'package:flutter_dropzone/flutter_dropzone.dart'; +import 'package:path/path.dart' as p; + +class DropzoneUploadWidget extends StatefulWidget { + final List> uploadedFiles; + final ValueChanged>> onFilesChanged; + final bool isLoading; + final String? error; + final String? success; + final double width; + final double height; + + const DropzoneUploadWidget({ + super.key, + required this.uploadedFiles, + required this.onFilesChanged, + this.isLoading = false, + this.error, + this.success, + this.width = 400, + this.height = 200, + }); + + @override + State createState() => _DropzoneUploadWidgetState(); +} + +class _DropzoneUploadWidgetState extends State { + DropzoneViewController? _dropzoneController; + bool _isDropzoneHighlighted = false; + bool _isLoading = false; + String? _error; + String? _success; + + @override + void initState() { + super.initState(); + _isLoading = widget.isLoading; + _error = widget.error; + _success = widget.success; + } + + Future _handleFiles(List files) async { + setState(() => _isLoading = true); + try { + List> newFiles = List.from(widget.uploadedFiles); + for (final file in files) { + final name = await _dropzoneController!.getFilename(file); + final bytes = await _dropzoneController!.getFileData(file); + if (bytes != null) { + final ref = FirebaseStorage.instance.ref().child( + 'events/temp/${DateTime.now().millisecondsSinceEpoch}_$name'); + final uploadTask = await ref.putData(bytes); + final url = await uploadTask.ref.getDownloadURL(); + if (!newFiles.any((f) => f['name'] == name && f['url'] == url)) { + newFiles.add({'name': name, 'url': url}); + } + } + } + widget.onFilesChanged(newFiles); + setState(() { + _success = "Fichier(s) ajouté(s) !"; + _error = null; + }); + } catch (e) { + setState(() { + _error = 'Erreur lors de l\'upload : $e'; + _success = null; + }); + } finally { + setState(() { + _isLoading = false; + _isDropzoneHighlighted = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: widget.width, + height: widget.height, + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: _isDropzoneHighlighted ? Colors.blue : Colors.grey, + width: 2, + ), + borderRadius: BorderRadius.circular(8), + color: _isDropzoneHighlighted ? Colors.blue.withOpacity(0.1) : null, + ), + child: Stack( + children: [ + DropzoneView( + onCreated: (controller) => _dropzoneController = controller, + onDropFiles: (files) async { + if (files == null) return; + await _handleFiles(files); + }, + onHover: () => setState(() => _isDropzoneHighlighted = true), + onLeave: () => setState(() => _isDropzoneHighlighted = false), + ), + Positioned.fill( + child: IgnorePointer( + child: widget.uploadedFiles.isEmpty + ? Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.cloud_upload, + size: 48, color: Colors.grey), + const SizedBox(height: 12), + const Text( + 'Glissez-déposez des fichiers ici ou cliquez sur "Ajouter"', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.grey, fontSize: 16), + ), + const SizedBox(height: 16), + ], + ) + : null, + ), + ), + if (widget.uploadedFiles.isNotEmpty) + Padding( + padding: const EdgeInsets.all(8.0), + child: _buildFileListAndButton(), + ), + if (widget.uploadedFiles.isEmpty) + Positioned( + bottom: 16, + left: 0, + right: 0, + child: Center( + child: SizedBox( + width: 160, + child: ElevatedButton.icon( + icon: const Icon(Icons.attach_file, size: 18), + label: const Text('Ajouter'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + minimumSize: const Size(80, 36), + textStyle: const TextStyle(fontSize: 14), + ), + onPressed: _isLoading + ? null + : () async { + final files = + await _dropzoneController?.pickFiles(); + if (files != null) { + await _handleFiles(files); + } + }, + ), + ), + ), + ), + if (_isLoading) + const Positioned.fill( + child: ColoredBox( + color: Color.fromARGB(80, 255, 255, 255), + child: Center( + child: SizedBox( + width: 32, + height: 32, + child: CircularProgressIndicator()), + ), + ), + ), + if (_error != null) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Padding( + padding: EdgeInsets.only(top: 8.0), + child: + Text(_error!, style: const TextStyle(color: Colors.red)), + ), + ), + if (_success != null) + Positioned( + bottom: 0, + left: 0, + right: 0, + child: Padding( + padding: const EdgeInsets.only(top: 32.0), + child: Text(_success!, + style: const TextStyle(color: Colors.green)), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFileListAndButton() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...widget.uploadedFiles.map((file) { + final fileName = file['name']!; + final ext = p.extension(fileName).toLowerCase(); + IconData icon; + if ([".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"] + .contains(ext)) { + icon = Icons.image; + } else if (ext == ".pdf") { + icon = Icons.picture_as_pdf; + } else if ([".txt", ".md", ".csv", ".json", ".xml"].contains(ext)) { + icon = Icons.description; + } else { + icon = Icons.attach_file; + } + return ListTile( + leading: Icon(icon, color: Colors.blueGrey), + title: Text(fileName, overflow: TextOverflow.ellipsis), + trailing: IconButton( + icon: const Icon(Icons.close), + onPressed: _isLoading + ? null + : () { + final newFiles = + List>.from(widget.uploadedFiles) + ..remove(file); + widget.onFilesChanged(newFiles); + }, + ), + contentPadding: EdgeInsets.zero, + dense: true, + ); + }).toList(), + SizedBox( + width: 160, + child: ElevatedButton.icon( + icon: const Icon(Icons.attach_file, size: 18), + label: const Text('Ajouter'), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8), + minimumSize: const Size(80, 36), + textStyle: const TextStyle(fontSize: 14), + ), + onPressed: _isLoading + ? null + : () async { + final files = await _dropzoneController?.pickFiles(); + if (files != null) { + await _handleFiles(files); + } + }, + ), + ), + ], + ); + } +} diff --git a/em2rp/lib/views/widgets/user_management/user_card.dart b/em2rp/lib/views/widgets/user_management/user_card.dart index c63ff43..e4e6cdb 100644 --- a/em2rp/lib/views/widgets/user_management/user_card.dart +++ b/em2rp/lib/views/widgets/user_management/user_card.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/utils/colors.dart'; -class UserCard extends StatelessWidget { +class UserCard extends StatefulWidget { final UserModel user; final VoidCallback onEdit; final VoidCallback onDelete; @@ -16,6 +16,66 @@ class UserCard extends StatelessWidget { required this.onDelete, }); + @override + State createState() => _UserCardState(); +} + +class _UserCardState extends State { + ImageProvider? _profileImage; + String? _lastUrl; + bool _isLoadingImage = false; + + @override + void didUpdateWidget(UserCard oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.user.profilePhotoUrl != widget.user.profilePhotoUrl) { + _loadProfileImage(); + } + } + + @override + void initState() { + super.initState(); + _loadProfileImage(); + } + + void _loadProfileImage() { + final url = widget.user.profilePhotoUrl; + if (url.isNotEmpty) { + setState(() { + _isLoadingImage = true; + _lastUrl = url; + }); + final image = NetworkImage(url); + image.resolve(const ImageConfiguration()).addListener( + ImageStreamListener( + (info, _) { + if (mounted) { + setState(() { + _profileImage = image; + _isLoadingImage = false; + }); + } + }, + onError: (error, stack) { + if (mounted) { + setState(() { + _profileImage = null; + _isLoadingImage = false; + }); + } + }, + ), + ); + } else { + setState(() { + _profileImage = null; + _isLoadingImage = false; + _lastUrl = null; + }); + } + } + @override Widget build(BuildContext context) { final width = MediaQuery.of(context).size.width; @@ -27,7 +87,7 @@ class UserCard extends StatelessWidget { elevation: 3, child: Container( constraints: BoxConstraints( - maxWidth: isMobile ? double.infinity : _desktopMaxWidth, + maxWidth: isMobile ? double.infinity : UserCard._desktopMaxWidth, ), padding: const EdgeInsets.all(12), child: @@ -47,13 +107,13 @@ class UserCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - "${user.firstName} ${user.lastName}", + "${widget.user.firstName} ${widget.user.lastName}", style: Theme.of(context).textTheme.titleSmall, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 2), Text( - user.email, + widget.user.email, style: Theme.of(context).textTheme.bodySmall, overflow: TextOverflow.ellipsis, ), @@ -65,7 +125,7 @@ class UserCard extends StatelessWidget { children: [ IconButton( icon: const Icon(Icons.edit, size: 20), - onPressed: onEdit, + onPressed: widget.onEdit, color: AppColors.rouge, padding: const EdgeInsets.all(8), constraints: const BoxConstraints( @@ -75,7 +135,7 @@ class UserCard extends StatelessWidget { ), IconButton( icon: const Icon(Icons.delete, size: 20), - onPressed: onDelete, + onPressed: widget.onDelete, color: AppColors.gris, padding: const EdgeInsets.all(8), constraints: const BoxConstraints( @@ -106,22 +166,22 @@ class UserCard extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - "${user.firstName} ${user.lastName}", + "${widget.user.firstName} ${widget.user.lastName}", style: Theme.of(context).textTheme.titleSmall, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Text( - user.email, + widget.user.email, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, ), - if (user.role.isNotEmpty) ...[ + if (widget.user.role.isNotEmpty) ...[ const SizedBox(height: 4), Text( - user.role, + widget.user.role, style: Theme.of(context).textTheme.bodySmall!.copyWith( color: AppColors.gris, fontSize: 11, @@ -143,7 +203,7 @@ class UserCard extends StatelessWidget { _buildButton( icon: Icons.edit, label: "Modifier", - onPressed: onEdit, + onPressed: widget.onEdit, color: AppColors.rouge, isNarrow: true, ), @@ -151,7 +211,7 @@ class UserCard extends StatelessWidget { _buildButton( icon: Icons.delete, label: "Supprimer", - onPressed: onDelete, + onPressed: widget.onDelete, color: AppColors.gris, isNarrow: true, ), @@ -163,7 +223,7 @@ class UserCard extends StatelessWidget { _buildButton( icon: Icons.edit, label: "Modifier", - onPressed: onEdit, + onPressed: widget.onEdit, color: AppColors.rouge, isNarrow: false, ), @@ -171,7 +231,7 @@ class UserCard extends StatelessWidget { _buildButton( icon: Icons.delete, label: "Supprimer", - onPressed: onDelete, + onPressed: widget.onDelete, color: AppColors.gris, isNarrow: false, ), @@ -218,13 +278,22 @@ class UserCard extends StatelessWidget { } Widget _profileAvatar(double size) { + if (_isLoadingImage && widget.user.profilePhotoUrl.isNotEmpty) { + return CircleAvatar( + radius: size / 2, + backgroundColor: Colors.grey[300], + child: SizedBox( + width: size * 0.5, + height: size * 0.5, + child: const CircularProgressIndicator(strokeWidth: 2), + ), + ); + } return CircleAvatar( radius: size / 2, - backgroundImage: user.profilePhotoUrl.isNotEmpty - ? NetworkImage(user.profilePhotoUrl) - : null, + backgroundImage: _profileImage, backgroundColor: Colors.grey[200], - child: user.profilePhotoUrl.isEmpty + child: (widget.user.profilePhotoUrl.isEmpty || _profileImage == null) ? Icon(Icons.person, size: size * 0.6, color: AppColors.noir) : null, ); diff --git a/em2rp/lib/views/widgets/user_management/user_multi_select_widget.dart b/em2rp/lib/views/widgets/user_management/user_multi_select_widget.dart new file mode 100644 index 0000000..a0beba3 --- /dev/null +++ b/em2rp/lib/views/widgets/user_management/user_multi_select_widget.dart @@ -0,0 +1,190 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/models/user_model.dart'; +import 'package:em2rp/views/widgets/image/profile_picture.dart'; + +class UserMultiSelectWidget extends StatelessWidget { + final List allUsers; + final List selectedUserIds; + final ValueChanged> onChanged; + final bool isLoading; + + const UserMultiSelectWidget({ + super.key, + required this.allUsers, + required this.selectedUserIds, + required this.onChanged, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context) { + if (isLoading) { + return const Center( + child: + SizedBox(width: 32, height: 32, child: CircularProgressIndicator()), + ); + } + return _UserMultiSelect( + allUsers: allUsers, + selectedUserIds: selectedUserIds, + onChanged: onChanged, + ); + } +} + +class _UserMultiSelect extends StatefulWidget { + final List allUsers; + final List selectedUserIds; + final ValueChanged> onChanged; + + const _UserMultiSelect({ + super.key, + required this.allUsers, + required this.selectedUserIds, + required this.onChanged, + }); + + @override + State<_UserMultiSelect> createState() => _UserMultiSelectState(); +} + +class _UserMultiSelectState extends State<_UserMultiSelect> { + void _openUserPicker() async { + final result = await showDialog>( + context: context, + builder: (context) => _UserPickerDialog( + allUsers: widget.allUsers, + initiallySelected: widget.selectedUserIds, + ), + ); + if (result != null) { + widget.onChanged(result); + } + } + + @override + Widget build(BuildContext context) { + final selectedUsers = widget.allUsers + .where((u) => widget.selectedUserIds.contains(u.uid)) + .toList(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 12, + runSpacing: 12, + children: selectedUsers + .map((user) => Chip( + avatar: ProfilePictureWidget(userId: user.uid, radius: 28), + label: Text('${user.firstName} ${user.lastName}', + style: const TextStyle(fontSize: 16)), + labelPadding: const EdgeInsets.symmetric(horizontal: 8), + deleteIcon: const Icon(Icons.close, size: 20), + onDeleted: () { + final newList = List.from(widget.selectedUserIds) + ..remove(user.uid); + widget.onChanged(newList); + }, + backgroundColor: Colors.grey[200], + padding: + const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + )) + .toList(), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + icon: const Icon(Icons.add), + label: const Text('Ajouter'), + onPressed: _openUserPicker, + ), + ], + ); + } +} + +class _UserPickerDialog extends StatefulWidget { + final List allUsers; + final List initiallySelected; + const _UserPickerDialog({ + required this.allUsers, + required this.initiallySelected, + }); + + @override + State<_UserPickerDialog> createState() => _UserPickerDialogState(); +} + +class _UserPickerDialogState extends State<_UserPickerDialog> { + String _search = ''; + late List _selected; + + @override + void initState() { + super.initState(); + _selected = List.from(widget.initiallySelected); + } + + @override + Widget build(BuildContext context) { + final filteredUsers = widget.allUsers.where((u) { + final query = _search.toLowerCase(); + return ('${u.firstName} ${u.lastName}').toLowerCase().contains(query); + }).toList(); + return AlertDialog( + title: const Text('Ajouter du personnel'), + content: SizedBox( + width: 400, + height: 400, + child: Column( + children: [ + TextField( + decoration: const InputDecoration( + labelText: 'Rechercher', + prefixIcon: Icon(Icons.search), + ), + onChanged: (v) => setState(() => _search = v), + ), + const SizedBox(height: 12), + Expanded( + child: filteredUsers.isEmpty + ? const Center(child: Text('Aucun utilisateur trouvé')) + : ListView.builder( + itemCount: filteredUsers.length, + itemBuilder: (context, i) { + final user = filteredUsers[i]; + final isChecked = _selected.contains(user.uid); + return CheckboxListTile( + value: isChecked, + onChanged: (checked) { + setState(() { + if (checked == true) { + _selected.add(user.uid); + } else { + _selected.remove(user.uid); + } + }); + }, + title: Text('${user.firstName} ${user.lastName}'), + subtitle: Text(user.email), + secondary: ProfilePictureWidget( + userId: user.uid, radius: 20), + ); + }, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, _selected), + child: const Text('Ajouter'), + ), + ], + ); + } +} diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index e1f06c4..6e9dcae 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -22,19 +22,19 @@ dependencies: table_calendar: ^3.0.9 intl: ^0.19.0 google_maps_flutter: ^2.5.0 - permission_handler: ^11.1.0 - geolocator: ^10.1.0 - flutter_map: ^6.1.0 + permission_handler: ^12.0.0+1 + geolocator: ^14.0.1 + flutter_map: ^8.1.1 latlong2: ^0.9.0 - flutter_launcher_icons: ^0.13.1 + flutter_launcher_icons: ^0.14.3 flutter_native_splash: ^2.3.9 url_launcher: ^6.2.2 - share_plus: ^7.2.1 + share_plus: ^11.0.0 path_provider: ^2.1.2 pdf: ^3.10.7 printing: ^5.11.1 - flutter_local_notifications: ^16.3.0 - timezone: ^0.9.2 + flutter_local_notifications: ^19.2.1 + timezone: ^0.10.1 flutter_secure_storage: ^9.0.0 http: ^1.1.2 flutter_dotenv: ^5.1.0 @@ -43,15 +43,15 @@ dependencies: cached_network_image: ^3.3.1 flutter_staggered_grid_view: ^0.7.0 shimmer: ^3.0.0 - flutter_slidable: ^3.0.1 + flutter_slidable: ^4.0.0 flutter_datetime_picker: ^1.5.1 flutter_colorpicker: ^1.0.3 flutter_rating_bar: ^4.0.1 - flutter_chat_ui: ^1.6.10 + flutter_chat_ui: ^2.3.1 flutter_chat_types: ^3.6.2 uuid: ^4.2.2 - file_picker: ^6.1.1 - flutter_dropzone: ^3.0.6 + file_picker: ^10.1.9 + flutter_dropzone: ^4.2.1 flutter_localizations: sdk: flutter diff --git a/em2rp/web/index.html b/em2rp/web/index.html index a6ee0a0..3b08ea3 100644 --- a/em2rp/web/index.html +++ b/em2rp/web/index.html @@ -31,6 +31,17 @@ em2rp +