From 3fab69cb00bd1e1d348094713f3cb057b0aa482d Mon Sep 17 00:00:00 2001 From: ElPoyo Date: Wed, 29 Oct 2025 10:57:42 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Gestion=20compl=C3=A8te=20des=20contain?= =?UTF-8?q?ers=20et=20refactorisation=20du=20mat=C3=A9riel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout de la gestion des containers (création, édition, suppression, affichage des détails). Introduction d'un système de génération de QR codes unifié et d'un mode de sélection multiple. **Features:** - **Gestion des Containers :** - Nouvelle page de gestion des containers (`container_management_page.dart`) avec recherche et filtres. - Formulaire de création/édition de containers (`container_form_page.dart`) avec génération d'ID automatique. - Page de détails d'un container (`container_detail_page.dart`) affichant son contenu et ses caractéristiques. - Ajout des routes et du provider (`ContainerProvider`) nécessaires. - **Modèle de Données :** - Ajout du `ContainerModel` pour représenter les boîtes, flight cases, etc. - Le modèle `EquipmentModel` a été enrichi avec des caractéristiques physiques (poids, dimensions). - **QR Codes :** - Nouveau service unifié (`UnifiedPDFGeneratorService`) pour générer des PDFs de QR codes pour n'importe quelle entité. - Services `PDFGeneratorService` et `ContainerPDFGeneratorService` transformés en wrappers pour maintenir la compatibilité. - Amélioration de la performance de la génération de QR codes en masse. - **Interface Utilisateur (UI/UX) :** - Nouvelle page de détails pour le matériel (`equipment_detail_page.dart`). - Ajout d'un `SelectionModeMixin` pour gérer la sélection multiple dans les pages de gestion. - Dialogues réutilisables pour l'affichage de QR codes (`QRCodeDialog`) et la sélection de format d'impression (`QRCodeFormatSelectorDialog`). - Ajout d'un bouton "Gérer les boîtes" sur la page de gestion du matériel. **Refactorisation :** - L' `IdGenerator` a été déplacé dans le répertoire `utils` et étendu pour gérer les containers. - Mise à jour de nombreuses dépendances `pubspec.yaml` vers des versions plus récentes. - Séparation de la logique d'affichage des containers et du matériel dans des widgets dédiés (`ContainerHeaderCard`, `EquipmentParentContainers`, etc.). --- em2rp/assets/icons/flight-case.png | Bin 0 -> 8075 bytes em2rp/assets/logos/LowQRectangleLogoBlack.png | Bin 0 -> 1527 bytes em2rp/assets/logos/RectangleLogoBlack.png | Bin 0 -> 45018 bytes em2rp/assets/logos/RectangleLogoWhite.png | Bin 0 -> 43212 bytes em2rp/lib/main.dart | 27 + em2rp/lib/mixins/selection_mode_mixin.dart | 144 +++ em2rp/lib/models/container_model.dart | 251 +++++ em2rp/lib/models/equipment_model.dart | 26 + em2rp/lib/providers/container_provider.dart | 165 ++++ .../container_pdf_generator_service.dart | 52 + em2rp/lib/services/container_service.dart | 378 +++++++ em2rp/lib/services/equipment_service.dart | 59 ++ em2rp/lib/services/pdf_generator_service.dart | 76 ++ em2rp/lib/services/qr_code_service.dart | 173 ++++ .../unified_pdf_generator_service.dart | 354 +++++++ em2rp/lib/utils/id_generator.dart | 155 +++ em2rp/lib/views/container_detail_page.dart | 793 +++++++++++++++ em2rp/lib/views/container_form_page.dart | 924 ++++++++++++++++++ .../lib/views/container_management_page.dart | 814 +++++++++++++++ em2rp/lib/views/equipment_detail_page.dart | 881 +++++++++++++++++ .../views/equipment_form/id_generator.dart | 26 - em2rp/lib/views/equipment_form_page.dart | 19 +- .../lib/views/equipment_management_page.dart | 788 ++++----------- .../views/widgets/common/qr_code_dialog.dart | 193 ++++ .../qr_code_format_selector_dialog.dart | 258 +++++ .../containers/container_equipment_tile.dart | 110 +++ .../containers/container_header_card.dart | 197 ++++ .../container_physical_characteristics.dart | 91 ++ .../equipment_parent_containers.dart | 177 ++++ em2rp/lib/views/widgets/nav/main_drawer.dart | 49 +- em2rp/pubspec.yaml | 16 +- 31 files changed, 6540 insertions(+), 656 deletions(-) create mode 100644 em2rp/assets/icons/flight-case.png create mode 100644 em2rp/assets/logos/LowQRectangleLogoBlack.png create mode 100644 em2rp/assets/logos/RectangleLogoBlack.png create mode 100644 em2rp/assets/logos/RectangleLogoWhite.png create mode 100644 em2rp/lib/mixins/selection_mode_mixin.dart create mode 100644 em2rp/lib/models/container_model.dart create mode 100644 em2rp/lib/providers/container_provider.dart create mode 100644 em2rp/lib/services/container_pdf_generator_service.dart create mode 100644 em2rp/lib/services/container_service.dart create mode 100644 em2rp/lib/services/pdf_generator_service.dart create mode 100644 em2rp/lib/services/qr_code_service.dart create mode 100644 em2rp/lib/services/unified_pdf_generator_service.dart create mode 100644 em2rp/lib/utils/id_generator.dart create mode 100644 em2rp/lib/views/container_detail_page.dart create mode 100644 em2rp/lib/views/container_form_page.dart create mode 100644 em2rp/lib/views/container_management_page.dart create mode 100644 em2rp/lib/views/equipment_detail_page.dart delete mode 100644 em2rp/lib/views/equipment_form/id_generator.dart create mode 100644 em2rp/lib/views/widgets/common/qr_code_dialog.dart create mode 100644 em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart create mode 100644 em2rp/lib/views/widgets/containers/container_equipment_tile.dart create mode 100644 em2rp/lib/views/widgets/containers/container_header_card.dart create mode 100644 em2rp/lib/views/widgets/containers/container_physical_characteristics.dart create mode 100644 em2rp/lib/views/widgets/equipment/equipment_parent_containers.dart diff --git a/em2rp/assets/icons/flight-case.png b/em2rp/assets/icons/flight-case.png new file mode 100644 index 0000000000000000000000000000000000000000..c90651d27730a0f821d59bd79260b9a374fca334 GIT binary patch literal 8075 zcmdscc~nzZ*Y`;XG74I&hzxnGtriCyNk9Y?s@D1jR0u&J5JyxXpb&;YNE8cgE$~>Y zD1+Ei84^fP<{%_CDpmwUyr5zTNM#74;$;XK-gB{Qz3W}y`u=$TeX>?oa`)M1pS^!) z?|shR_s0XCdlwq7Fa`iD-1qG_hXBCCzj$D{0RDW8d!d3qgedoYK8EmvyUzaWv-K%!7f=p7c;;2T z`14dxaoh4QY|>8sTvdMdho7=|4}SPM+40unTSbnW)_M>w?=ms;z)|(7)jB`c-@b^= zlvBvKrYls`IUL{MCEq^%ro16RG+8npqU-1mT?!TbpHEr7*&Z?ojp3_Gl&(mjPb{D5 zT(PQO`9#^uR|sy>>u+t}TCoZ+`#l7r(Cv)8gJ<+mrE)WpN|1WaG$O-RZvT3*MOldq zERb$8K>7rb@cE0oN>zcvrSKrGYUGxx?U6aYazxe5DDcc+g7l%T?3Ttqzs>WdAJRM; z+|yG;0R4lyQ^ugoWQ?jgfzIVyozIWd{#08Vf$pb>#0=U!i8vS)Kr+<`JpD_~AD~s+ z$_;a5by>n)?|*4aldgp#-liktK165n;u;>=H!n!@5Q;wPnZ^XZk*Cq8rra2Xf`8MO zGlP){AVxlz2_?c-;0`tlJtMrdID0zhq2$Z`}C>*2L zM@FxTBtaQbg#}-=JO9iibPR?gA=~7Kmd9N1!jNs=Il9iD4te~LP zODBg7m}5Y{DgESfi$vJu%`cIli7qFh`C=)%~&BPg#ASq1o*UC z$_<-VgH5k9$@;+Wm)0}l2r2Pr=EZj(?7*ILp~2Ti^bn08B!hYJ#AjgnJ0W6%5l7_| z0gk&vy!hH)z#vpleQ(b8Zr^ZGu?bQhIv;#hwO*#w>k`_VlY^kRsXQPh zB_2UN;@j;?-gYCQ{LFObMNL2NDJd{3Dm(J?PU<)`=6`(i|M*XO+1k2yZtNiaAZHIN zQ~8|Ypbtt$A5{&QgK z$B9MX509FFmlryHg9BVNf!uhNb+B`hZGh$!dO3R-j@-&9vGwJgLWjwkrfeaw_HjGx zar7L<)^W6;4YU*JiNC~-Z*@Bq@7d$p?T-iX`N z2?O5knL%Z{Cp9x;i~wHpo4lyg)^dGNMxEYKq`fPa4SE7zX?)Wz7NwoS1-uhAs#{B$ zme7$hy)AUh07GF2K-o^1=hpul!ff&-08+fse;SFtMSTAI*-2$4Ur`ctu)db<#Tu2~ ztQ!4A`CJ);C-H*yn*XJIlQ|r0My#rz{DP@8#T5PGSfX!eH zT&8Sjwyb>5KFHFQ)C4}R=ZBD!g6%5*VkfcktW8=5Zi!>tFa6OrcgD!!-g%BN);A+e zb^D})`6+)(%lh$O{X^j`2#0NToX-y=rT8owArxXuuc? z>i9e?m|TMqq9*x>upfQI00hT`$il zN-9;oXiFQh!Z9@6{+#bn6?`}!K$FlkxsY}9z2YIiiGGaMMcpWLWk#=>dti02W4vjL zy7OHwWdN7?vE|g1?1u8vR!AsC+=_)(^2e@=LC5euN>fKkE0Lyf5*s=GP7GE3KpdWQ zM$FThNAn!@%UnCDc`u^1d8nAxK2Q*`RlL~mK7!EsL~G{=+^w~mlk-bO8s$g!PJ{2d&ywkx%DsCH(qc!Ys_#FmNL7d z?Yu|>t%3|2IU!J52luDDM(k9WWuKc^*OvA)cPS5@=`+=Z&y2P$lY(!;@(Y$5SpY_wkF z;&ChZ*Kt`1HlMg1y3fRp@?mv4Gj+i7|C^3@S7!N2Jw0Vz)%zaaRhO#GjTg9eX$Fla z1Gk8Ao`3tW*wUE2VM`p{8u95PF9sI?-slnl80TTC^ZOSLfMwqTz|;Ti|3mqaxRokZZCMD@GUVhNa9m#4Q+>;(E z#7QR%7!`+8c}@#}jL4&J?pm~|8Zc(`%0f?C;`=`brd$w%jJQh!@w{GhsW6*rc-;eR zQsb^)dUcUUHwP(hAZqKSMP*GmhNnzN%C`c3|2Buf8OOL1M$Uli?Gus`)uT2FSpWDK z-yn^7RNvVKAF~-e4WH2^qAT;QaDM*|@OA6j78omUN#W5=Fo}Yi-RQ5%Bl%W`?|VX; zF-F^aR;(IQUfN5(dm+~ZSN9pFS$bbI>TQJETV;aT(etI5hcxqN=5c-JCHOrCm^~~< zy2~9lJn4PQmaJqP#}xM~Y)8CS6H1QzU{5bE$`(!BPM-67_-xUpFTipw2zx%HT*Kds ziIKTWtMo9lNjbu#9V>h8Ras)IRt;?;B)-CAqKnE(F|aoMdBkug1e43dJS2~ug2-(+o zp^OWjA7MEM(+I9dzxi+KhRJy zq+feT#q+9L+xRE~Tuw%>TIUyP=(l%%x66~234D>Q{IPMzGQ8&sO!n0MU8sD+*Myue zG1((m;NlLLoF6@;XXjCK-EN8tb`xJ-n)5Zmdd)mj-5MWwoA`05wKKum0i&#cSY6e) zvghl0WllF#s%ZJye)lhXNcr=WVl5p_o2R%-*FuVY$UcZRJ^s`{4cRn2Zd zB(`Ppe19iG;Ck@%^h|J&OXX&dw-IJ9`#+1n`HGdax4Ms%Ts7A`+E%Cs&-MOo>WzY= zs$B-YYMW)P9*`-d2)~ ztNyp`FHux)9*y_OR0Rv&I#^DMl6!4BOw;a)ip?sEo-%FIN#fXB&i=mXJBI`>G9Ikv z#P&_Uf8{W6l_$~4a#D2m^&i~kW~0*CTbXj^`dPcm@9L{nFJE5lqK${CT^mkwx>gfE zu8>E^#H^Na>F6H|UB|vkaPONgJ+!x&m`0gtcr@NMiCB9`OHy>g4ReYLVZ?T~hpNOw z=*>st0k_i+5sO?mbPQeT8tv2FD9A8sfsS&$c|!4yrVZ)rI=7ZVXL{H3-_Q9J_r1s# zWc2dYALnaZ3ruK{XALMcAv>yXI``0UiSD4w@A--goMJP6Dm5Zp-Qc{B(g{Mw6hhf*aex4nTjlQhr4dMJt>t@;BR;jvdg{7kKZ02l!O#?V7u)+Xcgwk_z_z z_Mdv62l{h-Sk+1szJaf<{aHGx4~9voF~Rwmg`Tu1K!ukUsJ}&*jvg?4S$X1Xn2|Wq z8On6ZI;rzwe2m?EOq3^V%N=|3qrxpE8f1D7aWy)Lktk){M;$ zpDb}8lx)QmEITZr%0*)3A8x1Un901+xS;koE(ia~HH^BOifvp4=M6FP`TWe~Ym9Mg zj4%y;MlTe^tA+VmnRnxxo_C1%RyB_CM*@kyesEQ~$~>bS-*eRsj9jyzO&51JDBT>- zUz~HJ| zktn`%uI3>u0OH<3mZ1$43lsKfd7M6GvIaefLs4<8xJh;Sx=;@=@-)7z!z|=g1U}Ih?YRiR%e|6A@@g@k zskP&UtR?_yLwpQtH`*ZEnB&?=t{1c8y%#xlD)hkQ`zA`^tjKll(ty1y2TY?7+;mtOE&*9*(GRa-`?)K;BAErVIDpTEdt@B*)O*l7S6Nf%lieU_2Nv!aK zv(9S0(RT>iINP{J6Q*kt&>ZN&evm#ob4QsM`?NQBxVEOXDSM2__zD2h_+GwfkRX$E z#Ihur4y6P`xQ3HkpbLL{)N^7*OVeCK3EKnZDH&;Q7NBUn*tUH|3b$0J(sF-=iw{r9 zk6Q%$PG&yo*fTgyw9vnaokZg#3PoUNV zN(||e3I)H@ZxiP_d$7VBoTI9JW{!7=7749-+cq}Y)MQ&QCL5H57%^t8jMJCmuoAX zakmNG)E5-l5$-v!1U$`$8RgmhY{xDkTuk9<2s0LJQ*if^&wAG_ z$4sSBr(q8XT}mlALh7ABS`4ntK>Prc-3%6)zL;N>DeQbFs%XRo7Co*na*atoy{8wIheV1 zS}J-GW66<8T5Q0Xpp`vj{(gP}?B4NqU)M~CCO_?OLBnsVDva-fOk!^h&LpkuSq32v zd(>^J6UTv~`0sfGKGh|zq0y_rNZMAUlM3tO%S%hPsY5I0H@GRrR+@&H~HQzM~ih9}-WUkxJ247=)6;q(fQqI8! zz=dNAby>n~#ooSyYQp@L|m}SkXkjh2drd97KyI>Jb`- zMef-Gb~H-F%C1UNT~_zAYO~Zry@oi7TZDIi#XUV~f9`32%b*t%BD54(XSIJPohExOHvS()n#&BSuAx>wA40$~DHh(OekNT9cDAc~Tgl(BWqy5Y1jPzHDcT&S>=LyxZl8 z#>nEuJpIk;&K}rFXgOw2>#eIgJuvS-CTHm>I{y)+vGljv5<=oBbU#v$&2KbI)p@01 zC9;b;G?oU;nooSy!=dCE$F)F2rhe^}oUF5*2eTd$7vVui(}aRYchiros?9N*DN_z1 z1MoCsm<{5zMm}Kw5U!CuP)W z1sR6jSiEdKIumg}5XR&hLgF{*h_W4OxJj>*sw+|5xX&p$S_MVN8ux@nIoZHG)_`1s zeo_XVVnf#CCsA+2s;f$_mE2HjwnbeXM*QSGXV7BkJug?L=s=X~C&lAJSb556Y&txO zc1@l56am_k&|M2*C!TR@n7>h=oy2N-noKga@l!BTrB>og8%Dim zsdZ)${{e6c5z)9@Xi)%TX8Z?^*?a)yD4CcVw`#4ggto$@IegXy@iTTx*=7llRfQJBkh z|A@;LvLIe%n%l)0q5eiQGjLwXTd}eixq^j$Druo^KWsYw zS*(2BMln6NuYKd7P()D2LjZ^2uEA!RsG%(0RF}Ff*Es0!36anhiQf16_IB8QvC|*B z+exOAW+>Qd8Phb~A8aK=dg58Zzwg$Z(3Rio^Fd#?(M?HEEgBg+(xt*1?GwSObvh4` z6V)Xm+QOM(A7PqEMdK0J`ou>j*DloUZs;Aiz8TABzaF%Yc@XF>%_Ef()b9rO@E#^` z(cPBXsE-xN1!TN-cl*-8>#c=)+RTEc1&WyHRnjkP%yhxaVJ~BuhN+8+^G5F!@`nZE z%ilb;ztNO{4?3jDSB67nmxXxB6i&MAxhbV8*-ATb4Ow>%sfC;P)A_tWjX!!B)Lzp>jI{E-_(G?-OR!R0=$Nk+cPqxY-;^oGkS;4-qTTJOI$^Q!l&$USXj7sl zXY>%ACLd4qdf69q>popzUNKjw#~(XPM`i~0+g0YVD5$i*cO_-r()_$~x*+1-vnw$N zG!M+S)rLAipjrjpo%4*rED9@rvgfL>a-m?IBDAY3z zY#$3>Ky+8#{VD&~?Mcl;Ig(y(ADfO2+J6{I&0lZyrryp-D^q$R;pr0*F)mczQJM}T|RBzSXJ(l*LM9PdLev= zBd{OTPrHH3yOU;6y{{0yiv(eL|L+@#XRZWZs6qI&a#xprPjdIxQ+4CE74phgsbIow zLHM*ya+QhHpq}+CMr8dDc_8hueq1vz93Od1w4$sz)`#oBs0*z$$MxtTmllLg$Lm!Y zNz>}%q$FM0(9xacRp#*Ow4caqPv8eDK9F!VwTdt?>;h_@T&r%>e^WbjEwxKb_*)3C z-i&=ocSdb=BGK60+|-Bsq^i+TwU^@gF?`S4v92k+w7lx$b;k=C5n|4}f>`a9YLo~3 zUQKqjm`J_k9eQt1HQa6OnZ!O!}j|Qk9&}RZI9P3rhLL z8)6DNigX2V8YM;Iuy4viN%Jn2L|I#!q}BIZS1F}C$IH7u5Q2W2T=bN7B%m=<& U&`hGejlf3s?e_de=+3zC-;W@;Gynhq literal 0 HcmV?d00001 diff --git a/em2rp/assets/logos/LowQRectangleLogoBlack.png b/em2rp/assets/logos/LowQRectangleLogoBlack.png new file mode 100644 index 0000000000000000000000000000000000000000..10879db44de306831c2fc3c757f86b0e65d65827 GIT binary patch literal 1527 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 b&Q4T=@l@HH4;1re!O7* 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 literal 0 HcmV?d00001 diff --git a/em2rp/assets/logos/RectangleLogoWhite.png b/em2rp/assets/logos/RectangleLogoWhite.png new file mode 100644 index 0000000000000000000000000000000000000000..41c7956099144bf6b0e93e0a3d758e61dcef79b0 GIT binary patch literal 43212 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 S( create: (context) => EquipmentProvider(), ), + ChangeNotifierProvider( + create: (context) => ContainerProvider(), + ), ChangeNotifierProvider( create: (context) => MaintenanceProvider(), ), @@ -123,6 +131,25 @@ class MyApp extends StatelessWidget { '/equipment_management': (context) => const AuthGuard( requiredPermission: "view_equipment", child: EquipmentManagementPage()), + '/container_management': (context) => const AuthGuard( + requiredPermission: "view_equipment", + child: ContainerManagementPage()), + '/container_form': (context) { + final args = ModalRoute.of(context)?.settings.arguments; + return AuthGuard( + requiredPermission: "manage_equipment", + child: ContainerFormPage( + container: args as ContainerModel?, + ), + ); + }, + '/container_detail': (context) { + final container = ModalRoute.of(context)!.settings.arguments as ContainerModel; + return AuthGuard( + requiredPermission: "view_equipment", + child: ContainerDetailPage(container: container), + ); + }, }, ); } diff --git a/em2rp/lib/mixins/selection_mode_mixin.dart b/em2rp/lib/mixins/selection_mode_mixin.dart new file mode 100644 index 0000000..aa7fe1f --- /dev/null +++ b/em2rp/lib/mixins/selection_mode_mixin.dart @@ -0,0 +1,144 @@ +import 'package:flutter/material.dart'; + +/// Mixin réutilisable pour gérer le mode sélection multiple +/// Utilisable dans equipment_management_page, container_management_page, etc. +mixin SelectionModeMixin on State { + // État du mode sélection + bool _isSelectionMode = false; + final Set _selectedIds = {}; + + // Getters + bool get isSelectionMode => _isSelectionMode; + Set get selectedIds => _selectedIds; + int get selectedCount => _selectedIds.length; + bool get hasSelection => _selectedIds.isNotEmpty; + + /// Active/désactive le mode sélection + void toggleSelectionMode() { + setState(() { + _isSelectionMode = !_isSelectionMode; + if (!_isSelectionMode) { + _selectedIds.clear(); + } + }); + } + + /// Active le mode sélection + void enableSelectionMode() { + if (!_isSelectionMode) { + setState(() { + _isSelectionMode = true; + }); + } + } + + /// Désactive le mode sélection et efface la sélection + void disableSelectionMode() { + if (_isSelectionMode) { + setState(() { + _isSelectionMode = false; + _selectedIds.clear(); + }); + } + } + + /// Toggle la sélection d'un item + void toggleItemSelection(String id) { + setState(() { + if (_selectedIds.contains(id)) { + _selectedIds.remove(id); + } else { + _selectedIds.add(id); + } + }); + } + + /// Sélectionne un item + void selectItem(String id) { + setState(() { + _selectedIds.add(id); + }); + } + + /// Désélectionne un item + void deselectItem(String id) { + setState(() { + _selectedIds.remove(id); + }); + } + + /// Vérifie si un item est sélectionné + bool isItemSelected(String id) { + return _selectedIds.contains(id); + } + + /// Sélectionne tous les items + void selectAll(List ids) { + setState(() { + _selectedIds.addAll(ids); + }); + } + + /// Efface la sélection + void clearSelection() { + setState(() { + _selectedIds.clear(); + }); + } + + /// Sélectionne/désélectionne tous les items + void toggleSelectAll(List ids) { + setState(() { + if (_selectedIds.length == ids.length) { + // Tout est sélectionné, on désélectionne tout + _selectedIds.clear(); + } else { + // Sélectionner tout + _selectedIds.addAll(ids); + } + }); + } + + /// Widget pour afficher le nombre d'éléments sélectionnés + Widget buildSelectionCounter({ + required Color backgroundColor, + required Color textColor, + String? customText, + }) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + ), + child: Text( + customText ?? '$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}', + style: TextStyle( + color: textColor, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + /// AppBar pour le mode sélection + PreferredSizeWidget buildSelectionAppBar({ + required String title, + required List actions, + Color? backgroundColor, + }) { + return AppBar( + backgroundColor: backgroundColor, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: disableSelectionMode, + ), + title: Text( + '$selectedCount $title sélectionné${selectedCount > 1 ? 's' : ''}', + style: const TextStyle(color: Colors.white), + ), + actions: actions, + ); + } +} + diff --git a/em2rp/lib/models/container_model.dart b/em2rp/lib/models/container_model.dart new file mode 100644 index 0000000..b414d2b --- /dev/null +++ b/em2rp/lib/models/container_model.dart @@ -0,0 +1,251 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Type de container +enum ContainerType { + flightCase, // Flight case + pelicase, // Pelicase + bag, // Sac + openCrate, // Caisse ouverte + toolbox, // Boîte à outils +} + +String containerTypeToString(ContainerType type) { + switch (type) { + case ContainerType.flightCase: + return 'FLIGHT_CASE'; + case ContainerType.pelicase: + return 'PELICASE'; + case ContainerType.bag: + return 'BAG'; + case ContainerType.openCrate: + return 'OPEN_CRATE'; + case ContainerType.toolbox: + return 'TOOLBOX'; + } +} + +ContainerType containerTypeFromString(String? type) { + switch (type) { + case 'FLIGHT_CASE': + return ContainerType.flightCase; + case 'PELICASE': + return ContainerType.pelicase; + case 'BAG': + return ContainerType.bag; + case 'OPEN_CRATE': + return ContainerType.openCrate; + case 'TOOLBOX': + return ContainerType.toolbox; + default: + return ContainerType.flightCase; + } +} + +String containerTypeLabel(ContainerType type) { + switch (type) { + case ContainerType.flightCase: + return 'Flight Case'; + case ContainerType.pelicase: + return 'Pelicase'; + case ContainerType.bag: + return 'Sac'; + case ContainerType.openCrate: + return 'Caisse Ouverte'; + case ContainerType.toolbox: + return 'Boîte à Outils'; + } +} + +/// Modèle de container/boîte pour le matériel +class ContainerModel { + final String id; // Identifiant unique (généré comme pour équipement) + final String name; // Nom du container + final ContainerType type; // Type de container + final EquipmentStatus status; // Statut actuel (même que équipement) + + // Caractéristiques physiques + final double? weight; // Poids à vide (kg) + final double? length; // Longueur (cm) + final double? width; // Largeur (cm) + final double? height; // Hauteur (cm) + + // Contenu + final List equipmentIds; // IDs des équipements contenus + + // Événement + final String? eventId; // ID de l'événement actuel (si en prestation) + + // Métadonnées + final String? notes; // Notes additionnelles + final DateTime createdAt; // Date de création + final DateTime updatedAt; // Date de mise à jour + + // Historique simple (optionnel) + final List history; // Historique des modifications + + ContainerModel({ + required this.id, + required this.name, + required this.type, + this.status = EquipmentStatus.available, + this.weight, + this.length, + this.width, + this.height, + this.equipmentIds = const [], + this.eventId, + this.notes, + required this.createdAt, + required this.updatedAt, + this.history = const [], + }); + + /// Vérifier si le container est vide + bool get isEmpty => equipmentIds.isEmpty; + + /// Nombre d'équipements dans le container + int get itemCount => equipmentIds.length; + + /// Calculer le volume (m³) + double? get volume { + if (length == null || width == null || height == null) return null; + return (length! * width! * height!) / 1000000; // cm³ to m³ + } + + /// Calculer le poids total (poids vide + équipements) + /// Nécessite la liste des équipements + double calculateTotalWeight(List equipment) { + double total = weight ?? 0.0; + for (final eq in equipment) { + if (equipmentIds.contains(eq.id) && eq.weight != null) { + total += eq.weight!; + } + } + return total; + } + + /// Factory depuis Firestore + factory ContainerModel.fromMap(Map map, String id) { + final List equipmentIdsRaw = map['equipmentIds'] ?? []; + final List equipmentIds = equipmentIdsRaw.map((e) => e.toString()).toList(); + + final List historyRaw = map['history'] ?? []; + final List history = historyRaw + .map((e) => ContainerHistoryEntry.fromMap(e as Map)) + .toList(); + + return ContainerModel( + id: id, + name: map['name'] ?? '', + type: containerTypeFromString(map['type']), + status: equipmentStatusFromString(map['status']), + weight: map['weight']?.toDouble(), + length: map['length']?.toDouble(), + width: map['width']?.toDouble(), + height: map['height']?.toDouble(), + equipmentIds: equipmentIds, + eventId: map['eventId'], + notes: map['notes'], + createdAt: (map['createdAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + updatedAt: (map['updatedAt'] as Timestamp?)?.toDate() ?? DateTime.now(), + history: history, + ); + } + + /// Convertir en Map pour Firestore + Map toMap() { + return { + 'name': name, + 'type': containerTypeToString(type), + 'status': equipmentStatusToString(status), + 'weight': weight, + 'length': length, + 'width': width, + 'height': height, + 'equipmentIds': equipmentIds, + 'eventId': eventId, + 'notes': notes, + 'createdAt': Timestamp.fromDate(createdAt), + 'updatedAt': Timestamp.fromDate(updatedAt), + 'history': history.map((e) => e.toMap()).toList(), + }; + } + + /// Copier avec modifications + ContainerModel copyWith({ + String? id, + String? name, + ContainerType? type, + EquipmentStatus? status, + double? weight, + double? length, + double? width, + double? height, + List? equipmentIds, + String? eventId, + String? notes, + DateTime? createdAt, + DateTime? updatedAt, + List? history, + }) { + return ContainerModel( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + status: status ?? this.status, + weight: weight ?? this.weight, + length: length ?? this.length, + width: width ?? this.width, + height: height ?? this.height, + equipmentIds: equipmentIds ?? this.equipmentIds, + eventId: eventId ?? this.eventId, + notes: notes ?? this.notes, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + history: history ?? this.history, + ); + } +} + +/// Entrée d'historique pour un container +class ContainerHistoryEntry { + final DateTime timestamp; + final String action; // 'added', 'removed', 'status_change', etc. + final String? equipmentId; // ID de l'équipement concerné (si applicable) + final String? previousValue; // Valeur précédente + final String? newValue; // Nouvelle valeur + final String? userId; // ID de l'utilisateur ayant fait la modification + + ContainerHistoryEntry({ + required this.timestamp, + required this.action, + this.equipmentId, + this.previousValue, + this.newValue, + this.userId, + }); + + factory ContainerHistoryEntry.fromMap(Map map) { + return ContainerHistoryEntry( + timestamp: (map['timestamp'] as Timestamp?)?.toDate() ?? DateTime.now(), + action: map['action'] ?? '', + equipmentId: map['equipmentId'], + previousValue: map['previousValue'], + newValue: map['newValue'], + userId: map['userId'], + ); + } + + Map toMap() { + return { + 'timestamp': Timestamp.fromDate(timestamp), + 'action': action, + 'equipmentId': equipmentId, + 'previousValue': previousValue, + 'newValue': newValue, + 'userId': userId, + }; + } +} + diff --git a/em2rp/lib/models/equipment_model.dart b/em2rp/lib/models/equipment_model.dart index 78dea46..4d0d91f 100644 --- a/em2rp/lib/models/equipment_model.dart +++ b/em2rp/lib/models/equipment_model.dart @@ -119,6 +119,12 @@ class EquipmentModel { // Boîtes parentes (plusieurs possibles) final List parentBoxIds; // IDs des boîtes contenant cet équipement + // Caractéristiques physiques + final double? weight; // Poids (kg) + final double? length; // Longueur (cm) + final double? width; // Largeur (cm) + final double? height; // Hauteur (cm) + // Dates & maintenance final DateTime? purchaseDate; // Date d'achat final DateTime? lastMaintenanceDate; // Dernière maintenance @@ -148,6 +154,10 @@ class EquipmentModel { this.availableQuantity, this.criticalThreshold, this.parentBoxIds = const [], + this.weight, + this.length, + this.width, + this.height, this.purchaseDate, this.lastMaintenanceDate, this.nextMaintenanceDate, @@ -179,6 +189,10 @@ class EquipmentModel { availableQuantity: map['availableQuantity']?.toInt(), criticalThreshold: map['criticalThreshold']?.toInt(), parentBoxIds: parentBoxIds, + weight: map['weight']?.toDouble(), + length: map['length']?.toDouble(), + width: map['width']?.toDouble(), + height: map['height']?.toDouble(), purchaseDate: (map['purchaseDate'] as Timestamp?)?.toDate(), nextMaintenanceDate: (map['nextMaintenanceDate'] as Timestamp?)?.toDate(), maintenanceIds: maintenanceIds, @@ -202,6 +216,10 @@ class EquipmentModel { 'availableQuantity': availableQuantity, 'criticalThreshold': criticalThreshold, 'parentBoxIds': parentBoxIds, + 'weight': weight, + 'length': length, + 'width': width, + 'height': height, 'lastMaintenanceDate': lastMaintenanceDate != null ? Timestamp.fromDate(lastMaintenanceDate!) : null, 'purchaseDate': purchaseDate != null ? Timestamp.fromDate(purchaseDate!) : null, 'nextMaintenanceDate': nextMaintenanceDate != null ? Timestamp.fromDate(nextMaintenanceDate!) : null, @@ -226,6 +244,10 @@ class EquipmentModel { int? availableQuantity, int? criticalThreshold, List? parentBoxIds, + double? weight, + double? length, + double? width, + double? height, DateTime? purchaseDate, DateTime? lastMaintenanceDate, DateTime? nextMaintenanceDate, @@ -248,6 +270,10 @@ class EquipmentModel { availableQuantity: availableQuantity ?? this.availableQuantity, criticalThreshold: criticalThreshold ?? this.criticalThreshold, parentBoxIds: parentBoxIds ?? this.parentBoxIds, + weight: weight ?? this.weight, + length: length ?? this.length, + width: width ?? this.width, + height: height ?? this.height, lastMaintenanceDate: lastMaintenanceDate ?? this.lastMaintenanceDate, purchaseDate: purchaseDate ?? this.purchaseDate, nextMaintenanceDate: nextMaintenanceDate ?? this.nextMaintenanceDate, diff --git a/em2rp/lib/providers/container_provider.dart b/em2rp/lib/providers/container_provider.dart new file mode 100644 index 0000000..47ac2f0 --- /dev/null +++ b/em2rp/lib/providers/container_provider.dart @@ -0,0 +1,165 @@ +import 'package:flutter/foundation.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/container_service.dart'; + +class ContainerProvider with ChangeNotifier { + final ContainerService _containerService = ContainerService(); + + ContainerType? _selectedType; + EquipmentStatus? _selectedStatus; + String _searchQuery = ''; + + ContainerType? get selectedType => _selectedType; + EquipmentStatus? get selectedStatus => _selectedStatus; + String get searchQuery => _searchQuery; + + /// Stream des containers avec filtres appliqués + Stream> get containersStream { + return _containerService.getContainers( + type: _selectedType, + status: _selectedStatus, + searchQuery: _searchQuery, + ); + } + + /// Définir le type sélectionné + void setSelectedType(ContainerType? type) { + _selectedType = type; + notifyListeners(); + } + + /// Définir le statut sélectionné + void setSelectedStatus(EquipmentStatus? status) { + _selectedStatus = status; + notifyListeners(); + } + + /// Définir la requête de recherche + void setSearchQuery(String query) { + _searchQuery = query; + notifyListeners(); + } + + /// Créer un nouveau container + Future createContainer(ContainerModel container) async { + await _containerService.createContainer(container); + notifyListeners(); + } + + /// Mettre à jour un container + Future updateContainer(String id, Map data) async { + await _containerService.updateContainer(id, data); + notifyListeners(); + } + + /// Supprimer un container + Future deleteContainer(String id) async { + await _containerService.deleteContainer(id); + notifyListeners(); + } + + /// Récupérer un container par ID + Future getContainerById(String id) async { + return await _containerService.getContainerById(id); + } + + /// Ajouter un équipement à un container + Future> addEquipmentToContainer({ + required String containerId, + required String equipmentId, + String? userId, + }) async { + final result = await _containerService.addEquipmentToContainer( + containerId: containerId, + equipmentId: equipmentId, + userId: userId, + ); + notifyListeners(); + return result; + } + + /// Retirer un équipement d'un container + Future removeEquipmentFromContainer({ + required String containerId, + required String equipmentId, + String? userId, + }) async { + await _containerService.removeEquipmentFromContainer( + containerId: containerId, + equipmentId: equipmentId, + userId: userId, + ); + notifyListeners(); + } + + /// Vérifier la disponibilité d'un container + Future> checkContainerAvailability({ + required String containerId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + return await _containerService.checkContainerAvailability( + containerId: containerId, + startDate: startDate, + endDate: endDate, + excludeEventId: excludeEventId, + ); + } + + /// Récupérer les équipements d'un container + Future> getContainerEquipment(String containerId) async { + return await _containerService.getContainerEquipment(containerId); + } + + /// Trouver tous les containers contenant un équipement + Future> findContainersWithEquipment(String equipmentId) async { + return await _containerService.findContainersWithEquipment(equipmentId); + } + + /// Vérifier si un ID existe + Future checkContainerIdExists(String id) async { + return await _containerService.checkContainerIdExists(id); + } + + /// Générer un ID unique pour un container + /// Format: BOX_{TYPE}_{NAME}_{NUMBER} + static String generateContainerId({ + required ContainerType type, + required String name, + int? number, + }) { + // Obtenir le type en majuscules + final typeStr = containerTypeToString(type); + + // Nettoyer le nom (enlever espaces, caractères spéciaux) + final cleanName = name + .replaceAll(' ', '_') + .replaceAll(RegExp(r'[^a-zA-Z0-9_-]'), '') + .toUpperCase(); + + if (number != null) { + return 'BOX_${typeStr}_${cleanName}_#$number'; + } + + return 'BOX_${typeStr}_$cleanName'; + } + + /// Assurer l'unicité d'un ID de container + static Future ensureUniqueContainerId( + String baseId, + ContainerService service, + ) async { + String uniqueId = baseId; + int counter = 1; + + while (await service.checkContainerIdExists(uniqueId)) { + uniqueId = '${baseId}_$counter'; + counter++; + } + + return uniqueId; + } +} + diff --git a/em2rp/lib/services/container_pdf_generator_service.dart b/em2rp/lib/services/container_pdf_generator_service.dart new file mode 100644 index 0000000..22c9151 --- /dev/null +++ b/em2rp/lib/services/container_pdf_generator_service.dart @@ -0,0 +1,52 @@ +import 'dart:typed_data'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/unified_pdf_generator_service.dart'; + +export 'package:em2rp/services/unified_pdf_generator_service.dart' show QRLabelFormat; + +/// Formats d'étiquettes disponibles pour containers (legacy - utilise QRLabelFormat maintenant) +@Deprecated('Utiliser QRLabelFormat directement') +typedef ContainerQRLabelFormat = QRLabelFormat; + +/// Service pour la génération de PDFs avec QR codes pour containers +/// WRAPPER LEGACY - Utilise maintenant UnifiedPDFGeneratorService +@Deprecated('Utiliser UnifiedPDFGeneratorService directement') +class ContainerPDFGeneratorService { + /// Génère un PDF avec des QR codes selon le format choisi + static Future generateQRCodesPDF({ + required List containerList, + required Map> containerEquipmentMap, + required QRLabelFormat format, + }) async { + // Pour les grandes étiquettes, inclure les équipements + if (format == QRLabelFormat.large) { + return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF( + items: containerList, + getId: (c) => c.id, + getTitle: (c) => c.name, + getSubtitle: (c) { + final equipment = containerEquipmentMap[c.id] ?? []; + final lines = [ + 'Contenu (${equipment.length}):', + ...equipment.take(5).map((eq) => '- ${eq.id}'), + if (equipment.length > 5) '... +${equipment.length - 5}', + ]; + return lines; + }, + format: format, + ); + } + + // Pour les petites et moyennes étiquettes, juste ID + nom + return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF( + items: containerList, + getId: (c) => c.id, + getTitle: (c) => c.name, + getSubtitle: null, + format: format, + ); + } +} + + diff --git a/em2rp/lib/services/container_service.dart b/em2rp/lib/services/container_service.dart new file mode 100644 index 0000000..15b34e3 --- /dev/null +++ b/em2rp/lib/services/container_service.dart @@ -0,0 +1,378 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +class ContainerService { + final FirebaseFirestore _firestore = FirebaseFirestore.instance; + + // Collection references + CollectionReference get _containersCollection => _firestore.collection('containers'); + CollectionReference get _equipmentCollection => _firestore.collection('equipments'); + + // CRUD Operations + + /// Créer un nouveau container + Future createContainer(ContainerModel container) async { + try { + await _containersCollection.doc(container.id).set(container.toMap()); + } catch (e) { + print('Error creating container: $e'); + rethrow; + } + } + + /// Mettre à jour un container + Future updateContainer(String id, Map data) async { + try { + data['updatedAt'] = Timestamp.fromDate(DateTime.now()); + await _containersCollection.doc(id).update(data); + } catch (e) { + print('Error updating container: $e'); + rethrow; + } + } + + /// Supprimer un container + Future deleteContainer(String id) async { + try { + // Récupérer le container pour obtenir les équipements + final container = await getContainerById(id); + if (container != null && container.equipmentIds.isNotEmpty) { + // Retirer le container des parentBoxIds de chaque équipement + for (final equipmentId in container.equipmentIds) { + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + final updatedParents = equipment.parentBoxIds.where((boxId) => boxId != id).toList(); + await _equipmentCollection.doc(equipmentId).update({ + 'parentBoxIds': updatedParents, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + } + } + } + + await _containersCollection.doc(id).delete(); + } catch (e) { + print('Error deleting container: $e'); + rethrow; + } + } + + /// Récupérer un container par ID + Future getContainerById(String id) async { + try { + final doc = await _containersCollection.doc(id).get(); + if (doc.exists) { + return ContainerModel.fromMap(doc.data() as Map, doc.id); + } + return null; + } catch (e) { + print('Error getting container: $e'); + rethrow; + } + } + + /// Récupérer tous les containers + Stream> getContainers({ + ContainerType? type, + EquipmentStatus? status, + String? searchQuery, + }) { + try { + Query query = _containersCollection; + + // Filtre par type + if (type != null) { + query = query.where('type', isEqualTo: containerTypeToString(type)); + } + + // Filtre par statut + if (status != null) { + query = query.where('status', isEqualTo: equipmentStatusToString(status)); + } + + return query.snapshots().map((snapshot) { + List containerList = snapshot.docs + .map((doc) => ContainerModel.fromMap(doc.data() as Map, doc.id)) + .toList(); + + // Filtre par recherche texte (côté client) + if (searchQuery != null && searchQuery.isNotEmpty) { + final lowerSearch = searchQuery.toLowerCase(); + containerList = containerList.where((container) { + return container.name.toLowerCase().contains(lowerSearch) || + container.id.toLowerCase().contains(lowerSearch); + }).toList(); + } + + return containerList; + }); + } catch (e) { + print('Error getting containers: $e'); + rethrow; + } + } + + /// Ajouter un équipement à un container + Future> addEquipmentToContainer({ + required String containerId, + required String equipmentId, + String? userId, + }) async { + try { + // Récupérer le container + final container = await getContainerById(containerId); + if (container == null) { + return {'success': false, 'message': 'Container non trouvé'}; + } + + // Vérifier si l'équipement n'est pas déjà dans ce container + if (container.equipmentIds.contains(equipmentId)) { + return {'success': false, 'message': 'Cet équipement est déjà dans ce container'}; + } + + // Récupérer l'équipement pour vérifier s'il est déjà dans d'autres containers + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (!equipmentDoc.exists) { + return {'success': false, 'message': 'Équipement non trouvé'}; + } + + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + // Avertir si l'équipement est déjà dans d'autres containers + List otherContainers = []; + if (equipment.parentBoxIds.isNotEmpty) { + for (final boxId in equipment.parentBoxIds) { + final box = await getContainerById(boxId); + if (box != null) { + otherContainers.add(box.name); + } + } + } + + // Mettre à jour le container + final updatedEquipmentIds = [...container.equipmentIds, equipmentId]; + await updateContainer(containerId, { + 'equipmentIds': updatedEquipmentIds, + }); + + // Mettre à jour l'équipement + final updatedParentBoxIds = [...equipment.parentBoxIds, containerId]; + await _equipmentCollection.doc(equipmentId).update({ + 'parentBoxIds': updatedParentBoxIds, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + + // Ajouter une entrée dans l'historique + await _addHistoryEntry( + containerId: containerId, + action: 'equipment_added', + equipmentId: equipmentId, + newValue: equipmentId, + userId: userId, + ); + + return { + 'success': true, + 'message': 'Équipement ajouté avec succès', + 'warnings': otherContainers.isNotEmpty + ? 'Attention : cet équipement est également dans les containers suivants : ${otherContainers.join(", ")}' + : null, + }; + } catch (e) { + print('Error adding equipment to container: $e'); + return {'success': false, 'message': 'Erreur: $e'}; + } + } + + /// Retirer un équipement d'un container + Future removeEquipmentFromContainer({ + required String containerId, + required String equipmentId, + String? userId, + }) async { + try { + // Récupérer le container + final container = await getContainerById(containerId); + if (container == null) throw Exception('Container non trouvé'); + + // Mettre à jour le container + final updatedEquipmentIds = container.equipmentIds.where((id) => id != equipmentId).toList(); + await updateContainer(containerId, { + 'equipmentIds': updatedEquipmentIds, + }); + + // Mettre à jour l'équipement + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + final updatedParentBoxIds = equipment.parentBoxIds.where((id) => id != containerId).toList(); + await _equipmentCollection.doc(equipmentId).update({ + 'parentBoxIds': updatedParentBoxIds, + 'updatedAt': Timestamp.fromDate(DateTime.now()), + }); + } + + // Ajouter une entrée dans l'historique + await _addHistoryEntry( + containerId: containerId, + action: 'equipment_removed', + equipmentId: equipmentId, + previousValue: equipmentId, + userId: userId, + ); + } catch (e) { + print('Error removing equipment from container: $e'); + rethrow; + } + } + + /// Vérifier la disponibilité d'un container et de son contenu pour un événement + Future> checkContainerAvailability({ + required String containerId, + required DateTime startDate, + required DateTime endDate, + String? excludeEventId, + }) async { + try { + final container = await getContainerById(containerId); + if (container == null) { + return {'available': false, 'message': 'Container non trouvé'}; + } + + // Vérifier le statut du container + if (container.status != EquipmentStatus.available) { + return { + 'available': false, + 'message': 'Container ${container.name} n\'est pas disponible (statut: ${container.status})', + }; + } + + // Vérifier la disponibilité de chaque équipement dans le container + List unavailableEquipment = []; + for (final equipmentId in container.equipmentIds) { + final equipmentDoc = await _equipmentCollection.doc(equipmentId).get(); + if (equipmentDoc.exists) { + final equipment = EquipmentModel.fromMap( + equipmentDoc.data() as Map, + equipmentDoc.id, + ); + + if (equipment.status != EquipmentStatus.available) { + unavailableEquipment.add('${equipment.name} (${equipment.status})'); + } + } + } + + if (unavailableEquipment.isNotEmpty) { + return { + 'available': false, + 'message': 'Certains équipements ne sont pas disponibles', + 'unavailableItems': unavailableEquipment, + }; + } + + return {'available': true, 'message': 'Container et tout son contenu disponibles'}; + } catch (e) { + print('Error checking container availability: $e'); + return {'available': false, 'message': 'Erreur: $e'}; + } + } + + /// Récupérer les équipements d'un container + Future> getContainerEquipment(String containerId) async { + try { + final container = await getContainerById(containerId); + if (container == null) return []; + + List equipment = []; + for (final equipmentId in container.equipmentIds) { + final doc = await _equipmentCollection.doc(equipmentId).get(); + if (doc.exists) { + equipment.add(EquipmentModel.fromMap(doc.data() as Map, doc.id)); + } + } + + return equipment; + } catch (e) { + print('Error getting container equipment: $e'); + rethrow; + } + } + + /// Trouver tous les containers contenant un équipement spécifique + Future> findContainersWithEquipment(String equipmentId) async { + try { + final snapshot = await _containersCollection + .where('equipmentIds', arrayContains: equipmentId) + .get(); + + return snapshot.docs + .map((doc) => ContainerModel.fromMap(doc.data() as Map, doc.id)) + .toList(); + } catch (e) { + print('Error finding containers with equipment: $e'); + rethrow; + } + } + + /// Ajouter une entrée d'historique + Future _addHistoryEntry({ + required String containerId, + required String action, + String? equipmentId, + String? previousValue, + String? newValue, + String? userId, + }) async { + try { + final container = await getContainerById(containerId); + if (container == null) return; + + final entry = ContainerHistoryEntry( + timestamp: DateTime.now(), + action: action, + equipmentId: equipmentId, + previousValue: previousValue, + newValue: newValue, + userId: userId, + ); + + final updatedHistory = [...container.history, entry]; + + // Limiter l'historique aux 100 dernières entrées + final limitedHistory = updatedHistory.length > 100 + ? updatedHistory.sublist(updatedHistory.length - 100) + : updatedHistory; + + await updateContainer(containerId, { + 'history': limitedHistory.map((e) => e.toMap()).toList(), + }); + } catch (e) { + print('Error adding history entry: $e'); + // Ne pas throw pour éviter de bloquer l'opération principale + } + } + + /// Vérifier si un ID de container existe déjà + Future checkContainerIdExists(String id) async { + try { + final doc = await _containersCollection.doc(id).get(); + return doc.exists; + } catch (e) { + print('Error checking container ID: $e'); + return false; + } + } +} + diff --git a/em2rp/lib/services/equipment_service.dart b/em2rp/lib/services/equipment_service.dart index c387ad5..0db0310 100644 --- a/em2rp/lib/services/equipment_service.dart +++ b/em2rp/lib/services/equipment_service.dart @@ -1,6 +1,7 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/models/alert_model.dart'; +import 'package:em2rp/models/maintenance_model.dart'; class EquipmentService { final FirebaseFirestore _firestore = FirebaseFirestore.instance; @@ -369,5 +370,63 @@ class EquipmentService { rethrow; } } + + /// Récupérer plusieurs équipements par leurs IDs + Future> getEquipmentsByIds(List ids) async { + try { + if (ids.isEmpty) return []; + + final equipments = []; + + // Firestore limite les requêtes whereIn à 10 éléments + // On doit donc diviser en plusieurs requêtes si nécessaire + for (int i = 0; i < ids.length; i += 10) { + final batch = ids.skip(i).take(10).toList(); + final query = await _equipmentCollection + .where(FieldPath.documentId, whereIn: batch) + .get(); + + for (var doc in query.docs) { + equipments.add( + EquipmentModel.fromMap( + doc.data() as Map, + doc.id, + ), + ); + } + } + + return equipments; + } catch (e) { + print('Error getting equipments by IDs: $e'); + rethrow; + } + } + + /// Récupérer les maintenances pour un équipement + Future> getMaintenancesForEquipment(String equipmentId) async { + try { + final maintenanceQuery = await _firestore + .collection('maintenances') + .where('equipmentIds', arrayContains: equipmentId) + .orderBy('scheduledDate', descending: true) + .get(); + + final maintenances = []; + for (var doc in maintenanceQuery.docs) { + maintenances.add( + MaintenanceModel.fromMap( + doc.data(), + doc.id, + ), + ); + } + + return maintenances; + } catch (e) { + print('Error getting maintenances for equipment: $e'); + rethrow; + } + } } diff --git a/em2rp/lib/services/pdf_generator_service.dart b/em2rp/lib/services/pdf_generator_service.dart new file mode 100644 index 0000000..8e0e44a --- /dev/null +++ b/em2rp/lib/services/pdf_generator_service.dart @@ -0,0 +1,76 @@ +import 'dart:typed_data'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/unified_pdf_generator_service.dart'; + +// Export QRLabelFormat pour rétrocompatibilité +export 'package:em2rp/services/unified_pdf_generator_service.dart' show QRLabelFormat; + +/// Service pour la génération de PDFs avec QR codes pour équipements +/// WRAPPER LEGACY - Utilise maintenant UnifiedPDFGeneratorService +@Deprecated('Utiliser UnifiedPDFGeneratorService directement') +class PDFGeneratorService { + /// Génère un PDF avec des QR codes selon le format choisi + static Future generateQRCodesPDF({ + required List equipmentList, + required QRLabelFormat format, + }) async { + // Pour les grandes étiquettes, ajouter les détails + if (format == QRLabelFormat.large) { + return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF( + items: equipmentList, + getId: (eq) => eq.id, + getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(), + getSubtitle: (eq) { + final details = []; + + // Marque + if (eq.brand != null && eq.brand!.isNotEmpty) { + details.add('Marque: ${eq.brand}'); + } + + // Modèle + if (eq.model != null && eq.model!.isNotEmpty) { + details.add('Modèle: ${eq.model}'); + } + + // Catégorie + details.add('Catégorie: ${_getCategoryLabel(eq.category)}'); + + return details; + }, + format: format, + ); + } + + // Pour petites et moyennes étiquettes, juste ID + marque/modèle + return UnifiedPDFGeneratorService.generateAdvancedQRCodesPDF( + items: equipmentList, + getId: (eq) => eq.id, + getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(), + getSubtitle: null, + format: format, + ); + } + + static String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.other: + return 'Autre'; + } + } +} + diff --git a/em2rp/lib/services/qr_code_service.dart b/em2rp/lib/services/qr_code_service.dart new file mode 100644 index 0000000..4dddde9 --- /dev/null +++ b/em2rp/lib/services/qr_code_service.dart @@ -0,0 +1,173 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; +import 'package:flutter/services.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:em2rp/utils/colors.dart'; + +/// Service pour la génération de QR codes optimisée +class QRCodeService { + // Cache pour éviter de régénérer les mêmes QR codes + static final Map _qrCache = {}; + static ui.Image? _cachedLogoImage; + + /// Génère un QR code simple sans logo + static Future generateQRCode( + String data, { + double size = 512, + bool useCache = true, + }) async { + // Vérifier le cache + if (useCache && _qrCache.containsKey(data)) { + return _qrCache[data]!; + } + + final qrValidationResult = QrValidator.validate( + data: data, + version: QrVersions.auto, + errorCorrectionLevel: QrErrorCorrectLevel.L, + ); + + if (qrValidationResult.status != QrValidationStatus.valid) { + throw Exception('QR code validation failed for data: $data'); + } + + final qrCode = qrValidationResult.qrCode!; + final painter = QrPainter.withQr( + qr: qrCode, + eyeStyle: const QrEyeStyle( + eyeShape: QrEyeShape.square, + color: AppColors.noir, + ), + gapless: true, + ); + + final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png); + final bytes = picData!.buffer.asUint8List(); + + // Mettre en cache + if (useCache) { + _qrCache[data] = bytes; + } + + return bytes; + } + + /// Génère un QR code avec logo embarqué + static Future generateQRCodeWithLogo( + String data, { + double size = 512, + bool useCache = true, + }) async { + final cacheKey = '${data}_logo'; + + // Vérifier le cache + if (useCache && _qrCache.containsKey(cacheKey)) { + return _qrCache[cacheKey]!; + } + + final qrValidationResult = QrValidator.validate( + data: data, + version: QrVersions.auto, + errorCorrectionLevel: QrErrorCorrectLevel.L, + ); + + if (qrValidationResult.status != QrValidationStatus.valid) { + throw Exception('QR code validation failed for data: $data'); + } + + final qrCode = qrValidationResult.qrCode!; + final embedded = await _loadLogoImage(); + + final painter = QrPainter.withQr( + qr: qrCode, + embeddedImage: embedded, + embeddedImageStyle: const QrEmbeddedImageStyle( + size: Size(80, 80), + ), + gapless: true, + ); + + final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png); + final bytes = picData!.buffer.asUint8List(); + + // Mettre en cache + if (useCache) { + _qrCache[cacheKey] = bytes; + } + + return bytes; + } + + /// Charge le logo depuis les assets (avec cache) + static Future _loadLogoImage() async { + if (_cachedLogoImage != null) { + return _cachedLogoImage!; + } + + final data = await rootBundle.load('assets/logos/SquareLogoBlack.png'); + final codec = await ui.instantiateImageCodec(data.buffer.asUint8List()); + final frame = await codec.getNextFrame(); + _cachedLogoImage = frame.image; + return _cachedLogoImage!; + } + + /// Génère plusieurs QR codes en parallèle (optimisé) + static Future> generateBulkQRCodes( + List dataList, { + double size = 512, + bool withLogo = false, + bool useCache = true, + }) async { + // Si tout est en cache, retourner immédiatement + if (useCache) { + final allCached = dataList.every((data) { + final key = withLogo ? '${data}_logo' : data; + return _qrCache.containsKey(key); + }); + + if (allCached) { + return dataList.map((data) { + final key = withLogo ? '${data}_logo' : data; + return _qrCache[key]!; + }).toList(); + } + } + + // Batching adaptatif optimisé selon la taille et le nombre + int batchSize; + if (size <= 200) { + batchSize = 100; // Petits QR : lots de 100 + } else if (size <= 300) { + batchSize = 50; // Moyens QR : lots de 50 + } else if (size <= 500) { + batchSize = 20; // Grands QR : lots de 20 + } else { + batchSize = 10; // Très grands : lots de 10 + } + + final List results = []; + + for (int i = 0; i < dataList.length; i += batchSize) { + final batch = dataList.skip(i).take(batchSize).toList(); + final batchResults = await Future.wait( + batch.map((data) => withLogo + ? generateQRCodeWithLogo(data, size: size, useCache: useCache) + : generateQRCode(data, size: size, useCache: useCache)), + ); + results.addAll(batchResults); + } + + return results; + } + + /// Vide le cache des QR codes + static void clearCache() { + _qrCache.clear(); + } + + /// Obtient la taille du cache + static int getCacheSize() { + return _qrCache.length; + } +} + diff --git a/em2rp/lib/services/unified_pdf_generator_service.dart b/em2rp/lib/services/unified_pdf_generator_service.dart new file mode 100644 index 0000000..e8136b5 --- /dev/null +++ b/em2rp/lib/services/unified_pdf_generator_service.dart @@ -0,0 +1,354 @@ +import 'dart:typed_data'; +import 'package:flutter/services.dart'; +import 'package:pdf/pdf.dart'; +import 'package:pdf/widgets.dart' as pw; +import 'package:em2rp/services/qr_code_service.dart'; + +/// Formats d'étiquettes QR disponibles +enum QRLabelFormat { small, medium, large } + +/// Interface pour les items qui peuvent avoir un QR code +abstract class QRCodeItem { + String get id; + String get displayName; +} + +/// Service unifié pour la génération de PDFs avec QR codes +/// Fonctionne avec n'importe quel type d'objet ayant un ID +class UnifiedPDFGeneratorService { + static Uint8List? _cachedLogoBytes; + + /// Tronque un texte s'il dépasse une longueur maximale + static String _truncateText(String text, int maxLength) { + if (text.length <= maxLength) { + return text; + } + return '${text.substring(0, maxLength - 3)}...'; + } + + /// Charge le logo en cache (optimisation) + static Future _ensureLogoLoaded() async { + if (_cachedLogoBytes == null) { + try { + final logoData = await rootBundle.load('assets/logos/LowQRectangleLogoBlack.png'); + _cachedLogoBytes = logoData.buffer.asUint8List(); + } catch (e) { + // Logo non disponible, on continue sans + _cachedLogoBytes = Uint8List(0); + } + } + } + + /// Génère un PDF avec des QR codes simples (juste ID + QR) + static Future generateSimpleQRCodesPDF({ + required List items, + required String Function(T) getId, + required QRLabelFormat format, + String Function(T)? getDisplayName, + }) async { + final pdf = pw.Document(); + + switch (format) { + case QRLabelFormat.small: + await _generateSmallQRCodesPDF(pdf, items, getId, getDisplayName); + break; + case QRLabelFormat.medium: + await _generateMediumQRCodesPDF(pdf, items, getId, getDisplayName); + break; + case QRLabelFormat.large: + await _generateLargeQRCodesPDF(pdf, items, getId, getDisplayName, null); + break; + } + + return pdf.save(); + } + + /// Génère un PDF avec des QR codes avancés (avec informations supplémentaires) + static Future generateAdvancedQRCodesPDF({ + required List items, + required String Function(T) getId, + required String Function(T) getTitle, + required List Function(T)? getSubtitle, + required QRLabelFormat format, + }) async { + final pdf = pw.Document(); + + switch (format) { + case QRLabelFormat.small: + await _generateSmallQRCodesPDF(pdf, items, getId, getTitle); + break; + case QRLabelFormat.medium: + await _generateMediumQRCodesPDF(pdf, items, getId, getTitle); + break; + case QRLabelFormat.large: + await _generateLargeQRCodesPDF(pdf, items, getId, getTitle, getSubtitle); + break; + } + + return pdf.save(); + } + + // ========================================================================== + // PETITS QR CODES (2x2 cm, 20 par page) + // ========================================================================== + + static Future _generateSmallQRCodesPDF( + pw.Document pdf, + List items, + String Function(T) getId, + String Function(T)? getDisplayName, + ) async { + const qrSize = 56.69; // 2cm en points + const itemsPerPage = 20; + + // Générer tous les QR codes en une fois (optimisé avec résolution réduite) + final allQRImages = await QRCodeService.generateBulkQRCodes( + items.map((item) => getId(item)).toList(), + size: 150, // Réduit de 200 à 150 pour performance optimale + withLogo: false, + useCache: true, + ); + + for (int pageStart = 0; pageStart < items.length; pageStart += itemsPerPage) { + final pageItems = items.skip(pageStart).take(itemsPerPage).toList(); + final pageQRImages = allQRImages.skip(pageStart).take(itemsPerPage).toList(); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Wrap( + spacing: 10, + runSpacing: 10, + children: List.generate(pageItems.length, (index) { + return pw.Container( + width: qrSize, + height: qrSize + 20, + child: pw.Column( + mainAxisAlignment: pw.MainAxisAlignment.center, + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Image(pw.MemoryImage(pageQRImages[index])), + pw.SizedBox(height: 2), + pw.Text( + getId(pageItems[index]), + style: pw.TextStyle( + fontSize: 6, + fontWeight: pw.FontWeight.bold, + ), + textAlign: pw.TextAlign.center, + ), + ], + ), + ); + }), + ); + }, + ), + ); + } + } + + // ========================================================================== + // QR CODES MOYENS (4x4 cm, 6 par page) + // ========================================================================== + + static Future _generateMediumQRCodesPDF( + pw.Document pdf, + List items, + String Function(T) getId, + String Function(T)? getDisplayName, + ) async { + const qrSize = 113.39; // 4cm en points + const itemsPerPage = 6; + + // Optimisé avec résolution réduite + final allQRImages = await QRCodeService.generateBulkQRCodes( + items.map((item) => getId(item)).toList(), + size: 250, // Réduit de 400 à 250 pour performance optimale + withLogo: false, + useCache: true, + ); + + for (int pageStart = 0; pageStart < items.length; pageStart += itemsPerPage) { + final pageItems = items.skip(pageStart).take(itemsPerPage).toList(); + final pageQRImages = allQRImages.skip(pageStart).take(itemsPerPage).toList(); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Wrap( + spacing: 20, + runSpacing: 20, + children: List.generate(pageItems.length, (index) { + return pw.Container( + width: qrSize, + height: qrSize + 30, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + mainAxisAlignment: pw.MainAxisAlignment.center, + children: [ + pw.Image(pw.MemoryImage(pageQRImages[index])), + pw.SizedBox(height: 4), + pw.Text( + getId(pageItems[index]), + style: pw.TextStyle( + fontSize: 10, + fontWeight: pw.FontWeight.bold, + ), + textAlign: pw.TextAlign.center, + ), + if (getDisplayName != null) ...[ + pw.SizedBox(height: 2), + pw.Text( + _truncateText(getDisplayName(pageItems[index]), 25), + style: const pw.TextStyle( + fontSize: 8, + color: PdfColors.grey700, + ), + textAlign: pw.TextAlign.center, + ), + ], + ], + ), + ); + }), + ); + }, + ), + ); + } + } + + // ========================================================================== + // GRANDES ÉTIQUETTES (QR + infos détaillées, 6 par page) + // ========================================================================== + + static Future _generateLargeQRCodesPDF( + pw.Document pdf, + List items, + String Function(T) getId, + String Function(T)? getTitle, + List Function(T)? getSubtitle, + ) async { + const qrSize = 100.0; + const itemsPerPage = 6; + + // Charger le logo une seule fois + await _ensureLogoLoaded(); + + // Générer les QR codes en bulk pour optimisation + final allQRImages = await QRCodeService.generateBulkQRCodes( + items.map((item) => getId(item)).toList(), + size: 300, // Réduit de 400 à 300 pour améliorer la performance + withLogo: false, + useCache: true, + ); + + for (int pageStart = 0; pageStart < items.length; pageStart += itemsPerPage) { + final pageItems = items.skip(pageStart).take(itemsPerPage).toList(); + final pageQRImages = allQRImages.skip(pageStart).take(itemsPerPage).toList(); + + pdf.addPage( + pw.Page( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(20), + build: (context) { + return pw.Wrap( + spacing: 10, + runSpacing: 10, + children: List.generate(pageItems.length, (index) { + final item = pageItems[index]; + return pw.Container( + width: 260, + height: 120, + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.grey400), + borderRadius: pw.BorderRadius.circular(4), + ), + padding: const pw.EdgeInsets.all(8), + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + // QR Code + pw.Container( + width: qrSize, + height: qrSize, + child: pw.Image( + pw.MemoryImage(pageQRImages[index]), + fit: pw.BoxFit.contain, + ), + ), + pw.SizedBox(width: 8), + // Informations + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + mainAxisAlignment: pw.MainAxisAlignment.start, + children: [ + // Logo - CENTRÉ ET PLUS GRAND + if (_cachedLogoBytes != null && _cachedLogoBytes!.isNotEmpty) + pw.Center( + child: pw.Container( + height: 25, // Augmenté de 15 à 25 + margin: const pw.EdgeInsets.only(bottom: 6), + child: pw.Image( + pw.MemoryImage(_cachedLogoBytes!), + fit: pw.BoxFit.contain, + ), + ), + ), + // ID (toujours affiché sur plusieurs lignes si nécessaire) + if (getTitle != null) ...[ + pw.SizedBox(height: 2), + pw.Text( + _truncateText(getTitle(item), 20), + style: pw.TextStyle( + fontSize: 10, + fontWeight: pw.FontWeight.bold, + ), + maxLines: 2, + ), + ], + pw.SizedBox(height: 2), + pw.Text( + getId(item), + style: const pw.TextStyle( + fontSize: 8, + color: PdfColors.grey700, + ), + maxLines: 1, + ), + if (getSubtitle != null) ...[ + pw.SizedBox(height: 4), + ...getSubtitle(item).take(5).map((line) { + return pw.Padding( + padding: const pw.EdgeInsets.only(bottom: 1), + child: pw.Text( + _truncateText(line, 25), + style: const pw.TextStyle( + fontSize: 6, + color: PdfColors.grey800, + ), + ), + ); + }), + ], + ], + ), + ), + ], + ), + ); + }), + ); + }, + ), + ); + } + } +} + diff --git a/em2rp/lib/utils/id_generator.dart b/em2rp/lib/utils/id_generator.dart new file mode 100644 index 0000000..34c059d --- /dev/null +++ b/em2rp/lib/utils/id_generator.dart @@ -0,0 +1,155 @@ +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/services/equipment_service.dart'; + +/// Générateur d'identifiants unifié pour l'application +/// Gère les équipements, containers et autres entités +class IdGenerator { + // ============================================================================ + // ÉQUIPEMENTS + // ============================================================================ + + /// Génère un ID pour un équipement + /// Format: {Marque4Chars}_{Modèle}_{#Numéro} + /// Exemple: BEAM_7R_#1 + static String generateEquipmentId({ + required String brand, + required String model, + int? number, + }) { + final brandTrim = brand.trim().replaceAll(' ', '_'); + final modelTrim = model.trim().replaceAll(' ', '_'); + + if (brandTrim.isEmpty && modelTrim.isEmpty) { + return 'EQ-${DateTime.now().millisecondsSinceEpoch}${number != null ? '_$number' : ''}'; + } + + final brandPrefix = brandTrim.length >= 4 + ? brandTrim.substring(0, 4) + : brandTrim; + + String baseId = modelTrim.isNotEmpty + ? '${brandPrefix}_$modelTrim' + : (brandPrefix.isNotEmpty ? brandPrefix : 'EQ'); + + // Empêcher les ID commençant par BOX_ (réservé aux containers) + if (baseId.toUpperCase().startsWith('BOX_')) { + baseId = 'EQ_$baseId'; + } + + if (number != null) { + baseId += '_#$number'; + } + + return baseId.toUpperCase(); + } + + /// Garantit l'unicité d'un ID d'équipement + static Future ensureUniqueEquipmentId( + String baseId, + EquipmentService service, + ) async { + // Vérifier que l'ID ne commence pas par BOX_ + if (baseId.toUpperCase().startsWith('BOX_')) { + baseId = 'EQ_$baseId'; + } + + if (await service.isIdUnique(baseId)) { + return baseId; + } + + return '${baseId}_${DateTime.now().millisecondsSinceEpoch}'; + } + + // ============================================================================ + // CONTAINERS + // ============================================================================ + + /// Génère un ID pour un container + /// Format: BOX_{Type}_{Nom}_{#Numéro} + /// Exemple: BOX_FLIGHT_CASE_BEAM_#1 + static String generateContainerId({ + required ContainerType type, + required String name, + int? number, + }) { + final typeStr = containerTypeToString(type); + final cleanName = _cleanId(name); + + if (number != null) { + return 'BOX_${typeStr}_${cleanName}_#$number'; + } + + return 'BOX_${typeStr}_$cleanName'; + } + + /// Garantit l'unicité d'un ID de container + /// Note: La vérification d'unicité doit être faite par l'appelant + static String ensureUniqueContainerId(String baseId) { + // Retourne simplement l'ID de base + // L'unicité sera vérifiée au niveau du provider/form + return baseId; + } + + /// Génère un ID unique avec un timestamp si nécessaire + static String generateUniqueContainerId(String baseId) { + return '${baseId}_${DateTime.now().millisecondsSinceEpoch}'; + } + + + // ============================================================================ + // UTILITAIRES + // ============================================================================ + + /// Nettoie une chaîne pour en faire un ID valide + /// - Supprime les espaces (remplacés par _) + /// - Supprime les caractères spéciaux + /// - Met en majuscules + static String _cleanId(String input) { + return input + .trim() + .toUpperCase() + .replaceAll(' ', '_') + .replaceAll(RegExp(r'[^A-Z0-9_-]'), ''); + } + + /// Valide qu'un ID d'équipement ne commence pas par un préfixe réservé + static String? validateEquipmentId(String id) { + if (id.isEmpty) { + return 'L\'identifiant ne peut pas être vide'; + } + + if (id.toUpperCase().startsWith('BOX_')) { + return 'Les ID commençant par BOX_ sont réservés aux containers'; + } + + return null; + } + + /// Valide qu'un ID de container commence bien par BOX_ + static String? validateContainerId(String id) { + if (id.isEmpty) { + return 'L\'identifiant ne peut pas être vide'; + } + + if (!id.toUpperCase().startsWith('BOX_')) { + return 'Les containers doivent avoir un ID commençant par BOX_'; + } + + return null; + } + + /// Détermine le type d'entité à partir d'un ID + static EntityType getEntityType(String id) { + if (id.toUpperCase().startsWith('BOX_')) { + return EntityType.container; + } + return EntityType.equipment; + } +} + +/// Type d'entité identifiable +enum EntityType { + equipment, + container, +} + diff --git a/em2rp/lib/views/container_detail_page.dart b/em2rp/lib/views/container_detail_page.dart new file mode 100644 index 0000000..4a36937 --- /dev/null +++ b/em2rp/lib/views/container_detail_page.dart @@ -0,0 +1,793 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/providers/container_provider.dart'; +import 'package:em2rp/views/equipment_detail_page.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:intl/intl.dart'; + +class ContainerDetailPage extends StatefulWidget { + final ContainerModel container; + + const ContainerDetailPage({super.key, required this.container}); + + @override + State createState() => _ContainerDetailPageState(); +} + +class _ContainerDetailPageState extends State { + late ContainerModel _container; + List _equipmentList = []; + bool _isLoadingEquipment = true; + + @override + void initState() { + super.initState(); + _container = widget.container; + _loadEquipment(); + } + + Future _loadEquipment() async { + setState(() { + _isLoadingEquipment = true; + }); + + try { + final containerProvider = context.read(); + final equipment = await containerProvider.getContainerEquipment(_container.id); + setState(() { + _equipmentList = equipment; + _isLoadingEquipment = false; + }); + } catch (e) { + setState(() { + _isLoadingEquipment = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors du chargement: $e')), + ); + } + } + } + + Future _refreshContainer() async { + final containerProvider = context.read(); + final updated = await containerProvider.getContainerById(_container.id); + if (updated != null) { + setState(() { + _container = updated; + }); + await _loadEquipment(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Détails du Container'), + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, + actions: [ + IconButton( + icon: const Icon(Icons.edit), + tooltip: 'Modifier', + onPressed: _editContainer, + ), + IconButton( + icon: const Icon(Icons.qr_code), + tooltip: 'QR Code', + onPressed: _showQRCode, + ), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: _handleMenuAction, + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red, size: 20), + SizedBox(width: 8), + Text('Supprimer', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + ), + ], + ), + body: RefreshIndicator( + onRefresh: _refreshContainer, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _buildHeaderCard(), + const SizedBox(height: 16), + _buildPhysicalCharacteristics(), + const SizedBox(height: 16), + _buildEquipmentSection(), + const SizedBox(height: 16), + if (_container.notes != null && _container.notes!.isNotEmpty) + _buildNotesSection(), + const SizedBox(height: 16), + _buildHistorySection(), + ], + ), + ), + ); + } + + Widget _buildHeaderCard() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getTypeIcon(_container.type), + size: 60, + color: AppColors.rouge, + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _container.id, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + _container.name, + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + ], + ), + const Divider(height: 32), + Row( + children: [ + Expanded( + child: _buildInfoItem( + 'Type', + containerTypeLabel(_container.type), + Icons.category, + ), + ), + Expanded( + child: _buildInfoItem( + 'Statut', + _getStatusLabel(_container.status), + Icons.info, + statusColor: _getStatusColor(_container.status), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildInfoItem( + 'Équipements', + '${_container.itemCount}', + Icons.inventory, + ), + ), + Expanded( + child: _buildInfoItem( + 'Poids total', + _calculateTotalWeight(), + Icons.scale, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildPhysicalCharacteristics() { + final hasDimensions = _container.length != null || + _container.width != null || + _container.height != null; + final hasWeight = _container.weight != null; + final hasVolume = _container.volume != null; + + if (!hasDimensions && !hasWeight) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Caractéristiques physiques', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Divider(height: 24), + if (hasWeight) + _buildCharacteristicRow( + 'Poids à vide', + '${_container.weight} kg', + Icons.scale, + ), + if (hasDimensions) ...[ + if (hasWeight) const SizedBox(height: 12), + _buildCharacteristicRow( + 'Dimensions (L×l×H)', + '${_container.length ?? '?'} × ${_container.width ?? '?'} × ${_container.height ?? '?'} cm', + Icons.straighten, + ), + ], + if (hasVolume) ...[ + const SizedBox(height: 12), + _buildCharacteristicRow( + 'Volume', + '${_container.volume!.toStringAsFixed(3)} m³', + Icons.view_in_ar, + ), + ], + ], + ), + ), + ); + } + + Widget _buildEquipmentSection() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Contenu du container', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(height: 24), + if (_isLoadingEquipment) + const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ) + else if (_equipmentList.isEmpty) + Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + children: [ + Icon( + Icons.inventory_2_outlined, + size: 60, + color: Colors.grey.shade400, + ), + const SizedBox(height: 12), + Text( + 'Aucun équipement dans ce container', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _equipmentList.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final equipment = _equipmentList[index]; + return _buildEquipmentTile(equipment); + }, + ), + ], + ), + ), + ); + } + + Widget _buildEquipmentTile(EquipmentModel equipment) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + leading: CircleAvatar( + backgroundColor: AppColors.rouge.withOpacity(0.1), + child: const Icon(Icons.inventory_2, color: AppColors.rouge), + ), + title: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (equipment.brand != null || equipment.model != null) + Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'), + const SizedBox(height: 4), + Row( + children: [ + _buildSmallBadge( + _getCategoryLabel(equipment.category), + Colors.blue, + ), + const SizedBox(width: 8), + if (equipment.weight != null) + _buildSmallBadge( + '${equipment.weight} kg', + Colors.grey, + ), + ], + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + tooltip: 'Voir détails', + onPressed: () => _viewEquipment(equipment), + ), + IconButton( + icon: const Icon(Icons.remove_circle, color: Colors.red, size: 20), + tooltip: 'Retirer', + onPressed: () => _removeEquipment(equipment), + ), + ], + ), + ); + } + + Widget _buildNotesSection() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Notes', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Divider(height: 24), + Text( + _container.notes!, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ); + } + + Widget _buildHistorySection() { + if (_container.history.isEmpty) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Historique', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Divider(height: 24), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _container.history.length > 10 + ? 10 + : _container.history.length, + separatorBuilder: (context, index) => const Divider(height: 16), + itemBuilder: (context, index) { + final entry = _container.history[ + _container.history.length - 1 - index]; // Plus récent en premier + return _buildHistoryEntry(entry); + }, + ), + ], + ), + ), + ); + } + + Widget _buildHistoryEntry(ContainerHistoryEntry entry) { + IconData icon; + Color color; + String description; + + switch (entry.action) { + case 'equipment_added': + icon = Icons.add_circle; + color = Colors.green; + description = 'Équipement ajouté: ${entry.equipmentId ?? "?"}'; + break; + case 'equipment_removed': + icon = Icons.remove_circle; + color = Colors.red; + description = 'Équipement retiré: ${entry.equipmentId ?? "?"}'; + break; + case 'status_change': + icon = Icons.sync; + color = Colors.blue; + description = + 'Statut changé: ${entry.previousValue} → ${entry.newValue}'; + break; + default: + icon = Icons.info; + color = Colors.grey; + description = entry.action; + } + + return Row( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + description, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 2), + Text( + DateFormat('dd/MM/yyyy HH:mm').format(entry.timestamp), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildInfoItem(String label, String value, IconData icon, + {Color? statusColor}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ); + } + + Widget _buildCharacteristicRow(String label, String value, IconData icon) { + return Row( + children: [ + Icon(icon, size: 20, color: AppColors.rouge), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 14), + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } + + Widget _buildSmallBadge(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + String _calculateTotalWeight() { + if (_equipmentList.isEmpty && _container.weight == null) { + return '-'; + } + + final totalWeight = _container.calculateTotalWeight(_equipmentList); + return '${totalWeight.toStringAsFixed(1)} kg'; + } + + + IconData _getTypeIcon(ContainerType type) { + switch (type) { + case ContainerType.flightCase: + return Icons.work; + case ContainerType.pelicase: + return Icons.work_outline; + case ContainerType.bag: + return Icons.shopping_bag; + case ContainerType.openCrate: + return Icons.inventory_2; + case ContainerType.toolbox: + return Icons.handyman; + } + } + + String _getStatusLabel(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return 'Disponible'; + case EquipmentStatus.inUse: + return 'En prestation'; + case EquipmentStatus.maintenance: + return 'Maintenance'; + case EquipmentStatus.outOfService: + return 'Hors service'; + default: + return 'Autre'; + } + } + + Color _getStatusColor(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return Colors.green; + case EquipmentStatus.inUse: + return Colors.orange; + case EquipmentStatus.maintenance: + return Colors.blue; + case EquipmentStatus.outOfService: + return Colors.red; + default: + return Colors.grey; + } + } + + String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.other: + return 'Autre'; + } + } + + void _handleMenuAction(String action) { + if (action == 'delete') { + _deleteContainer(); + } + } + + void _editContainer() { + Navigator.pushNamed( + context, + '/container_form', + arguments: _container, + ).then((_) => _refreshContainer()); + } + + void _showQRCode() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('QR Code - ${_container.name}'), + content: SizedBox( + width: 250, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + QrImageView( + data: _container.id, + version: QrVersions.auto, + size: 200, + ), + const SizedBox(height: 16), + Text( + _container.id, + style: const TextStyle(fontWeight: FontWeight.bold), + textAlign: TextAlign.center, + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Fermer'), + ), + ], + ), + ); + } + + void _viewEquipment(EquipmentModel equipment) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EquipmentDetailPage(equipment: equipment), + ), + ); + } + + Future _removeEquipment(EquipmentModel equipment) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Retirer l\'équipement'), + content: Text( + 'Êtes-vous sûr de vouloir retirer "${equipment.id}" de ce container ?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Retirer'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + try { + await context.read().removeEquipmentFromContainer( + containerId: _container.id, + equipmentId: equipment.id, + ); + await _refreshContainer(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Équipement retiré avec succès')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + } + } + + Future _deleteContainer() async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Êtes-vous sûr de vouloir supprimer le container "${_container.name}" ?\n\n' + 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + try { + await context.read().deleteContainer(_container.id); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Container supprimé avec succès')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + } + } +} + diff --git a/em2rp/lib/views/container_form_page.dart b/em2rp/lib/views/container_form_page.dart new file mode 100644 index 0000000..0804556 --- /dev/null +++ b/em2rp/lib/views/container_form_page.dart @@ -0,0 +1,924 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/providers/container_provider.dart'; +import 'package:em2rp/providers/equipment_provider.dart'; +import 'package:em2rp/utils/id_generator.dart'; + +class ContainerFormPage extends StatefulWidget { + final ContainerModel? container; + + const ContainerFormPage({super.key, this.container}); + + @override + State createState() => _ContainerFormPageState(); +} + +class _ContainerFormPageState extends State { + final _formKey = GlobalKey(); + + // Controllers + final _nameController = TextEditingController(); + final _idController = TextEditingController(); + final _weightController = TextEditingController(); + final _lengthController = TextEditingController(); + final _widthController = TextEditingController(); + final _heightController = TextEditingController(); + final _notesController = TextEditingController(); + + // Form fields + ContainerType _selectedType = ContainerType.flightCase; + EquipmentStatus _selectedStatus = EquipmentStatus.available; + bool _autoGenerateId = true; + final Set _selectedEquipmentIds = {}; + + bool _isEditing = false; + + @override + void initState() { + super.initState(); + if (widget.container != null) { + _isEditing = true; + _loadContainerData(); + } + } + + void _loadContainerData() { + final container = widget.container!; + _nameController.text = container.name; + _idController.text = container.id; + _selectedType = container.type; + _selectedStatus = container.status; + _weightController.text = container.weight?.toString() ?? ''; + _lengthController.text = container.length?.toString() ?? ''; + _widthController.text = container.width?.toString() ?? ''; + _heightController.text = container.height?.toString() ?? ''; + _notesController.text = container.notes ?? ''; + _selectedEquipmentIds.addAll(container.equipmentIds); + _autoGenerateId = false; + } + + void _updateIdFromName() { + if (_autoGenerateId && !_isEditing) { + final name = _nameController.text; + if (name.isNotEmpty) { + final baseId = IdGenerator.generateContainerId( + type: _selectedType, + name: name, + ); + _idController.text = baseId; + } + } + } + + void _updateIdFromType() { + if (_autoGenerateId && !_isEditing) { + final name = _nameController.text; + if (name.isNotEmpty) { + final baseId = IdGenerator.generateContainerId( + type: _selectedType, + name: name, + ); + _idController.text = baseId; + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(_isEditing ? 'Modifier Container' : 'Nouveau Container'), + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + + // Nom + TextFormField( + controller: _nameController, + decoration: const InputDecoration( + labelText: 'Nom du container *', + hintText: 'ex: Flight Case Beam 7R', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.label), + ), + onChanged: (_) => _updateIdFromName(), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer un nom'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // ID + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: TextFormField( + controller: _idController, + decoration: const InputDecoration( + labelText: 'Identifiant *', + hintText: 'ex: FLIGHTCASE_BEAM', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.qr_code), + ), + enabled: !_autoGenerateId || _isEditing, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer un identifiant'; + } + final validation = IdGenerator.validateContainerId(value); + return validation; + }, + ), + ), + if (!_isEditing) ...[ + const SizedBox(width: 8), + IconButton( + icon: Icon( + _autoGenerateId ? Icons.lock : Icons.lock_open, + color: _autoGenerateId ? AppColors.rouge : Colors.grey, + ), + tooltip: _autoGenerateId + ? 'Génération automatique' + : 'Saisie manuelle', + onPressed: () { + setState(() { + _autoGenerateId = !_autoGenerateId; + if (_autoGenerateId) { + _updateIdFromName(); + } + }); + }, + ), + ], + ], + ), + const SizedBox(height: 16), + + // Type + DropdownButtonFormField( + value: _selectedType, + decoration: const InputDecoration( + labelText: 'Type de container *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.category), + ), + items: ContainerType.values.map((type) { + return DropdownMenuItem( + value: type, + child: Text(containerTypeLabel(type)), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedType = value; + _updateIdFromType(); + }); + } + }, + ), + const SizedBox(height: 16), + + // Statut + DropdownButtonFormField( + value: _selectedStatus, + decoration: const InputDecoration( + labelText: 'Statut *', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.info), + ), + items: [ + EquipmentStatus.available, + EquipmentStatus.inUse, + EquipmentStatus.maintenance, + EquipmentStatus.outOfService, + ].map((status) { + String label; + switch (status) { + case EquipmentStatus.available: + label = 'Disponible'; + break; + case EquipmentStatus.inUse: + label = 'En prestation'; + break; + case EquipmentStatus.maintenance: + label = 'En maintenance'; + break; + case EquipmentStatus.outOfService: + label = 'Hors service'; + break; + default: + label = 'Autre'; + } + return DropdownMenuItem( + value: status, + child: Text(label), + ); + }).toList(), + onChanged: (value) { + if (value != null) { + setState(() { + _selectedStatus = value; + }); + } + }, + ), + const SizedBox(height: 24), + + // Section Caractéristiques physiques + Text( + 'Caractéristiques physiques', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Divider(), + const SizedBox(height: 16), + + // Poids + TextFormField( + controller: _weightController, + decoration: const InputDecoration( + labelText: 'Poids à vide (kg)', + hintText: 'ex: 15.5', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.scale), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Veuillez entrer un nombre valide'; + } + } + return null; + }, + ), + const SizedBox(height: 16), + + // Dimensions + Row( + children: [ + Expanded( + child: TextFormField( + controller: _lengthController, + decoration: const InputDecoration( + labelText: 'Longueur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Nombre invalide'; + } + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _widthController, + decoration: const InputDecoration( + labelText: 'Largeur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Nombre invalide'; + } + } + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: _heightController, + decoration: const InputDecoration( + labelText: 'Hauteur (cm)', + border: OutlineInputBorder(), + ), + keyboardType: TextInputType.numberWithOptions(decimal: true), + validator: (value) { + if (value != null && value.isNotEmpty) { + if (double.tryParse(value) == null) { + return 'Nombre invalide'; + } + } + return null; + }, + ), + ), + ], + ), + const SizedBox(height: 24), + + // Section Équipements + Text( + 'Équipements dans ce container', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Divider(), + const SizedBox(height: 16), + + // Liste des équipements sélectionnés + if (_selectedEquipmentIds.isNotEmpty) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_selectedEquipmentIds.length} équipement(s) sélectionné(s)', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _selectedEquipmentIds.map((id) { + return Chip( + label: Text(id), + deleteIcon: const Icon(Icons.close, size: 18), + onDeleted: () { + setState(() { + _selectedEquipmentIds.remove(id); + }); + }, + ); + }).toList(), + ), + ], + ), + ) + else + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, + ), + child: const Center( + child: Text( + 'Aucun équipement sélectionné', + style: TextStyle(color: Colors.grey), + ), + ), + ), + const SizedBox(height: 12), + + // Bouton pour ajouter des équipements + OutlinedButton.icon( + onPressed: _selectEquipment, + icon: const Icon(Icons.add), + label: const Text('Ajouter des équipements'), + style: OutlinedButton.styleFrom( + minimumSize: const Size(double.infinity, 48), + ), + ), + const SizedBox(height: 24), + + // Notes + TextFormField( + controller: _notesController, + decoration: const InputDecoration( + labelText: 'Notes', + hintText: 'Informations additionnelles...', + border: OutlineInputBorder(), + prefixIcon: Icon(Icons.notes), + ), + maxLines: 3, + ), + const SizedBox(height: 32), + + // Boutons + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + const SizedBox(width: 16), + ElevatedButton.icon( + onPressed: _saveContainer, + icon: const Icon(Icons.save, color: Colors.white), + label: Text( + _isEditing ? 'Mettre à jour' : 'Créer', + style: const TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + padding: const EdgeInsets.symmetric( + horizontal: 24, + vertical: 12, + ), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _selectEquipment() async { + final equipmentProvider = context.read(); + + await showDialog( + context: context, + builder: (context) => _EquipmentSelectorDialog( + selectedIds: _selectedEquipmentIds, + equipmentProvider: equipmentProvider, + ), + ); + + setState(() {}); + } + + Future _isIdUnique(String id) async { + final provider = context.read(); + final container = await provider.getContainerById(id); + return container == null; + } + + Future _saveContainer() async { + if (!_formKey.currentState!.validate()) { + return; + } + + try { + final containerProvider = context.read(); + + if (_isEditing) { + await _updateContainer(containerProvider); + } else { + await _createSingleContainer(containerProvider); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + } + + Future _createSingleContainer(ContainerProvider provider) async { + final baseId = _idController.text.trim(); + + // Vérifier l'unicité de l'ID directement + String uniqueId = baseId; + if (!await _isIdUnique(baseId)) { + uniqueId = '${baseId}_${DateTime.now().millisecondsSinceEpoch}'; + } + + final container = ContainerModel( + id: uniqueId, + name: _nameController.text.trim(), + type: _selectedType, + status: _selectedStatus, + equipmentIds: _selectedEquipmentIds.toList(), + weight: _weightController.text.isNotEmpty + ? double.tryParse(_weightController.text) + : null, + length: _lengthController.text.isNotEmpty + ? double.tryParse(_lengthController.text) + : null, + width: _widthController.text.isNotEmpty + ? double.tryParse(_widthController.text) + : null, + height: _heightController.text.isNotEmpty + ? double.tryParse(_heightController.text) + : null, + notes: _notesController.text.trim().isNotEmpty + ? _notesController.text.trim() + : null, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + await provider.createContainer(container); + + // Mettre à jour les parentBoxIds des équipements + for (final equipmentId in _selectedEquipmentIds) { + try { + await provider.addEquipmentToContainer( + containerId: uniqueId, + equipmentId: equipmentId, + ); + } catch (e) { + print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e'); + } + } + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Container créé avec succès')), + ); + } + } + + Future _updateContainer(ContainerProvider provider) async { + final container = widget.container!; + + await provider.updateContainer(container.id, { + 'name': _nameController.text.trim(), + 'type': containerTypeToString(_selectedType), + 'status': equipmentStatusToString(_selectedStatus), + 'equipmentIds': _selectedEquipmentIds.toList(), + 'weight': _weightController.text.isNotEmpty + ? double.tryParse(_weightController.text) + : null, + 'length': _lengthController.text.isNotEmpty + ? double.tryParse(_lengthController.text) + : null, + 'width': _widthController.text.isNotEmpty + ? double.tryParse(_widthController.text) + : null, + 'height': _heightController.text.isNotEmpty + ? double.tryParse(_heightController.text) + : null, + 'notes': _notesController.text.trim().isNotEmpty + ? _notesController.text.trim() + : null, + }); + + // Gérer les équipements ajoutés + final addedEquipment = _selectedEquipmentIds.difference(container.equipmentIds.toSet()); + for (final equipmentId in addedEquipment) { + try { + await provider.addEquipmentToContainer( + containerId: container.id, + equipmentId: equipmentId, + ); + } catch (e) { + print('Erreur lors de l\'ajout de l\'équipement $equipmentId: $e'); + } + } + + // Gérer les équipements retirés + final removedEquipment = container.equipmentIds.toSet().difference(_selectedEquipmentIds); + for (final equipmentId in removedEquipment) { + try { + await provider.removeEquipmentFromContainer( + containerId: container.id, + equipmentId: equipmentId, + ); + } catch (e) { + print('Erreur lors du retrait de l\'équipement $equipmentId: $e'); + } + } + + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Container mis à jour avec succès')), + ); + } + } + + @override + void dispose() { + _nameController.dispose(); + _idController.dispose(); + _weightController.dispose(); + _lengthController.dispose(); + _widthController.dispose(); + _heightController.dispose(); + _notesController.dispose(); + super.dispose(); + } +} + +/// Widget de dialogue pour sélectionner les équipements +class _EquipmentSelectorDialog extends StatefulWidget { + final Set selectedIds; + final EquipmentProvider equipmentProvider; + + const _EquipmentSelectorDialog({ + required this.selectedIds, + required this.equipmentProvider, + }); + + @override + State<_EquipmentSelectorDialog> createState() => _EquipmentSelectorDialogState(); +} + +class _EquipmentSelectorDialogState extends State<_EquipmentSelectorDialog> { + final TextEditingController _searchController = TextEditingController(); + EquipmentCategory? _filterCategory; + String _searchQuery = ''; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + width: MediaQuery.of(context).size.width * 0.8, + height: MediaQuery.of(context).size.height * 0.8, + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // En-tête + Row( + children: [ + const Icon(Icons.inventory, color: AppColors.rouge), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Sélectionner des équipements', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const Divider(), + const SizedBox(height: 16), + + // Barre de recherche + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher un équipement...', + prefixIcon: const Icon(Icons.search), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + }); + }, + ) + : null, + ), + onChanged: (value) { + setState(() { + _searchQuery = value; + }); + }, + ), + const SizedBox(height: 16), + + // Filtres par catégorie + SizedBox( + height: 50, + child: ListView( + scrollDirection: Axis.horizontal, + children: [ + ChoiceChip( + label: const Text('Tout'), + selected: _filterCategory == null, + onSelected: (selected) { + setState(() { + _filterCategory = null; + }); + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: _filterCategory == null ? Colors.white : Colors.black, + ), + ), + const SizedBox(width: 8), + ...EquipmentCategory.values.map((category) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ChoiceChip( + label: Text(_getCategoryLabel(category)), + selected: _filterCategory == category, + onSelected: (selected) { + setState(() { + _filterCategory = selected ? category : null; + }); + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: _filterCategory == category ? Colors.white : Colors.black, + ), + ), + ); + }), + ], + ), + ), + const SizedBox(height: 16), + + // Compteur de sélection + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.rouge.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, color: AppColors.rouge), + const SizedBox(width: 8), + Text( + '${widget.selectedIds.length} équipement(s) sélectionné(s)', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Liste des équipements + Expanded( + child: StreamBuilder>( + stream: widget.equipmentProvider.equipmentStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center(child: Text('Erreur: ${snapshot.error}')); + } + + var equipment = snapshot.data ?? []; + + // Filtrer par catégorie + if (_filterCategory != null) { + equipment = equipment.where((e) => e.category == _filterCategory).toList(); + } + + // Filtrer par recherche + if (_searchQuery.isNotEmpty) { + final query = _searchQuery.toLowerCase(); + equipment = equipment.where((e) { + return e.id.toLowerCase().contains(query) || + (e.brand?.toLowerCase().contains(query) ?? false) || + (e.model?.toLowerCase().contains(query) ?? false); + }).toList(); + } + + if (equipment.isEmpty) { + return const Center( + child: Text('Aucun équipement trouvé'), + ); + } + + return ListView.builder( + itemCount: equipment.length, + itemBuilder: (context, index) { + final item = equipment[index]; + final isSelected = widget.selectedIds.contains(item.id); + + return CheckboxListTile( + value: isSelected, + onChanged: (selected) { + setState(() { + if (selected == true) { + widget.selectedIds.add(item.id); + } else { + widget.selectedIds.remove(item.id); + } + }); + }, + title: Text( + item.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.brand != null || item.model != null) + Text('${item.brand ?? ''} ${item.model ?? ''}'), + const SizedBox(height: 4), + Text( + _getCategoryLabel(item.category), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + secondary: Icon( + _getCategoryIcon(item.category), + color: AppColors.rouge, + ), + activeColor: AppColors.rouge, + ); + }, + ); + }, + ), + ), + + // Boutons d'action + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + const SizedBox(width: 16), + ElevatedButton( + onPressed: () => Navigator.pop(context), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + ), + child: const Text( + 'Valider', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ], + ), + ), + ); + } + + String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.other: + return 'Autre'; + } + } + + IconData _getCategoryIcon(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return Icons.lightbulb; + case EquipmentCategory.sound: + return Icons.speaker; + case EquipmentCategory.video: + return Icons.videocam; + case EquipmentCategory.effect: + return Icons.auto_awesome; + case EquipmentCategory.structure: + return Icons.construction; + case EquipmentCategory.consumable: + return Icons.inventory; + case EquipmentCategory.cable: + return Icons.cable; + case EquipmentCategory.other: + return Icons.category; + } + } +} + diff --git a/em2rp/lib/views/container_management_page.dart b/em2rp/lib/views/container_management_page.dart new file mode 100644 index 0000000..0ab6cf2 --- /dev/null +++ b/em2rp/lib/views/container_management_page.dart @@ -0,0 +1,814 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/utils/permission_gate.dart'; +import 'package:em2rp/views/widgets/nav/main_drawer.dart'; +import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; +import 'package:em2rp/providers/container_provider.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/container_pdf_generator_service.dart'; +import 'package:em2rp/views/widgets/common/qr_code_dialog.dart'; +import 'package:em2rp/mixins/selection_mode_mixin.dart'; +import 'package:printing/printing.dart'; +import 'package:pdf/pdf.dart'; + +class ContainerManagementPage extends StatefulWidget { + const ContainerManagementPage({super.key}); + + @override + State createState() => + _ContainerManagementPageState(); +} + +class _ContainerManagementPageState extends State + with SelectionModeMixin { + final TextEditingController _searchController = TextEditingController(); + ContainerType? _selectedType; + EquipmentStatus? _selectedStatus; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.of(context).size.width < 800; + + return PermissionGate( + requiredPermissions: const ['view_equipment'], + fallback: Scaffold( + appBar: const CustomAppBar(title: 'Accès refusé'), + drawer: const MainDrawer(currentPage: '/container_management'), + body: const Center( + child: Padding( + padding: EdgeInsets.all(24.0), + child: Text( + 'Vous n\'avez pas les permissions nécessaires pour accéder à la gestion des containers.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16), + ), + ), + ), + ), + child: Scaffold( + appBar: isSelectionMode + ? AppBar( + backgroundColor: AppColors.rouge, + leading: IconButton( + icon: const Icon(Icons.close, color: Colors.white), + onPressed: toggleSelectionMode, + ), + title: Text( + '$selectedCount sélectionné(s)', + style: const TextStyle(color: Colors.white), + ), + actions: [ + if (hasSelection) ...[ + IconButton( + icon: const Icon(Icons.qr_code, color: Colors.white), + tooltip: 'Générer QR Codes', + onPressed: _generateQRCodesForSelected, + ), + IconButton( + icon: const Icon(Icons.delete, color: Colors.white), + tooltip: 'Supprimer', + onPressed: _deleteSelectedContainers, + ), + ], + ], + ) + : const CustomAppBar(title: 'Gestion des Containers'), + drawer: const MainDrawer(currentPage: '/container_management'), + floatingActionButton: !isSelectionMode + ? FloatingActionButton.extended( + onPressed: () => _navigateToForm(context), + backgroundColor: AppColors.rouge, + icon: const Icon(Icons.add, color: Colors.white), + label: const Text( + 'Nouveau Container', + style: TextStyle(color: Colors.white), + ), + ) + : null, + body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), + ), + ); + } + + Widget _buildMobileLayout() { + return Column( + children: [ + _buildSearchBar(), + _buildMobileFilters(), + Expanded(child: _buildContainerList()), + ], + ); + } + + Widget _buildDesktopLayout() { + return Row( + children: [ + SizedBox( + width: 250, + child: _buildSidebar(), + ), + const VerticalDivider(width: 1, thickness: 1), + Expanded( + child: Column( + children: [ + _buildSearchBar(), + Expanded(child: _buildContainerList()), + ], + ), + ), + ], + ); + } + + Widget _buildSearchBar() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.1), + spreadRadius: 1, + blurRadius: 3, + offset: const Offset(0, 1), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher un container...', + prefixIcon: const Icon(Icons.search, color: AppColors.rouge), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + ), + onChanged: (value) { + context.read().setSearchQuery(value); + }, + ), + ), + const SizedBox(width: 12), + if (!isSelectionMode) + IconButton( + icon: const Icon(Icons.checklist, color: AppColors.rouge), + tooltip: 'Mode sélection', + onPressed: toggleSelectionMode, + ), + ], + ), + ); + } + + Widget _buildMobileFilters() { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + color: Colors.grey.shade50, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + _buildTypeChip(null, 'Tous'), + const SizedBox(width: 8), + ...ContainerType.values.map((type) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: _buildTypeChip(type, containerTypeLabel(type)), + ); + }), + ], + ), + ), + ); + } + + Widget _buildTypeChip(ContainerType? type, String label) { + final isSelected = _selectedType == type; + return ChoiceChip( + label: Text(label), + selected: isSelected, + onSelected: (selected) { + setState(() { + _selectedType = selected ? type : null; + context.read().setSelectedType(_selectedType); + }); + }, + selectedColor: AppColors.rouge, + labelStyle: TextStyle( + color: isSelected ? Colors.white : AppColors.noir, + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + ), + ); + } + + Widget _buildSidebar() { + return Container( + color: Colors.grey.shade50, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Filtres', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.noir, + ), + ), + const SizedBox(height: 16), + + // Filtre par type + Text( + 'Type de container', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.noir, + ), + ), + const SizedBox(height: 8), + _buildFilterOption(null, 'Tous les types'), + ...ContainerType.values.map((type) { + return _buildFilterOption(type, containerTypeLabel(type)); + }), + + const Divider(height: 32), + + // Filtre par statut + Text( + 'Statut', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.noir, + ), + ), + const SizedBox(height: 8), + _buildStatusFilter(null, 'Tous les statuts'), + _buildStatusFilter(EquipmentStatus.available, 'Disponible'), + _buildStatusFilter(EquipmentStatus.inUse, 'En prestation'), + _buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'), + _buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'), + ], + ), + ); + } + + Widget _buildFilterOption(ContainerType? type, String label) { + final isSelected = _selectedType == type; + return RadioListTile( + title: Text(label), + value: type, + groupValue: _selectedType, + activeColor: AppColors.rouge, + dense: true, + contentPadding: EdgeInsets.zero, + onChanged: (value) { + setState(() { + _selectedType = value; + context.read().setSelectedType(_selectedType); + }); + }, + ); + } + + Widget _buildStatusFilter(EquipmentStatus? status, String label) { + final isSelected = _selectedStatus == status; + return RadioListTile( + title: Text(label), + value: status, + groupValue: _selectedStatus, + activeColor: AppColors.rouge, + dense: true, + contentPadding: EdgeInsets.zero, + onChanged: (value) { + setState(() { + _selectedStatus = value; + context.read().setSelectedStatus(_selectedStatus); + }); + }, + ); + } + + Widget _buildContainerList() { + return Consumer( + builder: (context, provider, child) { + return StreamBuilder>( + stream: provider.containersStream, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Text('Erreur: ${snapshot.error}'), + ); + } + + final containers = snapshot.data ?? []; + + if (containers.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inventory_2_outlined, + size: 80, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'Aucun container trouvé', + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade600, + ), + ), + ], + ), + ); + } + + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: containers.length, + itemBuilder: (context, index) { + final container = containers[index]; + return _buildContainerCard(container); + }, + ); + }, + ); + }, + ); + } + + Widget _buildContainerCard(ContainerModel container) { + final isSelected = isItemSelected(container.id); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: isSelected + ? const BorderSide(color: AppColors.rouge, width: 2) + : BorderSide.none, + ), + child: InkWell( + onTap: () { + if (isSelectionMode) { + toggleItemSelection(container.id); + } else { + _viewContainerDetails(container); + } + }, + onLongPress: () { + if (!isSelectionMode) { + toggleSelectionMode(); + toggleItemSelection(container.id); + } + }, + borderRadius: BorderRadius.circular(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (isSelectionMode) + Padding( + padding: const EdgeInsets.only(right: 16), + child: Checkbox( + value: isSelected, + onChanged: (value) { + toggleItemSelection(container.id); + }, + activeColor: AppColors.rouge, + ), + ), + + // Icône du type de container + Icon( + _getTypeIcon(container.type), + size: 40, + color: AppColors.rouge, + ), + + const SizedBox(width: 16), + + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + container.id, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + container.name, + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + _buildInfoChip( + containerTypeLabel(container.type), + Icons.category, + ), + const SizedBox(width: 8), + _buildInfoChip( + '${container.itemCount} items', + Icons.inventory, + ), + ], + ), + ], + ), + ), + + const SizedBox(width: 16), + + // Badge de statut + _buildStatusBadge(container.status), + + if (!isSelectionMode) ...[ + const SizedBox(width: 8), + PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) => _handleMenuAction(value, container), + itemBuilder: (context) => [ + const PopupMenuItem( + value: 'view', + child: Row( + children: [ + Icon(Icons.visibility, size: 20), + SizedBox(width: 8), + Text('Voir détails'), + ], + ), + ), + const PopupMenuItem( + value: 'edit', + child: Row( + children: [ + Icon(Icons.edit, size: 20), + SizedBox(width: 8), + Text('Modifier'), + ], + ), + ), + const PopupMenuItem( + value: 'qr', + child: Row( + children: [ + Icon(Icons.qr_code, size: 20), + SizedBox(width: 8), + Text('QR Code'), + ], + ), + ), + const PopupMenuItem( + value: 'delete', + child: Row( + children: [ + Icon(Icons.delete, color: Colors.red, size: 20), + SizedBox(width: 8), + Text('Supprimer', style: TextStyle(color: Colors.red)), + ], + ), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildInfoChip(String label, IconData icon) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: Colors.grey.shade700), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade700, + ), + ), + ], + ), + ); + } + + Widget _buildStatusBadge(EquipmentStatus status) { + Color color; + String label; + + switch (status) { + case EquipmentStatus.available: + color = Colors.green; + label = 'Disponible'; + break; + case EquipmentStatus.inUse: + color = Colors.orange; + label = 'En prestation'; + break; + case EquipmentStatus.maintenance: + color = Colors.blue; + label = 'Maintenance'; + break; + case EquipmentStatus.outOfService: + color = Colors.red; + label = 'Hors service'; + break; + default: + color = Colors.grey; + label = 'Autre'; + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color), + ), + child: Text( + label, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ); + } + + IconData _getTypeIcon(ContainerType type) { + switch (type) { + case ContainerType.flightCase: + return Icons.work; + case ContainerType.pelicase: + return Icons.work_outline; + case ContainerType.bag: + return Icons.shopping_bag; + case ContainerType.openCrate: + return Icons.inventory_2; + case ContainerType.toolbox: + return Icons.handyman; + } + } + + + void _handleMenuAction(String action, ContainerModel container) { + switch (action) { + case 'view': + _viewContainerDetails(container); + break; + case 'edit': + _editContainer(container); + break; + case 'qr': + showDialog( + context: context, + builder: (context) => QRCodeDialog.forContainer(container), + ); + break; + case 'delete': + _deleteContainer(container); + break; + } + } + + void _navigateToForm(BuildContext context) async { + final result = await Navigator.pushNamed(context, '/container_form'); + if (result == true) { + // Rafraîchir la liste + } + } + + void _viewContainerDetails(ContainerModel container) async { + await Navigator.pushNamed( + context, + '/container_detail', + arguments: container, + ); + } + + void _editContainer(ContainerModel container) async { + await Navigator.pushNamed( + context, + '/container_form', + arguments: container, + ); + } + + + Future _generateQRCodesForSelected() async { + if (!hasSelection) return; + + // Récupérer les containers sélectionnés + final containerProvider = context.read(); + final List selectedContainers = []; + final Map> containerEquipmentMap = {}; + + for (final id in selectedIds) { + final container = await containerProvider.getContainerById(id); + if (container != null) { + selectedContainers.add(container); + // Charger les équipements pour ce container + final equipment = await containerProvider.getContainerEquipment(id); + containerEquipmentMap[id] = equipment; + } + } + + if (selectedContainers.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Aucun container trouvé')), + ); + } + return; + } + + // Afficher le dialogue de sélection de format + final format = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Format des étiquettes'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.qr_code_2), + title: const Text('Petits QR codes'), + subtitle: const Text('2×2 cm - QR code + ID (20 par page)'), + onTap: () => Navigator.pop(context, ContainerQRLabelFormat.small), + ), + ListTile( + leading: const Icon(Icons.qr_code), + title: const Text('QR codes moyens'), + subtitle: const Text('4×4 cm - QR code + ID (6 par page)'), + onTap: () => Navigator.pop(context, ContainerQRLabelFormat.medium), + ), + ListTile( + leading: const Icon(Icons.label), + title: const Text('Grandes étiquettes'), + subtitle: const Text('QR code + ID + Type + Contenu (6 par page)'), + onTap: () => Navigator.pop(context, ContainerQRLabelFormat.large), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ], + ), + ); + + if (format == null || !mounted) return; + + // Générer et afficher le PDF + try { + final pdfBytes = await ContainerPDFGeneratorService.generateQRCodesPDF( + containerList: selectedContainers, + containerEquipmentMap: containerEquipmentMap, + format: format, + ); + + if (mounted) { + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => pdfBytes, + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de la génération: $e')), + ); + } + } + } + + Future _deleteContainer(ContainerModel container) async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Êtes-vous sûr de vouloir supprimer le container "${container.name}" ?\n\n' + 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + try { + await context.read().deleteContainer(container.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Container supprimé avec succès')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de la suppression: $e')), + ); + } + } + } + } + + Future _deleteSelectedContainers() async { + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Êtes-vous sûr de vouloir supprimer $selectedCount container(s) ?\n\n' + 'Cette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + + if (confirm == true && mounted) { + try { + final provider = context.read(); + for (final id in selectedIds) { + await provider.deleteContainer(id); + } + disableSelectionMode(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Containers supprimés avec succès')), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de la suppression: $e')), + ); + } + } + } + } +} + diff --git a/em2rp/lib/views/equipment_detail_page.dart b/em2rp/lib/views/equipment_detail_page.dart new file mode 100644 index 0000000..e625a36 --- /dev/null +++ b/em2rp/lib/views/equipment_detail_page.dart @@ -0,0 +1,881 @@ +import 'package:flutter/material.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/permission_gate.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_parent_containers.dart'; +import 'package:intl/intl.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 createState() => _EquipmentDetailPageState(); +} + +class _EquipmentDetailPageState extends State { + final EquipmentService _equipmentService = EquipmentService(); + List _maintenances = []; + bool _isLoadingMaintenances = true; + + @override + void initState() { + super.initState(); + _loadMaintenances(); + } + + Future _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 isMobile = MediaQuery.of(context).size.width < 800; + final userProvider = Provider.of(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(isMobile ? 16 : 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeader(), + const SizedBox(height: 24), + _buildMainInfoSection(), + const SizedBox(height: 24), + if (hasManagePermission) ...[ + _buildPriceSection(), + const SizedBox(height: 24), + ], + if (widget.equipment.category == EquipmentCategory.consumable || + widget.equipment.category == EquipmentCategory.cable) ...[ + _buildQuantitySection(), + const SizedBox(height: 24), + ], + if (widget.equipment.parentBoxIds.isNotEmpty) ...[ + EquipmentParentContainers( + parentBoxIds: widget.equipment.parentBoxIds, + ), + const SizedBox(height: 24), + ], + _buildDatesSection(), + const SizedBox(height: 24), + if (widget.equipment.notes != null && widget.equipment.notes!.isNotEmpty) ...[ + _buildNotesSection(), + const SizedBox(height: 24), + ], + _buildMaintenanceHistorySection(hasManagePermission), + const SizedBox(height: 24), + _buildAssociatedEventsSection(), + ], + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.rouge, AppColors.rouge.withValues(alpha: 0.8)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: AppColors.rouge.withValues(alpha: 0.3), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + CircleAvatar( + backgroundColor: Colors.white, + radius: 30, + child: Icon( + _getCategoryIcon(widget.equipment.category), + color: AppColors.rouge, + size: 32, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.equipment.id, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim().isNotEmpty + ? '${widget.equipment.brand ?? ''} ${widget.equipment.model ?? ''}'.trim() + : 'Marque/Modèle non défini', + style: const TextStyle( + fontSize: 16, + color: Colors.white70, + ), + ), + ], + ), + ), + if (widget.equipment.category != EquipmentCategory.consumable && + widget.equipment.category != EquipmentCategory.cable) + _buildStatusBadge(), + ], + ), + ], + ), + ); + } + + Widget _buildStatusBadge() { + final statusInfo = _getStatusInfo(widget.equipment.status); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: statusInfo.$2, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Text( + statusInfo.$1, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: statusInfo.$2, + ), + ), + ], + ), + ); + } + + Widget _buildMainInfoSection() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.info_outline, color: AppColors.rouge), + const SizedBox(width: 8), + Text( + 'Informations principales', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(height: 24), + _buildInfoRow('Catégorie', _getCategoryName(widget.equipment.category)), + if (widget.equipment.brand != null && widget.equipment.brand!.isNotEmpty) + _buildInfoRow('Marque', widget.equipment.brand!), + if (widget.equipment.model != null && widget.equipment.model!.isNotEmpty) + _buildInfoRow('Modèle', widget.equipment.model!), + if (widget.equipment.category != EquipmentCategory.consumable && + widget.equipment.category != EquipmentCategory.cable) + _buildInfoRow('Statut', _getStatusInfo(widget.equipment.status).$1), + ], + ), + ), + ); + } + + Widget _buildPriceSection() { + final hasPrices = widget.equipment.purchasePrice != null || widget.equipment.rentalPrice != null; + + if (!hasPrices) return const SizedBox.shrink(); + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.euro, color: AppColors.rouge), + const SizedBox(width: 8), + Text( + 'Prix', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(height: 24), + if (widget.equipment.purchasePrice != null) + _buildInfoRow( + 'Prix d\'achat', + '${widget.equipment.purchasePrice!.toStringAsFixed(2)} €', + ), + if (widget.equipment.rentalPrice != null) + _buildInfoRow( + 'Prix de location', + '${widget.equipment.rentalPrice!.toStringAsFixed(2)} €/jour', + ), + ], + ), + ), + ); + } + + Widget _buildQuantitySection() { + final availableQty = widget.equipment.availableQuantity ?? 0; + final totalQty = widget.equipment.totalQuantity ?? 0; + final criticalThreshold = widget.equipment.criticalThreshold ?? 0; + final isCritical = criticalThreshold > 0 && availableQty <= criticalThreshold; + + return Card( + elevation: 2, + color: isCritical ? Colors.red.shade50 : null, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + isCritical ? Icons.warning : Icons.inventory, + color: isCritical ? Colors.red : AppColors.rouge, + ), + const SizedBox(width: 8), + Text( + 'Quantités', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + color: isCritical ? Colors.red : null, + ), + ), + if (isCritical) ...[ + const SizedBox(width: 12), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + 'STOCK CRITIQUE', + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 12, + ), + ), + ), + ], + ], + ), + const Divider(height: 24), + _buildInfoRow( + 'Quantité disponible', + availableQty.toString(), + valueColor: isCritical ? Colors.red : null, + valueWeight: isCritical ? FontWeight.bold : null, + ), + _buildInfoRow('Quantité totale', totalQty.toString()), + if (criticalThreshold > 0) + _buildInfoRow('Seuil critique', criticalThreshold.toString()), + ], + ), + ), + ); + } + + + Widget _buildDatesSection() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.calendar_today, color: AppColors.rouge), + const SizedBox(width: 8), + Text( + 'Dates', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(height: 24), + if (widget.equipment.purchaseDate != null) + _buildInfoRow( + 'Date d\'achat', + DateFormat('dd/MM/yyyy').format(widget.equipment.purchaseDate!), + ), + if (widget.equipment.lastMaintenanceDate != null) + _buildInfoRow( + 'Dernière maintenance', + DateFormat('dd/MM/yyyy').format(widget.equipment.lastMaintenanceDate!), + ), + if (widget.equipment.nextMaintenanceDate != null) + _buildInfoRow( + 'Prochaine maintenance', + DateFormat('dd/MM/yyyy').format(widget.equipment.nextMaintenanceDate!), + valueColor: widget.equipment.nextMaintenanceDate!.isBefore(DateTime.now()) + ? Colors.red + : null, + ), + _buildInfoRow( + 'Créé le', + DateFormat('dd/MM/yyyy à HH:mm').format(widget.equipment.createdAt), + ), + _buildInfoRow( + 'Modifié le', + DateFormat('dd/MM/yyyy à HH:mm').format(widget.equipment.updatedAt), + ), + ], + ), + ), + ); + } + + Widget _buildNotesSection() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.notes, color: AppColors.rouge), + const SizedBox(width: 8), + Text( + 'Notes', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(height: 24), + Text( + widget.equipment.notes!, + style: const TextStyle(fontSize: 14), + ), + ], + ), + ), + ); + } + + Widget _buildMaintenanceHistorySection(bool hasManagePermission) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.build, color: AppColors.rouge), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Historique des maintenances', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const Divider(height: 24), + if (_isLoadingMaintenances) + const Center(child: CircularProgressIndicator()) + else if (_maintenances.isEmpty) + const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: Text( + 'Aucune maintenance enregistrée', + style: TextStyle(color: Colors.grey), + ), + ), + ) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _maintenances.length, + separatorBuilder: (context, index) => const Divider(), + itemBuilder: (context, index) { + final maintenance = _maintenances[index]; + return _buildMaintenanceItem(maintenance, hasManagePermission); + }, + ), + ], + ), + ), + ); + } + + Widget _buildMaintenanceItem(MaintenanceModel maintenance, bool showCost) { + final isCompleted = maintenance.completedDate != null; + final typeInfo = _getMaintenanceTypeInfo(maintenance.type); + + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + leading: CircleAvatar( + backgroundColor: isCompleted ? Colors.green.withValues(alpha: 0.2) : Colors.orange.withValues(alpha: 0.2), + child: Icon( + isCompleted ? Icons.check_circle : Icons.schedule, + color: isCompleted ? Colors.green : Colors.orange, + ), + ), + title: Text( + maintenance.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Row( + children: [ + Icon(typeInfo.$2, size: 16, color: Colors.grey[600]), + const SizedBox(width: 4), + Text(typeInfo.$1, style: TextStyle(color: Colors.grey[600], fontSize: 12)), + ], + ), + const SizedBox(height: 4), + Text( + isCompleted + ? 'Effectuée le ${DateFormat('dd/MM/yyyy').format(maintenance.completedDate!)}' + : 'Planifiée le ${DateFormat('dd/MM/yyyy').format(maintenance.scheduledDate)}', + style: TextStyle(fontSize: 12, color: Colors.grey[700]), + ), + if (showCost && maintenance.cost != null) ...[ + const SizedBox(height: 4), + Text( + 'Coût: ${maintenance.cost!.toStringAsFixed(2)} €', + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ], + ], + ), + ); + } + + Widget _buildAssociatedEventsSection() { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.event, color: AppColors.rouge), + const SizedBox(width: 8), + Text( + 'Événements associés', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(height: 24), + const Padding( + padding: EdgeInsets.all(16.0), + child: Center( + child: Text( + 'Fonctionnalité à implémenter', + style: TextStyle(color: Colors.grey, fontStyle: FontStyle.italic), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoRow( + String label, + String value, { + Color? valueColor, + FontWeight? valueWeight, + }) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 180, + child: Text( + label, + style: TextStyle( + fontWeight: FontWeight.w500, + color: Colors.grey[700], + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + color: valueColor, + fontWeight: valueWeight ?? FontWeight.w600, + ), + ), + ), + ], + ), + ); + } + + 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]), + ), + ], + ), + ), + 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 _exportQRCode() async { + try { + final qrImage = await QRCodeService.generateQRCode( + widget.equipment.id, + size: 1024, + useCache: false, + ); + + 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) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de l\'export: $e')), + ); + } + } + } + + void _editEquipment() { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EquipmentFormPage(equipment: widget.equipment), + ), + ).then((_) { + Navigator.pop(context); + }); + } + + void _deleteEquipment() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Confirmer la suppression'), + content: Text( + 'Voulez-vous vraiment supprimer "${widget.equipment.id}" ?\n\nCette action est irréversible.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () async { + Navigator.pop(context); + try { + await context + .read() + .deleteEquipment(widget.equipment.id); + if (mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Équipement supprimé avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur: $e')), + ); + } + } + }, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Supprimer'), + ), + ], + ), + ); + } + + IconData _getCategoryIcon(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return Icons.light_mode; + case EquipmentCategory.sound: + return Icons.volume_up; + case EquipmentCategory.video: + return Icons.videocam; + case EquipmentCategory.effect: + return Icons.auto_awesome; + case EquipmentCategory.structure: + return Icons.construction; + case EquipmentCategory.consumable: + return Icons.inventory_2; + case EquipmentCategory.cable: + return Icons.cable; + case EquipmentCategory.other: + return Icons.more_horiz; + } + } + + String _getCategoryName(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.other: + return 'Autre'; + } + } + + (String, Color) _getStatusInfo(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return ('Disponible', Colors.green); + case EquipmentStatus.inUse: + return ('En prestation', Colors.blue); + case EquipmentStatus.rented: + return ('Loué', Colors.orange); + case EquipmentStatus.lost: + return ('Perdu', Colors.red); + case EquipmentStatus.outOfService: + return ('HS', Colors.red[900]!); + case EquipmentStatus.maintenance: + return ('Maintenance', Colors.amber); + } + } + + (String, IconData) _getMaintenanceTypeInfo(MaintenanceType type) { + switch (type) { + case MaintenanceType.preventive: + return ('Préventive', Icons.schedule); + case MaintenanceType.corrective: + return ('Corrective', Icons.build); + case MaintenanceType.inspection: + return ('Inspection', Icons.search); + } + } +} + diff --git a/em2rp/lib/views/equipment_form/id_generator.dart b/em2rp/lib/views/equipment_form/id_generator.dart deleted file mode 100644 index 7fb22dd..0000000 --- a/em2rp/lib/views/equipment_form/id_generator.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:em2rp/services/equipment_service.dart'; - -class EquipmentIdGenerator { - static String generate({required String brand, required String model, int? number}) { - final brandTrim = brand.trim().replaceAll(' ', '_'); - final modelTrim = model.trim().replaceAll(' ', '_'); - if (brandTrim.isEmpty && modelTrim.isEmpty) { - return 'EQ-${DateTime.now().millisecondsSinceEpoch}${number != null ? '_$number' : ''}'; - } - final brandPrefix = brandTrim.length >= 4 ? brandTrim.substring(0, 4) : brandTrim; - String baseId = modelTrim.isNotEmpty ? '${brandPrefix}_$modelTrim' : (brandPrefix.isNotEmpty ? brandPrefix : 'EQ'); - if (number != null) { - baseId += '_#$number'; - } - return baseId; - } - - static Future ensureUniqueId(String baseId, EquipmentService service) async { - if (await service.isIdUnique(baseId)) { - return baseId; - } - return '${baseId}_${DateTime.now().millisecondsSinceEpoch}'; - } -} - diff --git a/em2rp/lib/views/equipment_form_page.dart b/em2rp/lib/views/equipment_form_page.dart index bc88fac..3a1d53a 100644 --- a/em2rp/lib/views/equipment_form_page.dart +++ b/em2rp/lib/views/equipment_form_page.dart @@ -9,7 +9,7 @@ import 'package:em2rp/utils/colors.dart'; import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:intl/intl.dart'; import 'package:em2rp/views/equipment_form/brand_model_selector.dart'; -import 'package:em2rp/views/equipment_form/id_generator.dart'; +import 'package:em2rp/utils/id_generator.dart'; class EquipmentFormPage extends StatefulWidget { final EquipmentModel? equipment; @@ -165,6 +165,15 @@ class _EquipmentFormPageState extends State { helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}', ), enabled: !isEditing, + validator: (value) { + if (value != null && value.isNotEmpty) { + // Empêcher les ID commençant par BOX_ (réservé aux containers) + if (value.toUpperCase().startsWith('BOX_')) { + return 'Les ID commençant par BOX_ sont réservés aux containers'; + } + } + return null; + }, ), const SizedBox(height: 16), @@ -585,13 +594,13 @@ class _EquipmentFormPageState extends State { // Générer les IDs if (numbers.isEmpty) { - String baseId = EquipmentIdGenerator.generate(brand: brand, model: model, number: null); - String uniqueId = await EquipmentIdGenerator.ensureUniqueId(baseId, _equipmentService); + String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null); + String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService); ids.add(uniqueId); } else { for (final num in numbers) { - String baseId = EquipmentIdGenerator.generate(brand: brand, model: model, number: num); - String uniqueId = await EquipmentIdGenerator.ensureUniqueId(baseId, _equipmentService); + String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: num); + String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService); ids.add(uniqueId); } } diff --git a/em2rp/lib/views/equipment_management_page.dart b/em2rp/lib/views/equipment_management_page.dart index 430c4c4..72f25ed 100644 --- a/em2rp/lib/views/equipment_management_page.dart +++ b/em2rp/lib/views/equipment_management_page.dart @@ -1,4 +1,3 @@ -import 'package:firebase_storage/firebase_storage.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:em2rp/utils/colors.dart'; @@ -8,46 +7,24 @@ import 'package:em2rp/views/widgets/nav/custom_app_bar.dart'; import 'package:em2rp/providers/equipment_provider.dart'; import 'package:em2rp/models/equipment_model.dart'; import 'package:em2rp/views/equipment_form_page.dart'; -import 'package:qr_flutter/qr_flutter.dart'; -import 'package:pdf/pdf.dart'; -import 'package:pdf/widgets.dart' as pw; -import 'package:printing/printing.dart'; -import 'dart:typed_data'; -import 'dart:ui' as ui; +import 'package:em2rp/views/equipment_detail_page.dart'; +import 'package:em2rp/views/widgets/common/qr_code_dialog.dart'; +import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart'; +import 'package:em2rp/mixins/selection_mode_mixin.dart'; class EquipmentManagementPage extends StatefulWidget { const EquipmentManagementPage({super.key}); @override - State createState() => _EquipmentManagementPageState(); + State createState() => + _EquipmentManagementPageState(); } -enum QRLabelFormat { small, medium, large } -class _EquipmentManagementPageState extends State { +class _EquipmentManagementPageState extends State + with SelectionModeMixin { final TextEditingController _searchController = TextEditingController(); EquipmentCategory? _selectedCategory; - bool _isSelectionMode = false; - final Set _selectedEquipmentIds = {}; - - void _toggleSelectionMode() { - setState(() { - _isSelectionMode = !_isSelectionMode; - if (!_isSelectionMode) { - _selectedEquipmentIds.clear(); - } - }); - } - - void _toggleEquipmentSelection(String id) { - setState(() { - if (_selectedEquipmentIds.contains(id)) { - _selectedEquipmentIds.remove(id); - } else { - _selectedEquipmentIds.add(id); - } - }); - } @override void dispose() { @@ -76,19 +53,19 @@ class _EquipmentManagementPageState extends State { ), ), child: Scaffold( - appBar: _isSelectionMode + appBar: isSelectionMode ? AppBar( backgroundColor: AppColors.rouge, leading: IconButton( icon: const Icon(Icons.close, color: Colors.white), - onPressed: _toggleSelectionMode, + onPressed: toggleSelectionMode, ), title: Text( - '${_selectedEquipmentIds.length} sélectionné(s)', + '$selectedCount sélectionné(s)', style: const TextStyle(color: Colors.white), ), actions: [ - if (_selectedEquipmentIds.isNotEmpty) ...[ + if (hasSelection) ...[ IconButton( icon: const Icon(Icons.qr_code, color: Colors.white), tooltip: 'Générer QR Codes', @@ -108,13 +85,13 @@ class _EquipmentManagementPageState extends State { IconButton( icon: const Icon(Icons.checklist), tooltip: 'Mode sélection', - onPressed: _toggleSelectionMode, + onPressed: toggleSelectionMode, ), ], ), drawer: const MainDrawer(currentPage: '/equipment_management'), body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(), - floatingActionButton: _isSelectionMode ? null : _buildFAB(), + floatingActionButton: isSelectionMode ? null : _buildFAB(), ), ); } @@ -134,30 +111,49 @@ class _EquipmentManagementPageState extends State { Widget _buildMobileLayout() { return Column( children: [ - // Barre de recherche + // Barre de recherche et bouton boîtes Padding( padding: const EdgeInsets.all(16.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: 'Rechercher par nom, modèle ou ID...', - prefixIcon: const Icon(Icons.search), - suffixIcon: _searchController.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchController.clear(); - context.read().setSearchQuery(''); - }, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher par nom, modèle ou ID...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + context.read().setSearchQuery(''); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + onChanged: (value) { + context.read().setSearchQuery(value); + }, + ), ), - ), - onChanged: (value) { - context.read().setSearchQuery(value); - }, + const SizedBox(width: 8), + // Bouton Gérer les boîtes + IconButton.filled( + onPressed: () { + Navigator.pushNamed(context, '/container_management'); + }, + icon: const Icon(Icons.inventory_2), + tooltip: 'Gérer les boîtes', + style: IconButton.styleFrom( + backgroundColor: AppColors.rouge, + foregroundColor: Colors.white, + ), + ), + ], ), ), // Menu horizontal de filtres par catégorie @@ -182,13 +178,19 @@ class _EquipmentManagementPageState extends State { onSelected: (selected) { if (selected) { setState(() => _selectedCategory = null); - context.read().setSelectedCategory(null); + context + .read() + .setSelectedCategory(null); } }, selectedColor: AppColors.rouge, labelStyle: TextStyle( - color: _selectedCategory == null ? Colors.white : AppColors.rouge, - fontWeight: _selectedCategory == null ? FontWeight.bold : FontWeight.normal, + color: _selectedCategory == null + ? Colors.white + : AppColors.rouge, + fontWeight: _selectedCategory == null + ? FontWeight.bold + : FontWeight.normal, ), ), ), @@ -217,23 +219,44 @@ class _EquipmentManagementPageState extends State { ), child: Column( children: [ - // En-tête - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.rouge.withOpacity(0.1), + // Bouton Gérer les boîtes + Padding( + padding: const EdgeInsets.all(16.0), + child: ElevatedButton.icon( + onPressed: () { + Navigator.pushNamed(context, '/container_management'); + }, + icon: const Icon(Icons.inventory_2, color: Colors.white), + label: const Text( + 'Gérer les boîtes', + style: TextStyle(color: Colors.white), + ), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), + minimumSize: const Size(double.infinity, 50), + ), ), + ), + const Divider(), + // En-tête filtres + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), child: Row( children: [ - Icon(Icons.inventory, color: AppColors.rouge), + Icon(Icons.filter_list, color: AppColors.rouge, size: 20), const SizedBox(width: 12), Expanded( child: Text( 'Filtres', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.bold, - color: AppColors.rouge, - ), + style: + Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + color: AppColors.rouge, + ), ), ), ], @@ -252,7 +275,9 @@ class _EquipmentManagementPageState extends State { icon: const Icon(Icons.clear, size: 20), onPressed: () { _searchController.clear(); - context.read().setSearchQuery(''); + context + .read() + .setSearchQuery(''); }, ) : null, @@ -260,7 +285,8 @@ class _EquipmentManagementPageState extends State { borderRadius: BorderRadius.circular(8), ), isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), ), onChanged: (value) { context.read().setSearchQuery(value); @@ -270,7 +296,8 @@ class _EquipmentManagementPageState extends State { const Divider(), // Filtres par catégorie Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + padding: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), child: Align( alignment: Alignment.centerLeft, child: Text( @@ -288,20 +315,28 @@ class _EquipmentManagementPageState extends State { ListTile( leading: Icon( Icons.all_inclusive, - color: _selectedCategory == null ? AppColors.rouge : Colors.grey[600], + color: _selectedCategory == null + ? AppColors.rouge + : Colors.grey[600], ), title: Text( 'Tout', style: TextStyle( - color: _selectedCategory == null ? AppColors.rouge : Colors.black87, - fontWeight: _selectedCategory == null ? FontWeight.bold : FontWeight.normal, + color: _selectedCategory == null + ? AppColors.rouge + : Colors.black87, + fontWeight: _selectedCategory == null + ? FontWeight.bold + : FontWeight.normal, ), ), selected: _selectedCategory == null, selectedTileColor: AppColors.rouge.withOpacity(0.1), onTap: () { setState(() => _selectedCategory = null); - context.read().setSelectedCategory(null); + context + .read() + .setSelectedCategory(null); }, ), ..._buildCategoryListTiles(), @@ -420,7 +455,8 @@ class _EquipmentManagementPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey[400]), + Icon(Icons.inventory_2_outlined, + size: 64, color: Colors.grey[400]), const SizedBox(height: 16), Text( 'Aucun équipement trouvé', @@ -454,20 +490,23 @@ class _EquipmentManagementPageState extends State { } Widget _buildEquipmentCard(EquipmentModel equipment) { - final isSelected = _selectedEquipmentIds.contains(equipment.id); + final isSelected = isItemSelected(equipment.id); return Card( margin: const EdgeInsets.only(bottom: 12), - color: _isSelectionMode && isSelected ? AppColors.rouge.withOpacity(0.1) : null, + color: isSelectionMode && isSelected + ? AppColors.rouge.withOpacity(0.1) + : null, child: ListTile( - leading: _isSelectionMode + leading: isSelectionMode ? Checkbox( value: isSelected, - onChanged: (value) => _toggleEquipmentSelection(equipment.id), + onChanged: (value) => toggleItemSelection(equipment.id), activeColor: AppColors.rouge, ) : CircleAvatar( - backgroundColor: _getStatusColor(equipment.status).withOpacity(0.2), + backgroundColor: + _getStatusColor(equipment.status).withOpacity(0.2), child: Icon( _getCategoryIcon(equipment.category), color: _getStatusColor(equipment.status), @@ -492,7 +531,9 @@ class _EquipmentManagementPageState extends State { children: [ const SizedBox(height: 4), Text( - '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim().isNotEmpty + '${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), @@ -505,7 +546,7 @@ class _EquipmentManagementPageState extends State { ], ], ), - trailing: _isSelectionMode + trailing: isSelectionMode ? null : Row( mainAxisSize: MainAxisSize.min, @@ -516,7 +557,8 @@ class _EquipmentManagementPageState extends State { PermissionGate( requiredPermissions: const ['manage_equipment'], child: IconButton( - icon: const Icon(Icons.add_shopping_cart, color: AppColors.rouge), + icon: const Icon(Icons.add_shopping_cart, + color: AppColors.rouge), tooltip: 'Restock', onPressed: () => _showRestockDialog(equipment), ), @@ -525,7 +567,10 @@ class _EquipmentManagementPageState extends State { IconButton( icon: const Icon(Icons.qr_code, color: AppColors.rouge), tooltip: 'QR Code', - onPressed: () => _showSingleQRCode(equipment), + onPressed: () => showDialog( + context: context, + builder: (context) => QRCodeDialog.forEquipment(equipment), + ), ), // Bouton Modifier (permission required) PermissionGate( @@ -547,8 +592,8 @@ class _EquipmentManagementPageState extends State { ), ], ), - onTap: _isSelectionMode - ? () => _toggleEquipmentSelection(equipment.id) + onTap: isSelectionMode + ? () => toggleItemSelection(equipment.id) : () => _viewEquipmentDetails(equipment), ), ); @@ -558,12 +603,15 @@ class _EquipmentManagementPageState extends State { final availableQty = equipment.availableQuantity ?? 0; final totalQty = equipment.totalQuantity ?? 0; final criticalThreshold = equipment.criticalThreshold ?? 0; - final isCritical = criticalThreshold > 0 && availableQty <= criticalThreshold; + final isCritical = + criticalThreshold > 0 && availableQty <= criticalThreshold; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: isCritical ? Colors.red.withOpacity(0.15) : Colors.grey.withOpacity(0.1), + color: isCritical + ? Colors.red.withOpacity(0.15) + : Colors.grey.withOpacity(0.1), borderRadius: BorderRadius.circular(8), border: Border.all( color: isCritical ? Colors.red : Colors.grey.shade400, @@ -706,10 +754,13 @@ class _EquipmentManagementPageState extends State { onPressed: () async { Navigator.pop(context); try { - await context.read().deleteEquipment(equipment.id); + await context + .read() + .deleteEquipment(equipment.id); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Équipement supprimé avec succès')), + const SnackBar( + content: Text('Équipement supprimé avec succès')), ); } } catch (e) { @@ -729,14 +780,14 @@ class _EquipmentManagementPageState extends State { } void _deleteSelectedEquipment() async { - if (_selectedEquipmentIds.isEmpty) return; + if (!hasSelection) return; showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Confirmer la suppression'), content: Text( - 'Voulez-vous vraiment supprimer ${_selectedEquipmentIds.length} équipement(s) ?', + 'Voulez-vous vraiment supprimer $selectedCount équipement(s) ?', ), actions: [ TextButton( @@ -748,17 +799,15 @@ class _EquipmentManagementPageState extends State { Navigator.pop(context); try { final provider = context.read(); - for (final id in _selectedEquipmentIds) { + for (final id in selectedIds) { await provider.deleteEquipment(id); } - setState(() { - _selectedEquipmentIds.clear(); - _isSelectionMode = false; - }); + disableSelectionMode(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${_selectedEquipmentIds.length} équipement(s) supprimé(s) avec succès'), + content: Text( + '$selectedCount équipement(s) supprimé(s) avec succès'), backgroundColor: Colors.green, ), ); @@ -780,7 +829,7 @@ class _EquipmentManagementPageState extends State { } void _generateQRCodesForSelected() async { - if (_selectedEquipmentIds.isEmpty) return; + if (!hasSelection) return; // Récupérer les équipements sélectionnés final provider = context.read(); @@ -789,7 +838,7 @@ class _EquipmentManagementPageState extends State { // On doit récupérer les équipements depuis le stream await for (final equipmentList in provider.equipmentStream.take(1)) { for (final equipment in equipmentList) { - if (_selectedEquipmentIds.contains(equipment.id)) { + if (isItemSelected(equipment.id)) { selectedEquipment.add(equipment); } } @@ -799,486 +848,22 @@ class _EquipmentManagementPageState extends State { if (selectedEquipment.isEmpty) return; if (selectedEquipment.length == 1) { - _showSingleQRCode(selectedEquipment.first); + // Un seul équipement : afficher le dialogue simple + showDialog( + context: context, + builder: (context) => QRCodeDialog.forEquipment(selectedEquipment.first), + ); } else { - _showMultipleQRCodesDialog(selectedEquipment); - } - } - - void _showSingleQRCode(EquipmentModel equipment) { - showDialog( - context: context, - builder: (context) => Dialog( - child: Container( - padding: const EdgeInsets.all(24), - constraints: const BoxConstraints(maxWidth: 400), - 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 - ${equipment.id}', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 24), - // QR Code - 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: equipment.id, - version: QrVersions.auto, - size: 250, - backgroundColor: Colors.white, - ), - ), - const SizedBox(height: 16), - // Informations - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - equipment.id, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 4), - Text( - '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), - style: TextStyle(color: Colors.grey[700]), - ), - ], - ), - ), - const SizedBox(height: 24), - // Bouton télécharger - ElevatedButton.icon( - onPressed: () => _downloadSingleQRCodeImage(equipment), - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.rouge, - minimumSize: const Size(double.infinity, 48), - ), - icon: const Icon(Icons.download, color: Colors.white), - label: const Text( - 'Télécharger l\'image', - style: TextStyle(color: Colors.white), - ), - ), - ], - ), - ), - ), - ); - } - - void _showMultipleQRCodesDialog(List equipmentList) { - showDialog( - context: context, - builder: (context) => Dialog( - child: Container( - padding: const EdgeInsets.all(24), - constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - const Icon(Icons.qr_code_2, color: AppColors.rouge, size: 32), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Générer ${equipmentList.length} QR Codes', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 24), - const Text( - 'Choisissez un format d\'étiquette :', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), - ), - const SizedBox(height: 16), - // Liste des équipements - Expanded( - child: Container( - decoration: BoxDecoration( - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(8), - ), - child: ListView.separated( - shrinkWrap: true, - itemCount: equipmentList.length, - separatorBuilder: (context, index) => const Divider(height: 1), - itemBuilder: (context, index) { - final equipment = equipmentList[index]; - return ListTile( - dense: true, - leading: const Icon(Icons.qr_code, size: 20), - title: Text( - equipment.id, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - subtitle: Text( - '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), - style: const TextStyle(fontSize: 12), - ), - ); - }, - ), - ), - ), - const SizedBox(height: 24), - // Boutons de format - _buildFormatButton( - context, - icon: Icons.qr_code, - title: 'Petits QR Codes', - subtitle: 'QR codes compacts (2x2 cm)', - onPressed: () { - Navigator.pop(context); - _generatePDF(equipmentList, QRLabelFormat.small); - }, - ), - const SizedBox(height: 12), - _buildFormatButton( - context, - icon: Icons.qr_code_2, - title: 'QR Moyens', - subtitle: 'QR codes taille moyenne (4x4 cm)', - onPressed: () { - Navigator.pop(context); - _generatePDF(equipmentList, QRLabelFormat.medium); - }, - ), - const SizedBox(height: 12), - _buildFormatButton( - context, - icon: Icons.label, - title: 'Grandes étiquettes', - subtitle: 'QR + ID + Marque/Modèle (10x5 cm)', - onPressed: () { - Navigator.pop(context); - _generatePDF(equipmentList, QRLabelFormat.large); - }, - ), - ], - ), - ), - ), - ); - } - - Widget _buildFormatButton( - BuildContext context, { - required IconData icon, - required String title, - required String subtitle, - required VoidCallback onPressed, - }) { - return InkWell( - onTap: onPressed, - borderRadius: BorderRadius.circular(8), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey[300]!), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(icon, color: AppColors.rouge, size: 32), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: TextStyle( - color: Colors.grey[600], - fontSize: 13, - ), - ), - ], - ), - ), - const Icon(Icons.arrow_forward_ios, size: 16), - ], - ), - ), - ); - } - - Future _downloadSingleQRCodeImage(EquipmentModel equipment) async { - try { - // Générer l'image QR code en haute résolution - final qrImage = await _generateQRImage(equipment.id, size: 1024); - - // Utiliser la bibliothèque printing pour sauvegarder l'image - await Printing.sharePdf( - bytes: qrImage, - filename: 'QRCode_${equipment.id}.png', - ); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Image QR Code téléchargée avec succès'), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur lors du téléchargement de l\'image: $e')), - ); - } - } - } - - Future _generatePDF(List equipmentList, QRLabelFormat format) async { - try { - final pdf = pw.Document(); - - switch (format) { - case QRLabelFormat.small: - await _generateSmallQRCodesPDF(pdf, equipmentList); - break; - case QRLabelFormat.medium: - await _generateMediumQRCodesPDF(pdf, equipmentList); - break; - case QRLabelFormat.large: - await _generateLargeQRCodesPDF(pdf, equipmentList); - break; - } - - await Printing.layoutPdf( - onLayout: (format) async => pdf.save(), - name: 'QRCodes_${DateTime.now().millisecondsSinceEpoch}.pdf', - ); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('PDF généré avec succès'), - backgroundColor: Colors.green, - ), - ); - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Erreur lors de la génération du PDF: $e')), - ); - } - } - } - - Future _generateSmallQRCodesPDF(pw.Document pdf, List equipmentList) async { - // Petits QR codes : 2x2 cm, 9 par page (3x3) - const qrSize = 56.69; // 2cm en points - const itemsPerRow = 4; - const itemsPerPage = 20; - - for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) { - final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList(); - final qrImages = await Future.wait( - pageEquipment.map((eq) => _generateQRImage(eq.id)), - ); - - pdf.addPage( - pw.Page( - pageFormat: PdfPageFormat.a4, - margin: const pw.EdgeInsets.all(20), - build: (context) { - return pw.Wrap( - spacing: 10, - runSpacing: 10, - children: List.generate(pageEquipment.length, (index) { - return pw.Container( - width: qrSize, - height: qrSize, - child: pw.Image(pw.MemoryImage(qrImages[index])), - ); - }), - ); - }, + // Plusieurs équipements : afficher le sélecteur de format + showDialog( + context: context, + builder: (context) => QRCodeFormatSelectorDialog( + equipmentList: selectedEquipment, ), ); } } - Future _generateMediumQRCodesPDF(pw.Document pdf, List equipmentList) async { - // QR moyens : 4x4 cm, 6 par page (2x3) - const qrSize = 113.39; // 4cm en points - const itemsPerRow = 2; - const itemsPerPage = 6; - - for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) { - final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList(); - final qrImages = await Future.wait( - pageEquipment.map((eq) => _generateQRImage(eq.id)), - ); - - pdf.addPage( - pw.Page( - pageFormat: PdfPageFormat.a4, - margin: const pw.EdgeInsets.all(20), - build: (context) { - return pw.Wrap( - spacing: 20, - runSpacing: 20, - children: List.generate(pageEquipment.length, (index) { - return pw.Container( - width: qrSize, - height: qrSize, - child: pw.Image(pw.MemoryImage(qrImages[index])), - ); - }), - ); - }, - ), - ); - } - } - - Future _generateLargeQRCodesPDF(pw.Document pdf, List equipmentList) async { - // Grandes étiquettes : 10x5 cm, 4 par page - const labelWidth = 283.46; // 10cm en points - const labelHeight = 141.73; // 5cm en points - const qrSize = 113.39; // 4cm en points - const itemsPerPage = 4; - - for (int pageStart = 0; pageStart < equipmentList.length; pageStart += itemsPerPage) { - final pageEquipment = equipmentList.skip(pageStart).take(itemsPerPage).toList(); - final qrImages = await Future.wait( - pageEquipment.map((eq) => _generateQRImage(eq.id)), - ); - - pdf.addPage( - pw.Page( - pageFormat: PdfPageFormat.a4, - margin: const pw.EdgeInsets.all(20), - build: (context) { - return pw.Wrap( - spacing: 10, - runSpacing: 10, - children: List.generate(pageEquipment.length, (index) { - final equipment = pageEquipment[index]; - return pw.Container( - width: labelWidth, - height: labelHeight, - padding: const pw.EdgeInsets.all(10), - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.grey300), - borderRadius: const pw.BorderRadius.all(pw.Radius.circular(5)), - ), - child: pw.Row( - children: [ - pw.Image(pw.MemoryImage(qrImages[index]), width: qrSize, height: qrSize), - pw.SizedBox(width: 15), - pw.Expanded( - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - mainAxisAlignment: pw.MainAxisAlignment.center, - children: [ - pw.Text( - equipment.id, - style: pw.TextStyle( - fontSize: 14, - fontWeight: pw.FontWeight.bold, - ), - ), - pw.SizedBox(height: 8), - pw.Text( - 'Marque: ${equipment.brand ?? 'N/A'}', - style: const pw.TextStyle(fontSize: 10), - ), - pw.SizedBox(height: 4), - pw.Text( - 'Modèle: ${equipment.model ?? 'N/A'}', - style: const pw.TextStyle(fontSize: 10), - ), - ], - ), - ), - ], - ), - ); - }), - ); - }, - ), - ); - } - } - - Future _generateQRImage(String data, {double size = 512}) async { - final qrValidationResult = QrValidator.validate( - data: data, - version: QrVersions.auto, - errorCorrectionLevel: QrErrorCorrectLevel.L, - ); - - if (qrValidationResult.status != QrValidationStatus.valid) { - throw Exception('QR code validation failed'); - } - - final qrCode = qrValidationResult.qrCode!; - final painter = QrPainter.withQr( - qr: qrCode, - color: const Color(0xFF000000), - emptyColor: const Color(0xFFFFFFFF), - gapless: true, - ); - - final picData = await painter.toImageData(size, format: ui.ImageByteFormat.png); - return picData!.buffer.asUint8List(); - } - void _showRestockDialog(EquipmentModel equipment) { final TextEditingController quantityController = TextEditingController(); bool addToTotal = false; @@ -1350,15 +935,18 @@ class _EquipmentManagementPageState extends State { border: OutlineInputBorder(), prefixIcon: Icon(Icons.inventory), hintText: 'Ex: 10 ou -5', - helperText: 'Nombre positif pour ajouter, négatif pour retirer', + helperText: + 'Nombre positif pour ajouter, négatif pour retirer', ), - keyboardType: const TextInputType.numberWithOptions(signed: true), + keyboardType: + const TextInputType.numberWithOptions(signed: true), autofocus: true, ), const SizedBox(height: 16), CheckboxListTile( title: const Text('Ajouter à la quantité totale'), - subtitle: const Text('Mettre à jour aussi la quantité totale'), + subtitle: + const Text('Mettre à jour aussi la quantité totale'), value: addToTotal, contentPadding: EdgeInsets.zero, onChanged: (bool? value) { @@ -1379,7 +967,8 @@ class _EquipmentManagementPageState extends State { final quantityText = quantityController.text.trim(); if (quantityText.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Veuillez entrer une quantité')), + const SnackBar( + content: Text('Veuillez entrer une quantité')), ); return; } @@ -1399,18 +988,23 @@ class _EquipmentManagementPageState extends State { final currentTotal = equipment.totalQuantity ?? 0; final newAvailable = currentAvailable + quantity; - final newTotal = addToTotal ? currentTotal + quantity : currentTotal; + final newTotal = + addToTotal ? currentTotal + quantity : currentTotal; if (newAvailable < 0) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('La quantité disponible ne peut pas être négative')), + const SnackBar( + content: Text( + 'La quantité disponible ne peut pas être négative')), ); return; } if (newTotal < 0) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('La quantité totale ne peut pas être négative')), + const SnackBar( + content: Text( + 'La quantité totale ne peut pas être négative')), ); return; } @@ -1422,9 +1016,9 @@ class _EquipmentManagementPageState extends State { }; await context.read().updateEquipment( - equipment.id, - updatedData, - ); + equipment.id, + updatedData, + ); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -1449,7 +1043,8 @@ class _EquipmentManagementPageState extends State { style: ElevatedButton.styleFrom( backgroundColor: AppColors.rouge, ), - child: const Text('Valider', style: TextStyle(color: Colors.white)), + child: const Text('Valider', + style: TextStyle(color: Colors.white)), ), ], ); @@ -1458,17 +1053,12 @@ class _EquipmentManagementPageState extends State { ); } - void _showQRCode(EquipmentModel equipment) { - // TODO: Afficher le QR code - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('QR Code pour ${equipment.name} - À implémenter')), - ); - } - void _viewEquipmentDetails(EquipmentModel equipment) { - // TODO: Naviguer vers la page de détails - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Détails de ${equipment.name} - À implémenter')), + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => EquipmentDetailPage(equipment: equipment), + ), ); } } diff --git a/em2rp/lib/views/widgets/common/qr_code_dialog.dart b/em2rp/lib/views/widgets/common/qr_code_dialog.dart new file mode 100644 index 0000000..252a8a6 --- /dev/null +++ b/em2rp/lib/views/widgets/common/qr_code_dialog.dart @@ -0,0 +1,193 @@ +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/services/qr_code_service.dart'; +import 'package:printing/printing.dart'; + +/// Widget réutilisable pour afficher un QR code avec option de téléchargement +/// Utilisable pour équipements, containers, et autres entités +class QRCodeDialog extends StatelessWidget { + final T item; + final String Function(T) getId; + final String Function(T) getTitle; + final List Function(T)? buildSubtitle; + + const QRCodeDialog({ + super.key, + required this.item, + required this.getId, + required this.getTitle, + this.buildSubtitle, + }); + + @override + Widget build(BuildContext context) { + final id = getId(item); + final title = getTitle(item); + + return Dialog( + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 400), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // En-tête + Row( + children: [ + const Icon(Icons.qr_code, color: AppColors.rouge, size: 32), + const SizedBox(width: 12), + Expanded( + child: Text( + 'QR Code - $id', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 24), + + // QR Code + 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: id, + version: QrVersions.auto, + size: 250, + backgroundColor: Colors.white, + ), + ), + const SizedBox(height: 16), + + // Informations + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + id, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + title, + style: TextStyle(color: Colors.grey[700]), + ), + if (buildSubtitle != null) ...[ + const SizedBox(height: 4), + ...buildSubtitle!(item), + ], + ], + ), + ), + const SizedBox(height: 24), + + // Bouton télécharger + ElevatedButton.icon( + onPressed: () => _downloadQRCode(context, id), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.rouge, + minimumSize: const Size(double.infinity, 48), + ), + icon: const Icon(Icons.download, color: Colors.white), + label: const Text( + 'Télécharger l\'image', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ), + ); + } + + Future _downloadQRCode(BuildContext context, String id) async { + try { + // Générer l'image QR code en haute résolution + final qrImage = await QRCodeService.generateQRCode( + id, + size: 1024, + useCache: false, + ); + + // Utiliser la bibliothèque printing pour sauvegarder l'image + await Printing.sharePdf( + bytes: qrImage, + filename: 'QRCode_$id.png', + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Image QR Code téléchargée avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Erreur lors du téléchargement: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + /// Factory pour équipement + static QRCodeDialog forEquipment(dynamic equipment) { + return QRCodeDialog( + item: equipment, + getId: (eq) => eq.id, + getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(), + ); + } + + /// Factory pour container + static QRCodeDialog forContainer(dynamic container) { + return QRCodeDialog( + item: container, + getId: (c) => c.id, + getTitle: (c) => c.name, + buildSubtitle: (c) { + return [ + Text( + _getContainerTypeLabel(c.type), + style: TextStyle( + color: Colors.grey[600], + fontSize: 12, + ), + ), + ]; + }, + ); + } + + static String _getContainerTypeLabel(dynamic type) { + // Simple fallback - à améliorer avec import du model + return type.toString().split('.').last; + } +} + diff --git a/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart b/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart new file mode 100644 index 0000000..7044f34 --- /dev/null +++ b/em2rp/lib/views/widgets/common/qr_code_format_selector_dialog.dart @@ -0,0 +1,258 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/equipment_model.dart'; +import 'package:em2rp/services/pdf_generator_service.dart'; +import 'package:printing/printing.dart'; + +/// Widget réutilisable pour sélectionner le format de génération de QR codes multiples +class QRCodeFormatSelectorDialog extends StatelessWidget { + final List equipmentList; + + const QRCodeFormatSelectorDialog({ + super.key, + required this.equipmentList, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + child: Container( + padding: const EdgeInsets.all(24), + constraints: const BoxConstraints(maxWidth: 600, maxHeight: 700), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // En-tête + Row( + children: [ + const Icon(Icons.qr_code_2, color: AppColors.rouge, size: 32), + const SizedBox(width: 12), + Expanded( + child: Text( + 'Générer ${equipmentList.length} QR Codes', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: () => Navigator.pop(context), + ), + ], + ), + const SizedBox(height: 24), + const Text( + 'Choisissez un format d\'étiquette :', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 16), + + // Liste des équipements + Expanded( + child: Container( + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: ListView.separated( + shrinkWrap: true, + itemCount: equipmentList.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final equipment = equipmentList[index]; + return ListTile( + dense: true, + leading: const Icon(Icons.qr_code, size: 20), + title: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + '${equipment.brand ?? ''} ${equipment.model ?? ''}'.trim(), + style: const TextStyle(fontSize: 12), + ), + ); + }, + ), + ), + ), + const SizedBox(height: 24), + + // Boutons de format + _FormatButton( + icon: Icons.qr_code, + title: 'Petits QR Codes', + subtitle: 'QR codes compacts (2x2 cm)', + onPressed: () { + Navigator.pop(context); + _generatePDF(context, equipmentList, QRLabelFormat.small); + }, + ), + const SizedBox(height: 12), + _FormatButton( + icon: Icons.qr_code_2, + title: 'QR Moyens', + subtitle: 'QR codes taille moyenne (4x4 cm)', + onPressed: () { + Navigator.pop(context); + _generatePDF(context, equipmentList, QRLabelFormat.medium); + }, + ), + const SizedBox(height: 12), + _FormatButton( + icon: Icons.label, + title: 'Grandes étiquettes', + subtitle: 'QR + ID + Marque/Modèle (10x5 cm)', + onPressed: () { + Navigator.pop(context); + _generatePDF(context, equipmentList, QRLabelFormat.large); + }, + ), + ], + ), + ), + ); + } + + Future _generatePDF( + BuildContext context, + List equipmentList, + QRLabelFormat format, + ) async { + // Afficher le dialogue de chargement + showDialog( + context: context, + barrierDismissible: false, + builder: (context) => Dialog( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(AppColors.rouge), + ), + const SizedBox(height: 20), + const Text( + 'Génération du PDF en cours...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + Text( + 'Génération de ${equipmentList.length} QR code(s)', + style: TextStyle( + fontSize: 14, + color: Colors.grey[600], + ), + ), + ], + ), + ), + ), + ); + + try { + // Génération du PDF + final pdfBytes = await PDFGeneratorService.generateQRCodesPDF( + equipmentList: equipmentList, + format: format, + ); + + // Fermer le dialogue de chargement + if (context.mounted) { + Navigator.pop(context); + } + + // Afficher le PDF + await Printing.layoutPdf( + onLayout: (format) async => pdfBytes, + name: 'QRCodes_${DateTime.now().millisecondsSinceEpoch}.pdf', + ); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('PDF généré avec succès'), + backgroundColor: Colors.green, + ), + ); + } + } catch (e) { + // Fermer le dialogue de chargement en cas d'erreur + if (context.mounted) { + Navigator.pop(context); + } + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Erreur lors de la génération du PDF: $e')), + ); + } + } + } +} + +/// Bouton de sélection de format +class _FormatButton extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onPressed; + + const _FormatButton({ + required this.icon, + required this.title, + required this.subtitle, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(icon, color: AppColors.rouge, size: 32), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: TextStyle( + color: Colors.grey[600], + fontSize: 13, + ), + ), + ], + ), + ), + const Icon(Icons.arrow_forward_ios, size: 16), + ], + ), + ), + ); + } +} + diff --git a/em2rp/lib/views/widgets/containers/container_equipment_tile.dart b/em2rp/lib/views/widgets/containers/container_equipment_tile.dart new file mode 100644 index 0000000..d72fbb0 --- /dev/null +++ b/em2rp/lib/views/widgets/containers/container_equipment_tile.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Widget pour afficher un équipement dans la liste d'un container +class ContainerEquipmentTile extends StatelessWidget { + final EquipmentModel equipment; + final VoidCallback onView; + final VoidCallback onRemove; + + const ContainerEquipmentTile({ + super.key, + required this.equipment, + required this.onView, + required this.onRemove, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + leading: CircleAvatar( + backgroundColor: AppColors.rouge.withOpacity(0.1), + child: const Icon(Icons.inventory_2, color: AppColors.rouge), + ), + title: Text( + equipment.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (equipment.brand != null || equipment.model != null) + Text('${equipment.brand ?? ''} ${equipment.model ?? ''}'), + const SizedBox(height: 4), + Row( + children: [ + _buildSmallBadge( + _getCategoryLabel(equipment.category), + Colors.blue, + ), + const SizedBox(width: 8), + if (equipment.weight != null) + _buildSmallBadge( + '${equipment.weight} kg', + Colors.grey, + ), + ], + ), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + tooltip: 'Voir détails', + onPressed: onView, + ), + IconButton( + icon: const Icon(Icons.remove_circle, color: Colors.red, size: 20), + tooltip: 'Retirer', + onPressed: onRemove, + ), + ], + ), + ); + } + + Widget _buildSmallBadge(String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + color: color, + fontWeight: FontWeight.bold, + ), + ), + ); + } + + String _getCategoryLabel(EquipmentCategory category) { + switch (category) { + case EquipmentCategory.lighting: + return 'Lumière'; + case EquipmentCategory.sound: + return 'Son'; + case EquipmentCategory.video: + return 'Vidéo'; + case EquipmentCategory.effect: + return 'Effets'; + case EquipmentCategory.structure: + return 'Structure'; + case EquipmentCategory.consumable: + return 'Consommable'; + case EquipmentCategory.cable: + return 'Câble'; + case EquipmentCategory.other: + return 'Autre'; + } + } +} + diff --git a/em2rp/lib/views/widgets/containers/container_header_card.dart b/em2rp/lib/views/widgets/containers/container_header_card.dart new file mode 100644 index 0000000..b46e89b --- /dev/null +++ b/em2rp/lib/views/widgets/containers/container_header_card.dart @@ -0,0 +1,197 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/models/equipment_model.dart'; + +/// Widget pour afficher la carte d'en-tête d'un container +class ContainerHeaderCard extends StatelessWidget { + final ContainerModel container; + final List equipmentList; + + const ContainerHeaderCard({ + super.key, + required this.container, + required this.equipmentList, + }); + + @override + Widget build(BuildContext context) { + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _getTypeIcon(container.type), + size: 60, + color: AppColors.rouge, + ), + const SizedBox(width: 20), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + container.id, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + container.name, + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade700, + ), + ), + ], + ), + ), + ], + ), + const Divider(height: 32), + Row( + children: [ + Expanded( + child: _buildInfoItem( + context, + 'Type', + containerTypeLabel(container.type), + Icons.category, + ), + ), + Expanded( + child: _buildInfoItem( + context, + 'Statut', + _getStatusLabel(container.status), + Icons.info, + statusColor: _getStatusColor(container.status), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildInfoItem( + context, + 'Équipements', + '${container.itemCount}', + Icons.inventory, + ), + ), + Expanded( + child: _buildInfoItem( + context, + 'Poids total', + _calculateTotalWeight(), + Icons.scale, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget _buildInfoItem( + BuildContext context, + String label, + String value, + IconData icon, { + Color? statusColor, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: statusColor, + ), + ), + ], + ); + } + + String _calculateTotalWeight() { + if (equipmentList.isEmpty && container.weight == null) { + return '-'; + } + + final totalWeight = container.calculateTotalWeight(equipmentList); + return '${totalWeight.toStringAsFixed(1)} kg'; + } + + IconData _getTypeIcon(ContainerType type) { + switch (type) { + case ContainerType.flightCase: + return Icons.work; + case ContainerType.pelicase: + return Icons.work_outline; + case ContainerType.bag: + return Icons.shopping_bag; + case ContainerType.openCrate: + return Icons.inventory_2; + case ContainerType.toolbox: + return Icons.handyman; + } + } + + String _getStatusLabel(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return 'Disponible'; + case EquipmentStatus.inUse: + return 'En prestation'; + case EquipmentStatus.maintenance: + return 'Maintenance'; + case EquipmentStatus.outOfService: + return 'Hors service'; + default: + return 'Autre'; + } + } + + Color _getStatusColor(EquipmentStatus status) { + switch (status) { + case EquipmentStatus.available: + return Colors.green; + case EquipmentStatus.inUse: + return Colors.orange; + case EquipmentStatus.maintenance: + return Colors.blue; + case EquipmentStatus.outOfService: + return Colors.red; + default: + return Colors.grey; + } + } +} + diff --git a/em2rp/lib/views/widgets/containers/container_physical_characteristics.dart b/em2rp/lib/views/widgets/containers/container_physical_characteristics.dart new file mode 100644 index 0000000..1c18c44 --- /dev/null +++ b/em2rp/lib/views/widgets/containers/container_physical_characteristics.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/container_model.dart'; + +/// Widget pour afficher les caractéristiques physiques d'un container +class ContainerPhysicalCharacteristics extends StatelessWidget { + final ContainerModel container; + + const ContainerPhysicalCharacteristics({ + super.key, + required this.container, + }); + + @override + Widget build(BuildContext context) { + final hasDimensions = container.length != null || + container.width != null || + container.height != null; + final hasWeight = container.weight != null; + final hasVolume = container.volume != null; + + if (!hasDimensions && !hasWeight) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Caractéristiques physiques', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + const Divider(height: 24), + if (hasWeight) + _buildCharacteristicRow( + 'Poids à vide', + '${container.weight} kg', + Icons.scale, + ), + if (hasDimensions) ...[ + if (hasWeight) const SizedBox(height: 12), + _buildCharacteristicRow( + 'Dimensions (L×l×H)', + '${container.length ?? '?'} × ${container.width ?? '?'} × ${container.height ?? '?'} cm', + Icons.straighten, + ), + ], + if (hasVolume) ...[ + const SizedBox(height: 12), + _buildCharacteristicRow( + 'Volume', + '${container.volume!.toStringAsFixed(3)} m³', + Icons.view_in_ar, + ), + ], + ], + ), + ), + ); + } + + Widget _buildCharacteristicRow(String label, String value, IconData icon) { + return Row( + children: [ + Icon(icon, size: 20, color: AppColors.rouge), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: const TextStyle(fontSize: 14), + ), + ), + Text( + value, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ], + ); + } +} + diff --git a/em2rp/lib/views/widgets/equipment/equipment_parent_containers.dart b/em2rp/lib/views/widgets/equipment/equipment_parent_containers.dart new file mode 100644 index 0000000..08fac81 --- /dev/null +++ b/em2rp/lib/views/widgets/equipment/equipment_parent_containers.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:em2rp/utils/colors.dart'; +import 'package:em2rp/models/container_model.dart'; +import 'package:em2rp/providers/container_provider.dart'; + +/// Widget pour afficher les containers parents d'un équipement +class EquipmentParentContainers extends StatefulWidget { + final List parentBoxIds; + + const EquipmentParentContainers({ + super.key, + required this.parentBoxIds, + }); + + @override + State createState() => _EquipmentParentContainersState(); +} + +class _EquipmentParentContainersState extends State { + List _containers = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadContainers(); + } + + Future _loadContainers() async { + if (widget.parentBoxIds.isEmpty) { + setState(() { + _isLoading = false; + }); + return; + } + + setState(() { + _isLoading = true; + }); + + try { + final containerProvider = context.read(); + final List containers = []; + + for (final boxId in widget.parentBoxIds) { + final container = await containerProvider.getContainerById(boxId); + if (container != null) { + containers.add(container); + } + } + + setState(() { + _containers = containers; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + if (widget.parentBoxIds.isEmpty) { + return const SizedBox.shrink(); + } + + return Card( + elevation: 2, + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.inventory_2, color: AppColors.rouge, size: 20), + const SizedBox(width: 8), + const Text( + 'Containers', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const Divider(height: 24), + if (_isLoading) + const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: CircularProgressIndicator(), + ), + ) + else if (_containers.isEmpty) + const Center( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text( + 'Cet équipement n\'est dans aucun container', + style: TextStyle(color: Colors.grey), + ), + ), + ) + else + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: _containers.length, + separatorBuilder: (context, index) => const Divider(height: 1), + itemBuilder: (context, index) { + final container = _containers[index]; + return _buildContainerTile(container); + }, + ), + ], + ), + ), + ); + } + + Widget _buildContainerTile(ContainerModel container) { + return ListTile( + contentPadding: const EdgeInsets.symmetric(vertical: 8), + leading: Icon( + _getTypeIcon(container.type), + color: AppColors.rouge, + size: 32, + ), + title: Text( + container.id, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(container.name), + const SizedBox(height: 4), + Text( + containerTypeLabel(container.type), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + trailing: const Icon(Icons.chevron_right), + onTap: () { + Navigator.pushNamed( + context, + '/container_detail', + arguments: container, + ); + }, + ); + } + + IconData _getTypeIcon(ContainerType type) { + switch (type) { + case ContainerType.flightCase: + return Icons.work; + case ContainerType.pelicase: + return Icons.work_outline; + case ContainerType.bag: + return Icons.shopping_bag; + case ContainerType.openCrate: + return Icons.inventory_2; + case ContainerType.toolbox: + return Icons.handyman; + } + } +} + diff --git a/em2rp/lib/views/widgets/nav/main_drawer.dart b/em2rp/lib/views/widgets/nav/main_drawer.dart index a7212b1..4158387 100644 --- a/em2rp/lib/views/widgets/nav/main_drawer.dart +++ b/em2rp/lib/views/widgets/nav/main_drawer.dart @@ -94,6 +94,37 @@ class MainDrawer extends StatelessWidget { ); }, ), + PermissionGate( + requiredPermissions: const ['view_equipment'], + child: ListTile( + leading: const Icon(Icons.inventory), + title: const Text('Gestion du Matériel'), + selected: currentPage == '/equipment_management', + selectedColor: AppColors.rouge, + onTap: () { + Navigator.pop(context); + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (context) => + const EquipmentManagementPage()), + ); + }, + ), + ), + PermissionGate( + requiredPermissions: const ['view_equipment'], + child: ListTile( + leading: const Icon(Icons.inventory_2), + title: const Text('Containers'), + selected: currentPage == '/container_management', + selectedColor: AppColors.rouge, + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/container_management'); + }, + ), + ), ExpansionTileTheme( data: const ExpansionTileThemeData( iconColor: AppColors.noir, @@ -152,24 +183,6 @@ class MainDrawer extends StatelessWidget { }, ), ), - PermissionGate( - requiredPermissions: const ['view_equipment'], - child: ListTile( - leading: const Icon(Icons.inventory), - title: const Text('Gestion du Matériel'), - selected: currentPage == '/equipment_management', - selectedColor: AppColors.rouge, - onTap: () { - Navigator.pop(context); - Navigator.pushReplacement( - context, - MaterialPageRoute( - builder: (context) => - const EquipmentManagementPage()), - ); - }, - ), - ), ], ), ), diff --git a/em2rp/pubspec.yaml b/em2rp/pubspec.yaml index c44cfad..5d63db6 100644 --- a/em2rp/pubspec.yaml +++ b/em2rp/pubspec.yaml @@ -10,12 +10,12 @@ dependencies: flutter: sdk: flutter - firebase_core: ^3.12.1 - firebase_auth: ^5.5.1 - cloud_firestore: ^5.6.5 - google_sign_in: ^6.2.2 + firebase_core: ^4.2.0 + firebase_auth: ^6.1.1 + cloud_firestore: ^6.0.3 + google_sign_in: ^7.2.0 provider: ^6.1.2 - firebase_storage: ^12.4.4 + firebase_storage: ^13.0.3 image_picker: ^1.1.2 universal_io: ^2.2.2 cupertino_icons: ^1.0.2 @@ -29,7 +29,7 @@ dependencies: flutter_launcher_icons: ^0.14.3 flutter_native_splash: ^2.3.9 url_launcher: ^6.2.2 - share_plus: ^11.0.0 + share_plus: ^12.0.1 path_provider: ^2.1.2 pdf: ^3.10.7 printing: ^5.11.1 @@ -38,7 +38,7 @@ dependencies: timezone: ^0.10.1 flutter_secure_storage: ^9.0.0 http: ^1.1.2 - flutter_dotenv: ^5.1.0 + flutter_dotenv: ^6.0.0 google_fonts: ^6.1.0 flutter_svg: ^2.0.9 cached_network_image: ^3.3.1 @@ -60,7 +60,7 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter - flutter_lints: ^5.0.0 + flutter_lints: ^6.0.0 flutter: uses-material-design: true