feat: recherche d'événements et gestion avancée de la suppression d'équipement
- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
- Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
- Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
- Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
- Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
- Refonte de l'interface mobile pour intégrer la barre de recherche.
- Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
- Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
- Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
- Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
@@ -10,6 +11,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:table_calendar/table_calendar.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/views/widgets/calendar_widgets/event_details.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:intl/date_symbol_data_local.dart';
|
||||
import 'package:em2rp/views/widgets/calendar_widgets/month_view.dart';
|
||||
import 'package:em2rp/views/widgets/calendar_widgets/week_view.dart';
|
||||
@@ -40,6 +42,14 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
int _selectedEventIndex = 0;
|
||||
String?
|
||||
_selectedUserId; // Filtre par utilisateur (null = tous les événements)
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
Timer? _searchDebounce;
|
||||
List<EventModel> _searchResults = [];
|
||||
String _searchQuery = '';
|
||||
String? _searchError;
|
||||
bool _isSearching = false;
|
||||
int _searchRequestId = 0;
|
||||
bool _isMobileSearchVisible = false;
|
||||
bool _isRefreshing = false;
|
||||
double _detailsPaneFraction = 0.35;
|
||||
|
||||
@@ -105,21 +115,6 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge les événements de manière asynchrone et sélectionne l'événement approprié
|
||||
/// DEPRECATED: Utiliser _loadCurrentMonthEvents à la place
|
||||
Future<void> _loadEventsAsync() async {
|
||||
PerformanceMonitor.start('CalendarPage.loadEventsAsync');
|
||||
await _loadEvents();
|
||||
|
||||
// Sélectionner l'événement approprié après le chargement
|
||||
if (mounted) {
|
||||
PerformanceMonitor.start('CalendarPage.selectDefaultEvent');
|
||||
_selectDefaultEvent();
|
||||
PerformanceMonitor.end('CalendarPage.selectDefaultEvent');
|
||||
}
|
||||
PerformanceMonitor.end('CalendarPage.loadEventsAsync');
|
||||
}
|
||||
|
||||
/// Sélectionne automatiquement l'événement le plus proche de maintenant
|
||||
void _selectDefaultEvent() {
|
||||
final eventProvider = Provider.of<EventProvider>(context, listen: false);
|
||||
@@ -188,9 +183,15 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif)
|
||||
/// TEMPORAIREMENT DÉSACTIVÉ - À réactiver quand permission ajoutée dans Firestore
|
||||
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
|
||||
@override
|
||||
void dispose() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Filtre les événements selon l'utilisateur sélectionné (si filtre actif).
|
||||
List<EventModel> _filterEventsByUser(List<EventModel> allEvents) {
|
||||
if (_selectedUserId == null) {
|
||||
return allEvents; // Pas de filtre, retourner tous les événements
|
||||
}
|
||||
@@ -208,6 +209,524 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
}).toList();
|
||||
}
|
||||
|
||||
bool _isSameDay(DateTime left, DateTime right) {
|
||||
return left.year == right.year &&
|
||||
left.month == right.month &&
|
||||
left.day == right.day;
|
||||
}
|
||||
|
||||
List<EventModel> _getEventsForDay(
|
||||
List<EventModel> events,
|
||||
DateTime? day, {
|
||||
EventModel? selectedEvent,
|
||||
}) {
|
||||
if (day == null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final dayEvents = events
|
||||
.where((event) => _isSameDay(event.startDateTime, day))
|
||||
.toList()
|
||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
|
||||
if (selectedEvent != null &&
|
||||
_isSameDay(selectedEvent.startDateTime, day) &&
|
||||
!dayEvents.any((event) => event.id == selectedEvent.id)) {
|
||||
dayEvents.add(selectedEvent);
|
||||
dayEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
}
|
||||
|
||||
return dayEvents;
|
||||
}
|
||||
|
||||
List<EventModel> _getDetailsEvents(List<EventModel> events) {
|
||||
final mergedEvents = [...events];
|
||||
|
||||
if (_selectedEvent != null &&
|
||||
!mergedEvents.any((event) => event.id == _selectedEvent!.id)) {
|
||||
mergedEvents.add(_selectedEvent!);
|
||||
}
|
||||
|
||||
mergedEvents.sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
return mergedEvents;
|
||||
}
|
||||
|
||||
String _formatSearchResultDate(DateTime dateTime) {
|
||||
return DateFormat('EEE d MMM yyyy • HH:mm', 'fr_FR').format(dateTime);
|
||||
}
|
||||
|
||||
Color _getStatusColor(EventStatus status) {
|
||||
switch (status) {
|
||||
case EventStatus.confirmed:
|
||||
return Colors.green;
|
||||
case EventStatus.canceled:
|
||||
return Colors.red;
|
||||
case EventStatus.waitingForApproval:
|
||||
default:
|
||||
return Colors.amber;
|
||||
}
|
||||
}
|
||||
|
||||
/// Combine uniquement le filtre utilisateur avec la vue calendrier.
|
||||
List<EventModel> _getFilteredEvents(List<EventModel> allEvents) {
|
||||
return _filterEventsByUser(allEvents);
|
||||
}
|
||||
|
||||
void _cancelPendingSearch() {
|
||||
_searchDebounce?.cancel();
|
||||
_searchDebounce = null;
|
||||
}
|
||||
|
||||
void _scheduleSearch(String value) {
|
||||
_cancelPendingSearch();
|
||||
|
||||
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
|
||||
_runSearch(value);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSearchChanged(String value) {
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
|
||||
if (isMobile && value.isNotEmpty && !_isMobileSearchVisible) {
|
||||
setState(() {
|
||||
_isMobileSearchVisible = true;
|
||||
});
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
|
||||
if (value.trim().isEmpty) {
|
||||
_cancelPendingSearch();
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_searchError = null;
|
||||
_isSearching = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
_scheduleSearch(value);
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
_cancelPendingSearch();
|
||||
|
||||
if (_searchController.text.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_searchController.clear();
|
||||
setState(() {
|
||||
_searchQuery = '';
|
||||
_searchResults = [];
|
||||
_searchError = null;
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runSearch(String value) async {
|
||||
final query = value.trim();
|
||||
if (query.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final localUserProvider = context.read<LocalUserProvider>();
|
||||
final userId = localUserProvider.uid;
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final searchId = ++_searchRequestId;
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
_searchError = null;
|
||||
_searchResults = [];
|
||||
});
|
||||
|
||||
try {
|
||||
final eventProvider = context.read<EventProvider>();
|
||||
final results = await eventProvider.searchEvents(
|
||||
userId: userId,
|
||||
query: query,
|
||||
);
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_searchQuery.trim() != query) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (searchId != _searchRequestId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_searchResults = results;
|
||||
_searchError = null;
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted || _searchQuery.trim() != query) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_searchResults = [];
|
||||
_searchError = 'Erreur lors de la recherche : $e';
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildDesktopFiltersBar({required bool canViewAllUserEvents}) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
color: Colors.grey[100],
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher (titre, description, lieu)',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
tooltip: 'Effacer la recherche',
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: _clearSearch,
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (canViewAllUserEvents) ...[
|
||||
const SizedBox(width: 12),
|
||||
_buildCompactUserFilter(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompactUserFilter() {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: UserFilterDropdown(
|
||||
selectedUserId: _selectedUserId,
|
||||
onUserSelected: (userId) {
|
||||
setState(() {
|
||||
_selectedUserId = userId;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileSearchBar() {
|
||||
return Container(
|
||||
color: Colors.grey[100],
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_isMobileSearchVisible ? Icons.search_off : Icons.search,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
tooltip: _isMobileSearchVisible
|
||||
? 'Masquer la recherche'
|
||||
: 'Afficher la recherche',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isMobileSearchVisible = !_isMobileSearchVisible;
|
||||
});
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_searchQuery.isEmpty
|
||||
? 'Rechercher un événement'
|
||||
: 'Recherche active',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_searchQuery.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Effacer la recherche',
|
||||
onPressed: _clearSearch,
|
||||
),
|
||||
],
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: _isMobileSearchVisible
|
||||
? Padding(
|
||||
key: const ValueKey('mobile-search-visible'),
|
||||
padding: const EdgeInsets.only(top: 4, left: 8, right: 8),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: _onSearchChanged,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Titre, description ou lieu',
|
||||
prefixIcon:
|
||||
const Icon(Icons.search, color: AppColors.rouge),
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('mobile-search-hidden'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchResultsPanel({required bool isMobile}) {
|
||||
final hasQuery = _searchQuery.trim().isNotEmpty;
|
||||
|
||||
if (!hasQuery && !_isSearching && _searchError == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final panelPadding = EdgeInsets.symmetric(
|
||||
horizontal: isMobile ? 8 : 16,
|
||||
vertical: 8,
|
||||
);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: panelPadding,
|
||||
color: Colors.grey[50],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(Icons.manage_search, color: AppColors.rouge, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
hasQuery
|
||||
? 'Résultats pour "$_searchQuery"'
|
||||
: 'Recherche d’événements',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isSearching)
|
||||
const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_searchError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_searchError!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
),
|
||||
] else if (!hasQuery) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Saisissez un titre, une description ou un lieu pour lancer la recherche.',
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
),
|
||||
] else if (!_isSearching) ...[
|
||||
const SizedBox(height: 8),
|
||||
if (_searchResults.isEmpty)
|
||||
Text(
|
||||
'Aucun résultat trouvé.',
|
||||
style: TextStyle(color: Colors.grey.shade700),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: isMobile ? 240 : 280,
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _searchResults.length,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final event = _searchResults[index];
|
||||
final isSelected = _selectedEvent?.id == event.id;
|
||||
|
||||
return Material(
|
||||
color: isSelected
|
||||
? AppColors.rouge.withOpacity(0.08)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
onTap: () => _onSearchResultSelected(event),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: _getStatusColor(event.status),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
event.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatSearchResultDate(
|
||||
event.startDateTime),
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade700,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (event.address.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
event.address,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(Icons.chevron_right,
|
||||
color: Colors.grey),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onSearchResultSelected(EventModel event) async {
|
||||
final localUserProvider = context.read<LocalUserProvider>();
|
||||
final eventProvider = context.read<EventProvider>();
|
||||
final userId = localUserProvider.uid;
|
||||
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final canViewAllEvents = localUserProvider.hasPermission('view_all_events');
|
||||
final selectedDay = DateTime(
|
||||
event.startDateTime.year,
|
||||
event.startDateTime.month,
|
||||
event.startDateTime.day,
|
||||
);
|
||||
final shouldLoadMonth = _focusedDay.year != event.startDateTime.year ||
|
||||
_focusedDay.month != event.startDateTime.month ||
|
||||
eventProvider.events.isEmpty;
|
||||
|
||||
if (shouldLoadMonth) {
|
||||
await eventProvider.loadMonthEvents(
|
||||
userId,
|
||||
event.startDateTime.year,
|
||||
event.startDateTime.month,
|
||||
canViewAllEvents: canViewAllEvents,
|
||||
);
|
||||
|
||||
eventProvider.preloadAdjacentMonths(
|
||||
userId,
|
||||
event.startDateTime.year,
|
||||
event.startDateTime.month,
|
||||
canViewAllEvents: canViewAllEvents,
|
||||
);
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final eventsForSelectedDay = _getEventsForDay(
|
||||
eventProvider.events,
|
||||
selectedDay,
|
||||
selectedEvent: event,
|
||||
);
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
|
||||
setState(() {
|
||||
_focusedDay = selectedDay;
|
||||
_selectedDay = selectedDay;
|
||||
_selectedEvent = event;
|
||||
_selectedEventIndex =
|
||||
eventsForSelectedDay.indexWhere((e) => e.id == event.id);
|
||||
if (_selectedEventIndex < 0) {
|
||||
_selectedEventIndex = 0;
|
||||
}
|
||||
_calendarCollapsed = false;
|
||||
if (isMobile) {
|
||||
_isMobileSearchVisible = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _changeWeek(int delta) {
|
||||
setState(() {
|
||||
_focusedDay = _focusedDay.add(Duration(days: 7 * delta));
|
||||
@@ -238,10 +757,12 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
|
||||
Widget _buildDesktopDetailsPane(List<EventModel> filteredEvents) {
|
||||
if (_selectedEvent != null) {
|
||||
final detailsEvents = _getDetailsEvents(filteredEvents);
|
||||
|
||||
return EventDetails(
|
||||
event: _selectedEvent!,
|
||||
selectedDate: _selectedDay,
|
||||
events: filteredEvents,
|
||||
events: detailsEvents,
|
||||
onSelectEvent: (event, date) {
|
||||
setState(() {
|
||||
_selectedEvent = event;
|
||||
@@ -296,6 +817,8 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
final canViewAllUserEvents =
|
||||
localUserProvider.hasPermission('view_all_user_events');
|
||||
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||
final showSearchResults =
|
||||
_searchQuery.trim().isNotEmpty || _isSearching || _searchError != null;
|
||||
|
||||
// Appliquer le filtre utilisateur si actif
|
||||
final filteredEvents = _getFilteredEvents(eventProvider.events);
|
||||
@@ -343,33 +866,11 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
drawer: const MainDrawer(currentPage: '/calendar'),
|
||||
body: Column(
|
||||
children: [
|
||||
// Filtre utilisateur dans le corps de la page
|
||||
if (canViewAllUserEvents && !isMobile)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
color: Colors.grey[100],
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.filter_list, color: AppColors.rouge),
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'Filtrer par utilisateur :',
|
||||
style: TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: UserFilterDropdown(
|
||||
selectedUserId: _selectedUserId,
|
||||
onUserSelected: (userId) {
|
||||
setState(() {
|
||||
_selectedUserId = userId;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isMobile)
|
||||
_buildMobileSearchBar()
|
||||
else
|
||||
_buildDesktopFiltersBar(canViewAllUserEvents: canViewAllUserEvents),
|
||||
if (showSearchResults) _buildSearchResultsPanel(isMobile: isMobile),
|
||||
// Corps du calendrier
|
||||
Expanded(
|
||||
child: isMobile
|
||||
@@ -426,18 +927,19 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout(List<EventModel> filteredEvents) {
|
||||
final eventsForSelectedDay = _selectedDay == null
|
||||
? []
|
||||
: filteredEvents
|
||||
.where((e) =>
|
||||
e.startDateTime.year == _selectedDay!.year &&
|
||||
e.startDateTime.month == _selectedDay!.month &&
|
||||
e.startDateTime.day == _selectedDay!.day)
|
||||
.toList()
|
||||
..sort((a, b) => a.startDateTime.compareTo(b.startDateTime));
|
||||
final eventsForSelectedDay = _getEventsForDay(
|
||||
filteredEvents,
|
||||
_selectedDay,
|
||||
selectedEvent: _selectedEvent,
|
||||
);
|
||||
final hasEvents = eventsForSelectedDay.isNotEmpty;
|
||||
final currentEvent =
|
||||
hasEvents && _selectedEventIndex < eventsForSelectedDay.length
|
||||
final selectedEventIndex = _selectedEvent == null
|
||||
? -1
|
||||
: eventsForSelectedDay
|
||||
.indexWhere((event) => event.id == _selectedEvent!.id);
|
||||
final currentEvent = hasEvents && selectedEventIndex >= 0
|
||||
? eventsForSelectedDay[selectedEventIndex]
|
||||
: hasEvents && _selectedEventIndex < eventsForSelectedDay.length
|
||||
? eventsForSelectedDay[_selectedEventIndex]
|
||||
: null;
|
||||
|
||||
@@ -581,7 +1083,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
child: EventDetails(
|
||||
event: eventsForSelectedDay[_selectedEventIndex],
|
||||
selectedDate: _selectedDay,
|
||||
events: eventsForSelectedDay.cast<EventModel>(),
|
||||
events: eventsForSelectedDay,
|
||||
onSelectEvent: (event, date) {
|
||||
final idx = eventsForSelectedDay
|
||||
.indexWhere((e) => e.id == event.id);
|
||||
@@ -600,7 +1102,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Vue détail (prend tout l'espace quand calendrier caché)
|
||||
// Vue détail (prend tout l'espace quand calendrier cache)
|
||||
if (_calendarCollapsed && _selectedDay != null)
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
@@ -647,7 +1149,7 @@ class _CalendarPageState extends State<CalendarPage> {
|
||||
child: EventDetails(
|
||||
event: currentEvent,
|
||||
selectedDate: _selectedDay,
|
||||
events: eventsForSelectedDay.cast<EventModel>(),
|
||||
events: eventsForSelectedDay,
|
||||
onSelectEvent: (event, date) {
|
||||
final idx = eventsForSelectedDay
|
||||
.indexWhere((e) => e.id == event.id);
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/services/equipment_service.dart';
|
||||
import 'package:em2rp/services/qr_code_service.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
import 'package:em2rp/utils/equipment_delete_utils.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/views/equipment_form_page.dart';
|
||||
import 'package:em2rp/views/widgets/equipment/equipment_referencing_containers.dart';
|
||||
@@ -45,7 +46,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
|
||||
Future<void> _loadMaintenances() async {
|
||||
try {
|
||||
final maintenances = await _equipmentService.getMaintenancesForEquipment(widget.equipment.id);
|
||||
final maintenances = await _equipmentService
|
||||
.getMaintenancesForEquipment(widget.equipment.id);
|
||||
setState(() {
|
||||
_maintenances = maintenances;
|
||||
_isLoadingMaintenances = false;
|
||||
@@ -57,8 +59,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
@@ -103,7 +103,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 3. Notes
|
||||
if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[
|
||||
if (widget.equipment.notes != null &&
|
||||
widget.equipment.notes!.isNotEmpty) ...[
|
||||
EquipmentNotesSection(notes: widget.equipment.notes!),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
@@ -185,7 +186,6 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
void _showQRCode() {
|
||||
showDialog(
|
||||
context: context,
|
||||
@@ -249,10 +249,12 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim(),
|
||||
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'
|
||||
.trim(),
|
||||
style: TextStyle(color: Colors.grey[700]),
|
||||
),
|
||||
if (widget.equipment.subCategory != null && widget.equipment.subCategory!.isNotEmpty) ...[
|
||||
if (widget.equipment.subCategory != null &&
|
||||
widget.equipment.subCategory!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'📁 ${widget.equipment.subCategory}',
|
||||
@@ -389,7 +391,8 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
if (!hasPermission) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Vous n\'avez pas la permission de gérer les maintenances'),
|
||||
content:
|
||||
Text('Vous n\'avez pas la permission de gérer les maintenances'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
@@ -423,31 +426,50 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
}
|
||||
|
||||
void _deleteEquipment() {
|
||||
final pageContext = context;
|
||||
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
|
||||
id: widget.equipment.id,
|
||||
name: widget.equipment.name,
|
||||
);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
context: pageContext,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
||||
content: Text(
|
||||
'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.',
|
||||
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
|
||||
equipmentLabel,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
// Fermer le dialog
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(dialogContext);
|
||||
|
||||
// Capturer le ScaffoldMessenger avant la suppression
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final navigator = Navigator.of(context);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
||||
final navigator = Navigator.of(pageContext);
|
||||
final provider = pageContext.read<EquipmentProvider>();
|
||||
|
||||
try {
|
||||
await context
|
||||
.read<EquipmentProvider>()
|
||||
.deleteEquipment(widget.equipment.id);
|
||||
final deleted =
|
||||
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
|
||||
context: pageContext,
|
||||
equipmentLabel: equipmentLabel,
|
||||
deleteEquipment: ({bool forceDelete = false}) {
|
||||
return provider.deleteEquipment(
|
||||
widget.equipment.id,
|
||||
forceDelete: forceDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Revenir à la page précédente
|
||||
navigator.pop();
|
||||
@@ -455,22 +477,26 @@ class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
||||
// Afficher le snackbar (même si le widget est démonté)
|
||||
scaffoldMessenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Équipement supprimé avec succès'),
|
||||
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Afficher l'erreur
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
SnackBar(
|
||||
content: Text(
|
||||
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
||||
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
||||
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:em2rp/utils/equipment_delete_utils.dart';
|
||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||
@@ -28,7 +29,6 @@ class EquipmentManagementPage extends StatefulWidget {
|
||||
_EquipmentManagementPageState();
|
||||
}
|
||||
|
||||
|
||||
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
with SelectionModeMixin<EquipmentManagementPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
@@ -66,7 +66,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
if (_scrollController.hasClients &&
|
||||
_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 300) {
|
||||
|
||||
// Vérifier qu'on peut charger plus
|
||||
if (provider.hasMore && !provider.isLoadingMore) {
|
||||
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
|
||||
@@ -76,7 +75,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
_isLoadingMore = false;
|
||||
}).catchError((error) {
|
||||
_isLoadingMore = false;
|
||||
DebugLog.error('[EquipmentManagementPage] Error loading next page', error);
|
||||
DebugLog.error(
|
||||
'[EquipmentManagementPage] Error loading next page', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -456,11 +456,13 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
Widget _buildEquipmentList() {
|
||||
return Consumer<EquipmentProvider>(
|
||||
builder: (context, provider, child) {
|
||||
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
||||
DebugLog.info(
|
||||
'[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
||||
|
||||
// Afficher l'indicateur de chargement initial uniquement
|
||||
if (provider.isLoading && provider.equipment.isEmpty) {
|
||||
DebugLog.info('[EquipmentManagementPage] Showing initial loading indicator');
|
||||
DebugLog.info(
|
||||
'[EquipmentManagementPage] Showing initial loading indicator');
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
@@ -490,7 +492,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
);
|
||||
}
|
||||
|
||||
DebugLog.info('[EquipmentManagementPage] Building list with ${equipments.length} items');
|
||||
DebugLog.info(
|
||||
'[EquipmentManagementPage] Building list with ${equipments.length} items');
|
||||
|
||||
// Calculer le nombre total d'items (équipements + indicateur de chargement)
|
||||
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
|
||||
@@ -526,124 +529,127 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
|
||||
// ✅ RepaintBoundary pour isoler le repaint de chaque carte
|
||||
return RepaintBoundary(
|
||||
key: ValueKey(equipment.id),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
color: isSelectionMode && isSelected
|
||||
? AppColors.rouge.withValues(alpha: 0.1)
|
||||
: null,
|
||||
child: ListTile(
|
||||
leading: isSelectionMode
|
||||
? Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) => toggleItemSelection(equipment.id),
|
||||
activeColor: AppColors.rouge,
|
||||
)
|
||||
: CircleAvatar(
|
||||
backgroundColor: equipment.category.color.withValues(alpha: 0.2),
|
||||
child: equipment.category.getIcon(
|
||||
size: 20,
|
||||
color: equipment.category.color,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
equipment.id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
// Afficher le badge de statut calculé dynamiquement
|
||||
if (equipment.category != EquipmentCategory.consumable &&
|
||||
equipment.category != EquipmentCategory.cable)
|
||||
EquipmentStatusBadge(equipment: equipment),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||
.trim()
|
||||
.isNotEmpty
|
||||
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim()
|
||||
: 'Marque/Modèle non défini',
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||
),
|
||||
// Afficher la sous-catégorie si elle existe
|
||||
if (equipment.subCategory != null && equipment.subCategory!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'📁 ${equipment.subCategory}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[500],
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
key: ValueKey(equipment.id),
|
||||
child: Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
color: isSelectionMode && isSelected
|
||||
? AppColors.rouge.withValues(alpha: 0.1)
|
||||
: null,
|
||||
child: ListTile(
|
||||
leading: isSelectionMode
|
||||
? Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) => toggleItemSelection(equipment.id),
|
||||
activeColor: AppColors.rouge,
|
||||
)
|
||||
: CircleAvatar(
|
||||
backgroundColor:
|
||||
equipment.category.color.withValues(alpha: 0.2),
|
||||
child: equipment.category.getIcon(
|
||||
size: 20,
|
||||
color: equipment.category.color,
|
||||
),
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
equipment.id,
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
// Afficher le badge de statut calculé dynamiquement
|
||||
if (equipment.category != EquipmentCategory.consumable &&
|
||||
equipment.category != EquipmentCategory.cable)
|
||||
EquipmentStatusBadge(equipment: equipment),
|
||||
],
|
||||
// Afficher la quantité disponible pour les consommables/câbles
|
||||
if (equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable) ...[
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
_buildQuantityDisplay(equipment),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: isSelectionMode
|
||||
? null
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
||||
if (equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable)
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['manage_equipment'],
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.add_shopping_cart,
|
||||
color: AppColors.rouge),
|
||||
tooltip: 'Restock',
|
||||
onPressed: () => _showRestockDialog(equipment),
|
||||
),
|
||||
),
|
||||
// Bouton QR Code
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
|
||||
tooltip: 'QR Code',
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) => QRCodeDialog.forEquipment(equipment),
|
||||
),
|
||||
),
|
||||
// Bouton Modifier (permission required)
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['manage_equipment'],
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit, color: AppColors.rouge),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: () => _editEquipment(equipment),
|
||||
),
|
||||
),
|
||||
// Bouton Supprimer (permission required)
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['manage_equipment'],
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => _deleteEquipment(equipment),
|
||||
Text(
|
||||
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||
.trim()
|
||||
.isNotEmpty
|
||||
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
||||
.trim()
|
||||
: 'Marque/Modèle non défini',
|
||||
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
||||
),
|
||||
// Afficher la sous-catégorie si elle existe
|
||||
if (equipment.subCategory != null &&
|
||||
equipment.subCategory!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'📁 ${equipment.subCategory}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey[500],
|
||||
fontSize: 12,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: isSelectionMode
|
||||
? () => toggleItemSelection(equipment.id)
|
||||
: () => _viewEquipmentDetails(equipment),
|
||||
),
|
||||
)
|
||||
);
|
||||
// Afficher la quantité disponible pour les consommables/câbles
|
||||
if (equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildQuantityDisplay(equipment),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: isSelectionMode
|
||||
? null
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
||||
if (equipment.category == EquipmentCategory.consumable ||
|
||||
equipment.category == EquipmentCategory.cable)
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['manage_equipment'],
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.add_shopping_cart,
|
||||
color: AppColors.rouge),
|
||||
tooltip: 'Restock',
|
||||
onPressed: () => _showRestockDialog(equipment),
|
||||
),
|
||||
),
|
||||
// Bouton QR Code
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
|
||||
tooltip: 'QR Code',
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
QRCodeDialog.forEquipment(equipment),
|
||||
),
|
||||
),
|
||||
// Bouton Modifier (permission required)
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['manage_equipment'],
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.edit, color: AppColors.rouge),
|
||||
tooltip: 'Modifier',
|
||||
onPressed: () => _editEquipment(equipment),
|
||||
),
|
||||
),
|
||||
// Bouton Supprimer (permission required)
|
||||
PermissionGate(
|
||||
requiredPermissions: const ['manage_equipment'],
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.delete, color: Colors.red),
|
||||
tooltip: 'Supprimer',
|
||||
onPressed: () => _deleteEquipment(equipment),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: isSelectionMode
|
||||
? () => toggleItemSelection(equipment.id)
|
||||
: () => _viewEquipmentDetails(equipment),
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildQuantityDisplay(EquipmentModel equipment) {
|
||||
@@ -705,7 +711,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Actions
|
||||
void _createNewEquipment() {
|
||||
Navigator.push(
|
||||
@@ -726,39 +731,64 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
}
|
||||
|
||||
void _deleteEquipment(EquipmentModel equipment) {
|
||||
final pageContext = context;
|
||||
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
|
||||
id: equipment.id,
|
||||
name: equipment.name,
|
||||
);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
content: Text('Voulez-vous vraiment supprimer "${equipment.name}" ?'),
|
||||
context: pageContext,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
||||
content: Text(
|
||||
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
|
||||
equipmentLabel,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(dialogContext);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
||||
final provider = pageContext.read<EquipmentProvider>();
|
||||
|
||||
try {
|
||||
await context
|
||||
.read<EquipmentProvider>()
|
||||
.deleteEquipment(equipment.id);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Équipement supprimé avec succès')),
|
||||
);
|
||||
final deleted =
|
||||
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
|
||||
context: pageContext,
|
||||
equipmentLabel: equipmentLabel,
|
||||
deleteEquipment: ({bool forceDelete = false}) {
|
||||
return provider.deleteEquipment(
|
||||
equipment.id,
|
||||
forceDelete: forceDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (!deleted) {
|
||||
return;
|
||||
}
|
||||
scaffoldMessenger.showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
}
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -768,46 +798,78 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
void _deleteSelectedEquipment() async {
|
||||
if (!hasSelection) return;
|
||||
|
||||
final pageContext = context;
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Confirmer la suppression'),
|
||||
context: pageContext,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
||||
content: Text(
|
||||
'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?',
|
||||
EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
|
||||
selectedCount,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Annuler'),
|
||||
onPressed: () => Navigator.pop(dialogContext),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(dialogContext);
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
||||
final provider = pageContext.read<EquipmentProvider>();
|
||||
|
||||
try {
|
||||
final provider = context.read<EquipmentProvider>();
|
||||
final equipmentById = {
|
||||
for (final equipment
|
||||
in provider.equipment)
|
||||
equipment.id: equipment,
|
||||
};
|
||||
|
||||
var deletedCount = 0;
|
||||
for (final id in selectedIds) {
|
||||
await provider.deleteEquipment(id);
|
||||
final label = EquipmentDeleteUtils.resolveEquipmentLabel(
|
||||
id: id,
|
||||
name: equipmentById[id]?.name,
|
||||
);
|
||||
final deleted = await EquipmentDeleteUtils
|
||||
.deleteWithFutureAssignmentCheck(
|
||||
context: pageContext,
|
||||
equipmentLabel: label,
|
||||
deleteEquipment: ({bool forceDelete = false}) {
|
||||
return provider.deleteEquipment(
|
||||
id,
|
||||
forceDelete: forceDelete,
|
||||
);
|
||||
},
|
||||
);
|
||||
if (deleted) {
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
disableSelectionMode();
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'$selectedCount équipement(s) supprimé(s) avec succès'),
|
||||
backgroundColor: Colors.green,
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
|
||||
deletedCount,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Erreur: $e')),
|
||||
);
|
||||
}
|
||||
scaffoldMessenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Supprimer'),
|
||||
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -853,7 +915,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first),
|
||||
builder: (context) =>
|
||||
QRCodeDialog.forEquipment(selectedEquipment.first),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -1046,7 +1109,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
|
||||
await context.read<EquipmentProvider>().updateEquipment(updatedEquipment);
|
||||
await context
|
||||
.read<EquipmentProvider>()
|
||||
.updateEquipment(updatedEquipment);
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -1184,7 +1249,8 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
|
||||
content: Text(
|
||||
'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
|
||||
backgroundColor: Colors.orange,
|
||||
),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user