From 54666b077a9acbd688de40684d80b103b92dac8f Mon Sep 17 00:00:00 2001 From: orfelorfel23 Date: Sat, 30 May 2026 23:09:36 +0200 Subject: [PATCH] Fix iOS crash, storage, and printing issues; setup apk build --- App.tsx | 33 ++- app.json | 2 +- assets/frames/frame_test.png | Bin 0 -> 55887 bytes eas.json | 5 +- index.ts | 3 + package-lock.json | 279 +++++++++++++++++++++ package.json | 5 + src/components/DraggableSticker.tsx | 196 +++++++++++++++ src/components/FrameOverlay.tsx | 40 +++ src/components/FramePicker.tsx | 137 ++++++++++ src/components/Icon.tsx | 26 ++ src/components/StickerTray.tsx | 160 ++++++++++++ src/data/frames.ts | 27 ++ src/screens/AdminScreen.tsx | 319 ++++++++++++++++++++++- src/screens/CameraScreen.tsx | 300 +++++++++++++++++----- src/screens/PreviewScreen.tsx | 375 ++++++++++++++++++++++++---- src/services/settings.ts | 21 ++ src/services/storage.ts | 15 +- src/styles/theme.ts | 27 +- 19 files changed, 1821 insertions(+), 149 deletions(-) create mode 100644 assets/frames/frame_test.png create mode 100644 src/components/DraggableSticker.tsx create mode 100644 src/components/FrameOverlay.tsx create mode 100644 src/components/FramePicker.tsx create mode 100644 src/components/Icon.tsx create mode 100644 src/components/StickerTray.tsx create mode 100644 src/data/frames.ts diff --git a/App.tsx b/App.tsx index 776186c8..eb53a5a3 100644 --- a/App.tsx +++ b/App.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from 'react'; import { StyleSheet, View, SafeAreaView, StatusBar } from 'react-native'; +import { GestureHandlerRootView } from 'react-native-gesture-handler'; import CameraScreen from './src/screens/CameraScreen'; import PreviewScreen from './src/screens/PreviewScreen'; import AdminScreen from './src/screens/AdminScreen'; @@ -46,6 +47,12 @@ export default function App() { setCurrentScreen('preview'); }; + // Called by CameraScreen when burst mode completes all photos + const handleBurstComplete = (uris: string[]) => { + setPhotoUris((prev) => [...prev, ...uris]); + setCurrentScreen('preview'); + }; + const handleRetakeLast = () => { // Remove last captured photo const updatedUris = [...photoUris]; @@ -85,8 +92,11 @@ export default function App() { ); case 'preview': @@ -97,6 +107,11 @@ export default function App() { onRetakeLast={handleRetakeLast} onAddAnother={handleAddAnother} onReset={handleReset} + frameMode={settings.frameMode} + initialFrameId={settings.selectedFrameId} + dateOverlay={settings.dateOverlay} + dateOverlayPosition={settings.dateOverlayPosition} + eventText={settings.eventText} /> ); case 'admin': @@ -112,32 +127,38 @@ export default function App() { ); } }; return ( - - + + + + ); } const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#000000', + backgroundColor: '#050510', }, loadingContainer: { flex: 1, - backgroundColor: '#050507', + backgroundColor: '#050510', justifyContent: 'center', alignItems: 'center', }, diff --git a/app.json b/app.json index 398b2731..965962ce 100644 --- a/app.json +++ b/app.json @@ -13,7 +13,7 @@ "android": { "package": "com.schnappix", "adaptiveIcon": { - "backgroundColor": "#121214", + "backgroundColor": "#050510", "foregroundImage": "./Icon.png" }, "permissions": [ diff --git a/assets/frames/frame_test.png b/assets/frames/frame_test.png new file mode 100644 index 0000000000000000000000000000000000000000..73f3e380b7abef7520a4874de508b8bc94b5edd0 GIT binary patch literal 55887 zcmbrlcQ~8v8#kVXrdCU7QFNg-TeEi6qb+LGh!OKqBM52LUZK>}s-pI8m1+_RC1R86 zP`gDaG2&6QW=ie#-s$uG9lzsv|9u@Cxf98i>psuVxjyH4JvKMJ$$eD#CuCa zc$xJF9#^{G1H35oz`*u_rN766VAuQZAahrg-vfD51FJJC@>k@qDDs1EJ^~vd~ zl%Fbb&#xO^=f2QXa_!djTiCc`Mn~l0j)i4hYQu)V!-lUk6PqU{JbQOBQD+RFU-*CjMVk_Lcv#Eyf45eiuPGNO`|han;v#SD ze&~hHzL6#RTl0N2%~0gT>seKaXmR#O_vuzXlt+Za?2_I={7MRI3npR)uXF^m*}K^V zL5bLSs+LO_BnR?sQ1DZ_-fbav%i};Q7f@@ zSYf|h^t1kO780;JCtL}(1q99~Es^mvFWrc9&2{`2yA%oCOXB$@ugPy`GoK_#KU2FqCDm;%}}7l_K*5t zmdf*%qtc{8cC9kGx?F^|Go5ysd6B8aNTfb-A?kIPMy9;Vge$rj3zu@n^U&;M8fwlE{QOdrN$g{ymXUXxa3*tQ$ul8Gn{lcS# zs2#TqRgsz+ zvbC}m5IWbwXA99vQKbZ;oj21dwWVmH3>{js+V@fLz>HQ|&PCM-rIYq@@l)38KDa2~ z-NVwIgZb^5Nwpb@YOOVOXYVk`IB^aP7q1O;MjL{)V0%~fhALuaYCH(qvT2fJry{LC z+aSK-LKdaPEHsuhc(+<1mabq|^GUHH zb>z8Ryq>S*Rq08!u^``0kGA5C0Q&nktJ6kHbNsxo#DC()FO`!Kxxfr$A`-@VzI(xtM-ftI$*MHXEa5~bYdUo#je5m6^ zJ}sxC5i*(r6G$J*kd0aTC(ZvMEjA7@z;{c|bPHq79w448gqYdG-*2ziv*ht;g>R{2 z5crDBpn9sZy!a98>3fxrI7O0WzB&1Y|7 zMxT_=aa<2!z){>|N=oG(zU|xAbPJFOHh~{ez>#01T3w28Ks(oe5(G(sDmY9{ttW4h zN+GssdV7Z%mlt&OjZ99Q553RwoBeTjl;_<`X`~$M2PZJMuM-^kyfQR@aTia;A-b@d zmhl0XFa`%L{S0pmx#wfp_1V9hFAtC0B5bL;-|X;*IJ1%dX9m+fFIt#JtQFrFYYulW z@&%t^USMpIk1S_mUA@<**{_WVtHR3J#K3+WBtw@#QA}`Kd0>r)&zsd>!p8u;pC`Ui zV2>j32E6^e{#IFkM>p$CK#g3yaNmEP#6$y5n!P(rJs*?C=d_fCAnBEprAHxznVirE z#%=foEFH@c;QO%gYtnxpvMDPOUg*dnsP_9{R4_bDLq@FCl+fQO2jQP-dMspqN`kR* z#zb180*_*pE5oJVvb!f)eWzTCp}nF;Qi&r5Ob-SRQhA=J|N~iHVcvj zD<8nm1)&_<2+{1lL=~hALF(4@yBAGYjsM*JB>)^kxsm30qG`wCOVOP#BpUDgdLjqH zeJjk)H822AST>BObyBFE(ge7qTzojB45H8_AAeqy*}`-6{>!TC_g zA01Kg-L)DgLT>&Vi*aa4fQLvp2cg7uGxbLAi!^KFfgKwPWa8);n^yW|*T7i^JIB%p;yvJCh>Qi2NH~rZ7JBwwFOY(AvnYO5#E=98Q$!1?;b z{}wjF6B5i$bk3sA_jZ%*bEL~nuDQCD`duh-Om|drKB^`1?Nx$L)90G)VCtsmJn2{4 z|8|-3#oJrjEa;X&=QP$Wrd^6`pW;Zr5kR!;t+X$q&dW1i$3fICREE7FiekP=pIkTo z`lin)eDNoh5U|u&URcX55)*)JWskb!Ls7LdOJVQvYI*ZVp8s4R=kj#32eEJT#qMWw z)lJA~l1;>C)=fqP>onHzeh?Bn35ZEhY84ETS5E5pGD1%M&A?Gbs?^`*&xg`0rPnHT zAY;Ct@p@{x3QQI}2Ww<|%RCr6@>D$(rxyFw3uw!L_80w6;t+&>!4PgH0cKNMCWi@x z$ecVB4gl;&p)au(w63L~Vcq+F=M;0L_kI5hk)@O2EUz*8+REOP23+f3zS!#kY0P+*o-cm|)?|Cvdv1*=C4r+a6 zNmK!zWh|Ka%8NnS}pWt=icF6W7AoKl8AD4R#o&@#vnQyjl zY&D6PCN9OptK@GXryAxT_uF3nlkh2@!%=cm)e#(}JJWwL;GvVhuh@s5K+DA%EB>>e zRqvSfam#xSi7M{80dEAiLibz`#<~J3Kl_$9DoHJCQY&KIDRl4h_4{C!5#)Zax>lI$ zP0)@xKX7jAg3BSL0Cp3ZYLu!-Zn&gBq5vgvEE{VMKC-X2w$ioDeWS>`a7F0H0h0Rf z@w*_lO393;lb~MMa+R>L8*Vt!23WEGLOR16Ax#kDJYB??|CJN0;>I;3e@)5p^lwS-Z?wKQw9|wAH6IhY;fNk$t+MCdiW8w8M46 zPrJQk=xH`B2~GI2<_#KR=FA3dM31=>hp_Q@O?z%-kA84PZoi`VP7&K_?!Dv8F`YPD z1IWJt*l1`k2V$pbz)R_m=*V^`yf~P1 za;@y^Loz8Z`002z*?!5(5S*czVv z)iidgk;4hPcaDar1@TIeg%!!qHPWOzbEs2Pf2aa%b4B7l@Fm{dTpPNal)ncIKoXEvghNsxsy-x`VVE2sbsz$FQ4NP5rS3ikX=ve+Ta~0 znG@94R!GOZFZ0EVXX;?KbIURPKTtlG#?-(=K$h0@qWw$5t(ot;F)MVVH(xL3PO=iT z3*1;MhueLAG46>B{s7Hm->$qI@~YtN96@Y?T4B8=G}<`zC^EU^ZN~5;HmaM_*uMHm ziE9vV1&0V$l4(QzlWv5?D)Wsyr2uk&&hCJhd0oPW(HjNxf}eFRy;wYVwYuFq(eDKH zSS42-yzWe`j}qOR$mxjMl?K8(N=EBw2-v*uHTO70KnJuZ$`l6@7`ycs%6xxnk^Zac zDfp%g0YtThd(HVuPdtH#gNJRS3liu-Z%=v+6jKE-1!yl+p3RRV6DpFQ_DU~xjvw8Z zv@v%pm43JvTI|#a_yC!;N>i{C^mNwnQ&VoRCp4Pf0RcoDYqOLvmC;EuOAtxdkmlBp ztdy|oBUdnb?3F9&L)7N`bj`#sAJv1&!N+`SFM(;q9G^fPj7e;Hjl5S)bB*=I-rGMk z)!wZ%X*th+4@KzsKf2X-m-A%-s@8;}~9W5l#h} zY{hHTEKKnKnEdiRWx8OU0~A;LNqoL{F&qmDY#KoA~$?iS$O4ty4RFl`(1< zWA?1cqgqGGb8ZEUStJFXW{NSs!+sQ;G78!EV_4|>tasT)>Zbv_AhW@Vuibw&xd{$( zxckw-IQ*!(z`1HZKNF!>2&&wrV7>@tz=HA(7HV)5`#E2kbz@|uX3hP){tbtV$oy3!Mz?>gL@_U zih!m5wnHK|a_ux-sgDp9o3Az`4g&cQk}m484h>hCFX~Kgr;Ysr`Pi@&V%{ zJ^{_Q`BQ+TG|-Zz$^kKz$OBQ6EZeyWWZZwx_AI6uY!8BW?V4|0WFEmdN)zQxYdRj{ zD8idG$sK=#S562OlRk7%Y;n$ZxX?lRl*!27Hji%VZ?Bp63iZ-g-ClnWxNW?ptf@z9 zqvZHCen;0>2l=&R{zMDdx^EfiMDG%ty?w|J8Rx9+)S$X-QQ6!6#_a(re>+t?>sXGJ z3VN$F<;|E>;$0x6KFaU+(g|hNG&LPuh1*m&Y#F4?gT7Nxog!XR)YrvuwZ;>oGQ0`S z&`pqxWPejphkSoU;lqs=IYRcs&*qGj(b4=rKeB2R;frV`(uV0r+FFd1MBV%Y z@KwPlvdZEny91>Fb3+@k!z5`wHj#y2n3b^77Q1@s9Zd{QzR@`Dr#O+D^8=zI=xAhA4es->k}wZ1q<-ol}Dzfq{?qo$&qxv}TJ z`k&<4*(Q|a;!_3tOu5S9S63{PYJ8ilU!X2;{mXJ(>vu-kPJ$9VQa8sIqf`lD%R%Nm zbf?1j-!vTiC4IupcDL{}irm!uJ(!IYv`nJTe(@vt0- z&m2vc1Be?7=PXZkvL7n~N97|r1KGdN-zYno#{xd_RwJXF%vlI&x~R$7ThYBGBr~)3 z@+DV#)P)ShFxUn%3=IYffH~jX$5O-J$y_&e&j{G`K?+kgg&fej2J~m~o)Uxg>>&?Q z9+o>;?m}1Coiao(XsYB7byO?zFIq5M>1nh}e_}2wu3x^+ZIsP9|7h0urx%WuYXv|1arT9C)|q`eb#rx;JmD>iHf}A70F>5W@^wf) z130|?pz5qcjg%iYX2fmMllf*jDg@pDx@^aPR%D27GZQ5lTv!)n~ zo+MX_6?hXON7<_v)G`|VNZkP_)IpO*Urq5+%lO&Nm1j7udCz0Eh`Zy31-gA*Ps_vC z4yufBh~>q)=E5$xVKDmvC?sGx%a40bWg(n;5=#JB($RTCi~kZ{oUt9Xhke5cIh2T< z=JQxrx^T*1V(w}XT&KF7{e9MIra?@MSzdZVz8o#GWp%wg23<6`F)10Y3t zN;_3*ocJekR?uOOl8NuaK57!Rb~$Af zsA!?V1zCT|P-GeOYMaE0SDEDlw{D9#Lzw2dUXzn>|!T#4@ znjkqNEtga!-Z8{bLQ(RV>#^ld*Yk-&y=xIB$Tt+#mwg5BT6vQrn?G#(-pUBCZ{su~ z&DsW>+IG)X_!NN;xa-=7mCzl3h2LRCz#WoY-RKo-jxuQ9ZNgCZ{I<5R>D53eMhBxO zILwKUKpJBTsM~yZy{Y3hN&3H zN1%eX1;P|iLI3#mF971_lJOLVcUd=|2KQAJfy>pqztE>3N!Lb#2bVL`tn_jqd|Nut?54I2JxlGiRl>lBr9@4%B^ z*I1|2i1=~hRLSvHGIR)DA}5txLbMJFMXK~SP^8O&tZQ2(!@sMOxH>5^KPOwKRT!|k zr7dHflWbfwW~+504;zkM0XaiRgg^92QYM*CEYr?b!8FfZ;c!KT8V!o(9UG}=zsYZ` za59sHrI+q^G-S|vH%mw7Q6oAn&hM;K4R!;mIaD*H1&?@orp80`)0b*j-*Y>~8u5hNMw)<_ z2_SVB*+_KTW-)x-S_)k${S&p#Gb(O(#0J>S&EBZ^aEZM?_i|DVYX%)oz9%u%-DKKw z<#6+(_Fhq9_qknuhL)!*DxjhH4Y1!5xC{-prub(2o3FrOiB-pMpR@KCKy{*9cSfU0 z6Jkof${M5gP|w%r+&9UGqVUtHU%cmx3}|OgdHWb^a?~BEQ=ysp&|@!*;tNMxH#R!U z=8Nb8=pw?tY9-jZ3?MYaX46FrF3$;tYX7t${K`BLy4V8V^RZ7IPWrf?vk>Xg);P&t znC0E(HwV$-Y7*bN4iq!i+84PyIZJ`L~?&?rd;!^g)#a% z^2^69iK@AXlH9c}A!bX-u)6cOk;G6GwK^uNWGT$=WJS?JOdllL(uX4UMl4;UnbpNA zftU76Zq<>G=*a0;o!l_ju2eCwFCl?i;Q1MjA3l{_zrJsctIRE61|*ZS#UQ71FF)9{ z`fbuT>hopDSwBZT(4EwgSiL(lsHHXp;RV=UcN*^?rKb7QB3MgZZwL;j>@>T%Hgdzy z^g*LChnjX{R9=bR5=`0|)DnO8*=6_S>><6QMmHZnsLSrRT64#`z=73o=#TrNn&_n` z&GyX1{lixj|Ke~2A;`Uv!HZpat?l#C*6Obxtuyx&^vmvl-g;^^oT2AV^mQXN$(f)D zmG#zZa$%A5S!czYgtjD=fUi%1Df+w zTvyDzIjeT|bM?g-& z`phc7=(seE3Hj?EXyvdRche*c*o(24nq!p zJCZo)|KmG^<%2#IP^7W(xM%8;aQ2i~ZP>Gx7K*FpU+uq@K1{ka?Zs{V=$;Rs(^x*@owH|c7svhb z{X=ytZtf>H_6e%RI~27NxjXu<0CS^+4o^VuRmRotz?o%(>**_u7MMOH@FJM_+!5BSSFxephb; z`N!VnMoyIL`kySbK_>btNh-5?KNjE;1rIdh9Y24S4F%G>{q@CmX5lpZ&&hM5> z;7!$Iwiz!cL~A^ON~ae$RxcXj1ehXH`(`srWUuw&@Zwk8XAR|mYQgUs3J-^(;_UPK zKkdDZv+dGo77PPg2%3;+_NGfO$0=OUR?xu(bR5F#6j{RJ;OL4g@iT1;d;<|B<&h1;V+iZHY&~k96>sp6=i4}!fBQ^0)XGoEnRpaU)+RljZuabV+>K9fhi}(+cH$pXo?U|C*Ku#8gV`Oj zHi_(FJt{$-v9B7H==k!=d7v$(4gm5zz_;3f;;ICWM^k9>@N1@C zbVE$S9TMdn`2A=%q~VOf#VpTL8>3(CLC>T@p8{Qy0{#c5xwLew&+pE@l}2iGVZ0Tk z6_dTJV{sx%N=uTAgQjSG!Br7OOeEUR-9_bfHEngPw`&^kpNG#3kA?Cw{e_OTbk6A? zN_-eSn_*kgv)}rnea<{JN%Da2s#@~M8?FZm!t3$W7062O!u7IFL|ZguS!iLw@nKmO zrz7ATg!#9Z_MejTGiV4i6oHaEKGlmje+4FMM#cE~4c~3~vh%pfW z+N^q*HZzp5i&Ai~*}u9j-`Bx5(#(v%roFlQRBUK-!NOptz$$g(X!#ozWM1<@SjNKl z$G8m*T;lNJxx8b05=DjKIlE|;wRXd7$Cz@k_AS59qP1Wx8b8018Cul7|cO_kPPb}>tQwR0SE<0fM zuAY{hHGdvbH~poJo%P%L0Ler}pr<2E*yRs%%A*rQCy%=5>q2AoQe52cwWZHpa2U}X z?tczD9z7@jXg_thTk(gk!BEB@a` zY&B5iJer5+YDuoCodiV9jg|GCl`PQ!h}!nDCvNXfStHTiJbuGve}6CD3+)II>n;?f z3SP+8IC1q#^_xy}*BfnR#e9Ed$UUsN)3VY|SPeU6%GT8&dmw&Gh9QTFY|vgKX1X8S zef@as{;0g`(Bk6dPz&j5o*o7>_|#v&&0@=QaEM6f(WBwxr5MZWL*kIb9Km6g{Sa6956gE$FF-WB@JP204Zmn6Gc4v0Dmb@ zEPRz}C^DDV|9aS10D=4L!w41Xj@GG+C>g$Yy+^d)dxs=%4|GqIVsycNVn2sp{_-h~ za$_YBAR~;5=2_Y_woJlH{h}+P!WvqDM<5+5bG!-ntj_}TElT{h+Ugl6ll#uS%}nt& zY|C)RHH<3NiuRwodMAO9_0rPrY>n(xzqZhesOaUq`?b3Fb#MDOW^9^-j+b|BcLg9* z;}1p^kBImW_t^qW@A;T&Y#%L`7;Ka5b9dFAd7DwO+J#NksTc;g!gO*n+iuvJpYA|K$LqP$0FzHyv`R=lDC8iBM5?}+*%jR24Lu) zz*p6 z2q9zL&vT<#OoB(?KD5$#eM_($a&UaB3@Fda!_3njf?1Wz4TDjG*HTy2FG5F(QjfkY z_!L5+)r`m=6n9I8owC*JkN;*1G`2_T`<``zzL!bDvY&&Nr(lR`K6|;_wkmG16j-LK zMg9B9s_r6IcqZbi8Wi+~@G!)5SXt}i7hZ$TkO*}ms<&;vI!?d6)cz;A`{Zd{614)G zToUUybF1*?_5|h{q+DrK75goBDz0b+M#}aQIrBz|e@TQ@rc7@!;y=qHs>>a&jCh*0kp{3_*-vWb+|P#Dw&B*{zC%)H^0Ris!%FXaXoi zcVBb6^j)z|lVo|H&(DOIH9*d}^YC$iEZe4vgH}2g?4`um4|ukktZmZLAXBn|-9NH0v9!sZqI^Vcfu(?Qu5JL26tzw!z`oav&^3s!|9OkO{t$Ii&mCb+UI@w`7G|~Fi5S`!q3hgOxOQ(39a;|^Jw`? z*}n_)RCK&FKk8~mU;(XtH)76l^9JqrTynAXq+24*Q<>B0Q67WdXL zu_=jkJW;IIG!|-urXgMixd=XAU;J=YcdF+-D%1gdI(PSe)|DgRU1Gi&U=$Xc5^owL zYc(wdYR{n!DcdF%fS(d)j=(15fEImV^P_V+t*iFhGD^RB*iCOCH&a82%c`G+dwrI6 zW5j_5F^$Hr0gOa)Vu!)ena&{({uX7%lldm5ecHb(7PcG*hG0;d#y*|5U52^bbcL>7 z(I1`Ls5~|Q$$VCPJXM;o79(RBS>t_-Km@u(92jM0xz(Gc^rH7!o^^Y6@CQ5~(~h?L z33qtFjn-CvGrOI&OKUi>4qv51@Fe6wxDGcwJRYIl4vH=^Z%y0&=RNBDUK;uMj=kdJ zKXZ4eJb(sVDa@?3NWk zk5dZ<>vBY857mFPtJ79HJN!&k{@)u&JA)dX3=3n$SKg>nT19jJe5w_4oO7%wpeqM~ zutO!T?%Y4YWM`;{I_(_`u>?vC3vK7~X}jUBZLfyQgvkSX^WWE}{1}lr&Z^I)%SB^! zArrKzXOG!{p_3rSxj7dT`B&=?9|72v#L7OHy?bo;o)G)qcPoLO(;thZ5)==AQphw% zpamXoF;k5)hor_KG(FotW|7BrmH74?xb{F>?y&`za(36qvO`7L`0^#Yd)5d9x_f{3 z48~ac1F1_s+ISRlX;f@?by`)jTUfEuLSZ$m+Q#FwN>%s|0^s7`(*jiP16?{zbhrO) zwz0N(b>!RQUXW;>v2P0sd=~NRry2azLfUlRo$o3?m#8D`wrO2n`_!{Q%6m_iv!kA> zL;bbQz5fd(dYYJX8xS)eLz>YPr(63v*V6@0tYXroXqF0H=P>OstzodPX0U)P)ivHp zu!C!*PW{tQyk}T>qvvQWp$h*)Jtwdx;ACQs-wC@tS>uoW+jd7BvyQnp+92IQ;UgNM zH|(LohDRP~VuI|wNDwhJZ@Bb3nk8SDXx356S02(R9Xu?R5_WYdtG29RJ`-l^?oy-k zp2l6Sd>Npz#F)RV*dsZ&2=>*1;pc$ho*P{?agSOXPkDUw6 z=UZS_GfNX(ZP8?6TPM{BTEUw55C=IGRDSn_8X}@r6n@h5dJ+tj zlsAYYVdSv3%o2g!8S3PNZ#uV}rgdNuZIu3wcGjR2iVsM)WD*}u^ABmUeM6_mW=_Fm zgG+jr7h$%Wl1E16(XqJn(X&fRWl%G-jKOrP1jn3>Qe*6Ht@7nMbokCbzeB+g;+?9n z9>aXOPXY}LYe*cC4F>r8pm#~|x}#8;q?@5p1^I~TX}(qT5Ux@~ zUhceE-?G`OEBTQ}Y*U4d=R7X&s?e)VE?Wcbh?}6+f?d~yIN!~h8g?hJ?4*5Nrs{ix zoTNYt!4b31a|^*3>kH%t-OVY5ftC=YJq*n@7&oL!_(_C6_J=qDEmcxQAz^uD_q{y) z=wW4-Bisg3WEME)NDo7s*#;>xk2AjTv~K!BTA)oFb+qC7jL_w#;nAVL@8sU%i`r9VPWf&yR{?N3 zu4@GKdwh7q&GaUiHW~jQ~Rc0js zN7gbQI`0I464WHE30?mBaq}@Zb(8P!@yb#c?c{M+>ZIqxMAPe4Q%z2-oOKU;GT!Fa zX*3^WZL*iV6c_9g6yjadjCLw|p9zdp7<@N?lcL5|C%vl^v%i`v&#&gC+_6Wk<4M>TsQ!$D7R=Y8T+# zn`vu<1x-Vuu)Ryns=caLmYbzU^Ib^%bEAymY74z!^Mv{j^NY8(lYu*V$;$N$99R^m z@Xe|>c{$*EGmc)bytTm&{|2B@cy_ARy@l4N_FXmpG+a9^&U8}=;LcG`iy}!}5-)pj`#lfn-h4})d*Zem?h1r5NOSAGYr)={79=2I^&O#K<=Y{wKti+s6 zpJlLO*?eu7mqe2YP#TES< zD2XP*&MAN~JV5P6L?vW6%EYvSbz17L-A=pN+Oqv8d;1J=a}ApBYTkczM49CaREJtcZHLRQ50!Rq$1 zVcw6NJRifs?_ZPsS;`Z3gNMw5k~?CTLaC{6I}N*I4~ol=HJk-P$d}b0UN9TGZ;QE( z`%c;0j^T4mQK~wM`}BIHlz?-B$JgKg#>JiAAE~#0hhUFMel~k(kn0Pu(0{#vM?gdN zZM&!QJ_C}pVaQSQs;9lBG9fD*IsnKJ87tkJr1KV+f+G?t00S#u65FB~X!bqNL146~b!u_n3QirBrmk@)ovK$L;&rvv4cKACm;wkZAGoU{dMH;i!ExC@|;`4J_1k=0sfI~wX zjHLSL@co=n{z_Tsa&+#YWxYu8y9{L`KsE&IUQOuOhkLzq*smq zQG=e$tO(tf=zfb@TfD#}gEd|Yizo#TeWwr;4FKKI?WAJ`!DgRH;bq|6GCqR1ZlAz5 zdRrT3?=`D7VZ3waUPeW9Eik`bi}w65wJ02N#~xY%6Ps{a(y_CZ137`3H1(&$QSD5B>Gd{y? z+XI=}z@eE8#-if?FDgpf-V0p?&1G$wslF*x;?Lo}dwEq$du&2aqTm?u%BTyR@v=c; zX+zW-j}GNOw+8Dn&ojbNrAU?M(FEsJ6l7g?yDjoH%;7W_W=xVLBB+koqU%&#$5BrX7 z>vC)?tj>K;!X4YPlJ3epFvXusF1EL=S7=!Epe_1l3NKDZhOX_KsBnpFn%`T5AW8JbwT5q(E$kDb);K3xRbn zHO^RBm!R%5+mpSJdulG|DB}Y+h7`32FelXdG=~YD^sF7cvh*?#ObSt5QGGv#`@BUIIcGglC!1uguZvp$RjPq9W1w(k4T$X#Srebn zYW=Yxol@+bOd}tapERF7r+@6vxANzTmbqXZvzG^~<4RK`0VZAO4 z;Po^?twk72+59iGZ4rJ_0is}*aZl>(w{a>m#-nVqR{6^MLc%#|-19maQg83Y*defKK~ zb$0OrwLu(ch!p7M6}@@u{wJ4uG}@M?O%M8^DfX@Yz4&*{EBWB1tkQqy=conLQMsC0 z_d8WrIn$J|o}k8(&L7KUHq{uBm{R19)Bu$}(mPBOncN6(d}YAex1LU3NEWa8PI80RbW?(dWCnUp2TJ2jnPTj@l%%Q*5y4&;Cu(&*zI5!i83K zysDVFMNevVyZ0xJ3A6SLnH*@qLP)D~$z#0Npl;5~X*1x^ORY^ZK@G?_R2sjxfI}Rk zzym!CKQK}1im$?4IG2sP{;lP%l&tlWS7a&YlJ1Ybj^zk5h^Y@JNn?L168WTCuwG)# zpch7hWLnR~mjJDMEBj_?{pmT7PnD9ZlAK!YZ89Q>>xHgHYlki|5zU};LtEJoxUL2l zkKgS7%R+fxf#ZNJ1Pwq%4}eORGxqe+rYqIQPbOQmnBV=etBDo=Z8=cuj^u}3yV+@qJKGV(k^j5$c=C``xB z>M^b}0;qvFa&CHSeXm>KRpwM&`1tZh-SIl%t-r3nHw5Z-Sh4znj5x0a_42Uw_6ma zV{bjsiq@(wcVcfkgH9AtY}yY%<%NC@J$q;=%JgNNA6ab{@xgY*DBL|;^I_LYSR9ai zl#`kbsP76@*b#6Q7iq04y}&`P4w0jb8w`J}xNW4wjzcfdkyCaBsF-wsolbGg0+!cZ zx?2G>uU$^G5nftZ?1MtYneVI|wN)~%>2_RO%|N}q${i8~AR7TBJ(DkHqPt;Yc_|f^ z?}eTn6193wpn9mWoLnQjqFOgMgV9kW?v&VL8oKdc(i>nlK>7J!RzRF+GyX^`@2(EB zmkuq=9*33L^sS^#?NOuDnURcG*$+-&Eh(CF^6Ly{B6X(s?r0T2DRXZbGlHwL$20HK zH==tMD>bo!Jm{=XS>4nf)KN84U=Cb{F!!dlpR!H%&AxX#*+9u)lrqWX@I!&e>dyh< z(Wk|CDkK)a$qATwg3LO4qktS?(-2$^A@%uJD)hK)Wx1cdtg`g5h$G4YXc(49LH1v3 zJ>zgcp#2m@bPyp2LR+Vpl|a&^!{RZUoC>+LnumPHEwpd$XPGED@ir+1oNKIsZ%H%D z{uGjWCQi*IF9EaSx5twNJumNSDy461Qwv|6a1uae)$wg5b9w2W-m2|Jk7_Dq7qV*+ zhI}KxGVwBNO17;7MUh#clb*JXfPXawT0JJX;(WLkAB>wvg^x!Jq?O9zx!()8xT+epPEC}N7+4U@JTP7Ss5a?R#b?s07r?Ib-KUy!`xWm zBOOr<3?^`%hBvWo4Zv=$-EduOGmj-CC@y%s|+ANk2y(On;;6B=Ti_U%Zm((dzWvyV?W99T>SmSb z7EXxk@Hrr$9Do!F1PHGWMua=o^2K8F#YcVo@KYOwM>xyK?L!u*izYXGfWert_Tdn; zW9rdz&Bl-=Ia<~9iVU`Fw+oiBCkX@($pYE$bC8?h3UDtdX#RcQ4R^2gYwVRp+zya2 zuq=nFMwj;@fLRi?2;_g8?jf~oWH1p7&RYJq`7~$5-qn|%(}4ceD$oaP1)NR3^F)(o zh5qBlTE-0X6gRoAQ516ln3Ju92Xf$ zHKZE?DJNNHtR@>s;jM0a$SNE5q{dk|N2AA(_yxfZ;;lq1erJUeUAS>* zCO4O%kLyB)nhHsix^6ekpc=!jYNpBe^kx$*49REbc%i5K&`4lbAJp5mAN-`T{#jCY zMVKnHg1Stb7H#0&LNauH00ga(m1D@!{J_LsZYAKEjifEa()3y?U_2egEj9*%8N*Od ziMj@P&R%W=lKW9yW-|0Z2c2I)W)lpRo#f7^E%;VB1Y25yRg{L2c9BYBL-qq=pVHqM z{vV#MJFcm$X{Xb{1p(<6$_i4XBTZ#d5do!!7Eqd$KosdM5tVfn5D<`Vp$MT$?-wjA zML;n064ru-7RAt!?*w?ifBnhLz30rCGxN+d&#YH&q##nBFQ{imxwKa4>#6L_iY+gh zj$ZmU_`Ak&UCk2zohkOgL*(ZPy@Vb=yTGj{luKR zLkNxh=9vz*iWIFrFQvTXFD9N+4p`k5^c+c=&I3_EoZ-m0BX4xw8W*%FtvShu{W$av zi{Tfkxy)vDPuvQWot&>l{BAm=sa%`2(exm{CVquL_K|ZKR~Ow(m`IVL%Q{7Md3#v& zI%UNlCpV_dE|Ga7OF%tZ`r7G7OXwxcZ}v4aY6LA;3jjt{mt3TW*AB!D=I`Rw|KOoWr`s;w=t))Q zdm?Hk!e0VZ9O}8p%JPnu*}64;8v~u~1JBnE6U^LOCbNcX&flCY%eb)62V9L@-=}i` z(Yk*0UO==Y|4SKo&V{$jUvV1RHxxnnsquH>^_z1HCS6dN72igAUS%ehZhbeJ_vn1K zOXVpavGMc(&_|j|l0d8izs5yLQhfp}QJ)%}7DF)!7?7o=LWYI7XUcaPIeG5!9bQQ8 zr#U;vpz?oDl$^P8PyLHp;}L$lc*&jy5Zu(Mi(Ec|9x}Vr1w3vJN4S1#k7hpb$F|@$ zOcKKOy;tO3DHUI8_X0!@eGuxpQKHjzw$8}{u1Coo8x_*_pa9lv^P>V=ao5Gv1X*5( z%;dJI)=Q1lI;VaUX%MZj21nT47qE%hhaDi97uSY&*<36+`J-<%2R1n94s>pz_I!<1eWz>e}<&t|Yd#E+zbZWk992FPw zGIvN*J=+P;;07d9XymtPCx6}UQA?*3^cPkAQOCY^o@xPA`?l(p;xC1PQ2*+G2#ag9gip#~pu}&Q*pOuXF4#e^1ffhR;uw zsx76kvtMzH&6qI?RA1Pz!A>ZM9_y&Je6Ra>-A&MSRnN%Y##rio3uJ7bz{s|2T66+< zG}wQdCVORI_vuYGm8X#h@-F(tps?F7)6>&v^^s9!!lrAU{(p*!in)#G$n?Di_oV!NDfJ1k^yD;3tf66 zrE@%|-_rTT%Zs^%M#U0>z)lCA0M-V$L?cz{OSY#MhB~avU%j2@Tb~dFCX>wlFiZ0% zwiMOd`DtrTXEE`Vg|YXH3-BUQ07D|}EWv>aj62II6P(K6SgSGPG*6e)30U5SbWh=D{+`a0o1=!hcR3B%fl2PT6Cr{0fyi(#1_cKzadSm=PwvQKbwO%)OQs9srljj?1O>g4I)p}yJ@Ncr?-m?C~?_~+Kd*z8r69CEls zMo5$u!-X8>Hyl~K0)0HU-UMzY&TtOmMy$63Ird zv@FAM6bt9F4*`5b8i?e)1fjrbM%~7X>mOG!AXjpez}crFr;&Qx4l}D?JH7jzSf!XI zBqXej?0fHvWkGts%3ATTK-2R%Z#Z4vUgJKv9u8XCV7)%}1ia^&BDUx9Z;Ft9=r|*y z(v^dr2~84phd^0Iu_J%h8l$vu=91c@(V^+?)#Rd%3kOuH?lL-3^qd$Gc|Wn6!*-EV z@3$qoTqvuxIn$*Z72txk_$@*yyP>(W^K2`mZeguTN5KE4?&2dySkU{q>A19Q6*((; z3l^ObVL-}MME#b_kUjz8@$u%H(tYa^u=B|C!p=-ACnsh7u|&7xlT-Dk-LnQ^sn~@6 zZJWY|-&acWe6y3%PoJu1658KcMy}}|3bqPtwc{*?wWOdw#M(+OuxBRJB+J}7q<7Hv zuTqpROD_u`_Fbh=J}03Uo?BjPX4CXN7+86Nzfja+c&G;3eH-t;GnLYw^mAe*US2T5 zu&886x~@SM-u@Pv#H+M+Ef;*Quga^<*lZX}hMQ<~#gC44q;QPBk1!eE7^_(cAs0Cr zGw_TF+HiSZ?Sh28-aSgKWp76M_%D=|zdwGFb$&|S?0RjLn>^y{Y$&KG-nDDi%dfhN zbE7zJhP-f#h&%%5lH*YHcq6Zz)tOZ310lZ;-|^zR0z!p<6|;UHoo>~x5XFuKn(#=m zHlG5p1*vgMT(>@Um&Z9C}x30Q-^uSAzEvWED;9Z^8pQJ%&(q7*@cebqF z_GR*e%dehJuoibI+|qD``+NT0VlAJVrkR=+{<42m^W5f(@PyVg=t2}u;ne|q_(Wow zy@q>*HHKYftaNlGs;ucA`Rc{>+2iXY-=X>N=GTST-~)9kM;5jr+Jcz)%dil~%*2jB zcl+{crCQ}T=@~8ya69>>j6s1r3;os~s{fSG0HPj@!2<+75bX?;Vrqs1o^1YnpXRp) zDjke0nwD_=L?4T*^ccq}(RBUHr4Em;p}5SY(IA;StpdSMhkWY4zmwDuRx}5mfLTR% z9O(U_4O#GA(@iV_(OoLj2T2c%9%_%@PM>ogTw&)_4IR5(@>6)sVZ;unvS8&{$v3q4sHikz*%$fBX>Pt`vG_PrskZLyHHen{~Gy-KD01U@{cYyL5dcH!S$XMODiq{4Db z2$7M!wh1YB^45<8JDx3hW~x^!o#T+hL2TxIxR_zMotbHV&)_xws#Vy7p~sn(r)DU+ zkXYg0cAm~f3nlVf>FH0MLFUiXt@uZFq8MiO*NtY)%-Ob7^ZNnX<-RAJVjh4J3w3i{ zd^x*ft5yck(x(3i4pZ-VVz1jGWLO%jZ-O4$SaH!a^%^b{1TUN`4>*UmX_Gf^j%~+X zsP2B@IkTxuP#^@AQI6U^PF8XCT7MSIdT5d1@AVV>&qDm`>VjG;Bd#xFNBz2U3cT^P zKbv9;t6s5RM9P=ts0=g!ZJ<#|WsrTSERA4TKv{pmN{^P*lLyco zoq<6xqDdo|pXMN(qPs{LoV@O`v!k(H!cqov_jdvef~@O;DfICi+iYy*#d* zC@2ONG!8&BalT#b^2tpj$NmH2Z*H=v49s&j3P64~Z;n%SB+Rohdssd5(}9w7tY znIuqs7c#P5e;DT=$AXZ!#6R?rrj=3g2Y}E#gpN3GKCS86)sX?TIX0PmkH8sUHrcpd zhu1Ga``idh2bP(9`jfeHh<;H8sIYmmb+laeu9>pZE&Y9=nsEc_7>FD*8ltn(r*zc4 zi&*lC5^}$QV+m5R(jgyy5GNY|ge<+LRRS)7o!f$qALJjT)|NYLVpTH*n6 zO3E7fR@SwAaCUhS11;@nWhkJ9Whw4^3EmN-@6=C z$wH4p--cr)ccOT<4r0~YQ`(OU9omtlszEJ|_poMtzV~<7ii^c9r%n1ei+vHrlw(cj zR6F-y_?%=imIpS?GQ|@vTql6lzy0WW%7HmGwpS}O$v0hEWqk@k(;#+j{=G??&~esx zKF5Frd@d5zEOig#@9+J*=Nn~egyFr$9pk`*Te5^%)-iVP(P#7k$e82^__OwNBhjQ! z#2hz;)N@zF<8?^}Bs}I}1vggt_T1T|{+jGH=6*^BoRGh}ij)WVUn?RKrt{I|tqIqN zjFpulJaE+f@8s21<>b>6)f--y0IHYDUTnvp3Y=hB48Wu`RE)|rGoN78T%>0I8khtg z(-b8~!A9{oOnjPuaUA*$1cMr>y&&$ZFLympU56M@EG1f){{iXKat+^W$gwa^1v2m0 z6U;JtarDek7wV!DY%1yM(yGkA-`-hLD*$Iz@sjq{YPW^QRh1DcJEiBYEbeR*Uo<|6 zsy@&;%yLbs!qk!*4|yjZ9`c9maz@~)(i);IGJ-l5DYC`%-I!gKR!!){3iq-z#ziYc&Z zj2_p-2ZO(@kYABzeUH{^-88qX3|~xr*O6xUO4HXAf+WH<;Y0t>;GXvTYv`hIWGFh# zhA~>T9UyWoA*EfHFSiGS1iqD9Lh;=gvi9njdJ%%)(K3whQX&Y^pG9=V>8M|h3H5+1+rNxH;2nTl0PX3 zW}^i;HXr)_fV69T5eWz8h*j|4)`7zFl7~mr5m-znSNI=wtXQwV2kNVw6?*ztX1~-p zeT@&e#zkkN8URCO>R{YYXgL79I#8|>1pji3z(G%&Hx1?}FAT+{dO_Eyx2U>IH>TWS zH2#PVcFh%(MuzjuOEWf?&QQ&r-5A7=d+qc95n#5s5VeU>EP9Dl7ZXO4cn>1Zh;?}r zIs+``MQGyhc&$-9J&0(bvQw1dTWSJXjYbe6z)Sq^38y{Bv*;O+lSd>430-DPD3};C z`bCJf@be(3$fOx<_!XLu%gINZ89kwx1RIWN^!cxziG9592dJ*+fC!ux!~*nB1U&>B z6HrIgoCT?0phjW=*uLAG$A|sWlWAlNBAOB{b_h}0J5dp7`N5NSnGNK>z}&7EqU=zj z+^R$mkCDxuKT5ra5v~9Bl;j|Iv?sP(-Bu`VkW(IlJRaD6)XIN{HN1t{*q41cz(vyR z7V`m#?=HDpFJ#(Ud%*foD}_LyCl=yLt7kY;7dex1s(=R5Fj3U6g(bdgix zWvIZpBih)EdK(FJFHrI$1Ch8%+R9N-DFT+%*xggqikX6kD;T`vF|MOLKw#N2dA80Y zp2Wx)e7P)J`W3#3kjZxgf?-?YH>3HX`jAV#=oGV>!Swj$$U(?~B2@NLz)9}nj3=*4(vmUVZBhW8eN8nW? z!CSVX1?j+3EMC@jsc6VudO}-kb&${EF2$Q*xL5$LXqZCIya8S9RH(^|-31?0;C+*s z(wc46qR($0RB(488Zs36bBZj));5LOlIn-8TtE-jCC;oq7?53)5sHkWv5ujQ4WIzX zhBU`jrfRqEZ$7>Eq1LmJ7k%`BBfdwBAxXfZShkd+--eZ>7fG!ZBA3upoT8vGzRbtY z)r9g5gDV;xSpCQHstGxJJa1-VXD5NQu&Dm>ii$$E4|Dk?t49Tcb^5#J^QIpOwDPB2 zW$-{za2_ebcgnAxhoKR-i&e~H)dmh+{|MYl2@`n)xai?z8#Bz;m8-e^N(4c`ZMV@= z{exxFoAB`0?c`m<_?S25c~XgGO^%ue-3PGnD_Z>mFv?Q|82u+_@N_Fr@_7-`IPXNb zc4fs7r#Uf6y?Z_F21C2>@c1(`E$9G2n_geVJ(bm^;ra2`r}n*vT7skLPl|ef;AbHe zvdhNXpw(M>J{uPcRtMhxWz~_mzY?<86}wFD&bk-0FX$mP)T>dDxWH!>VJN;QJ~WF|%{cwPjk zf1O*`qnB{1PWx)}gCl;zS)Xc{77##?knO!dp$t^H{kECX)45>fkv_7&Rmg(tA7|lx zUEIVN$?NG{Iwc^acGPXPNMmDQ!PWeL6m3pX<27`@;rK-CKwtNzmnX^ZENF-+j~- z-`pZ^8K9`=1{tOCs11}C6u;Z>9rr1|!WC+6tA4DJQ%`P&awUy_h6JR-GVjE0~JI5UOxF6-I~%k97Qf83xL0O^} z72&DtKMHZ7=OjE_CP4zS^ee2O9faBN`HUjqdCuq4iiQIs;l=RMuHnr}%cPQ;|1L&9 z9o}sQOw4To(9$+>6*wq8o+ePYD=E&_NCzgy4K$B+s8Z0gh|+f(Wi?ssVcd6QD@$?d zMdZni%K-sSm=96NI$rElS-A;~WiKKfcw+oz@Q9~i_;DpUd2c@u1$r0F#^0?Gk6Mg6m4j+wioROuyPxv0H32di%jP#2H>d*(Rsq#g8HMjoX;r@3 zu!?15^>j2SR92{K&&f3^FD6;HrLGpzybc8)SQDokQi0-PbpudQzHshl8>p{?&K_HE zQ7T5iHR~}AX`JnAgbBidiWq3q9J0|5yqqiSZCzB6t&u{(vh@of&aYO`{y|;W_%gkX zA;ic>3-j!octq0V4^#LQQk0wXOMk<>*Xl`X&yR!Yt~eX;O-otqex4bz0WN`vOz!!t zei}bsn=|gOQRU#1bP1@BFFiRvUbTP!Nyk$DdFDb~MV^PxS$tV`f{wAh5*IRSvueDk zHY9}C=I=c;Gu8kT$CKYR=*&=z2h{7rB#KJ!0sReWx*Ap1`Af=}KpxnEUBwnj&&2ND z;Er4=dMBG}3p%_U05^8JQBCBYccE@MT{b_a3_dTPgCoYN3q9jJp}71=hi~N*>48w z9U3G#;+I9`mp{C6%~+Wa7?4Z~Y{1aV*c@6A;kN9%+!WDBZ}S$-+x7; z+2E~gR(XDwhZpkgpOI4V6ZfH3nG+$$=*yitoK3*r`njtQT?mq$f1BUaa7sm+69+GT zMZ3sH+Runw0Cs!HZa^JgR;p$%Xw#SWP`0uHck&$t>qem{T5zpeP_uVgug_I+`>}G( zaeTB4EC=}&(aN&rs$9((c(k&D`@#da)onM*|Mbe*IGnBA9f3u2)b)vB)UGg=JC8B{Qh1iP zWggK3&zA}Z)Ja7^u;r%mz9!2XSC3tCHUlUGnI!WoL;=V{hf`HRNM%kgRZZA`QEF6l zsy<>W9Y#+1hkPFBZtU0r>Zz5!>5lx&^a*DTDl1i$3WOx@*lasQ^T31P)94no14psa zn}NS5eKPU8S5YPaxILjQOZzPLaM3yuO%W`&19v7kk||dD);scT6j7igJ~9z+);ql^ z=1?t1Oa@}7b5t`G9`7vvo9Wlj%^B^QgfIb|7>-~WdJzh%Ed|qh0gMVrrMDIi$Oa<_ zp|{DC=Ryc!6lv-iAS_*5DKf*1do37$c{T}?W%dGZBG_I_&_T9@41%p9YT-Fx%Z9D` zhW##5D9)8X^B1q%Ka=L%^HJ%p%=;5a4*Qu`EPn;)-gSl! z9yg}2dg(1ij`U*8{T|*AU5=qaBxnv)#IqfRH?LaA7Fg%0-H4PJnn#!aQO9>3K8-9zF(s>?(K-hXi|lJDt1|Hx z2P!AkW0x0y1H3s#0=cC?_i`TAD=Cbr9RK77rKOz@c{aN{H zNK?Z2lcD~8ykdg&Y@Cowwkb3FLb)(Jh%OhX1G;8up5GmiCotKFCPeV5a+e(0=6j^P z!j?^GLMd1Y#d8~X_O_rMz+-krCAfsP)y-Ifyd!K*1-m^9t&+M)F<@!(ceDzx60R3jocrmQs{VC)=gL6}?ByNVXmTTI znNNJ1mx-$a`G=mJ8u>+-C{GisCVGSm1g*6!=<-uDqr=L_<`(560OSoVSj**4G?utbA9JIYv@G0kp7FxdTe@rzB>&sp=^&qm*&I$NsdGV;wgG&Pb;nFXAx`&UJLVp>%Tw1kb*XRk!UPzHD&p28do)gm^aCF#B}Gw z!aV35Q=!#t@f;ADHG@_@SE*+|zF6T&@eeC!tK(X`SXAK~05xI^Yt7E?x{i0%;QLgZ zXwK9-6Wqp!RLLEIXLxawoZ$+{fu+SD*2iV7K5|T}O8#mzj=g*C?3rgP6)-AcqA~PY} z8QKa4^5m0!lPt)LI6~f~efQT>V!&S(_IZ!4-U6^))#V zEVkNCEhurp3*ceW#Ul5=0NaRym#Tw@xbEdn>E%G<4#jPjJm6Vamp@V!~ySd$v5YrA(I4|$elWXO^S&C_maD*4?Z znOY@ep?8kKhs4HJK6mbfnVxMJB+bt5OnTd2O-RG;u!jeR-SszD6fE1dJS39=;Cg7& z2zp>~zse;iT5hbJTn5WAx;yZWW;AXv#vX)<>y5bFSNczL&$_HfSj2%fUw|IsfMz&; z|EghoRzjoQfF2Mrw3XhbBa}hY%U9lgU`Vcc49PJ zrK};rt9YBFLL7#%AOTklZ>Kd3PI@soHv9D~!5QzRH zFr@&Hm;eMdNZLmm94>TIeL+2Ujws_k+?G&`@21jl)T7i6 zNO5`fhNEY-eBqlht<^8%B*%s(#Z;*`8OLGWe$lp{ zQ$OOxz3)?qF6-+JSw3Hv;>hB_6E3oL22)v<{SX9tpaybWpTI$uR%1Tn0_&2hgv+Cgbgp4v!ttQOU3(n`)GEnCseaZS&D;%|?lf zVwH&9I9~MtFHV;mFw7Coz5wYt2+?}3ZSd0lscm3a5)ZqZcWza@Wy_di7e!tw%N~yA z0;1ee7&~D4w!rj__rGEs%qlP->ceoD6GhpBq^s03p#MjxP893bXa{=lihlP6r6z9>^GE84K<)Eys75oIw;Z4`7H)Gub1(bcXC^w>Dq7wxdi*Mpa-JszC^WgYo|1r`1C%iOTJE?{o4FYlp z)>SgI3qUC~)HbISK=fN2^f~Z7rJ=Vsj>Uf}g_cRvCTk;e+zD0&kgli97P#@OMgA&s zR8%~`ykjKsS7b}v^Ti0vwjH>pa`f2hg{Nu(sJVe^zy${)+5l6krtp1q!Y5wb8zxAw-Q{lhq zDdY2~)H0TAjuWD7YQO?8YuK~Jd3r-h_GL`|3Ah6iFOsR(sMi7hd(gQRb?8NP@5Gat zDT&%@9|8b;dU{TU>C$zaWBzd9%OE=Ck1cvwkDK^5b>81xE3{@Q*BEC9tEI#;T4__cg&iXIqQFyp`>B&+z5!yZXvkgA=}m4Waz>+^Pd{Lw2t?OBay}7lQ--pgZs3 zDW44N*jF*8-2s-?E&(pObVq8A@Qaxf4PX#Tc~ufHqoX#jYWx|NM$@8azjR|2eZ z+uivIf`sG1FAT45hDQJMLy>~zAj%-hjMavoGv`aNYqG4#5{?j}zzaF)8u9JlJ>R3i z1@*C~CRCZ8Zs{byxLiy|96}7ictGd5pwP!iWH}1!(MWv*0LMyY>h`L#2zlI*T?1C@ zQNCGAB>Tu`i8j<TuFda+K0r!x6tJ_@xv1sK+bE0>BFtT z@qI=!65a`|*WCNC(_bAI;5tlTt?-TT{a8pBgiGNijy13gA{~KqZEw5n6YRSp2@WB zjJvbc*;9=xYnwsk>9e$CbhU1hVy!n!IQO4%KbPjFPQL@X1b9Y5P>q}dph;MG?Oai* zZzbYf)&-8a#kA%XjC1QB4i8{+mBusiysnPPHD}A--ss&=_3qz6bAYRGn#Hu2o6ua! z6;m1J%&~S6Q>E{Ped_CK)4mOKxoge2M!GMrnkP&uM8UVtW119yG>A@zMkMT><=A6v zWq~MK;VrE|)N*Opk%eLDIxj>JSTP1jDou0q0{W`>T2z-u&heHej$()5Imi|+9ja)} z4{u4q-K)S;^xyIoU6P7L~ROvc0N#y{TY9GvgLjtMFa=72R46 z128A8fK(=OE%twt$4KC?{_5f1kf&O$`VeAvHH96R^OhhgkqWzy$R^NBPK zH${DoJN)_(!WW~L&4m&FnJyL;8bRL;Y>^6h+Zb3G8pl0<>Nb3Ijwq*W@ht{w-c9Ev z3!Ok7Zf(c5bC?p%Q8at&Dl2elyCufz=b^VT8T;mS8fc|2s;-4W97M5D2V{zTw7zjS z^)94rk5Logy`~Efwm4h-Ru|XdDm}Cgu@E^jN7Y+?Z9Lt*)G?M|$I)?}&v$5C&>4|=T2*W0p}G$t*iXAniNG5jNRv?2sEq!7 zK$HK8jc%F27j9ptE^c2Y=M)T*%B^@5II>NeMJOV~^>5c>SCKocTVd*l3F1SV{oYHJ zOr!Rs_n=K-Q{0*Ax9MJ71US77g$)wId4_>fuL#jzC3>M5nk9-5abvI`I4)84ZNfa^ z<;jo6=DXphuo6h4c!GuL0sx%>9X{Oh$VddZ&C%su?EGB+)Ht#S!Q;@lgp03zJzPrA zEh!<`+EasgdcZQ3IUp(#b4lQrKahL(LubQ#in516K=Q1oapNe#1b8PKT_fRxK$-zY zA5V*~woldSh=5|u-ReF*4J;o$yF2{u(|BeTSDb27gNl_+4y;tQY2azM!fg#^m4}f) z(;6=qq03T_hlqO;_aeao&E!c;yxBN)FpRV8o^tWF3z+qikrkBp0|<=Z5sMdzjI4R( zk;3Cb7|@h@A5?_9m(RQ8W(=HSI<^5KfTBm)%BCPY&Ya>IZ~Oc$9tSyz87?f3IiFVgj;`@ z;z9!rxfgs16Ef05D8|9nfJmjEnru^J;Cb5)4&r)i%&YjnxUiYClVl?D!MWz)B86Du z-Vx&}x5SBo8YS*ESq-uUuDO+o+k+g8&+u#gWU6ejgiMoIA$oLS+0_|P{<9wq=NGY?##D<@ynpVIAyw*)`uCu<>f8R5M0jOdn$0rs4bEbOIF&Nh&Js*- z_dB$Qo1S%9U$ig)O^a~|xBvf+M;E_Xm8mfI?g7}-A{yI(>!bcaDS{r54QYL)J@XEP z!|8BQ)QLdup^^Xn#0LRfbI6?;N(ft-0&NiH?KuHXCMdbsrKyj_gVw*yw8P%=N)Fpk zN}eF*+2)<|m1~+RnRWssvKG|W&I^s`6#!f4>LSPI|L2;Fj~<9(YFHEPiDk$o&Yhjr75l6gxEG6h zJ_Kh4bkI%zp~)YtK{zKNTc!Y#a=EM!sQ|nSqL`oAW`*R2$Iab%foAN(o9`~$VR%qr zjE)q&r%E!wzVixKnKc-r@-e|=G=YwLItUAn@u?}d`S3EMrV39W<9}(^5`^oa2@)H-alJt@T15P zmS7<|j`|iVJ%)`*o}qLQIlSMeG;&>xFb8QfycRwSo2>f0_*k|$8mze&A)w{5H zc~p5#5c-}LMe(Na3-w0^Vrp_+f~8`!b!x@ok%@=NgR;fPAwHr9TpQG3FJv!|A@)D? z8F&_26w@RgEJILLstm<}r29BsmkI__S|7q0y#Q50{H0)qi?bnP&AJDfon#{HZpq{I zdEbCg0C9aeue8<(MIQ4JIuTUM&RW#Q3iO1G-&IO;!1XBDG+D#gBcCYg{%px_rPl2Kz!GKZyKHTCozM#3eyLm zzju+$%OLCBtu;>tun3DOY6`9+f>i>ytxHj<9!HblnlvGGCb?=7h%;celmR~Y(rxhG zAkpzvVf4luZ42p-VWNl7+{bC|Jd$Zs6WEn&?* zqki4y(9_L(thC$`re9+#!Lf-B8W_GkMRfp`8=FDpx{6SssJx;?U^17SIr8DlgQhR_ z_U%{x-w99?P$3RlSbd14cHl4E=@Qd$#vb0WS*{$X zl1Yw@K4K1F&=r{D3qrg+&~I8Au)HNd=KH_vaja<+Y@7(mpus^O59B9A%s8pW@_}47?L*-`e)vaU=}TC{mh4{G`?&X z?fuGS?abKcH5z_+ISiHwPFJOhL4%do7cAiuEL$uD955sR2Mp9%cAsAHMV^o=-`Jby znD{&BD+NK7q%N?P0kavuM>)->$roG|dU>EuEw39!z7Xt&w$9m$E7E_Bfg{Y?B=6VA zRi+Mag|%QNfB;=_(wDnuDrD~nEbOcxmuteI@1kG8f3?dFRC_9j-9 z5#_CWrfOmbOcg!C5sW6tDlzXpYd{2Tf$IPcjYHK%zP`r_az(`uO=ZuKB#oYZ%B`aq zwK(JjFxFX;Jvc)EF-VlYV`mqu{r*bVkwsdP=~6Ir7t=2foLbYQ<`uTnYV5A55d`SR zsa<yw7sCW(T`4j6y|d<;RzB?EO3o!}sHF-ku) zhErTwIC|bTb(oD@zL7hqjoV$8#Jf->QH2;ni5@Q(UONJYOED>Q)&6CMpuPe1VB3<>SNO~6Yx%i9LE+#RtxX=w zGXZ0^nP9LHd(&q<&w@bF6Iul05>^p@Nbc{-O~Qy$m>xb@%0zaMxLx_dpg^eWMMyK& zR`=;tK8_TDsbNClvbR;Osi3J}i_m7{;IRfPLGoWUL3wcH!p+#k<@LD(y!0UP`-v95fp9rQ_ zwo$Z?G~rfJsVVv^<8pvuAaA3@+9|pmGYmB1o3eBc6kP9YnAZbt;q{&7@3yxqq{U2AEEWI5Kw-u{`S+c=i{w|$}W-B%A%L%*U zI%LJS#!CQg;$xP&z+NtY9`fWbz`uL3KT7ouaM>25Xwob)>{bQUM}zZ3XAjv7ZnImt zO_DSbevLn3U2rNZHBVvR6#f(d#jFn4U=fQp!0?3DIO&RzRc~{mbhBNmm6b&(XJ0kf z-dntD2HODU%89WET7$knK-CZcDkFotsb>kWHls2S;}!`y$8ztT6lQMGhZ5)6(j#0? zoy#-<1!fuIs@wHV-D>x4u{V+*zHG^bUe~aUX$ZNxJF=Aowz`t^f~0}$L%CZ6$;ntI z$qp&O!YrW36*nY)bie3s7a)8sc{U)l<z%&?m?NGYBoRp-MJj%55-2s+>gsjKf=?X!Q z!gc~S4)6M{!%u(@@PI!cgZ)6P!3o^u4Zv}J2IO}YBB-&r1`UE@Y7H3gEJG)JoiB{v z^I@?Zo^zvUO}3GO?q^-FIpVVNCQY1N20q5SM;yQmpGY7Mt+}d25TZZg$8PWH_DOm+ zJz<|`)8T$%rCY}(qP$A0gp$`AC95;C*!x0xL=1eu;8_KPb;HTOcJ`7Z;b7n?rIt&% z;VW}DOtS9(H(>cWa}=79ODV8< zxAWyP@V@Ui&zGA(Zy|jIaK|9>8hiL1a0ztV~TCX|{NCh4S-`%?@UQ8RuG?UEcSP!j=}qX40(t zRjtMcU93b3AX;|O;70#Ny(SwLF6t;`7~Z;e48Wz!P{}=;7k#@B*}zkPs-EQN_ANSk z-Fu;aP>e+i|2CW|1PXJGWR}`%Y@qGfeH_zqZ_hYLNscV5ux#`^#qA3}pfnmIP6(zZ zMjGtd<`DurU7h+(K{@d%&;pSK?&RsY^94L{z_&@p=4p9~gexnRgq8!W-~lmgH`jlf z+bIWMKME)>Vxu#+LK#iK6zN{~MGPK_?VU!X)PwHk%DRb?7#cYMl1ekH1oGVjpMkc~ z;LnYp%G61=vQv|zk84&w%Vm{m)a;oRSGo}N%=7#AeA(Na)-Bv?7(#P&vQ&aIpdJaj zmQLA|^wDwOX4Kp*+R0=h12hxpPX3l=O27i@|E@|STd-hxd1$!YKbC)Ga)iS*C9nyr zsrl|=s!~ze14Ga2>YeC<___S!pa;qSU_F<|;!u7U{-q9}n%$Gju^%`DqAVdOTWIKa z*SxxBSGJ| zGee-U49Z$xPP~J2t4?{A@{yV4u+MV>ApJ;XX+z1^6Uil=(=4D*6;MEjtjq?!?aiOv zeg7bU*m$2W|0b%~1%!jIc^ekJTEzImeY<+H8ul1wU25EA!lxfZw#ThJ;qBl_`=^KbBwNUzw2F+W^CeY@`~EAM9j2W7dl<4R?6+et zu2wcqvT7%1S%fgoN3fQO!&^AQF?C&Zugzrv+MHLEO5f9^S9B7mQvaPZ>9ltCKx=aC;8L`CHAxVq+{eM^7bQ*H~>QF-t|23}b- z%f{=D?}1i=$CXfTjTgg>wkGsTfwjaz*+zk9qy0m?LQ{d z^6CO66Vrsy#9&!3-4LrZd~FL*^DTBz+jk4paL6tyzIpyH&x!82H>IN$zha>?y7z1h zMs;R9AR9iEqRN)oi55CD@DaWMd@a`qnF9c;cgA$pN&kvcB%m+A zO8Yj|54n0a0dP#4J}|Ey?2K6SE$Kc{Uj`>F$7?m$(?47K620X# zfAh2ei!lxjVOP(~ZqYG!At6Wt8RK{4+t2nLxy-9HOpYmpn9&y7s zNW4(e_OY|KU0%CS6&Dgrzfn0hm)U~|RZ=1PD1KyG|7cpw>S7hQSy^@^m`%lad&j1B zOj$GXA%)`w^M_3?;uqovd5hm)EUz{=ThgrZdiErw$5t=ketH{=vNM$Zu!vE>M5cr3 zpjki3%+U?h>y6aO36UH6Hg52hox5x)`S@ zyN)`=?t#Sz%0h7tm|%R5Gk6D0=cDpc6`ZTjq|~J9*aRXXd4j#OWhVc;=@`Yf<}ilQ zur?cT-t#EA@C2_CPdsVJm#rcW758sWQ0I>ioR#YC|1uOwmC7=6+|J4JHp1SXVmkal zg2Xa)I5OcqN6V9Xo8Bhjg1y7zd0MJJJ%RMAG4C#bn(wbKa~D#DOR6WOsegbGF7i!a zv>cY(=O(rBoP9Qu1yGWkBxmpi|5UX{>QQWJMW8sR7mTAaOw**js5!&n7;q0hNdI`p zcGIoYS|j=RpncaG{!3X*)YU}+n|NV{VbB#evo z+b^8;x;|y}oLJ=PjPsE}#N^3(kNsJ)dXK~ay`MsL3eScdnWJG$+&%Hv$G_hQR%z4| zfMIZBiGU6Z`$*Df5g#@@Z4WaB3^MLIK#ScHi4|03zTdzhu;JgKaAzl~5u`$NA?fno zj2&w5134<$BCEP;q2_2U1uB{t`m#hOC5KGYP&hF52Y+Gwd9p(iQ$8G#jo50vQ#lB- z#J3lLBZ^1VYA$reF+^t)>Fn8kj#qr5jCrV*<%j(2p%j(vWh^1PFRP)G zT&1NZKUKJ33Jzaa+S^<$9n~zcV)GRvE-!n8ZSTSsFByLe_ray)YiP^tyxGmm zPpfZSTA829{=6X>rQ4}^%Tc2AZLjyHwo!{)Z5zXYh=#$+;~rvKbJ9Og8u^<3OsElF z^a`6fffbH$^g$0zamPmGzW#5?UCF+a_omW&{JEf%2s)V{$e5ItRLAKM$^k+`s>)Xd{UnmBCrRZP$`BJT-MYQuQ zc&am(aJ69e;81aVAboS)D6n_U`-});Npdwc5;r9HCDE% z#r>JZFWgx3arosc5v)|NF&O9Dz6igAuv%pS2B1$^@AXb%Z*Mb){DGx^lgH{V9g@gC%rc z1Z~q7@NUS0t66+$xS%oYzp3oTR{xr+r}9aqZ3{-dUXM0rlbU8(S!#H)FaGRH+zPd4 zK_|>Tf^mSAK+a_rI~pnYdCzntI(!(l%_!(|dU5Zx3u^%6R_lM@-o>N{aPL)`vjKT;jyX zNlf4TMyMRlBQ26nMn6}R zCX~SvcY!5V6q(T%15b|YzdqZZl;kksRqUPAL|(wj1m63Q@}Z}gu_feR_^i`{tFmSC z^xUGg`@+ID%Q6NzBQnp)%~<+AOxW0b7-+rqdiqCbtDjnxJmVqeY0ib8Z6D%NGjOw7 zV)wU=e2stdByHgmQ{BI>SL#eHJ_qaLEapvpRZmfsQF~H%xbrTTRIgX^`yaZ2RZ>3Q zuS?#)=)bnM5qkB#-N`#(7ebUe9l2hKU;ODh&bb=!clf!!5aW1!gk#Oh$VV6dzHyz< z$$Mo6$-_!de^=M^H-8jzR9xMhSrxzZsw%O~bhxXcq;4ZmvUTMD+WYRWCX+W_T@-8q zR~Jzb(KUdI1x0#OS-~L1MgpOW(n0`%(2IzIih{loq$8rV6hcYpO%^fI!Gsb@)F2Y7 zKq#Tz3Hv?g{tx$>pE!q^d1sz^>gRc8X4<>)dL?ri-#?g$T0cVHsjzeq+&+Za4CX`G zERgc^JM4X*Js|82JebZY6+X~N<^Cu=R3Ff&x=X56+wm`7616S2ew=v3D6oKGp0DC& zA-VOsrGDL@8e1xDVfPGdNGuMUtS^r0()+nE@24{$EiN;r$d?ad&B96|xs--cE=l#Q zYRF{9ADPJU3B#GM@USEFma2~ivw@SQGbH%B8eIaa%n9-Su9mcOyMXy~;#Bq6=g5a8 zQGdcRT4Ey6@^b33^xo8b+(pRSvIBRct@qpxUnX>feLzH8Z?{j*h*u74@RQH|^II&V zVR<%_lTS>Q+XIQ%7*mz@z=E&c)jIAja|_S&E%>-?m66>1jL(>Y*a7C);xBkuo+kRx zgoR7K>{1|Pi(#>6qjq$f*Tnp|{Rfzes&bMLHQ+B-=^<&cHpEOT{55rTymBzK|JYx@ zEaqFDE70~Ch+F2*`IAauN4`ua#jp-UT{suDsGdJR_DxL zJA7>H@S&JF)WmVp<(F(A)I*e1SBH%i7{T+_GvQAjV@9N#`r_!<@UmlcO>_p%Eh69k z7~enU%XDwI6Ps$^xg%djZs`^jmd>dzTY41IC0}^Ye-F^!7FqVz~zQnx20 zVR14s)|uZIueU3YZ&BN2xUl5UgVizg5TUyCU}y>+;OHJ|e~*7hWuM12@OdV2@~PUc zCo*D(WlogPVtp@Yl}$uB;eIamB3j3>^=yCa69r5svQ7fuKXh@z*42|&nx`N<_yTk# zVlu7wMBT?&5-2g4yUYWrRNOCT{}l^ZRb=XJ_40lAapM_hNCv*-2zkhtsnV4Y^U1h| z1l1#;uM{)q)IIQsx#^@8Nda$_&f$v2=-w;d`M1YFI6PLF!qKdoDRnk{itG}%mpQb6@|Exx4O*=(%zgU`X|4>PiXz^!m z7h=sA3IbZuCQWDi;3u^CPcRN8PoEUY(v%u|CK_HtxybAt*#7HbhvH&O<_p){`p*(l zKgXxK8wOQ_wR;>&C)meoB}+6We@;opq*h_~JL>NI%De%M;QP}!>xh{kM5wF5#lfcx zn`XhtKhC%J^Z)WdjBJ{XjpFK8;r}`>f-(=b4$&KYY&B; zoP2$|!RCy%K9!eO_dW1{q^BLaxE#;#w=wcUL1*NaE3Hnn*zTn@S6g^De#*DzbWvW0>3Hnn*zTn@S6g^De#*DzbWvW z0>3Hnn*#q|P(TUe&KG1UH!)-BX#?H}8oamvq%(BqEWHk70qAo}MOOrg+)Wfw<S zJ1D3KN=dt8385gZmz-blGp#LWPMYZYGPhAS21x{iBtzpyUICDpeA#h&0E}XD1%Squ zP=7qAqrP<=iaO$s#7^Ae6S9Ewn$?yrfUu+_&!k^E2Wk3m0@c-6Ast3 zk*_plZO=EHDS$h*dYPOr3fnJe?#xzSAwKlSo?P>{iNg_ke)1&ZK@tj5RJX(?p!pj4yiN9mNk$QS)akLg>t~RNC z))?X@h}zfa&>O*DE0gC+lMFGlJ~wCZY>S`Ii*SPJe*QT$%X=SM`aii%aw1>F>k$ zCviMvm-+x5)<*X_b`UH5LHkXZR={fZip#R8SY8o=jPUY;PIZms7ruv!$t_Xg>nG@Y z1;hWxN=KEFJaA}8p(2^xl36#NoGqgyBnn?bKw)?enDR!3g#XS$c4K+|dk2g?uR?n@ z9!Vo%|+>ktuy5oqNYeFPtzp}Bj~Qh|X=%#aXdtL(DQ7x$&yF@TiN z_PlDij6hOvIyzS~;PfWMA(I*N^;JVi9_oM1iGeS`12pL2)W2-*>f6|XzV{P5Qn$p8 z9wbCVRzs;<@=d%<2qYC_U3c*bQ?D?njry6}0T&4xzdGX@t1Mv!+hPtn)kZQ63HD?L z87|?odpQWQV(qK5r!h|6JuYH-9fyJ1;7eXmV?~!8bcZC2>~b8vECPC&pCuLdfsV6M z=z%YWgq<2wu1&js$VVg6jAsVmHC=3OHT(>C80TP$PJng-Rgm-9L~+y40G}CACYI;w z^`5!n_3c?C(#4bK15KST<)a}HsPa5W?njxS#yU>g2$<=9rYledOxot^%nZgd)c=C> z0WROtt~jrE-~oZ9`JIbF9CGudo)4J`JWr4iNFy720Op`uRUurhN*5_lX6KUrO?q!X z-u1DL;Su|Q@LJW-Q+cDxVbhqtNTG*|0(&bE-7?MXt=~i+M4j7$8j>`#(k^~^f}FKo zpfT?&=hrcq@N9Sh3MkB_E0BW5yO6Ot2gmk0c-ylRXj1R7;V@#=*_EmI5{7<0Fy(O)%9-cTXG=K=r((8@2IGWhF+iIW# zS$78LfTQCl{NFJ@Gzi4>%G$BgK9wD09rYb#U*b!m!&$mFV$ujySu`Og5i@Vu+;--N z%K{7VNx;S*8OkuSP8uf_1207z7M`JhM^q1~)LG*DS!^zQPH6FHQRqD&Cih|FItE$v z&iWlF-PFs)@2?bwn?t9AE#w5UykEy{J%$;X2%A5xq|+>_K7qk&g;jftg1n)<`j1_ekz7=r9HeJ&y(q{zQu<4bN}53}Hn@*uHo zfM0piTA^XD?16!@efEe4V~ba)W{ASIJ+DZe2)j+Y_`4)~OG&QV0@S^9H? zNm>Wn^({rbP{ zygp#&=`6l-%}>1Y-p2^S`$@UQ_waQMIth{Xrxj_}hUKG-1HIHH+Z)e$2Y(H?*cl>d zes@Ji5D*0Nq~XjcZPy^4qfC0BV6dT~Pew-~^^)0%FoI6ccWCl_X#oG866XTch{o{} zTPp*7H`L=l@&C<0El-2xY5;XXl|>WbGOqV=jtT{i%J}^!$cF?a7wIxE7Zi?a=!h!- zJG2S&j{-?WxwtQm^8nuMb9fn-ilY`WkVOfXPg481kKkgmWmOprKS&GW41+CtKPpn- z<#?|{&{fI(FEn;)7hx7{kGlzBq;97wI%F{3HSRXA>Z}_V^ZN+)chc=K&kYKBK18C(uT?tk=Ki@Be z5KY1=vyk5Q0`;zx7ts?7*scLV_Hf4Cg}f!vEhH}*#|(U4vNo;3{#Hm}+rf_Gedz z4IZu)K9_{WnLtwbm0(CrR$Y@mf8f&&IqaXo=jf>jcjKAo@P%URv~nC$s?GI`A^3vb zR#5kIsBg=epH_tQv1{{PeV0o6_Gx;q4hkbn;7b=Rw=5*NPzyHH7^qqD#NCc69O{2c zz_2vbc2;bwPU8F$y}gchvK&YU+a+?#@}-&0J1}nTxdU5KuYGBZHrcWhbh25RhccqD zX5zjd;3Asnov?HCChysuBH1$kev}i)>V4e1I>vUObq1dj6r>!nNjhPVc4aVTfg)tV zNR#`i+YYt={b}q^L2X&BM*G)sd4VzozT!U-guTe)rD))&II;FDR?)VvQ7X3Sct~6n z{g3~d6k${&72J-1(CJ0i#gR*sfQ7mWRKWMizx@sTb_buNM}vwIOvB4P8-q)JLDo5F zrmQ;QS1v^rO`+@9Tsx>+P#PKDT2QV;A0QP5mh1H`5Q@J|{6}=jb3m)zR3M+eA}DD3 zn+`*m*o1!_*NbCBBSImL2#groGnIbuhlQ;AvEpye|E)o?oy|=UXd89<8My#c=qBDR zQ3t%gb%~H&fniUiI^p~*x28`K6~e`v^f$y_iCD*ofjh}BT~HwFBygUIZzIps%DD;% zckF%M9SAip`N{CVCG2wbq-xU>5rf?aP%^GBs963=rgP`T@gbv`Ubrnbh;eTb+TBkHU97(s>2Mi@mq`R+c;#f+8%^OgM<|R{H=f_4{DMJCi9Q6jR?P@T zrYV<}7l@-k)4?5EQ25qD{wjR^Z~7@jnz+?jg?R5@LZG3??OXS7NN5Zn{I-sv2^gzC zePDsgx@1(2nIcwV#f3oDN#@YtritFl z6_UQKvb|awojb}i(375dKO)nWhy^XH2a=(KDSVC50KGQ>l$!5$5Z!QVYSXHtU`Tyn zM%ATT*PcwC@JITITTQlcfnVWl$D3lEUVNNK=vD4};)U`{*E&i+j)*ZXq=t9iCXB8r zT1h@KO%8N(F&jFo9W&83(6<|NQ%08<@%2}ITd+D^NtzoL1{vThDSI?;oL)#4xRr~U zyKg%|-csfvZ#Od!z{KKbmH6*AH+~w`*~~$Q8Yw z0C#8%s#K*{uxtWXavFTLB}O)$e$MP#T)?_NR$f)TGgo4iUw8rWz5;m*O5sg+n59tm zP5s7e+RCHKeXs9?LEZcienOL!zfBj#`yiT>)j%cyTZ}?Xnz!!8P}2pm4)H4Nw8<4O z1Qc8Hjhi<}+q3y!K{Zt=KiE=O21|Iuc1<(})q;JSxGOdb7znuM<8XPEF9#h>VW$F@ zmvIGS4o)oL*wnoEByoJC`FddmYOume>5JpU6Tla>Nh&~If}k!Imq&-|CUd^EN}`{% zeFb-sEK=b$1IW5YsFG&IIJ@#v7$q+&@R=4$E6q@>n8S}XZfOpk)JPBn7gwR7)MVQp z&5#crlzlcEw#0=*)6k&@q+-Chs?=dj)7(t0Sd+ zq!W0Yhh869l%GO=w6{W&9md==)(pFLPkEvjUX#S>#%ukj3C%hw-+JH@xMRz}jia5q ztzhBe{f6{F&lC93>D$OiCUDVCfzsalOCuHgVtVgeG{NcGh_z7QWCxgRXbJ`5soQ#< zyq$=(JCFjfO*hgB@&g%@9kd;xN9d|%R-_nF*BXi$DvZ2tlWXBr^kW#Fes#ta3U&gX z!_eGzgL=a%fl0SBcJZWUMu6wXmXr`=i?1h=5zuR>f5?z)RaN zph-XIB`^eRU4iDEbv6KZqzAvz++o8yGNABlDIiz;lo&N{SOHPZQmLy#?AwYWWPkmU zh**n;uNw=s#+gePG*Nux%Qx4C!}Dc(yXU_|WO|?D^~;4cV_@XTvAz{wK^&n5D%CuF z)~=Vt+c<+IsJUvq3c)~lsPZTs2a3{N39~55H`juUT4@-`9eZKghd?Uc-g+MhWh2gW zeLaCQpwzFTCYmRz@}`f)tul~&q0 z;@8f|29Pf@zFXgnjg85cx>cpNgRl>8#NezI zC3SKWMl@fnMkA`@IgQxSzWmFc^FDHYXA!MG%a^(kIvu-xWOv1xElauUFkJIQ%8w4$ zBMxC6^KJ`~LaZ^-nA}3H=Y|VnE}-U?~A1?I7Og9 ztRC3BwlGJNu&gSv&m)LN33SbU|VG`@h;HU!YO$8`(blE)t0X zu3aBFeQ$igs!{n)b_BwGOsjZ`sxdVz`b)y+P~>p=1we@Dc;(>z=*q!3PK5U9DJ%>5 zOKIYU(BpLlx`$j@*hb_6M-Dn?F=CM1)FE1}lx418Xni}WsWLQP(sOX&#cUIRSYFXO zOB~hD2(XTWFM+_-Uzqo{yvzFvpPC?hHlF%!^|qdxz%f9qxj>+NUZ2&?O_Btdo&t!c zCh;iWQ>m0cGR)BTsH*e@7LxxSIHV?22CJxFLKKWRcunXriS(>2=hOq;E|vT)R9TC>UPD~?Pcvy0f%YD;APtMe7U7yXyg$}!RdUPt0f_~ z)x^t1kAT5jrEr>&_9SL`z%lyJ)tOv)TgwI#5?@*07`}?^B z2d6j2o_*kigXs(g5voex*(dyZDCeAqQxqkg#m&R?N&4H5SYd4+4ajFt7|)C$Lhk~c z@a3pxz%p|78*nHn;es;&{fghD2ewasPxZjLgGg~giTf_1v?hg#9oM{)TV?C2m+1bn1j%jaN% ziwIsTky8S#M8kn~cskAfd0;D&#oFhO_S;@rnZbAuQ#IuR*#`F@0yxZmztnIo|Q6})~v_Ct>yFKWlD69^Ob={n9-R;#v?Fbt>l*%w=IF|U0I>m+ZfU7^nL=< z_pXQx5VH5edrIU3J}OR@IR0f0i|97R+TjpFgeDN%8d_d0X`bd^@jIZ^9)OA!1a3p4 z!Nu|$=U-%MJ2p^BHWri22Kwy)%rVRsTxM>p6rHXsj};Hj)%+B4lrAA|zRuwJin{B1mrojsuhifCUqW{Nw?khxy}4y^e(Aw-^vz>; zaHL@Yh6O^;iN8yg=%a|!dYu|yz0cylv86ab$}qATL7dWruk7?)fHAjoP9RPkltdNSiq!Y^l|C% zA&-V;?7)G^L{a8Gv~)N*BBPU=I&gU=EAsW2a*PWOnX%cMk}asJ_vv zi9=3dlYf@Ml)D|AmAAZU^Aq_zWL(%Teav=GXe*gV+YQFfp*@6!GZ3kkKG*B>6eP`4 zdnvbB&$c2X-iun*$kSh2%6dB~Q0BKE6dgKN+{b9==0zgAeNUydv%RYBZpF7=2r4H% z06wJ+%NVFs_02!WEj^<50MDmBDaiqMqdqscrp zm9>#)`%bw#iFnxd96+M{Vi;vuiGG?s`h%!SX?Z5e+SZ-N*!dw+wVOxD?G8c_Cdq&; zaim%)W<4?gQHg6#DIWn1T|GBseLQ`vc7EH1?lfMxIiLiS1U!ZUQcn51um9qtKZMRZ z$1u=K{i?gZtc6Ap&VYmmCTCs>gJDw+nSetkU|^sXwpN88S^vaF+M_wYT5e0xYe!tXD;3`Uyqyx3IGz=h4H7W2-kr>JD(wxRkIKXA zR7N5x4}cuY=m6X|NsQP*OYWu#8QGy{2$l0_ThHEeTa(`Mb@` zy?N`?7{yNrq?YV3*x`xU90|0C0erT5>YDU8& z1OQ2orrz~I3X!N9_0F9%N`>V;-lNGAi!1+y4N?fd9sL@8+B0+T5mib#G0tyft~xby z2Z72@4DGb%wt=9=DQzGL06iBi|N7@FmUP-zfJ|kIt7=MGS#`J)#FQ(0sF4H`h|s9; z1=XCzVAj8Btj85QVtlvvYe<%dB83Q7gMh}s%EzGp=yvr?yl%%s>otQ^3*+KgiO-MT ztAcY#{@NIx?gfGKmXCeVNGt#G5ouYKKXDs)F;q8zF0kYl%iu#MGhg&~kR{Dqa525D z!bm6JLU1*;|&5S z27~GGKel|CRB4gB+o3a~JD-ZhbSh$5b5A_#-U;>9;5kFS#}xFFY9RncJmC1YI1 z&RUYV6bamcd39O4aj2M|Q#$qBKG2X@9(sk`wa_er+A`hL?_Q;LsO-Ib& zfg4&{vHg(ZtJKW9q?!ysRiw30OiOBjk7LC11-XDQ5uDhmz^0`GZreyUu5actX)~*D zHZ$>69$|V>AZAX5hn3GJ#eAuY{A=in0O~``Oas=(bjg!1S7ljBsih6R;LgBN^JAL& zr@}F1AGp`zA*}?CD%yC#v)fpofvT@jYA)ugi`=p*ILPdh0_iim%hWhGgQ$GlSDZ-T z<12sCaNO#<$BJv>5i3_#Bz|oOoN#s{IP7bTQ-nE@jb{|FE?Q8{z1)jEVv_DBi*eJ# z)WOp|-FJhW;!=ALAiL*F0h1PxbkO){BMmTlKO&x1t6KCWV;R6Ks!}$j(Q6RTp{iyH5O|PwU9%Tx zP?w*x)saXEztf$Dw}wyRb}vVeiuta7K|!ToJ7oyPf!d^ltW-`IcDU#3%0L@@shjk< zOo6T>z_7DS(F*!SpG~;R!M2bX*^}3QGSMfJvHp@{D;IFfzu}~M%DMm&S4rt$PyVtZ zBx|q)kukab^aDIxMDp-Juf@$0-~G#7sp>xeT+|aH>UL}zdoa2zX5TrqjI#WNGr!n- z(v{aMh?d=7$hdgo?g@QY%|1h_m30ABHW?`X}i&CI2)1(^?Z0`NzG#>W~BmF+uUszIv< zFI>(1ihMZ*V0ZWPc=)npBQVLaa*K=ft z*$*R0tEzU3o3@zYF0qPeHo*Xm#T|oDbP_o-GLBoByX{sPAZ&~Rm^y~oNR=<>C9AlYuav`vjmmTQsMyVi9Il(=I2FtLH2mhpdwn0 zy;gSYa*){-r)UaavLTSFLC!io9l(LQtM^MXGg{klkCz(+i@Om;TaN>uTn3-K91_zg zTSn15HR-NAkP9QLJh1Os-#PGd<~@=JPLNLwP5rt6y^hgW+tGAacI_z01Sf3Il-?4H zxq`zfkle4#v`(HZ-BaHq5{(rJK1|1gQ`I(*fz+{+O$Xmri^`bHm};`dTGFO&=uy`L zL1;xD)pSA-noP!dmFo1ilDCr_8&=#om*KwfNW6<(IknDN^jEoQ$-@|s9Qd}mt| ziiXbkO|P#V%J&(lO5jjc`wI3rHheuDDzFu~_ANhi6I8Shob0CKA*;T+aT(*8$w8GZ z`b{p*Ev)XR*^QPL{rsRtzGO{iU0<*CeqGU`$osAbZ^80sr&p~kElTEkG+eQ%=T8O| zZ2n21RS!&w8Lh@N_u(F@&N!|kfT1US(X))qHR5AAxR@eS?F_m6`U;YdD_U z$b0R%&sU+8 z=O5vSNgC-eYq^Y!Z!WAdvaJOp5C^ zqA%_3aEh=bszh?+EpQ#Zkl&1?PS_vvV03m@bg{Ih)2hZ#J&1AuzTXmJ?K-s1QT&4l z4^X26O>U`Bc5@&n3AnKoTaYXj#-og3{kEcd&b-^U5g0w6Pcw(y--!WE(dShf59$oa8 zJAeRmH0kRpoVG(hJZI&h6+eWIHn>}3m#po+fs3A){NV#t)u@us+!-MMryexjdoB}iatAEVjPNVKG2v{nD%}*F=xte})1x>>wQ&A3SP7K*9`^&tLq51ktV#q0>5$t8q?@@EkpkcY%^`M4zmmi?DGWDM!N`kiBPa!Qxya& zSg9UUTRY@>dcM$wsO)<4-#w?~-uTy@$oLjgY5X2psdcTx0J`L2w!`eH*{$NkJTc*av#sTWA-AFDAEC=;u6zypmfUsrNnQ8}n=lgGID5oDc6?qT0ksZs<>eErGx>vMjZu!;bHPi^51|rN*v+aMK6o71 zu=nfVhXpqCbi#3u^|xI4Y@-z}VO;$RG4(-{Sf3*!|xvK8dU)(< zkw4PHDB<314bo(C*YIS*6GD(&yMMU`btsV3`Y&ji$3+p+2k(BGyTW5~#ky5W($Jx^ z#ke|5*28rk1J!b?+q)`O(_(PsyZ7ImuV6!`eO_yuYX|I(K=kCdGNJ&cR!cV8G&94k z$lf_7MR+|vU0IlrKaoh$IJ@_fnDG_f5MzL`-puXUTXg8>yRw2F^RTa_=JlkJ5o*x*54&j+Tu!btt;0?IT#I z8KYw4PHpNR*$rDFJo46DKfIGbbt83et=jR1E37VS?PW2PymhA%UCP%Jrd@k2eXX3g zE~g9j&fg|s_t7O1EqCs$)IK(ZEIwh<*Ea(VEA2J`wnLbnHxY&j4SQ63qj$&6Wu271Thy65p0xJQBKQR z0j?UvH4?vxE7awU>mLoT%_~~16>P@eMPNhgjILJ^_=SwIw%b8+Z*6W_(*l3(!nWnU zC@~AuxrtgpHX2!OPJb>SCi}hx*gbR=nV-rPCv>Mbcs-wWOFJUreU4+j*QzRC!%F&3 ztIe9~tdP54I;VA_kAE7%9;W&VPlc$+XQ!YAHt+c+ z%?*=(P%E0-<8xzz_ZQQb=gDhium}rCKQh)`+LG1ZHwJFo-~GR#0`l0IK?m(pi8|+Q zqH1ggZdgccX-(Q#8{5K2l~EN`tY4~SKP@9y%CF*1gPGpDiK?~Ps24e?xc7uMa^Sjl zLE!4HQ;tbv;37MIH1}o}`F_<#Y@n79W43-{z(Tv5;!?rC!&_!}ph^U@IE&jlt}Mv3 z*Vg$ImIujYO(rXHjkInKm<_1emgIxwR`KQTS5atuL7}&3B+$pQ3}uEXc=@;LEx(en zKvGj4o?5qBz0UJ{+86FUv6V0c8nW&D8wvRqw+!-@37L#&f`sv7>Op_=W*?>!qn;6! zr<>zW)-l&E2eiuSvfSkK?-s!nHml;z&*WEU1sdb5d9sWV@Vu@w@7M8=nvl@d(p&|0 z@CHfSZ##sL%8BJ~`4715D{F~~oq=vPrnj>Hq6&@R1@yYVu6mI=6.9.0" } }, + "node_modules/@egjs/hammerjs": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", + "integrity": "sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==", + "license": "MIT", + "dependencies": { + "@types/hammerjs": "^2.0.36" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/@expo/cli": { "version": "54.0.25", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.25.tgz", @@ -2359,6 +2376,54 @@ "node": ">=6.9.0" } }, + "node_modules/@fortawesome/fontawesome-common-types": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.7.2.tgz", + "integrity": "sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/fontawesome-svg-core": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-6.7.2.tgz", + "integrity": "sha512-yxtOBWDrdi5DD5o1pmVdq3WMCvnobT0LU6R8RyyVXPvFRd2o79/0NCuQoCjNTeZz9EzA9xS3JxNWfv54RIHFEA==", + "license": "MIT", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/free-solid-svg-icons": { + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-6.7.2.tgz", + "integrity": "sha512-GsBrnOzU8uj0LECDfD5zomZJIjrPhIlWU82AHwa2s40FKH+kcxQaBvBo3Z4TxyZHIyX8XTDxsyA33/Vx9eFuQA==", + "license": "(CC-BY-4.0 AND MIT)", + "dependencies": { + "@fortawesome/fontawesome-common-types": "6.7.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@fortawesome/react-native-fontawesome": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@fortawesome/react-native-fontawesome/-/react-native-fontawesome-0.3.2.tgz", + "integrity": "sha512-CiWfJWSZHRg12VXlaeFnaa5yJVPOrjsSFEvF6ntz3cnjg4oN3cvauL+JATacMCl0v9xzib32qC1WZAvvGkfB4w==", + "license": "MIT", + "dependencies": { + "humps": "^2.0.1", + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@fortawesome/fontawesome-svg-core": "~1 || ~6", + "react-native": ">= 0.67", + "react-native-svg": ">= 11.x" + } + }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", @@ -2965,6 +3030,12 @@ "@types/node": "*" } }, + "node_modules/@types/hammerjs": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/@types/hammerjs/-/hammerjs-2.0.46.tgz", + "integrity": "sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==", + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -3571,6 +3642,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -4058,6 +4135,56 @@ "utrie": "^1.0.2" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4188,6 +4315,61 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.4.7", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", @@ -4252,6 +4434,18 @@ "once": "^1.4.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/env-editor": { "version": "0.4.2", "resolved": "https://registry.npmjs.org/env-editor/-/env-editor-0.4.2.tgz", @@ -4896,6 +5090,21 @@ "hermes-estree": "0.29.1" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/hosted-git-info": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-7.0.2.tgz", @@ -4990,6 +5199,12 @@ "node": ">= 14" } }, + "node_modules/humps": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/humps/-/humps-2.0.1.tgz", + "integrity": "sha512-E0eIbrFWUhwfXJmsbdjRQFQPrl5pTEoKlz163j1mTqqUnU9PgR4AgB8AIITzuB3vLBdxZXyZ9TDIrwB2OASz4g==", + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -5914,6 +6129,12 @@ "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "license": "Apache-2.0" }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -6535,6 +6756,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -7013,6 +7246,23 @@ "node": ">= 6" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -7190,6 +7440,21 @@ } } }, + "node_modules/react-native-gesture-handler": { + "version": "2.24.0", + "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.24.0.tgz", + "integrity": "sha512-ZdWyOd1C8axKJHIfYxjJKCcxjWEpUtUWgTOVY2wynbiveSQDm8X/PDyAKXSer/GOtIpjudUbACOndZXCN3vHsw==", + "license": "MIT", + "dependencies": { + "@egjs/hammerjs": "^2.0.17", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-is-edge-to-edge": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.3.1.tgz", @@ -7215,6 +7480,20 @@ "react-native-worklets": "0.5 - 0.8" } }, + "node_modules/react-native-svg": { + "version": "15.15.5", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.5.tgz", + "integrity": "sha512-L4go5jA+GWutdJ/JucuN20cjAbMg1HmMtAP+wZ+3JLCf6Jd0bhXQHxciRP/AQm/FlrIEZwkMcHNZP+FXAiic0w==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-view-shot": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/react-native-view-shot/-/react-native-view-shot-4.0.3.tgz", diff --git a/package.json b/package.json index 9efcccb2..a6bcdde9 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,9 @@ "version": "1.0.0", "main": "index.ts", "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.7.2", + "@fortawesome/free-solid-svg-icons": "^6.7.2", + "@fortawesome/react-native-fontawesome": "^0.3.2", "buffer": "^6.0.3", "expo": "~54.0.0", "expo-build-properties": "~1.0.10", @@ -13,7 +16,9 @@ "ipp-encoder": "^5.0.0", "react": "19.1.0", "react-native": "0.81.5", + "react-native-gesture-handler": "~2.24.0", "react-native-reanimated": "~4.1.1", + "react-native-svg": "~15.15.0", "react-native-view-shot": "4.0.3", "react-native-worklets": "0.5.1" }, diff --git a/src/components/DraggableSticker.tsx b/src/components/DraggableSticker.tsx new file mode 100644 index 00000000..2d638ccd --- /dev/null +++ b/src/components/DraggableSticker.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useSharedValue, + useAnimatedStyle, + runOnJS, +} from 'react-native-reanimated'; +import { THEME } from '../styles/theme'; + +export interface StickerData { + id: string; + type: 'emoji' | 'text'; + content: string; + x: number; + y: number; + scale: number; + rotation: number; +} + +interface DraggableStickerProps { + sticker: StickerData; + isSelected: boolean; + onSelect: (id: string) => void; + onDelete: (id: string) => void; + onUpdate: (id: string, updates: Partial) => void; +} + +export default function DraggableSticker({ + sticker, + isSelected, + onSelect, + onDelete, + onUpdate, +}: DraggableStickerProps) { + const translateX = useSharedValue(sticker.x); + const translateY = useSharedValue(sticker.y); + const scale = useSharedValue(sticker.scale); + const rotation = useSharedValue(sticker.rotation); + + const savedTranslateX = useSharedValue(sticker.x); + const savedTranslateY = useSharedValue(sticker.y); + const savedScale = useSharedValue(sticker.scale); + const savedRotation = useSharedValue(sticker.rotation); + + const selectSticker = () => { + onSelect(sticker.id); + }; + + const updateSticker = (x: number, y: number, s: number, r: number) => { + onUpdate(sticker.id, { x, y, scale: s, rotation: r }); + }; + + const panGesture = Gesture.Pan() + .onStart(() => { + savedTranslateX.value = translateX.value; + savedTranslateY.value = translateY.value; + runOnJS(selectSticker)(); + }) + .onUpdate((event) => { + translateX.value = savedTranslateX.value + event.translationX; + translateY.value = savedTranslateY.value + event.translationY; + }) + .onEnd(() => { + runOnJS(updateSticker)( + translateX.value, + translateY.value, + scale.value, + rotation.value + ); + }) + .minDistance(5); + + const pinchGesture = Gesture.Pinch() + .onStart(() => { + savedScale.value = scale.value; + }) + .onUpdate((event) => { + const newScale = savedScale.value * event.scale; + scale.value = Math.max(0.3, Math.min(4, newScale)); + }) + .onEnd(() => { + runOnJS(updateSticker)( + translateX.value, + translateY.value, + scale.value, + rotation.value + ); + }); + + const rotationGesture = Gesture.Rotation() + .onStart(() => { + savedRotation.value = rotation.value; + }) + .onUpdate((event) => { + rotation.value = savedRotation.value + event.rotation; + }) + .onEnd(() => { + runOnJS(updateSticker)( + translateX.value, + translateY.value, + scale.value, + rotation.value + ); + }); + + const tapGesture = Gesture.Tap() + .onEnd(() => { + runOnJS(selectSticker)(); + }); + + const composedDrag = Gesture.Simultaneous(panGesture, pinchGesture, rotationGesture); + const gesture = Gesture.Exclusive(composedDrag, tapGesture); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [ + { translateX: translateX.value }, + { translateY: translateY.value }, + { scale: scale.value }, + { rotate: `${rotation.value}rad` }, + ], + })); + + const isText = sticker.type === 'text'; + + return ( + + + {isText ? ( + + {sticker.content} + + ) : ( + {sticker.content} + )} + {isSelected && ( + onDelete(sticker.id)} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + × + + )} + + + ); +} + +const styles = StyleSheet.create({ + stickerWrapper: { + position: 'absolute', + alignItems: 'center', + justifyContent: 'center', + zIndex: 100, + }, + emojiText: { + fontSize: 48, + textAlign: 'center', + }, + textStickerBox: { + backgroundColor: 'rgba(5, 5, 16, 0.7)', + paddingVertical: 4, + paddingHorizontal: 10, + borderRadius: 6, + borderWidth: 1, + borderColor: THEME.colors.accent, + }, + textStickerContent: { + fontSize: 14, + fontWeight: 'bold', + color: THEME.colors.text, + textAlign: 'center', + textShadowColor: THEME.colors.cyanGlow, + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 6, + }, + deleteBtn: { + position: 'absolute', + top: -10, + right: -10, + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: THEME.colors.error, + alignItems: 'center', + justifyContent: 'center', + zIndex: 200, + }, + deleteBtnText: { + color: '#fff', + fontSize: 14, + fontWeight: 'bold', + lineHeight: 16, + }, +}); diff --git a/src/components/FrameOverlay.tsx b/src/components/FrameOverlay.tsx new file mode 100644 index 00000000..eaca3c66 --- /dev/null +++ b/src/components/FrameOverlay.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { StyleSheet, Image, View } from 'react-native'; + +interface FrameOverlayProps { + frameAsset: any; // require() asset +} + +/** + * Renders a frame PNG overlay on top of the photo. + * The frame PNG must have a transparent center (alpha=0) so the photo shows through. + * Positioned absolutely to cover the entire photo canvas. + */ +export default function FrameOverlay({ frameAsset }: FrameOverlayProps) { + if (!frameAsset) return null; + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 50, + }, + frameImage: { + width: '100%', + height: '100%', + }, +}); diff --git a/src/components/FramePicker.tsx b/src/components/FramePicker.tsx new file mode 100644 index 00000000..f6842f35 --- /dev/null +++ b/src/components/FramePicker.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { + StyleSheet, + Text, + View, + TouchableOpacity, + ScrollView, + Image, +} from 'react-native'; +import { FRAMES, FrameDefinition } from '../data/frames'; +import { THEME } from '../styles/theme'; + +interface FramePickerProps { + selectedFrameId: string | null; + onSelectFrame: (frameId: string | null) => void; +} + +/** + * Horizontal scrollable frame picker. + * Shows "Kein Rahmen" (no frame) + all available frames from the registry. + */ +export default function FramePicker({ selectedFrameId, onSelectFrame }: FramePickerProps) { + if (FRAMES.length === 0) { + return ( + + Keine Rahmen verfügbar + + ); + } + + return ( + + Rahmen wählen + + {/* No frame option */} + onSelectFrame(null)} + > + + + + Kein Rahmen + + + {/* Frame options */} + {FRAMES.map((frame: FrameDefinition) => ( + onSelectFrame(frame.id)} + > + + {frame.name} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + marginBottom: THEME.spacing.md, + }, + emptyContainer: { + padding: THEME.spacing.md, + alignItems: 'center', + }, + emptyText: { + color: THEME.colors.textMuted, + fontSize: 13, + fontStyle: 'italic', + }, + title: { + fontSize: 13, + fontWeight: 'bold', + color: THEME.colors.textMuted, + letterSpacing: 1, + textTransform: 'uppercase', + marginBottom: THEME.spacing.sm, + }, + scrollContent: { + gap: 8, + }, + frameThumb: { + width: 64, + alignItems: 'center', + padding: 4, + borderRadius: THEME.borderRadius.sm, + borderWidth: 1, + borderColor: 'transparent', + }, + frameThumbActive: { + borderColor: THEME.colors.primary, + backgroundColor: 'rgba(255, 43, 214, 0.1)', + }, + noFrameBox: { + width: 48, + height: 64, + backgroundColor: THEME.colors.surfaceSecondary, + borderRadius: 4, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: THEME.colors.border, + }, + noFrameText: { + fontSize: 20, + color: THEME.colors.textMuted, + }, + thumbImage: { + width: 48, + height: 64, + borderRadius: 4, + }, + frameName: { + fontSize: 10, + color: THEME.colors.textMuted, + marginTop: 3, + textAlign: 'center', + }, +}); diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx new file mode 100644 index 00000000..31be920d --- /dev/null +++ b/src/components/Icon.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; +import { THEME } from '../styles/theme'; + +interface IconProps { + icon: IconDefinition; + size?: number; + color?: string; + style?: any; +} + +/** + * Schnappix Icon wrapper around FontAwesome. + * Defaults to the neon accent cyan color. + */ +export default function Icon({ icon, size = 24, color, style }: IconProps) { + return ( + + ); +} diff --git a/src/components/StickerTray.tsx b/src/components/StickerTray.tsx new file mode 100644 index 00000000..a7e20266 --- /dev/null +++ b/src/components/StickerTray.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { + StyleSheet, + Text, + View, + TouchableOpacity, + ScrollView, +} from 'react-native'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { faCalendarDay, faClock, faChampagneGlasses, faXmark } from '@fortawesome/free-solid-svg-icons'; +import { THEME } from '../styles/theme'; + +// Curated party emoji set (Mix of A + D per user request) +const PARTY_EMOJIS = [ + '🎉', '🥳', '🎈', '🍾', '🥂', '💃', '🕺', '👑', + '🌟', '💖', '🔥', '🎶', '🍻', '🎭', '🤩', '😎', + '🤪', '😍', '🤗', '✨', '💫', '⭐', '🎊', '🎀', + '🦄', '🌈', '🎵', '🎤', '💎', '🎁', +]; + +interface StickerTrayProps { + onAddEmoji: (emoji: string) => void; + onAddDateSticker: (format: 'date' | 'datetime') => void; + onAddEventSticker: () => void; + onClose: () => void; + eventText: string; +} + +export default function StickerTray({ + onAddEmoji, + onAddDateSticker, + onAddEventSticker, + onClose, + eventText, +}: StickerTrayProps) { + return ( + + {/* Header row */} + + Sticker hinzufügen + + + + + + {/* Date/Time quick-add buttons */} + + onAddDateSticker('date')} + > + + Datum + + onAddDateSticker('datetime')} + > + + Datum + Uhrzeit + + {eventText ? ( + + + + {eventText.length > 15 ? eventText.substring(0, 15) + '…' : eventText} + + + ) : null} + + + {/* Emoji grid */} + + {PARTY_EMOJIS.map((emoji, idx) => ( + onAddEmoji(emoji)} + activeOpacity={0.6} + > + {emoji} + + ))} + + + ); +} + +const styles = StyleSheet.create({ + container: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + backgroundColor: 'rgba(13, 13, 26, 0.95)', + borderTopWidth: 1, + borderTopColor: THEME.colors.border, + paddingBottom: 8, + zIndex: 500, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 6, + }, + headerTitle: { + fontSize: 13, + fontWeight: 'bold', + color: THEME.colors.textMuted, + letterSpacing: 1, + textTransform: 'uppercase', + }, + closeBtn: { + padding: 4, + }, + dateRow: { + flexDirection: 'row', + paddingHorizontal: 12, + paddingBottom: 6, + gap: 8, + }, + dateBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: THEME.colors.surfaceSecondary, + paddingVertical: 5, + paddingHorizontal: 10, + borderRadius: THEME.borderRadius.sm, + borderWidth: 1, + borderColor: THEME.colors.border, + }, + dateBtnText: { + fontSize: 12, + color: THEME.colors.accent, + fontWeight: '600', + }, + emojiScroll: { + paddingHorizontal: 8, + paddingVertical: 4, + }, + emojiBtn: { + width: 44, + height: 44, + justifyContent: 'center', + alignItems: 'center', + marginHorizontal: 2, + borderRadius: THEME.borderRadius.sm, + backgroundColor: 'rgba(255, 255, 255, 0.05)', + }, + emojiText: { + fontSize: 28, + }, +}); diff --git a/src/data/frames.ts b/src/data/frames.ts new file mode 100644 index 00000000..47c60c98 --- /dev/null +++ b/src/data/frames.ts @@ -0,0 +1,27 @@ +/** + * Frame registry for Schnappix photo frames. + * + * To add a new frame: + * 1. Place a transparent PNG (1200×1800px, sRGB, alpha channel) into assets/frames/ + * 2. Add an entry below with require() pointing to the asset + * + * Frame PNG requirements: + * - Format: PNG with transparency (alpha channel) + * - Resolution: 1200 × 1800 px (3:2 portrait) + * - The photo area must be fully transparent + * - Keep each file under 500 KB + */ + +export interface FrameDefinition { + id: string; + name: string; + asset: any; // require() return type +} + +// Placeholder frame entries — replace the assets once you have real frame PNGs. +// Uncomment entries below when you add the actual PNG files to assets/frames/. +export const FRAMES: FrameDefinition[] = [ + // { id: 'balloons', name: 'Luftballons', asset: require('../../assets/frames/frame_balloons.png') }, + // { id: 'party_name', name: 'Party Name', asset: require('../../assets/frames/frame_party_name.png') }, + // { id: 'neon', name: 'Neon Glow', asset: require('../../assets/frames/frame_neon.png') }, +]; diff --git a/src/screens/AdminScreen.tsx b/src/screens/AdminScreen.tsx index 2d4b680c..c9162140 100644 --- a/src/screens/AdminScreen.tsx +++ b/src/screens/AdminScreen.tsx @@ -10,9 +10,23 @@ import { Alert, } from 'react-native'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { + faUserGear, + faArrowLeft, + faPrint, + faCameraRetro, + faLock, + faBorderAll, + faCalendarDay, + faImages, + faFloppyDisk, + faChampagneGlasses, +} from '@fortawesome/free-solid-svg-icons'; import { THEME } from '../styles/theme'; import KioskMode from '../../modules/kiosk-mode'; import { AppSettings, saveSettings } from '../services/settings'; +import { FRAMES } from '../data/frames'; interface AdminScreenProps { currentSettings: AppSettings; @@ -20,6 +34,30 @@ interface AdminScreenProps { onClose: () => void; } +type FrameMode = 'off' | 'always' | 'available'; +type DateOverlayMode = 'off' | 'date' | 'datetime'; +type DatePosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-center'; + +const FRAME_MODE_OPTIONS: { value: FrameMode; label: string }[] = [ + { value: 'off', label: 'Aus' }, + { value: 'always', label: 'Immer an' }, + { value: 'available', label: 'Verfügbar' }, +]; + +const DATE_OVERLAY_OPTIONS: { value: DateOverlayMode; label: string }[] = [ + { value: 'off', label: 'Aus' }, + { value: 'date', label: 'Nur Datum' }, + { value: 'datetime', label: 'Datum + Uhrzeit' }, +]; + +const DATE_POSITION_OPTIONS: { value: DatePosition; label: string }[] = [ + { value: 'bottom-right', label: 'Unten rechts' }, + { value: 'bottom-left', label: 'Unten links' }, + { value: 'bottom-center', label: 'Unten Mitte' }, + { value: 'top-right', label: 'Oben rechts' }, + { value: 'top-left', label: 'Oben links' }, +]; + export default function AdminScreen({ currentSettings, onSave, onClose }: AdminScreenProps) { const [countdown, setCountdown] = useState(String(currentSettings.countdownDuration)); const [printerIp, setPrinterIp] = useState(currentSettings.printerIp); @@ -28,6 +66,16 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS const [kioskActive, setKioskActive] = useState(false); const [isDeviceOwner, setIsDeviceOwner] = useState(false); + // New settings state + const [frameMode, setFrameMode] = useState(currentSettings.frameMode); + const [selectedFrameId, setSelectedFrameId] = useState(currentSettings.selectedFrameId); + const [dateOverlay, setDateOverlay] = useState(currentSettings.dateOverlay); + const [dateOverlayPosition, setDateOverlayPosition] = useState(currentSettings.dateOverlayPosition); + const [eventText, setEventText] = useState(currentSettings.eventText); + const [burstCount, setBurstCount] = useState(String(currentSettings.burstCount)); + const [burstInterval, setBurstInterval] = useState(String(currentSettings.burstIntervalSec)); + const [welcomeText, setWelcomeText] = useState(currentSettings.welcomeText); + // Load Kiosk and Device Owner status on mount useEffect(() => { const checkKioskStatus = () => { @@ -99,11 +147,36 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS return; } + const burst = parseInt(burstCount, 10); + if (isNaN(burst) || burst < 1 || burst > 5) { + Alert.alert('Ungültige Eingabe', 'Die Serienbildanzahl muss zwischen 1 und 5 liegen.'); + return; + } + + const interval = parseInt(burstInterval, 10); + if (isNaN(interval) || interval < 3 || interval > 30) { + Alert.alert('Ungültige Eingabe', 'Der Abstand zwischen Serienbildern muss zwischen 3 und 30 Sekunden liegen.'); + return; + } + + if (frameMode === 'always' && selectedFrameId === null && FRAMES.length > 0) { + Alert.alert('Hinweis', 'Bitte wähle einen Rahmen aus, wenn der Modus "Immer an" aktiviert ist.'); + return; + } + const updated: AppSettings = { countdownDuration: duration, printerIp: ipTrimmed, adminPassword: password.trim(), kioskModeEnabled: kioskActive, + frameMode, + selectedFrameId: frameMode === 'always' ? selectedFrameId : null, + dateOverlay, + dateOverlayPosition, + eventText: eventText.trim(), + burstCount: burst, + burstIntervalSec: interval, + welcomeText: welcomeText.trim() || 'Willkommen zu unserer Feier!', }; try { @@ -115,19 +188,47 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS } }; + // Renders a 3-way toggle segment control + const renderSegment = ( + options: { value: T; label: string }[], + current: T, + onChange: (v: T) => void, + ) => ( + + {options.map((opt) => ( + onChange(opt.value)} + > + + {opt.label} + + + ))} + + ); + return ( - SCHNAPPIX EINSTELLUNGEN + + + EINSTELLUNGEN + + Zurück zur Fotobox - {/* Printer Configurations */} + {/* ─── Printer Configuration ─── */} - Drucker-Konfiguration + + + Drucker-Konfiguration + Konfiguriere die lokale IP-Adresse deines über WLAN verbundenen Canon CP1300 Druckers. @@ -144,9 +245,12 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS - {/* Booth Behavior */} + {/* ─── Booth Behavior ─── */} - Fotobox-Verhalten + + + Fotobox-Verhalten + Countdown-Dauer (Sekunden) + + Begrüßungstext + + + + Event-Name (für Datum-Sticker) + + - {/* Security & Kiosk Mode */} + {/* ─── Continuous Shooting ─── */} - Kiosk-Modus & Sicherheit + + + Serienbild / Burst-Modus + + + Mehrere Fotos automatisch hintereinander aufnehmen. Bei Anzahl = 1 wird nur ein einzelnes Foto aufgenommen. + + + Anzahl Fotos pro Durchgang (1–5) + + + + Abstand zwischen Fotos (Sekunden, 3–30) + + + + + {/* ─── Photo Frames ─── */} + + + + Fotorahmen + + + Rahmen über die Fotos legen. "Aus" deaktiviert Rahmen, "Immer an" erzwingt einen gewählten Rahmen, "Verfügbar" lässt Gäste einen Rahmen auswählen. + + {renderSegment(FRAME_MODE_OPTIONS, frameMode, setFrameMode)} + + {frameMode === 'always' && FRAMES.length > 0 && ( + + Ausgewählter Rahmen + + {FRAMES.map((frame) => ( + setSelectedFrameId(frame.id)} + > + + {frame.name} + + + ))} + + + )} + + {frameMode === 'always' && FRAMES.length === 0 && ( + + Noch keine Rahmen hinterlegt. Lege PNG-Dateien in assets/frames/ ab. + + )} + + + {/* ─── Date/Time Overlay ─── */} + + + + Datum/Uhrzeit-Einblendung + + + Datum automatisch auf jedes Foto einblenden. Unabhängig davon ist der Datum-Sticker in der Bearbeitung immer verfügbar. + + {renderSegment(DATE_OVERLAY_OPTIONS, dateOverlay, setDateOverlay)} + + {dateOverlay !== 'off' && ( + + Position der Einblendung + {renderSegment(DATE_POSITION_OPTIONS, dateOverlayPosition, setDateOverlayPosition)} + + )} + + + {/* ─── Kiosk Mode ─── */} + + + + Kiosk-Modus & Sicherheit + Sperre den Bildschirm, um zu verhindern, dass Gäste die App schließen oder auf die Systemeinstellungen zugreifen. @@ -210,7 +432,9 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS + {/* ─── Save Button ─── */} + EINSTELLUNGEN SPEICHERN @@ -233,6 +457,11 @@ const styles = StyleSheet.create({ borderColor: THEME.colors.border, backgroundColor: THEME.colors.surface, }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + }, title: { fontSize: 22, fontWeight: '900', @@ -240,6 +469,9 @@ const styles = StyleSheet.create({ letterSpacing: 2, }, closeBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, backgroundColor: THEME.colors.surfaceSecondary, borderWidth: 1, borderColor: THEME.colors.border, @@ -266,11 +498,16 @@ const styles = StyleSheet.create({ padding: THEME.spacing.lg, marginBottom: THEME.spacing.lg, }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + marginBottom: THEME.spacing.xs, + }, cardTitle: { fontSize: 18, fontWeight: 'bold', color: THEME.colors.text, - marginBottom: THEME.spacing.xs, }, cardDesc: { fontSize: 14, @@ -295,6 +532,64 @@ const styles = StyleSheet.create({ paddingHorizontal: THEME.spacing.md, fontSize: 16, }, + segmentRow: { + flexDirection: 'row', + backgroundColor: THEME.colors.surfaceSecondary, + borderRadius: THEME.borderRadius.sm, + borderWidth: 1, + borderColor: THEME.colors.border, + marginBottom: THEME.spacing.md, + overflow: 'hidden', + }, + segmentBtn: { + flex: 1, + paddingVertical: 10, + alignItems: 'center', + justifyContent: 'center', + }, + segmentBtnActive: { + backgroundColor: THEME.colors.primary, + }, + segmentText: { + fontSize: 13, + fontWeight: '600', + color: THEME.colors.textMuted, + }, + segmentTextActive: { + color: THEME.colors.text, + }, + frameChipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginTop: THEME.spacing.xs, + }, + frameChip: { + paddingVertical: 6, + paddingHorizontal: 14, + borderRadius: THEME.borderRadius.round, + borderWidth: 1, + borderColor: THEME.colors.border, + backgroundColor: THEME.colors.surfaceSecondary, + }, + frameChipActive: { + borderColor: THEME.colors.primary, + backgroundColor: 'rgba(255, 43, 214, 0.15)', + }, + frameChipText: { + fontSize: 13, + color: THEME.colors.textMuted, + fontWeight: '600', + }, + frameChipTextActive: { + color: THEME.colors.primary, + }, + warningText: { + fontSize: 12, + color: THEME.colors.error, + fontStyle: 'italic', + marginTop: THEME.spacing.xs, + }, statusRow: { flexDirection: 'row', marginBottom: THEME.spacing.xs, @@ -315,7 +610,7 @@ const styles = StyleSheet.create({ color: THEME.colors.textMuted, }, statusWarning: { - color: THEME.colors.accent, + color: THEME.colors.error, }, kioskWarningText: { fontSize: 12, @@ -341,12 +636,18 @@ const styles = StyleSheet.create({ width: '100%', maxWidth: 680, height: 52, + flexDirection: 'row', backgroundColor: THEME.colors.primary, borderRadius: THEME.borderRadius.md, justifyContent: 'center', alignItems: 'center', marginTop: THEME.spacing.sm, marginBottom: THEME.spacing.xl, + shadowColor: THEME.colors.primary, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.4, + shadowRadius: 12, + elevation: 8, }, saveBtnText: { color: THEME.colors.text, diff --git a/src/screens/CameraScreen.tsx b/src/screens/CameraScreen.tsx index ca4458dd..00da0274 100644 --- a/src/screens/CameraScreen.tsx +++ b/src/screens/CameraScreen.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef } from 'react'; +import React, { useState, useEffect, useRef, useCallback } from 'react'; import { StyleSheet, Text, @@ -11,7 +11,23 @@ import { } from 'react-native'; import { CameraView, useCameraPermissions } from 'expo-camera'; import { File, Paths } from 'expo-file-system'; -import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, FadeIn, FadeOut } from 'react-native-reanimated'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + withTiming, + FadeIn, + FadeOut, + withSequence, + Easing, + runOnJS, +} from 'react-native-reanimated'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { + faUserGear, + faCameraRetro, + faXmark, +} from '@fortawesome/free-solid-svg-icons'; import { THEME } from '../styles/theme'; import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera'; import { saveToGallery } from '../services/storage'; @@ -19,21 +35,29 @@ import { saveToGallery } from '../services/storage'; interface CameraScreenProps { countdownDuration: number; onPhotoCaptured: (uri: string) => void; + onBurstComplete: (uris: string[]) => void; onCancel: () => void; isIdle?: boolean; onStartBooth?: () => void; onNavigateToAdmin?: () => void; adminPassword?: string; + welcomeText?: string; + burstCount?: number; + burstIntervalSec?: number; } export default function CameraScreen({ countdownDuration, onPhotoCaptured, + onBurstComplete, onCancel, isIdle = false, onStartBooth, onNavigateToAdmin, adminPassword = '1234', + welcomeText = 'Willkommen zu unserer Feier!', + burstCount = 1, + burstIntervalSec = 5, }: CameraScreenProps) { const [permission, requestPermission] = useCameraPermissions(); const [isUsbConnected, setIsUsbConnected] = useState(false); @@ -41,6 +65,15 @@ export default function CameraScreen({ const [isCapturing, setIsCapturing] = useState(false); const [hasStarted, setHasStarted] = useState(false); + // Burst mode state + const [burstIndex, setBurstIndex] = useState(0); + const [burstUris, setBurstUris] = useState([]); + const [showBurstIndicator, setShowBurstIndicator] = useState(''); + const [isBurstWaiting, setIsBurstWaiting] = useState(false); + + // Flash effect + const flashOpacity = useSharedValue(0); + // Admin Modal States const [passwordModalVisible, setPasswordModalVisible] = useState(false); const [enteredPassword, setEnteredPassword] = useState(''); @@ -48,6 +81,7 @@ export default function CameraScreen({ const usbCameraRef = useRef(null); const expoCameraRef = useRef(null); + const burstTimerRef = useRef(null); // Reanimated countdown pulse animation const scale = useSharedValue(1); @@ -65,6 +99,18 @@ export default function CameraScreen({ transform: [{ scale: scale.value }], })); + const flashAnimatedStyle = useAnimatedStyle(() => ({ + opacity: flashOpacity.value, + })); + + // Trigger white flash effect + const triggerFlash = useCallback(() => { + flashOpacity.value = withSequence( + withTiming(1, { duration: 80, easing: Easing.out(Easing.quad) }), + withTiming(0, { duration: 400, easing: Easing.in(Easing.quad) }) + ); + }, []); + // 1. Request built-in camera permission on mount (just in case we fallback) useEffect(() => { if (!permission || !permission.granted) { @@ -96,19 +142,35 @@ export default function CameraScreen({ }; }, []); + // Cleanup burst timer on unmount + useEffect(() => { + return () => { + if (burstTimerRef.current) clearTimeout(burstTimerRef.current); + }; + }, []); + // 3. Start the countdown on screen load (only when NOT idle) useEffect(() => { if (isIdle) { setHasStarted(false); + setBurstIndex(0); + setBurstUris([]); return; } + startCountdown(); + + return () => {}; + }, [countdownDuration, isIdle]); + + const startCountdown = () => { let timer: any; let count = countdownDuration; setCountdown(count); setHasStarted(true); setIsCapturing(false); + setShowBurstIndicator(''); const runTimer = () => { if (count > 1) { @@ -120,16 +182,15 @@ export default function CameraScreen({ setIsCapturing(true); timer = setTimeout(() => { capture(); - }, 800); // give the user a split second to smile + }, 800); } }; timer = setTimeout(runTimer, 1000); - return () => { - if (timer) clearTimeout(timer); - }; - }, [countdownDuration, isIdle]); + // Store timer for cleanup + burstTimerRef.current = timer; + }; // 4. Capture photo function const capture = async () => { @@ -138,20 +199,15 @@ export default function CameraScreen({ const tempUri = tempFile.uri; try { + triggerFlash(); + + let capturedUri: string; + if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) { // USB Camera Capture console.log('Capturing from USB Camera...'); const path = await usbCameraRef.current.takePicture(tempUri); - - // Auto-save individual photo directly to gallery - try { - await saveToGallery(path); - console.log('Auto-saved USB capture to gallery'); - } catch (e) { - console.error('Failed to auto-save USB capture:', e); - } - - onPhotoCaptured(path); + capturedUri = path; } else { // Fallback Camera Capture console.log('Capturing from built-in camera fallback...'); @@ -160,20 +216,47 @@ export default function CameraScreen({ quality: 0.95, skipProcessing: false, }); - - // Auto-save individual photo directly to gallery - try { - await saveToGallery(photo.uri); - console.log('Auto-saved fallback capture to gallery'); - } catch (e) { - console.error('Failed to auto-save fallback capture:', e); - } - - onPhotoCaptured(photo.uri); + capturedUri = photo.uri; } else { throw new Error('Kamera-Referenz ist nicht verfügbar.'); } } + + // Auto-save individual photo to gallery + try { + await saveToGallery(capturedUri); + console.log('Auto-saved capture to gallery'); + } catch (e) { + console.error('Failed to auto-save capture:', e); + } + + // Handle burst mode + const effectiveBurst = burstCount || 1; + const newIndex = burstIndex + 1; + const newUris = [...burstUris, capturedUri]; + + if (effectiveBurst > 1 && newIndex < effectiveBurst) { + // More burst photos to take + setBurstIndex(newIndex); + setBurstUris(newUris); + setShowBurstIndicator(`Foto ${newIndex} von ${effectiveBurst} ✓`); + setIsCapturing(false); + setIsBurstWaiting(true); + + // Wait burstIntervalSec then start next countdown + burstTimerRef.current = setTimeout(() => { + setIsBurstWaiting(false); + startCountdown(); + }, burstIntervalSec * 1000); + } else if (effectiveBurst > 1) { + // Final burst photo + setBurstIndex(0); + setBurstUris([]); + onBurstComplete(newUris); + } else { + // Single shot mode + onPhotoCaptured(capturedUri); + } } catch (error: any) { console.error('Capture failed:', error); alert('Fehler beim Aufnehmen des Fotos: ' + error.message); @@ -190,7 +273,9 @@ export default function CameraScreen({ const handlePasswordSubmit = () => { if (enteredPassword === adminPassword) { setPasswordModalVisible(false); - onNavigateToAdmin?.(); + setTimeout(() => { + onNavigateToAdmin?.(); + }, 350); // Delay navigation on iOS to avoid unmounting a view with an open modal } else { setErrorText('Falsches Passwort'); setEnteredPassword(''); @@ -231,16 +316,17 @@ export default function CameraScreen({ onPress={handleSettingsTap} activeOpacity={0.7} > - ⚙️ + SCHNAPPIX - Willkommen zu unserer Feier! + {welcomeText} + ZUM STARTEN ÜBERALL TIPPEN @@ -294,7 +380,7 @@ export default function CameraScreen({ ); } - // Capture Mode overlays (Countdown and Cancel button) + // Capture Mode overlays (Countdown, Burst indicator, and Cancel button) return ( {/* Source indicator */} @@ -304,8 +390,28 @@ export default function CameraScreen({ + {/* Burst progress indicator */} + {(burstCount || 1) > 1 && ( + + + + Foto {burstIndex + 1} / {burstCount} + + + )} + + {/* Burst waiting indicator */} + {isBurstWaiting && showBurstIndicator !== '' && ( + + + {showBurstIndicator} + Nächstes Foto in {burstIntervalSec}s… + + + )} + {/* Countdown overlay */} - {hasStarted && ( + {hasStarted && !isBurstWaiting && ( {countdown} @@ -314,8 +420,9 @@ export default function CameraScreen({ )} {/* Cancel Button */} - {!isCapturing && ( + {!isCapturing && !isBurstWaiting && ( + ABBRECHEN )} @@ -340,6 +447,12 @@ export default function CameraScreen({ {/* Render UI controls/overlays on top */} {renderContent()} + + {/* White Flash Overlay */} + ); } @@ -360,11 +473,20 @@ const styles = StyleSheet.create({ width: '100%', height: '100%', }, + flashOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: '#FFFFFF', + zIndex: 9999, + }, idleOverlayContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', - backgroundColor: 'rgba(0, 0, 0, 0.15)', + backgroundColor: 'rgba(5, 5, 16, 0.25)', }, captureOverlayContainer: { flex: 1, @@ -378,29 +500,25 @@ const styles = StyleSheet.create({ width: 50, height: 50, borderRadius: THEME.borderRadius.round, - backgroundColor: 'rgba(30, 30, 35, 0.75)', + backgroundColor: 'rgba(13, 13, 26, 0.8)', borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.1)', + borderColor: 'rgba(255, 43, 214, 0.25)', justifyContent: 'center', alignItems: 'center', zIndex: 999, }, - settingsIcon: { - fontSize: 24, - color: THEME.colors.text, - }, welcomeBox: { alignItems: 'center', padding: THEME.spacing.xl, borderRadius: THEME.borderRadius.lg, - backgroundColor: 'rgba(18, 18, 20, 0.8)', + backgroundColor: 'rgba(5, 5, 16, 0.85)', borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.15)', + borderColor: 'rgba(255, 43, 214, 0.2)', width: '60%', - shadowColor: '#000', - shadowOffset: { width: 0, height: 10 }, + shadowColor: THEME.colors.primary, + shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.3, - shadowRadius: 20, + shadowRadius: 30, elevation: 10, }, logo: { @@ -409,9 +527,9 @@ const styles = StyleSheet.create({ color: THEME.colors.primary, letterSpacing: 8, marginBottom: THEME.spacing.sm, - textShadowColor: 'rgba(0, 0, 0, 0.5)', - textShadowOffset: { width: 0, height: 2 }, - textShadowRadius: 4, + textShadowColor: THEME.colors.neonGlow, + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 20, }, welcomeTitle: { fontSize: 22, @@ -426,13 +544,24 @@ const styles = StyleSheet.create({ backgroundColor: THEME.colors.accent, marginBottom: THEME.spacing.xl, borderRadius: THEME.borderRadius.sm, + shadowColor: THEME.colors.accent, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.8, + shadowRadius: 8, }, startButton: { + flexDirection: 'row', + alignItems: 'center', paddingVertical: THEME.spacing.md, paddingHorizontal: THEME.spacing.xl, backgroundColor: THEME.colors.primary, borderRadius: THEME.borderRadius.round, marginBottom: THEME.spacing.xl, + shadowColor: THEME.colors.primary, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.5, + shadowRadius: 12, + elevation: 8, }, startButtonText: { color: THEME.colors.text, @@ -448,18 +577,62 @@ const styles = StyleSheet.create({ hudContainer: { position: 'absolute', bottom: 20, - backgroundColor: 'rgba(0, 0, 0, 0.7)', + backgroundColor: 'rgba(5, 5, 16, 0.8)', paddingVertical: THEME.spacing.xs, paddingHorizontal: THEME.spacing.md, borderRadius: THEME.borderRadius.sm, borderWidth: 0.5, - borderColor: 'rgba(255, 255, 255, 0.1)', + borderColor: 'rgba(255, 43, 214, 0.15)', }, cameraSourceIndicator: { color: THEME.colors.text, fontSize: 14, fontWeight: 'bold', }, + burstBadge: { + position: 'absolute', + top: 24, + right: 24, + flexDirection: 'row', + alignItems: 'center', + gap: 6, + backgroundColor: 'rgba(5, 5, 16, 0.85)', + paddingVertical: 6, + paddingHorizontal: 14, + borderRadius: THEME.borderRadius.round, + borderWidth: 1, + borderColor: THEME.colors.accent, + }, + burstBadgeText: { + color: THEME.colors.accent, + fontSize: 14, + fontWeight: 'bold', + }, + burstWaitingBox: { + backgroundColor: 'rgba(5, 5, 16, 0.9)', + paddingVertical: THEME.spacing.xl, + paddingHorizontal: THEME.spacing.xxl, + borderRadius: THEME.borderRadius.lg, + borderWidth: 1, + borderColor: THEME.colors.accent, + alignItems: 'center', + shadowColor: THEME.colors.accent, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.4, + shadowRadius: 20, + elevation: 8, + }, + burstWaitingText: { + fontSize: 28, + fontWeight: '900', + color: THEME.colors.accent, + textAlign: 'center', + }, + burstWaitingSub: { + fontSize: 16, + color: THEME.colors.textMuted, + marginTop: THEME.spacing.sm, + }, countdownOverlay: { position: 'absolute', top: 0, @@ -470,19 +643,19 @@ const styles = StyleSheet.create({ alignItems: 'center', }, countdownBox: { - backgroundColor: 'rgba(20, 20, 25, 0.85)', + backgroundColor: 'rgba(5, 5, 16, 0.88)', paddingVertical: THEME.spacing.xl, paddingHorizontal: THEME.spacing.xxl, borderRadius: THEME.borderRadius.lg, borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.15)', + borderColor: 'rgba(255, 43, 214, 0.25)', minWidth: 180, alignItems: 'center', justifyContent: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 10 }, + shadowColor: THEME.colors.primary, + shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.4, - shadowRadius: 15, + shadowRadius: 20, elevation: 8, }, countdownText: { @@ -490,17 +663,22 @@ const styles = StyleSheet.create({ fontWeight: '900', color: THEME.colors.text, textAlign: 'center', + textShadowColor: THEME.colors.neonGlow, + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 15, }, backButton: { position: 'absolute', top: 24, left: 24, - backgroundColor: 'rgba(30, 30, 35, 0.75)', + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(13, 13, 26, 0.8)', paddingVertical: THEME.spacing.sm, paddingHorizontal: THEME.spacing.lg, borderRadius: THEME.borderRadius.round, borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.1)', + borderColor: 'rgba(255, 43, 214, 0.2)', }, backButtonText: { color: THEME.colors.text, @@ -546,10 +724,10 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: THEME.colors.border, alignItems: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 10 }, - shadowOpacity: 0.4, - shadowRadius: 15, + shadowColor: THEME.colors.primary, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.3, + shadowRadius: 20, elevation: 10, }, modalTitle: { diff --git a/src/screens/PreviewScreen.tsx b/src/screens/PreviewScreen.tsx index 98ae8f5e..cd1f9fce 100644 --- a/src/screens/PreviewScreen.tsx +++ b/src/screens/PreviewScreen.tsx @@ -1,18 +1,32 @@ -import React, { useState, useRef } from 'react'; +import React, { useState, useRef, useCallback } from 'react'; import { StyleSheet, Text, View, Image, TouchableOpacity, - ScrollView, ActivityIndicator, } from 'react-native'; import ViewShot from 'react-native-view-shot'; import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; +import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome'; +import { + faPrint, + faDownload, + faPlus, + faRotateLeft, + faRightFromBracket, + faFaceGrinStars, + faBorderAll, +} from '@fortawesome/free-solid-svg-icons'; import { THEME } from '../styles/theme'; import { printImageLocal } from '../services/printer'; import { saveToGallery } from '../services/storage'; +import DraggableSticker, { StickerData } from '../components/DraggableSticker'; +import StickerTray from '../components/StickerTray'; +import FrameOverlay from '../components/FrameOverlay'; +import FramePicker from '../components/FramePicker'; +import { FRAMES } from '../data/frames'; interface PreviewScreenProps { photoUris: string[]; @@ -20,6 +34,14 @@ interface PreviewScreenProps { onRetakeLast: () => void; onAddAnother: () => void; onReset: () => void; + // Frame settings + frameMode: 'off' | 'always' | 'available'; + initialFrameId: string | null; + // Date overlay settings + dateOverlay: 'off' | 'date' | 'datetime'; + dateOverlayPosition: string; + // Event text + eventText: string; } type CollageLayout = 'single' | 'strip' | 'grid' | 'duo'; @@ -30,44 +52,147 @@ export default function PreviewScreen({ onRetakeLast, onAddAnother, onReset, + frameMode, + initialFrameId, + dateOverlay, + dateOverlayPosition, + eventText, }: PreviewScreenProps) { const [layout, setLayout] = useState('single'); const [isProcessing, setIsProcessing] = useState(false); const [statusMessage, setStatusMessage] = useState(''); - + + // Sticker state + const [stickers, setStickers] = useState([]); + const [selectedStickerId, setSelectedStickerId] = useState(null); + const [showStickerTray, setShowStickerTray] = useState(false); + + // Frame state + const [selectedFrameId, setSelectedFrameId] = useState( + frameMode === 'always' ? initialFrameId : null + ); + const viewShotRef = useRef(null); const currentPhoto = photoUris[photoUris.length - 1]; + // Get the active frame asset + const activeFrame = selectedFrameId + ? FRAMES.find((f) => f.id === selectedFrameId) + : null; + + // Format date/time for overlays and stickers + const formatDate = useCallback((mode: 'date' | 'datetime'): string => { + const now = new Date(); + const date = now.toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); + if (mode === 'datetime') { + const time = now.toLocaleTimeString('de-DE', { + hour: '2-digit', + minute: '2-digit', + }); + return `${date} ${time}`; + } + return date; + }, []); + + // Get date overlay position styles + const getDatePositionStyle = () => { + switch (dateOverlayPosition) { + case 'bottom-left': + return { bottom: 12, left: 12 }; + case 'top-right': + return { top: 12, right: 12 }; + case 'top-left': + return { top: 12, left: 12 }; + case 'bottom-center': + return { bottom: 12, left: 0, right: 0, alignItems: 'center' as const }; + case 'bottom-right': + default: + return { bottom: 12, right: 12 }; + } + }; + + // ── Sticker handlers ── + const addSticker = useCallback((type: 'emoji' | 'text', content: string) => { + const newSticker: StickerData = { + id: `sticker_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`, + type, + content, + x: 100 + Math.random() * 40 - 20, + y: 100 + Math.random() * 40 - 20, + scale: 1, + rotation: 0, + }; + setStickers((prev) => [...prev, newSticker]); + setSelectedStickerId(newSticker.id); + }, []); + + const handleAddEmoji = useCallback((emoji: string) => { + addSticker('emoji', emoji); + }, [addSticker]); + + const handleAddDateSticker = useCallback((format: 'date' | 'datetime') => { + addSticker('text', formatDate(format)); + }, [addSticker, formatDate]); + + const handleAddEventSticker = useCallback(() => { + const date = formatDate('date'); + addSticker('text', `${eventText} • ${date}`); + }, [addSticker, formatDate, eventText]); + + const handleStickerSelect = useCallback((id: string) => { + setSelectedStickerId((prev) => (prev === id ? null : id)); + }, []); + + const handleStickerDelete = useCallback((id: string) => { + setStickers((prev) => prev.filter((s) => s.id !== id)); + setSelectedStickerId(null); + }, []); + + const handleStickerUpdate = useCallback((id: string, updates: Partial) => { + setStickers((prev) => + prev.map((s) => (s.id === id ? { ...s, ...updates } : s)) + ); + }, []); + + // Deselect stickers when tapping the photo area + const handleCanvasPress = useCallback(() => { + setSelectedStickerId(null); + }, []); + + // ── Capture the final image ── + const captureComposite = async (): Promise => { + if (viewShotRef.current) { + return await viewShotRef.current.capture(); + } + return currentPhoto; + }; + // Triggers the view capture and the print job const handlePrint = async () => { setIsProcessing(true); setStatusMessage('Dein Foto wird vorbereitet...'); try { - let printUri = currentPhoto; - - // If we are printing a collage layout, capture the ViewShot container - if (layout !== 'single') { - setStatusMessage('Collage wird generiert...'); - const capturedUri = await viewShotRef.current.capture(); - printUri = capturedUri; - } + setStatusMessage('Bild wird generiert...'); + const printUri = await captureComposite(); setStatusMessage('Druckauftrag wird an Drucker gesendet...'); - // 1. Silent Print via IPP await printImageLocal(printUri, { ipAddress: printerIp, jobName: 'Schnappix Fotobox', }); setStatusMessage('Wird in Galerie gespeichert...'); - // 2. Save permanently to DCIM/Schnappix await saveToGallery(printUri); setStatusMessage('Druck erfolgreich! Viel Spaß! 🎉'); setTimeout(() => { setIsProcessing(false); - onReset(); // Go back to Home Screen + onReset(); }, 2000); } catch (error: any) { console.error('Printing failed:', error); @@ -81,12 +206,7 @@ export default function PreviewScreen({ setIsProcessing(true); setStatusMessage('Bild wird generiert...'); try { - let saveUri = currentPhoto; - - if (layout !== 'single') { - const capturedUri = await viewShotRef.current.capture(); - saveUri = capturedUri; - } + const saveUri = await captureComposite(); setStatusMessage('Wird in Galerie gespeichert...'); await saveToGallery(saveUri); @@ -103,18 +223,17 @@ export default function PreviewScreen({ } }; - // Auto-save collage and exit + // Auto-save and exit const handleExit = async () => { - // If layout is single, it has already been auto-saved to gallery right when captured - if (layout === 'single') { + if (stickers.length === 0 && layout === 'single' && !activeFrame && dateOverlay === 'off') { onReset(); return; } setIsProcessing(true); - setStatusMessage('Collage wird gespeichert...'); + setStatusMessage('Wird gespeichert...'); try { - const capturedUri = await viewShotRef.current.capture(); + const capturedUri = await captureComposite(); await saveToGallery(capturedUri); setStatusMessage('Erfolgreich gespeichert!'); setTimeout(() => { @@ -122,13 +241,13 @@ export default function PreviewScreen({ onReset(); }, 1000); } catch (error) { - console.error('Auto-saving collage on exit failed:', error); + console.error('Auto-saving on exit failed:', error); setIsProcessing(false); onReset(); } }; - // Render the selected collage inside the capture container + // ── Render the collage ── const renderCollageView = () => { switch (layout) { case 'strip': @@ -183,20 +302,54 @@ export default function PreviewScreen({ return ( {/* Left panel: Preview Canvas */} - - {layout === 'single' ? ( - renderCollageView() - ) : ( - // ViewShot wraps the collage to capture it at exactly 3:2 ratio (1200x1800 px) for the printer - - {renderCollageView()} - + + + {renderCollageView()} + + {/* Frame Overlay */} + {activeFrame && } + + {/* Date/Time Overlay */} + {dateOverlay !== 'off' && ( + + + {formatDate(dateOverlay)} + + + )} + + {/* Stickers */} + {stickers.map((sticker) => ( + + ))} + + + {/* Sticker Tray (outside ViewShot — not captured) */} + {showStickerTray && ( + setShowStickerTray(false)} + eventText={eventText} + /> )} - + {/* Right panel: Controls */} @@ -252,28 +405,79 @@ export default function PreviewScreen({ Aufgenommene Fotos: {photoUris.length} / 4 + {/* Editing Tools Row */} + + setShowStickerTray(!showStickerTray)} + > + + + Sticker + + + + {frameMode === 'available' && ( + { + if (selectedFrameId !== null) { + setSelectedFrameId(null); + } + // FramePicker is shown below + }} + > + + + Rahmen + + + )} + + + {/* Frame Picker (when mode = available) */} + {frameMode === 'available' && ( + + )} + {/* Action Buttons */} - JETZT DRUCKEN 🖨️ + + JETZT DRUCKEN - Nur auf Tablet speichern 💾 + + Nur auf Tablet speichern {photoUris.length < 4 && ( - + Weiteres Foto aufnehmen + + Weiteres Foto aufnehmen )} - Letztes wiederholen + + Wiederholen + Beenden @@ -304,7 +508,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', padding: THEME.spacing.md, - backgroundColor: '#050507', + backgroundColor: '#030308', }, viewShotContainer: { width: 320, @@ -327,6 +531,9 @@ const styles = StyleSheet.create({ textAlign: 'center', marginBottom: THEME.spacing.md, letterSpacing: 2, + textShadowColor: THEME.colors.neonGlow, + textShadowOffset: { width: 0, height: 0 }, + textShadowRadius: 10, }, layoutSelector: { flexDirection: 'row', @@ -346,6 +553,11 @@ const styles = StyleSheet.create({ layoutBtnActive: { backgroundColor: THEME.colors.primary, borderColor: THEME.colors.primary, + shadowColor: THEME.colors.primary, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.4, + shadowRadius: 8, + elevation: 4, }, layoutBtnDisabled: { opacity: 0.3, @@ -359,13 +571,43 @@ const styles = StyleSheet.create({ fontSize: 14, color: THEME.colors.textMuted, textAlign: 'center', - marginBottom: THEME.spacing.lg, + marginBottom: THEME.spacing.sm, + }, + editToolsRow: { + flexDirection: 'row', + justifyContent: 'center', + gap: 10, + marginBottom: THEME.spacing.md, + }, + editToolBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: THEME.borderRadius.round, + borderWidth: 1, + borderColor: THEME.colors.border, + backgroundColor: THEME.colors.surfaceSecondary, + }, + editToolBtnActive: { + backgroundColor: THEME.colors.primary, + borderColor: THEME.colors.primary, + }, + editToolText: { + fontSize: 13, + fontWeight: '600', + color: THEME.colors.accent, + }, + editToolTextActive: { + color: THEME.colors.text, }, actions: { width: '100%', }, actionBtn: { width: '100%', + flexDirection: 'row', paddingVertical: THEME.spacing.md, borderRadius: THEME.borderRadius.md, alignItems: 'center', @@ -374,6 +616,11 @@ const styles = StyleSheet.create({ }, printBtn: { backgroundColor: THEME.colors.primary, + shadowColor: THEME.colors.primary, + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.4, + shadowRadius: 12, + elevation: 8, }, saveBtn: { backgroundColor: 'transparent', @@ -381,7 +628,7 @@ const styles = StyleSheet.create({ borderColor: THEME.colors.border, }, addBtn: { - backgroundColor: THEME.colors.accent, + backgroundColor: THEME.colors.accentDark, }, actionBtnText: { color: THEME.colors.text, @@ -396,9 +643,11 @@ const styles = StyleSheet.create({ }, smallBtn: { flex: 0.48, + flexDirection: 'row', paddingVertical: THEME.spacing.sm, borderRadius: THEME.borderRadius.sm, alignItems: 'center', + justifyContent: 'center', borderWidth: 1, }, retakeBtn: { @@ -410,19 +659,20 @@ const styles = StyleSheet.create({ backgroundColor: 'transparent', }, smallBtnText: { - color: THEME.colors.text, + color: THEME.colors.textMuted, fontSize: 14, fontWeight: '600', }, + // ── Photo Canvases ── singleCanvas: { width: 320, height: 480, backgroundColor: '#fff', padding: 10, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, + shadowColor: THEME.colors.primary, + shadowOffset: { width: 0, height: 0 }, shadowOpacity: 0.3, - shadowRadius: 10, + shadowRadius: 15, elevation: 5, }, singleImage: { @@ -496,6 +746,24 @@ const styles = StyleSheet.create({ resizeMode: 'cover', borderRadius: 2, }, + // ── Date Overlay ── + dateOverlay: { + position: 'absolute', + zIndex: 60, + }, + dateOverlayText: { + fontSize: 11, + fontWeight: 'bold', + color: '#FFFFFF', + textShadowColor: 'rgba(0, 0, 0, 0.8)', + textShadowOffset: { width: 1, height: 1 }, + textShadowRadius: 3, + backgroundColor: 'rgba(0, 0, 0, 0.35)', + paddingVertical: 2, + paddingHorizontal: 6, + borderRadius: 3, + }, + // ── Loading ── loadingOverlay: { position: 'absolute', top: 0, @@ -514,6 +782,11 @@ const styles = StyleSheet.create({ alignItems: 'center', borderWidth: 1, borderColor: THEME.colors.border, + shadowColor: THEME.colors.primary, + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.3, + shadowRadius: 20, + elevation: 10, }, loadingText: { color: THEME.colors.text, diff --git a/src/services/settings.ts b/src/services/settings.ts index cb0b0485..ea48c60f 100644 --- a/src/services/settings.ts +++ b/src/services/settings.ts @@ -5,6 +5,19 @@ export interface AppSettings { printerIp: string; adminPassword: string; kioskModeEnabled: boolean; + // Photo Frames + frameMode: 'off' | 'always' | 'available'; + selectedFrameId: string | null; + // Date/Time overlay + dateOverlay: 'off' | 'date' | 'datetime'; + dateOverlayPosition: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-center'; + // Event text (shown in date sticker & welcome) + eventText: string; + // Continuous shooting / burst + burstCount: number; // 1 = single shot, 2–5 = burst + burstIntervalSec: number; // seconds between burst shots + // Welcome screen text + welcomeText: string; } const settingsFile = new File(Paths.document, 'settings.json'); @@ -14,6 +27,14 @@ const DEFAULT_SETTINGS: AppSettings = { printerIp: '192.168.1.100', adminPassword: '1234', kioskModeEnabled: false, + frameMode: 'off', + selectedFrameId: null, + dateOverlay: 'off', + dateOverlayPosition: 'bottom-right', + eventText: '', + burstCount: 1, + burstIntervalSec: 5, + welcomeText: 'Willkommen zu unserer Feier!', }; /** diff --git a/src/services/storage.ts b/src/services/storage.ts index faa38daf..81a75cef 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -1,4 +1,4 @@ -import { Asset, Album, getPermissionsAsync, requestPermissionsAsync } from 'expo-media-library'; +import { getPermissionsAsync, requestPermissionsAsync, createAssetAsync, getAlbumAsync, createAlbumAsync, addAssetsToAlbumAsync } from 'expo-media-library'; const ALBUM_NAME = 'Schnappix'; @@ -33,28 +33,27 @@ export async function saveToGallery(localUri: string): Promise { } // 1. Create a media asset from the local file - const asset = await Asset.create(localUri); + const asset = await createAssetAsync(localUri); try { // 2. Check if the "Schnappix" album already exists - const album = await Album.get(ALBUM_NAME); + const album = await getAlbumAsync(ALBUM_NAME); if (!album) { // 3. If it doesn't exist, create it with our asset - await Album.create(ALBUM_NAME, [asset], false); + await createAlbumAsync(ALBUM_NAME, asset, false); console.log(`Created new album "${ALBUM_NAME}" and saved photo.`); } else { // 4. If it exists, add our asset to it - await album.add(asset); + await addAssetsToAlbumAsync([asset], album, false); console.log(`Saved photo to existing album "${ALBUM_NAME}".`); } - const assetUri = await asset.getUri(); - return assetUri; + return asset.uri; } catch (e: any) { console.error('Error saving asset to album, returning fallback asset URI:', e); try { - return await asset.getUri(); + return asset.uri; } catch { return asset.id; } diff --git a/src/styles/theme.ts b/src/styles/theme.ts index 48e4133d..b719b98b 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -1,18 +1,21 @@ export const THEME = { colors: { - background: '#0B0B0E', - surface: '#121217', - surfaceSecondary: '#1C1C24', - primary: '#6C5DD3', - primaryDark: '#564AB1', - accent: '#FF7A9A', + background: '#050510', + surface: '#0D0D1A', + surfaceSecondary: '#151528', + primary: '#ff2bd6', + primaryDark: '#cc22ab', + accent: '#28dfff', + accentDark: '#1fb3cc', text: '#FFFFFF', - textMuted: '#8F90A6', - border: 'rgba(255, 255, 255, 0.08)', - glassBackground: 'rgba(18, 18, 23, 0.75)', - overlay: 'rgba(0, 0, 0, 0.85)', - success: '#3F8CFF', - error: '#FF6A55', + textMuted: '#7a7a9e', + border: 'rgba(255, 43, 214, 0.15)', + glassBackground: 'rgba(13, 13, 26, 0.85)', + overlay: 'rgba(5, 5, 16, 0.92)', + success: '#28dfff', + error: '#ff4466', + neonGlow: 'rgba(255, 43, 214, 0.4)', + cyanGlow: 'rgba(40, 223, 255, 0.4)', }, fonts: { // Falls back to system sans-serif which is clean and modern on Android/iOS