1125 lines
50 KiB
Dart
1125 lines
50 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:em2rp/providers/event_provider.dart';
|
|
import 'package:em2rp/models/event_model.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:em2rp/views/widgets/inputs/int_stepper_field.dart';
|
|
import 'package:em2rp/models/user_model.dart';
|
|
import 'package:em2rp/views/widgets/image/profile_picture.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:firebase_storage/firebase_storage.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'dart:convert';
|
|
import 'package:http/http.dart' as http;
|
|
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';
|
|
|
|
class EventAddPage extends StatefulWidget {
|
|
const EventAddPage({super.key});
|
|
|
|
@override
|
|
State<EventAddPage> createState() => _EventAddPageState();
|
|
}
|
|
|
|
class _EventAddPageState extends State<EventAddPage> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final TextEditingController _nameController = TextEditingController();
|
|
final TextEditingController _descriptionController = TextEditingController();
|
|
final TextEditingController _priceController = TextEditingController();
|
|
final TextEditingController _installationController = TextEditingController();
|
|
final TextEditingController _disassemblyController = TextEditingController();
|
|
final TextEditingController _addressController = TextEditingController();
|
|
DateTime? _startDateTime;
|
|
DateTime? _endDateTime;
|
|
bool _isLoading = false;
|
|
String? _error;
|
|
String? _success;
|
|
String? _selectedEventType;
|
|
final List<String> _eventTypes = ['Bal', 'Mariage', 'Anniversaire'];
|
|
int _descriptionMaxLines = 3;
|
|
List<String> _selectedUserIds = [];
|
|
List<UserModel> _allUsers = [];
|
|
bool _isLoadingUsers = true;
|
|
List<Map<String, String>> _uploadedFiles = [];
|
|
DropzoneViewController? _dropzoneController;
|
|
bool _isDropzoneHighlighted = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_descriptionController.addListener(_handleDescriptionChange);
|
|
_fetchUsers();
|
|
}
|
|
|
|
void _handleDescriptionChange() {
|
|
final lines = '\n'.allMatches(_descriptionController.text).length + 1;
|
|
setState(() {
|
|
_descriptionMaxLines = lines.clamp(3, 6);
|
|
});
|
|
}
|
|
|
|
Future<void> _fetchUsers() async {
|
|
final snapshot = await FirebaseFirestore.instance.collection('users').get();
|
|
setState(() {
|
|
_allUsers = snapshot.docs
|
|
.map((doc) => UserModel.fromMap(doc.data(), doc.id))
|
|
.toList();
|
|
_isLoadingUsers = false;
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_descriptionController.dispose();
|
|
_priceController.dispose();
|
|
_installationController.dispose();
|
|
_disassemblyController.dispose();
|
|
_addressController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _pickAndUploadFiles() async {
|
|
final result = await FilePicker.platform
|
|
.pickFiles(allowMultiple: true, withData: true);
|
|
if (result != null && result.files.isNotEmpty) {
|
|
setState(() => _isLoading = true);
|
|
try {
|
|
List<Map<String, String>> files = [];
|
|
for (final file in result.files) {
|
|
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();
|
|
files.add({'name': fileName, 'url': url});
|
|
} else {
|
|
setState(() {
|
|
_error = "Impossible de lire le fichier ${file.name}";
|
|
});
|
|
}
|
|
}
|
|
setState(() {
|
|
_uploadedFiles.addAll(files);
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = 'Erreur lors de l\'upload : $e';
|
|
});
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<String?> moveEventFileHttp({
|
|
required String sourcePath,
|
|
required String destinationPath,
|
|
}) async {
|
|
final url = Uri.parse(
|
|
'https://us-central1-em2rp-951dc.cloudfunctions.net/moveEventFileV2');
|
|
final user = FirebaseAuth.instance.currentUser;
|
|
final idToken = await user?.getIdToken();
|
|
final response = await http.post(
|
|
url,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
if (idToken != null) 'Authorization': 'Bearer $idToken',
|
|
},
|
|
body: jsonEncode({
|
|
'data': {
|
|
'sourcePath': sourcePath,
|
|
'destinationPath': destinationPath,
|
|
}
|
|
}),
|
|
);
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
if (data['url'] != null) {
|
|
return data['url'] as String;
|
|
} else if (data['result'] != null && data['result']['url'] != null) {
|
|
return data['result']['url'] as String;
|
|
}
|
|
return null;
|
|
} else {
|
|
print('Erreur Cloud Function: \\n${response.body}');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (!_formKey.currentState!.validate() ||
|
|
_startDateTime == null ||
|
|
_endDateTime == null ||
|
|
_selectedEventType == null ||
|
|
_addressController.text.isEmpty) return;
|
|
if (_endDateTime!.isBefore(_startDateTime!) ||
|
|
_endDateTime!.isAtSameMomentAs(_startDateTime!)) {
|
|
setState(() {
|
|
_error = "La date de fin doit être postérieure à la date de début.";
|
|
});
|
|
return;
|
|
}
|
|
setState(() {
|
|
_isLoading = true;
|
|
_error = null;
|
|
_success = null;
|
|
});
|
|
try {
|
|
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
|
final newEvent = EventModel(
|
|
id: '',
|
|
name: _nameController.text.trim(),
|
|
description: _descriptionController.text.trim(),
|
|
startDateTime: _startDateTime!,
|
|
endDateTime: _endDateTime!,
|
|
price: double.tryParse(_priceController.text) ?? 0.0,
|
|
installationTime: int.tryParse(_installationController.text) ?? 0,
|
|
disassemblyTime: int.tryParse(_disassemblyController.text) ?? 0,
|
|
eventTypeId: _selectedEventType!,
|
|
customerId: '',
|
|
address: _addressController.text.trim(),
|
|
workforce: _selectedUserIds
|
|
.map((id) => FirebaseFirestore.instance.collection('users').doc(id))
|
|
.toList(),
|
|
latitude: 0.0,
|
|
longitude: 0.0,
|
|
documents: _uploadedFiles,
|
|
);
|
|
final docRef = await FirebaseFirestore.instance
|
|
.collection('events')
|
|
.add(newEvent.toMap());
|
|
final eventId = docRef.id;
|
|
List<Map<String, String>> newFiles = [];
|
|
for (final file in _uploadedFiles) {
|
|
final fileName = file['name']!;
|
|
final oldUrl = file['url']!;
|
|
String sourcePath;
|
|
final tempPattern = RegExp(r'events/temp/[^?]+');
|
|
final match = tempPattern.firstMatch(oldUrl);
|
|
if (match != null) {
|
|
sourcePath = match.group(0)!;
|
|
} else {
|
|
final tempFileName =
|
|
Uri.decodeComponent(oldUrl.split('/').last.split('?').first);
|
|
sourcePath = tempFileName;
|
|
}
|
|
final destinationPath = 'events/$eventId/$fileName';
|
|
final newUrl = await moveEventFileHttp(
|
|
sourcePath: sourcePath,
|
|
destinationPath: destinationPath,
|
|
);
|
|
if (newUrl != null) {
|
|
newFiles.add({'name': fileName, 'url': newUrl});
|
|
} else {
|
|
newFiles.add({'name': fileName, 'url': oldUrl});
|
|
}
|
|
}
|
|
await docRef.update({'documents': newFiles});
|
|
final localUserProvider =
|
|
Provider.of<LocalUserProvider>(context, listen: false);
|
|
final userId = localUserProvider.uid;
|
|
final canViewAllEvents =
|
|
localUserProvider.hasPermission('view_all_events');
|
|
if (userId != null) {
|
|
await eventProvider.loadUserEvents(userId,
|
|
canViewAllEvents: canViewAllEvents);
|
|
}
|
|
setState(() {
|
|
_success = "Événement créé avec succès !";
|
|
});
|
|
if (context.mounted) Navigator.of(context).pop();
|
|
} catch (e) {
|
|
setState(() {
|
|
_error = "Erreur lors de la création : $e";
|
|
});
|
|
} finally {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Widget _buildSectionTitle(String title) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 16.0, bottom: 8.0),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
title,
|
|
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('Créer un événement'),
|
|
),
|
|
body: Center(
|
|
child: SingleChildScrollView(
|
|
child: Card(
|
|
elevation: 6,
|
|
margin: const EdgeInsets.all(24),
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 0.0, bottom: 4.0),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
'Informations principales',
|
|
style: const TextStyle(
|
|
fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
),
|
|
TextFormField(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Nom de l\'événement',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.event),
|
|
),
|
|
validator: (v) =>
|
|
v == null || v.isEmpty ? 'Champ requis' : null,
|
|
),
|
|
const SizedBox(height: 16),
|
|
DropdownButtonFormField<String>(
|
|
value: _selectedEventType,
|
|
items: _eventTypes
|
|
.map((type) => DropdownMenuItem<String>(
|
|
value: type,
|
|
child: Text(type),
|
|
))
|
|
.toList(),
|
|
onChanged: (val) =>
|
|
setState(() => _selectedEventType = val),
|
|
decoration: const InputDecoration(
|
|
labelText: 'Type d\'événement',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.category),
|
|
),
|
|
validator: (v) =>
|
|
v == null ? 'Sélectionnez un type' : null,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: () async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: DateTime.now(),
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime(2099),
|
|
);
|
|
if (picked != null) {
|
|
final time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.now(),
|
|
);
|
|
if (time != null) {
|
|
setState(() {
|
|
_startDateTime = DateTime(
|
|
picked.year,
|
|
picked.month,
|
|
picked.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
if (_endDateTime != null &&
|
|
(_endDateTime!
|
|
.isBefore(_startDateTime!) ||
|
|
_endDateTime!.isAtSameMomentAs(
|
|
_startDateTime!))) {
|
|
_endDateTime = null;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
},
|
|
child: AbsorbPointer(
|
|
child: TextFormField(
|
|
readOnly: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Début',
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.calendar_today),
|
|
suffixIcon: const Icon(Icons.edit_calendar),
|
|
),
|
|
controller: TextEditingController(
|
|
text: _startDateTime == null
|
|
? ''
|
|
: DateFormat('dd/MM/yyyy HH:mm')
|
|
.format(_startDateTime!),
|
|
),
|
|
validator: (v) => _startDateTime == null
|
|
? 'Champ requis'
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: GestureDetector(
|
|
onTap: _startDateTime == null
|
|
? null
|
|
: () async {
|
|
final picked = await showDatePicker(
|
|
context: context,
|
|
initialDate: _startDateTime!
|
|
.add(const Duration(hours: 1)),
|
|
firstDate: _startDateTime!,
|
|
lastDate: DateTime(2099),
|
|
);
|
|
if (picked != null) {
|
|
final time = await showTimePicker(
|
|
context: context,
|
|
initialTime: TimeOfDay.now(),
|
|
);
|
|
if (time != null) {
|
|
setState(() {
|
|
_endDateTime = DateTime(
|
|
picked.year,
|
|
picked.month,
|
|
picked.day,
|
|
time.hour,
|
|
time.minute,
|
|
);
|
|
});
|
|
}
|
|
}
|
|
},
|
|
child: AbsorbPointer(
|
|
child: TextFormField(
|
|
readOnly: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Fin',
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.calendar_today),
|
|
suffixIcon: const Icon(Icons.edit_calendar),
|
|
),
|
|
controller: TextEditingController(
|
|
text: _endDateTime == null
|
|
? ''
|
|
: DateFormat('dd/MM/yyyy HH:mm')
|
|
.format(_endDateTime!),
|
|
),
|
|
validator: (v) => _endDateTime == null
|
|
? 'Champ requis'
|
|
: (_startDateTime != null &&
|
|
_endDateTime != null &&
|
|
(_endDateTime!.isBefore(
|
|
_startDateTime!) ||
|
|
_endDateTime!.isAtSameMomentAs(
|
|
_startDateTime!)))
|
|
? 'La date de fin doit être après la date de début'
|
|
: null,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextFormField(
|
|
controller: _priceController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Prix (€)',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.euro),
|
|
hintText: '1050.50',
|
|
),
|
|
keyboardType:
|
|
const TextInputType.numberWithOptions(decimal: true),
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(
|
|
RegExp(r'^\d*\.?\d{0,2}')),
|
|
],
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Le prix est requis';
|
|
}
|
|
final price =
|
|
double.tryParse(value.replaceAll(',', '.'));
|
|
if (price == null) {
|
|
return 'Veuillez entrer un nombre valide';
|
|
}
|
|
if (price < 0) {
|
|
return 'Le prix ne peut pas être négatif';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
_buildSectionTitle('Détails'),
|
|
AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
constraints: BoxConstraints(
|
|
minHeight: 48,
|
|
maxHeight: 48.0 * 10,
|
|
),
|
|
child: TextFormField(
|
|
controller: _descriptionController,
|
|
minLines: 1,
|
|
maxLines: _descriptionMaxLines > 10
|
|
? 10
|
|
: _descriptionMaxLines,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.description),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: IntStepperField(
|
|
label: 'Installation (h)',
|
|
controller: _installationController,
|
|
min: 0,
|
|
max: 99,
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: IntStepperField(
|
|
label: 'Démontage (h)',
|
|
controller: _disassemblyController,
|
|
min: 0,
|
|
max: 99,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
_buildSectionTitle('Adresse'),
|
|
TextFormField(
|
|
controller: _addressController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Adresse',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.location_on),
|
|
),
|
|
validator: (v) =>
|
|
v == null || v.isEmpty ? 'Champ requis' : null,
|
|
),
|
|
_buildSectionTitle('Personnel'),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: _isLoadingUsers
|
|
? const CircularProgressIndicator()
|
|
: UserMultiSelect(
|
|
allUsers: _allUsers,
|
|
selectedUserIds: _selectedUserIds,
|
|
onChanged: (ids) =>
|
|
setState(() => _selectedUserIds = ids),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
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<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(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: _isLoading
|
|
? null
|
|
: () => Navigator.of(context).pop(),
|
|
child: const Text('Annuler'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ElevatedButton.icon(
|
|
icon: const Icon(Icons.check),
|
|
onPressed: _isLoading ? null : _submit,
|
|
label: _isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child:
|
|
CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Text('Créer'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|