Dropzone OK et refactor page event_add

This commit is contained in:
2025-05-27 20:08:39 +02:00
parent 49dffff1bf
commit 9489183b68
8 changed files with 627 additions and 590 deletions

View File

@ -17,6 +17,8 @@ import 'package:firebase_auth/firebase_auth.dart';
import 'package:em2rp/providers/local_user_provider.dart'; import 'package:em2rp/providers/local_user_provider.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_dropzone/flutter_dropzone.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 { class EventAddPage extends StatefulWidget {
const EventAddPage({super.key}); const EventAddPage({super.key});
@ -530,13 +532,12 @@ class _EventAddPageState extends State<EventAddPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( Expanded(
child: _isLoadingUsers child: UserMultiSelectWidget(
? const CircularProgressIndicator()
: UserMultiSelect(
allUsers: _allUsers, allUsers: _allUsers,
selectedUserIds: _selectedUserIds, selectedUserIds: _selectedUserIds,
onChanged: (ids) => onChanged: (ids) =>
setState(() => _selectedUserIds = ids), setState(() => _selectedUserIds = ids),
isLoading: _isLoadingUsers,
), ),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@ -545,328 +546,14 @@ class _EventAddPageState extends State<EventAddPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionTitle('Documents'), _buildSectionTitle('Documents'),
if (kIsWeb) DropzoneUploadWidget(
Container( uploadedFiles: _uploadedFiles,
constraints: const BoxConstraints( onFilesChanged: (files) =>
minHeight: 180, minWidth: 200), setState(() => _uploadedFiles = files),
decoration: BoxDecoration( isLoading: _isLoading,
border: Border.all( error: _error,
color: _isDropzoneHighlighted success: _success,
? 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<PlatformFile>(
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(),
], ],
), ),
), ),
@ -906,219 +593,4 @@ class _EventAddPageState extends State<EventAddPage> {
), ),
); );
} }
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<UserModel> allUsers;
final List<String> selectedUserIds;
final ValueChanged<List<String>> 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<List<String>>(
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<String>.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<UserModel> allUsers;
final List<String> initiallySelected;
const _UserPickerDialog(
{required this.allUsers, required this.initiallySelected});
@override
State<_UserPickerDialog> createState() => _UserPickerDialogState();
}
class _UserPickerDialogState extends State<_UserPickerDialog> {
String _search = '';
late List<String> _selected;
@override
void initState() {
super.initState();
_selected = List<String>.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'),
),
],
);
}
} }

View File

@ -80,7 +80,7 @@ class EventDetails extends StatelessWidget {
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( SelectableText(
event.name, event.name,
style: Theme.of(context).textTheme.headlineMedium?.copyWith( style: Theme.of(context).textTheme.headlineMedium?.copyWith(
color: AppColors.noir, color: AppColors.noir,
@ -127,7 +127,7 @@ class EventDetails extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( SelectableText(
event.description, event.description,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
@ -140,13 +140,13 @@ class EventDetails extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( SelectableText(
event.address, event.address,
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
if (event.latitude != 0.0 || event.longitude != 0.0) ...[ if (event.latitude != 0.0 || event.longitude != 0.0) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( SelectableText(
'${event.latitude}° N, ${event.longitude}° E', '${event.latitude}° N, ${event.longitude}° E',
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
), ),
@ -188,7 +188,12 @@ class EventDetails extends StatelessWidget {
} }
return ListTile( return ListTile(
leading: Icon(icon, color: Colors.blueGrey), 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( trailing: IconButton(
icon: const Icon(Icons.download), icon: const Icon(Icons.download),
onPressed: () async { onPressed: () async {

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:cloud_firestore/cloud_firestore.dart';
class ProfilePictureWidget extends StatelessWidget { class ProfilePictureWidget extends StatefulWidget {
final String? userId; // Modifié pour être nullable final String? userId;
final double radius; final double radius;
final String? defaultImageUrl; final String? defaultImageUrl;
@ -14,27 +14,57 @@ class ProfilePictureWidget extends StatelessWidget {
}); });
@override @override
Widget build(BuildContext context) { State<ProfilePictureWidget> createState() => _ProfilePictureWidgetState();
// Vérifier si userId est null ou vide
if (userId == null || userId!.isEmpty) {
return _buildDefaultAvatar(radius, defaultImageUrl);
} }
return FutureBuilder<DocumentSnapshot>( class _ProfilePictureWidgetState extends State<ProfilePictureWidget> {
future: FirebaseFirestore.instance.collection('users').doc(userId).get(), late Future<DocumentSnapshot?> _userFuture;
@override
void initState() {
super.initState();
_userFuture = _getUserFuture();
}
@override
void didUpdateWidget(ProfilePictureWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.userId != widget.userId) {
_userFuture = _getUserFuture();
}
}
Future<DocumentSnapshot?> _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) {
if (widget.userId == null || widget.userId!.isEmpty) {
return _buildDefaultAvatar(widget.radius, widget.defaultImageUrl);
}
return FutureBuilder<DocumentSnapshot?>(
future: _userFuture,
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) { if (snapshot.connectionState == ConnectionState.waiting) {
return _buildLoadingAvatar(radius); return _buildLoadingAvatar(widget.radius);
} else if (snapshot.hasError) { } else if (snapshot.hasError) {
print("Error loading profile: ${snapshot.error}"); print("Error loading profile: ${snapshot.error}");
return _buildDefaultAvatar(radius, defaultImageUrl); return _buildDefaultAvatar(widget.radius, widget.defaultImageUrl);
} else if (snapshot.data != null && snapshot.data!.exists) { } else if (snapshot.data != null && snapshot.data!.exists) {
final userData = snapshot.data!.data() as Map<String, dynamic>?; final userData = snapshot.data!.data() as Map<String, dynamic>?;
final profilePhotoUrl = userData?['profilePhotoUrl'] as String?; final profilePhotoUrl = userData?['profilePhotoUrl'] as String?;
if (profilePhotoUrl != null && profilePhotoUrl.isNotEmpty) { if (profilePhotoUrl != null && profilePhotoUrl.isNotEmpty) {
return CircleAvatar( return CircleAvatar(
radius: radius, radius: widget.radius,
backgroundImage: NetworkImage(profilePhotoUrl), backgroundImage: NetworkImage(profilePhotoUrl),
onBackgroundImageError: (e, stack) { onBackgroundImageError: (e, stack) {
print("Error loading profile image: $e"); print("Error loading profile image: $e");
@ -42,7 +72,7 @@ class ProfilePictureWidget extends StatelessWidget {
); );
} }
} }
return _buildDefaultAvatar(radius, defaultImageUrl); return _buildDefaultAvatar(widget.radius, widget.defaultImageUrl);
}, },
); );
} }

View File

@ -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<Map<String, String>> uploadedFiles;
final ValueChanged<List<Map<String, String>>> 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<DropzoneUploadWidget> createState() => _DropzoneUploadWidgetState();
}
class _DropzoneUploadWidgetState extends State<DropzoneUploadWidget> {
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<void> _handleFiles(List<dynamic> files) async {
setState(() => _isLoading = true);
try {
List<Map<String, String>> 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<Map<String, String>>.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);
}
},
),
),
],
);
}
}

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:em2rp/models/user_model.dart'; import 'package:em2rp/models/user_model.dart';
import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/utils/colors.dart';
class UserCard extends StatelessWidget { class UserCard extends StatefulWidget {
final UserModel user; final UserModel user;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
@ -16,6 +16,66 @@ class UserCard extends StatelessWidget {
required this.onDelete, required this.onDelete,
}); });
@override
State<UserCard> createState() => _UserCardState();
}
class _UserCardState extends State<UserCard> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final width = MediaQuery.of(context).size.width; final width = MediaQuery.of(context).size.width;
@ -27,7 +87,7 @@ class UserCard extends StatelessWidget {
elevation: 3, elevation: 3,
child: Container( child: Container(
constraints: BoxConstraints( constraints: BoxConstraints(
maxWidth: isMobile ? double.infinity : _desktopMaxWidth, maxWidth: isMobile ? double.infinity : UserCard._desktopMaxWidth,
), ),
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: child:
@ -47,13 +107,13 @@ class UserCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
"${user.firstName} ${user.lastName}", "${widget.user.firstName} ${widget.user.lastName}",
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
user.email, widget.user.email,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
@ -65,7 +125,7 @@ class UserCard extends StatelessWidget {
children: [ children: [
IconButton( IconButton(
icon: const Icon(Icons.edit, size: 20), icon: const Icon(Icons.edit, size: 20),
onPressed: onEdit, onPressed: widget.onEdit,
color: AppColors.rouge, color: AppColors.rouge,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
constraints: const BoxConstraints( constraints: const BoxConstraints(
@ -75,7 +135,7 @@ class UserCard extends StatelessWidget {
), ),
IconButton( IconButton(
icon: const Icon(Icons.delete, size: 20), icon: const Icon(Icons.delete, size: 20),
onPressed: onDelete, onPressed: widget.onDelete,
color: AppColors.gris, color: AppColors.gris,
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
constraints: const BoxConstraints( constraints: const BoxConstraints(
@ -106,22 +166,22 @@ class UserCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
"${user.firstName} ${user.lastName}", "${widget.user.firstName} ${widget.user.lastName}",
style: Theme.of(context).textTheme.titleSmall, style: Theme.of(context).textTheme.titleSmall,
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
user.email, widget.user.email,
style: Theme.of(context).textTheme.bodySmall, style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center, textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (user.role.isNotEmpty) ...[ if (widget.user.role.isNotEmpty) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
user.role, widget.user.role,
style: Theme.of(context).textTheme.bodySmall!.copyWith( style: Theme.of(context).textTheme.bodySmall!.copyWith(
color: AppColors.gris, color: AppColors.gris,
fontSize: 11, fontSize: 11,
@ -143,7 +203,7 @@ class UserCard extends StatelessWidget {
_buildButton( _buildButton(
icon: Icons.edit, icon: Icons.edit,
label: "Modifier", label: "Modifier",
onPressed: onEdit, onPressed: widget.onEdit,
color: AppColors.rouge, color: AppColors.rouge,
isNarrow: true, isNarrow: true,
), ),
@ -151,7 +211,7 @@ class UserCard extends StatelessWidget {
_buildButton( _buildButton(
icon: Icons.delete, icon: Icons.delete,
label: "Supprimer", label: "Supprimer",
onPressed: onDelete, onPressed: widget.onDelete,
color: AppColors.gris, color: AppColors.gris,
isNarrow: true, isNarrow: true,
), ),
@ -163,7 +223,7 @@ class UserCard extends StatelessWidget {
_buildButton( _buildButton(
icon: Icons.edit, icon: Icons.edit,
label: "Modifier", label: "Modifier",
onPressed: onEdit, onPressed: widget.onEdit,
color: AppColors.rouge, color: AppColors.rouge,
isNarrow: false, isNarrow: false,
), ),
@ -171,7 +231,7 @@ class UserCard extends StatelessWidget {
_buildButton( _buildButton(
icon: Icons.delete, icon: Icons.delete,
label: "Supprimer", label: "Supprimer",
onPressed: onDelete, onPressed: widget.onDelete,
color: AppColors.gris, color: AppColors.gris,
isNarrow: false, isNarrow: false,
), ),
@ -218,13 +278,22 @@ class UserCard extends StatelessWidget {
} }
Widget _profileAvatar(double size) { Widget _profileAvatar(double size) {
if (_isLoadingImage && widget.user.profilePhotoUrl.isNotEmpty) {
return CircleAvatar( return CircleAvatar(
radius: size / 2, radius: size / 2,
backgroundImage: user.profilePhotoUrl.isNotEmpty backgroundColor: Colors.grey[300],
? NetworkImage(user.profilePhotoUrl) child: SizedBox(
: null, width: size * 0.5,
height: size * 0.5,
child: const CircularProgressIndicator(strokeWidth: 2),
),
);
}
return CircleAvatar(
radius: size / 2,
backgroundImage: _profileImage,
backgroundColor: Colors.grey[200], 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) ? Icon(Icons.person, size: size * 0.6, color: AppColors.noir)
: null, : null,
); );

View File

@ -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<UserModel> allUsers;
final List<String> selectedUserIds;
final ValueChanged<List<String>> 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<UserModel> allUsers;
final List<String> selectedUserIds;
final ValueChanged<List<String>> 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<List<String>>(
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<String>.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<UserModel> allUsers;
final List<String> initiallySelected;
const _UserPickerDialog({
required this.allUsers,
required this.initiallySelected,
});
@override
State<_UserPickerDialog> createState() => _UserPickerDialogState();
}
class _UserPickerDialogState extends State<_UserPickerDialog> {
String _search = '';
late List<String> _selected;
@override
void initState() {
super.initState();
_selected = List<String>.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'),
),
],
);
}
}

View File

@ -22,19 +22,19 @@ dependencies:
table_calendar: ^3.0.9 table_calendar: ^3.0.9
intl: ^0.19.0 intl: ^0.19.0
google_maps_flutter: ^2.5.0 google_maps_flutter: ^2.5.0
permission_handler: ^11.1.0 permission_handler: ^12.0.0+1
geolocator: ^10.1.0 geolocator: ^14.0.1
flutter_map: ^6.1.0 flutter_map: ^8.1.1
latlong2: ^0.9.0 latlong2: ^0.9.0
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.14.3
flutter_native_splash: ^2.3.9 flutter_native_splash: ^2.3.9
url_launcher: ^6.2.2 url_launcher: ^6.2.2
share_plus: ^7.2.1 share_plus: ^11.0.0
path_provider: ^2.1.2 path_provider: ^2.1.2
pdf: ^3.10.7 pdf: ^3.10.7
printing: ^5.11.1 printing: ^5.11.1
flutter_local_notifications: ^16.3.0 flutter_local_notifications: ^19.2.1
timezone: ^0.9.2 timezone: ^0.10.1
flutter_secure_storage: ^9.0.0 flutter_secure_storage: ^9.0.0
http: ^1.1.2 http: ^1.1.2
flutter_dotenv: ^5.1.0 flutter_dotenv: ^5.1.0
@ -43,15 +43,15 @@ dependencies:
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1
flutter_staggered_grid_view: ^0.7.0 flutter_staggered_grid_view: ^0.7.0
shimmer: ^3.0.0 shimmer: ^3.0.0
flutter_slidable: ^3.0.1 flutter_slidable: ^4.0.0
flutter_datetime_picker: ^1.5.1 flutter_datetime_picker: ^1.5.1
flutter_colorpicker: ^1.0.3 flutter_colorpicker: ^1.0.3
flutter_rating_bar: ^4.0.1 flutter_rating_bar: ^4.0.1
flutter_chat_ui: ^1.6.10 flutter_chat_ui: ^2.3.1
flutter_chat_types: ^3.6.2 flutter_chat_types: ^3.6.2
uuid: ^4.2.2 uuid: ^4.2.2
file_picker: ^6.1.1 file_picker: ^10.1.9
flutter_dropzone: ^3.0.6 flutter_dropzone: ^4.2.1
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter

View File

@ -31,6 +31,17 @@
<title>em2rp</title> <title>em2rp</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<style>
html, body, #flt-glass-pane {
user-select: text !important;
-webkit-user-select: text !important;
-moz-user-select: text !important;
-ms-user-select: text !important;
}
.dropzone-view, .dropzone-view * {
pointer-events: auto !important;
}
</style>
</head> </head>
<body> <body>
<script src="flutter_bootstrap.js" async></script> <script src="flutter_bootstrap.js" async></script>