eac103491f
- **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.
503 lines
17 KiB
Dart
503 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:em2rp/models/equipment_model.dart';
|
|
import 'package:em2rp/models/maintenance_model.dart';
|
|
import 'package:em2rp/providers/equipment_provider.dart';
|
|
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';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_header_section.dart';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_main_info_section.dart';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_notes_section.dart';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_associated_events_section.dart';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_current_events_section.dart';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_price_section.dart';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_maintenance_history_section.dart';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_dates_section.dart';
|
|
import 'package:em2rp/views/maintenance_form_page.dart';
|
|
import 'package:qr_flutter/qr_flutter.dart';
|
|
import 'package:printing/printing.dart';
|
|
|
|
class EquipmentDetailPage extends StatefulWidget {
|
|
final EquipmentModel equipment;
|
|
|
|
const EquipmentDetailPage({super.key, required this.equipment});
|
|
|
|
@override
|
|
State<EquipmentDetailPage> createState() => _EquipmentDetailPageState();
|
|
}
|
|
|
|
class _EquipmentDetailPageState extends State<EquipmentDetailPage> {
|
|
final EquipmentService _equipmentService = EquipmentService();
|
|
List<MaintenanceModel> _maintenances = [];
|
|
bool _isLoadingMaintenances = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadMaintenances();
|
|
}
|
|
|
|
Future<void> _loadMaintenances() async {
|
|
try {
|
|
final maintenances = await _equipmentService
|
|
.getMaintenancesForEquipment(widget.equipment.id);
|
|
setState(() {
|
|
_maintenances = maintenances;
|
|
_isLoadingMaintenances = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_isLoadingMaintenances = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
final isDesktop = screenWidth >= 1200;
|
|
final userProvider = Provider.of<LocalUserProvider>(context);
|
|
final hasManagePermission = userProvider.hasPermission('manage_equipment');
|
|
|
|
return Scaffold(
|
|
appBar: CustomAppBar(
|
|
title: widget.equipment.id,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.qr_code),
|
|
tooltip: 'Générer QR Code',
|
|
onPressed: _showQRCode,
|
|
),
|
|
if (hasManagePermission)
|
|
IconButton(
|
|
icon: const Icon(Icons.edit),
|
|
tooltip: 'Modifier',
|
|
onPressed: _editEquipment,
|
|
),
|
|
if (hasManagePermission)
|
|
IconButton(
|
|
icon: const Icon(Icons.delete, color: Colors.red),
|
|
tooltip: 'Supprimer',
|
|
onPressed: _deleteEquipment,
|
|
),
|
|
],
|
|
),
|
|
body: SingleChildScrollView(
|
|
padding: EdgeInsets.all(screenWidth < 800 ? 16 : 24),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// 1. Titre de la machine
|
|
EquipmentHeaderSection(equipment: widget.equipment),
|
|
const SizedBox(height: 24),
|
|
|
|
// 2. Info principale
|
|
EquipmentMainInfoSection(equipment: widget.equipment),
|
|
const SizedBox(height: 24),
|
|
|
|
// 3. Notes
|
|
if (widget.equipment.notes != null &&
|
|
widget.equipment.notes!.isNotEmpty) ...[
|
|
EquipmentNotesSection(notes: widget.equipment.notes!),
|
|
const SizedBox(height: 24),
|
|
],
|
|
|
|
// 4. Événements en cours
|
|
EquipmentCurrentEventsSection(equipment: widget.equipment),
|
|
const SizedBox(height: 24),
|
|
|
|
// 5. Événements passés / à venir
|
|
EquipmentAssociatedEventsSection(equipment: widget.equipment),
|
|
const SizedBox(height: 24),
|
|
|
|
// 6-8. Prix, Historique des maintenances, Dates en layout responsive
|
|
if (isDesktop)
|
|
_buildDesktopTwoColumnLayout(hasManagePermission)
|
|
else
|
|
_buildMobileLayout(hasManagePermission),
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
// Containers contenant cet équipement
|
|
// Note: On utilise EquipmentReferencingContainers qui recherche dynamiquement
|
|
// les containers au lieu de se baser sur parentBoxIds qui peut être désynchronisé
|
|
EquipmentReferencingContainers(
|
|
equipmentId: widget.equipment.id,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Layout 2 colonnes pour desktop
|
|
Widget _buildDesktopTwoColumnLayout(bool hasManagePermission) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Colonne gauche
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
// Prix
|
|
EquipmentPriceSection(equipment: widget.equipment),
|
|
const SizedBox(height: 24),
|
|
// Historique des maintenances
|
|
EquipmentMaintenanceHistorySection(
|
|
maintenances: _maintenances,
|
|
isLoading: _isLoadingMaintenances,
|
|
hasManagePermission: hasManagePermission,
|
|
onAddMaintenance: hasManagePermission ? _planMaintenance : null,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 24),
|
|
// Colonne droite
|
|
Expanded(
|
|
child: EquipmentDatesSection(equipment: widget.equipment),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Layout simple colonne pour mobile/tablette
|
|
Widget _buildMobileLayout(bool hasManagePermission) {
|
|
return Column(
|
|
children: [
|
|
EquipmentPriceSection(equipment: widget.equipment),
|
|
const SizedBox(height: 24),
|
|
EquipmentMaintenanceHistorySection(
|
|
maintenances: _maintenances,
|
|
isLoading: _isLoadingMaintenances,
|
|
hasManagePermission: hasManagePermission,
|
|
onAddMaintenance: hasManagePermission ? _planMaintenance : null,
|
|
),
|
|
const SizedBox(height: 24),
|
|
EquipmentDatesSection(equipment: widget.equipment),
|
|
],
|
|
);
|
|
}
|
|
|
|
void _showQRCode() {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => Dialog(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(24),
|
|
constraints: const BoxConstraints(maxWidth: 500),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.qr_code, color: AppColors.rouge, size: 32),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
'QR Code - ${widget.equipment.id}',
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close),
|
|
onPressed: () => Navigator.pop(context),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.grey[300]!),
|
|
),
|
|
child: QrImageView(
|
|
data: widget.equipment.id,
|
|
version: QrVersions.auto,
|
|
size: 300,
|
|
backgroundColor: Colors.white,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[100],
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
widget.equipment.id,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'
|
|
.trim(),
|
|
style: TextStyle(color: Colors.grey[700]),
|
|
),
|
|
if (widget.equipment.subCategory != null &&
|
|
widget.equipment.subCategory!.isNotEmpty) ...[
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'📁 ${widget.equipment.subCategory}',
|
|
style: TextStyle(
|
|
color: Colors.grey[600],
|
|
fontSize: 13,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => _exportQRCode(),
|
|
icon: const Icon(Icons.download),
|
|
label: const Text('Télécharger PNG'),
|
|
style: OutlinedButton.styleFrom(
|
|
minimumSize: const Size(0, 48),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: ElevatedButton.icon(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.rouge,
|
|
minimumSize: const Size(0, 48),
|
|
),
|
|
icon: const Icon(Icons.close, color: Colors.white),
|
|
label: const Text(
|
|
'Fermer',
|
|
style: TextStyle(color: Colors.white),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _exportQRCode() async {
|
|
// Afficher le dialog de chargement
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => Dialog(
|
|
child: Container(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const CircularProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(AppColors.rouge),
|
|
),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'Génération du QR Code...',
|
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
|
|
try {
|
|
// Exécuter la génération dans un isolate séparé pour éviter le freeze
|
|
final qrImage = await compute(
|
|
_generateQRCodeIsolate,
|
|
widget.equipment.id,
|
|
);
|
|
|
|
// Fermer le dialog de chargement
|
|
if (mounted) {
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
// Partager le fichier
|
|
if (mounted) {
|
|
await Printing.sharePdf(
|
|
bytes: qrImage,
|
|
filename: 'QRCode_${widget.equipment.id}.png',
|
|
);
|
|
}
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('QR Code exporté avec succès'),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
// Fermer le dialog de chargement en cas d'erreur
|
|
if (mounted) {
|
|
Navigator.pop(context);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erreur lors de l\'export: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Fonction statique pour exécuter la génération QR code dans un isolate
|
|
static Future<Uint8List> _generateQRCodeIsolate(String equipmentId) async {
|
|
return await QRCodeService.generateQRCode(
|
|
equipmentId,
|
|
size: 1024,
|
|
useCache: false,
|
|
);
|
|
}
|
|
|
|
/// Planifier une nouvelle maintenance pour cet équipment
|
|
Future<void> _planMaintenance() async {
|
|
final userProvider = Provider.of<LocalUserProvider>(context, listen: false);
|
|
final hasPermission = userProvider.hasPermission('manage_maintenances');
|
|
|
|
if (!hasPermission) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content:
|
|
Text('Vous n\'avez pas la permission de gérer les maintenances'),
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final result = await Navigator.push<bool>(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => MaintenanceFormPage(
|
|
initialEquipmentIds: [widget.equipment.id],
|
|
),
|
|
),
|
|
);
|
|
|
|
// Recharger les maintenances si une maintenance a été créée
|
|
if (result == true && mounted) {
|
|
await _loadMaintenances();
|
|
}
|
|
}
|
|
|
|
void _editEquipment() {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => EquipmentFormPage(equipment: widget.equipment),
|
|
),
|
|
).then((_) {
|
|
Navigator.pop(context);
|
|
});
|
|
}
|
|
|
|
void _deleteEquipment() {
|
|
final pageContext = context;
|
|
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
|
|
id: widget.equipment.id,
|
|
name: widget.equipment.name,
|
|
);
|
|
showDialog(
|
|
context: pageContext,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
|
content: Text(
|
|
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
|
|
equipmentLabel,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
// Fermer le dialog
|
|
Navigator.pop(dialogContext);
|
|
|
|
// Capturer le ScaffoldMessenger avant la suppression
|
|
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
|
final navigator = Navigator.of(pageContext);
|
|
final provider = pageContext.read<EquipmentProvider>();
|
|
|
|
try {
|
|
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();
|
|
|
|
// Afficher le snackbar (même si le widget est démonté)
|
|
scaffoldMessenger.showSnackBar(
|
|
const SnackBar(
|
|
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
// Afficher l'erreur
|
|
scaffoldMessenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|