From 277db06d26634ce18779f96ac5a6be652ff05db3 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 25 May 2026 05:10:43 -0400 Subject: [PATCH] add dashboard to phone app --- .gitlab-ci.yml | 62 + android/app/src/main/AndroidManifest.xml | 3 + app_search.jpeg | Bin 50069 -> 0 bytes ios/Flutter/Profile.xcconfig | 2 + ios/Podfile | 3 +- ios/Podfile.lock | 35 + ios/Runner.xcodeproj/project.pbxproj | 119 +- .../contents.xcworkspacedata | 3 + lib/core/constants/app_constants.dart | 22 +- lib/core/models/marketplace.dart | 2 +- lib/core/models/notification.dart | 31 + lib/core/models/offer.dart | 68 ++ lib/core/models/report.dart | 62 + lib/core/models/reservation.dart | 6 + lib/core/models/settings.dart | 63 + lib/core/models/vehicle.dart | 3 + lib/core/router/app_router.dart | 178 +-- lib/core/services/analytics_service.dart | 15 + lib/core/services/api_client.dart | 10 +- lib/core/services/auth_service.dart | 2 +- lib/core/services/contract_service.dart | 293 +++++ lib/core/services/customer_service.dart | 37 + lib/core/services/marketplace_service.dart | 6 +- lib/core/services/notification_service.dart | 29 + lib/core/services/offer_service.dart | 41 + lib/core/services/payment_service.dart | 52 + lib/core/services/reservation_service.dart | 60 +- lib/core/services/settings_service.dart | 16 + lib/core/services/team_service.dart | 62 + lib/core/services/vehicle_service.dart | 128 +- .../auth/screens/employee_login_screen.dart | 33 +- .../client/screens/client_home_screen.dart | 540 +++++---- lib/features/client/screens/client_shell.dart | 23 +- .../client/screens/my_bookings_screen.dart | 17 +- .../providers/dashboard_providers.dart | 106 ++ .../dashboard/screens/contract_screen.dart | 665 ++++++++++ .../dashboard/screens/contracts_screen.dart | 251 ++++ .../screens/customer_detail_screen.dart | 297 ++++- .../screens/customer_form_screen.dart | 182 +++ .../dashboard/screens/customers_screen.dart | 39 +- .../dashboard/screens/dashboard_shell.dart | 171 ++- .../dashboard/screens/home_screen.dart | 194 ++- .../screens/new_reservation_screen.dart | 320 +++++ .../screens/notifications_screen.dart | 174 +++ .../dashboard/screens/offers_screen.dart | 452 +++++++ .../screens/online_reservations_screen.dart | 342 ++++++ .../dashboard/screens/reports_screen.dart | 296 +++++ .../screens/reservation_detail_screen.dart | 610 +++++++++- .../screens/reservations_screen.dart | 56 +- .../dashboard/screens/settings_screen.dart | 186 +++ .../dashboard/screens/team_screen.dart | 341 ++++++ .../screens/vehicle_detail_screen.dart | 1080 +++++++++++++++-- .../screens/vehicle_form_screen.dart | 347 ++++++ .../dashboard/screens/vehicles_screen.dart | 45 +- lib/l10n/app_ar.arb | 2 +- lib/l10n/app_en.arb | 2 +- lib/l10n/app_fr.arb | 2 +- lib/l10n/app_localizations.dart | 2 +- lib/l10n/app_localizations_ar.dart | 2 +- lib/l10n/app_localizations_en.dart | 2 +- lib/l10n/app_localizations_fr.dart | 2 +- lib/widgets/preferences_bar.dart | 125 +- lib/widgets/reservation_card.dart | 2 +- lib/widgets/status_badge.dart | 6 + lib/widgets/vehicle_card.dart | 2 +- pubspec.lock | 112 ++ pubspec.yaml | 1 + 67 files changed, 7872 insertions(+), 570 deletions(-) create mode 100644 .gitlab-ci.yml delete mode 100644 app_search.jpeg create mode 100644 ios/Flutter/Profile.xcconfig create mode 100644 ios/Podfile.lock create mode 100644 lib/core/models/notification.dart create mode 100644 lib/core/models/offer.dart create mode 100644 lib/core/models/report.dart create mode 100644 lib/core/models/settings.dart create mode 100644 lib/core/services/contract_service.dart create mode 100644 lib/core/services/notification_service.dart create mode 100644 lib/core/services/offer_service.dart create mode 100644 lib/core/services/payment_service.dart create mode 100644 lib/core/services/settings_service.dart create mode 100644 lib/core/services/team_service.dart create mode 100644 lib/features/dashboard/screens/contract_screen.dart create mode 100644 lib/features/dashboard/screens/contracts_screen.dart create mode 100644 lib/features/dashboard/screens/customer_form_screen.dart create mode 100644 lib/features/dashboard/screens/new_reservation_screen.dart create mode 100644 lib/features/dashboard/screens/notifications_screen.dart create mode 100644 lib/features/dashboard/screens/offers_screen.dart create mode 100644 lib/features/dashboard/screens/online_reservations_screen.dart create mode 100644 lib/features/dashboard/screens/reports_screen.dart create mode 100644 lib/features/dashboard/screens/settings_screen.dart create mode 100644 lib/features/dashboard/screens/team_screen.dart create mode 100644 lib/features/dashboard/screens/vehicle_form_screen.dart diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..76cbffc --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,62 @@ +image: ghcr.io/cirruslabs/flutter:stable + +workflow: + rules: + - if: $CI_COMMIT_TAG + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + - if: $CI_COMMIT_BRANCH + +stages: + - verify + - build + +variables: + PUB_CACHE: "$CI_PROJECT_DIR/.pub-cache" + +cache: + key: + files: + - pubspec.lock + paths: + - .pub-cache/ + - .dart_tool/ + +before_script: + - flutter --version + - flutter pub get + +analyze: + stage: verify + script: + - flutter analyze --no-fatal-infos + +test: + stage: verify + script: + - flutter test + +build_android_apk: + stage: build + script: + - flutter build apk --release + artifacts: + when: always + expire_in: 7 days + paths: + - build/app/outputs/flutter-apk/app-release.apk + rules: + - if: $CI_COMMIT_TAG + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + +build_android_appbundle: + stage: build + script: + - flutter build appbundle --release + artifacts: + when: always + expire_in: 7 days + paths: + - build/app/outputs/bundle/release/app-release.aab + rules: + - if: $CI_COMMIT_TAG + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index df5c145..5c58c1b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + 29SvrAumyl#bbf0S zZ=G}ASKoWjx!?c&cdc=)nR)toW@gRIUJFnAPsc$xveI{?L2z&&5FBuVPKQ7eAQU7d zWTY!7$jHd3s3>R{1eh4;=onY=@UaQVuTfHvUn3)jSh zydf&cEyB*j%MD`!hl+}dfsR3fiAlmuO-9ZA)5mEY2p0tbiWq?aM+1V#g+suFJN*K> z1}KRD2UGW#10Dep?g|nBy9(69j$a^vO$dnaS55~(7zl76cpL;A5D0$L>o@TKceC;C zY3tU96wfzvL+Ejq{qbnvpMmfhH9t>Z9U)`5iTvay2sLa?lqTLoW`am{Ym8sz!WE37 z1nSBI_yTO6bE#f8Aad=ndSZf{_i#)rA8x@42oZBBP9tl{4~;!x#a|A%ZC#EJ1FtAH zpbvK=7l|DyPC*ea`w4JcY^Aa#J&UhbTAiS+tfE6X`!-7_E?rRU1rpT>QZ05Bmq9ijL*t%v%MSak$8aJi(LrS3wK}gG_>zWRFqpDZGISa0Q zael)VA#K;zH2?UMzMTd}xhbFWuyjWzN4*`FXteT!{ZKsv?k{EzsO9+2ZLNE?G)76b z6mQ+*psmK7(GvNbE(SD--yRMNV#aQCCq}+&`m@e%;P5%@jCGz+5A1kRlfC+Rr}1Y9 zLp@M#v7(y#`uQqH(L;C3jBiek#gr1#V3KXuFj?A#eWD!qy&xE4YYk$rTI9BHJB-$hx5f*$4N6Jr*IB<3XILJ3))MQtpbfpksxgS5^ z7cHo;JYo2P=uB0CC?GSgl5F@@shc1Z9{Tgve*MEZ3=S$zI=lUg3$dyd$QA~JtGO?= zb@wK=1Z2y!!}2PA)P3gt9{$=^SBNBZimBm%Wkpr<-E6&%M)yVzy`3A6u7hkr_`>&A zoxWkD;GqrNfzVFo+|aw(Y{ggE6A&c%oSO1x%xnJCtnxU2xY0A=7Y=l$a1ZRD*;nHm z?~ETC-tHD9k!+^e6lLQd(r_xbA=Zk#A3L;>;LDnJ{~D4NsddX@3F3CJ<}B&DG0#DQ z&g8%h-y?v~TiV7Qv;V9Azp(hs*#ENgkh4=qN)IWl*bPwMoKdXL5q-T7HMd8>u%2!Ue?prH#)^N*mp4P^e! zw-oL1i^c*1O>p!0*jb1FPo1t$Un}CCj~fVnDp{peuxR}FL2n?SCfreTMlk1i*6V(p z7>3E7J_$M-L*Z%)gXWVwe~wy5;S1cE@(9ljh&#nW*mpNaV`A|S5as|r6z}Hz`@%#& zFmR&FCWNv=6oIh>lff`KdS3bd3sR$p&IeRuEYMdy04m}*FiV(+#NWT?%ujI_1iBqG zJTXbXN`XEI4Xz=@Fn5A~_+|#cW9xn7@J(bc*>gfqtMla}`~G+Hw+c`|zxDz_B4sxM z9E70_(V{BKZ2zI+1PYGq*#la}yy+;1PLd)m<@tTcy^8k46bDQJif0sli99bqf0%#D zX$+}^*cQZEt$vkIG-)hfu2+%{3HNz`I#uP}32{}4rAKw66#fRCrvia?j;bFLIfiWB z1-W5ETnrv6l}#synDSVV6YuZtf8DLO=$})**gh{BTlxOem?JX;^WXU zAfB>kC32kaH4@@{djQN^o+trfoOeoi9usZP+oT)!fY<4;jaZqf)0O_Adz-_pxA$*? z{^(@UYJ986p>;PE4X0}%t=L&|?IW`ZCCn6$LEneHH%#RF?_NW2xuS4B;DeOI^$_?&EU716cxd z9us|mN1X`*{URh+YGFS2ZbB5x$5N>#Y)e9l)i9Y>pWLzni(H4CN=#Y9{Lc^5b>*Q-4D zPeHnE+ihA$)i={mLEwzkSGY_W-J3N@ysCCoH-AJnSyj-IAn0j}XO4DT$XNUpv(A~+ za_dl42E7c zS*3|jMfUT`N0ua{jYu2Mt9^-1NVI&3CVWcuEPkJPUirtGUvVmY89D<#J~k(blm5X> zi7)>d#l*{eeKV3FB>vi$MbY7I6h-LM=l+B%f@E6=o_IPXU4d0Rv#aex*1mML;CbmB zVvCGE>hSAAg5cR_81gyli(aSB5x;CtQN#1{))7RDA|%A3pgseuRA_wdk|7qXwB!j& z0xGbuJSUvzR6Q?Y$gH`DAbeT03ueIU4-z%#;6n;bN8VIl~fb<1t9;8hOiOXmfMbHXcA9@3+OnlZ1JM^(=@ zM+*1L>4$E_^yc-2@-fYnG67tXkpa0wjc zf>SThVO!m-K9pb`QSb_Gmg80~vx?h&=Y1!{*&&e~(xjtnNo=!@FQ3Ia) zrRAK5qoIW8wT8eX3$NobH*9u!c%~TFsB0t%$5Nd z`saxt4L^dRfq?aSbz_ALqgBJ_-?rNea%9H~cCd!=OG`bIaTb{ij{t73+pZl0Yv}BG zD+2CEfNf#%E<%N0okU_(nisqzC24HrDhM{y*J$Kk)|UqIFSSUTVHMW~kc!1~!phHY zrcW-)=NW+H4>bVld5zRBfTsr6-bh!$F6qjdQD>#!WlUC;v)}mU8eY5a`|9DfMF^!6 zQhW{IFNDf@m;-+LfFqE6hwAy$WB>ue+W=%_v#cjIe}@=t1~l&AWt08R|Lf0_BV#p+ zkbyw9H@>aiy7`l4!_`bn!~@D-Zis^r0QuA?R9^o-NKio|J>VKh>Qr}Oax<^& zV=t6sFBC&~5^67$_xVh}_eKbLEgz0sX2gBuAq9681^EkTl!W4SdU#HK|JPLBOpm;A zpf`W-L%LRNk@}GQ#00*kSt{>Psta+bAiO{tD)@k~07M2nWO&pU*Z!5~QGc(-Z_)h! zHR!i!|EB!awW`CPNlfS3TBJcCB0rusLbJ~FPw4s?S%6|Z>>`< zDu29zK_R&ZNoReQ{byU|85Nc9&$dkNGjXT}f3IaG{JoZm3hM~fKx9urt0zlJRfns^ zU2FXHmS^o?_2d);gvfRGJo2r1Rb7^1mt6r6SS|0+nrB#|yO>tkN{}gw2z^rtU}{=c zeYR_2&kky@uGG6vR{+7nlc_9Qt2*xrFoD%W9Zo0O2PdW2yj@R&H?Jd#15zf$4p$?M zt?G}Rb`Ki~^j?14LlW$4{jE4ar`$7z0PKHA75`9kA@6Vc^VLr`&hzjw7XDo0%%nMM zoL+-#KQa12A?)*1=glnQ$2$Um*>XX3PW$s$x&j$8tEOL*UgM83{Ksj`xz&GhW%)WQ|IX-lq6JsKu-gWUlbLvpp7`3$TVgLY52C&iz6sPM`|$I0d;u$Z9b-0mvz6ci5_lr=GL=lI|3A1nuCG z=d6eI@2L-kuk}MJ=IYKpp*w2R1hC|4KNC)7MYi4sFuOc~5dot<7{(PMw8Z8sIF)+{ z5vj+Z>b{5xpo@we0d&1(5DEB&^&BULC_BF7TOTOr79?dvmOPQ*<*uu z;m>7L0(#=s^8ySm&l3Qt4MzgwpvdqYCd<>J5NF_l8O&J+_;D)qLa!re|Apnjl)q86 z657r!d8TPq9a{v>TyV*l*c1jQK*>3E$^H~10{sHGTJ6H~GB5z(5{!Bb9+-_{txO?; zb5(~B!FmjM5F8fZlgkq#vVn*rPSB+=M+W_JE}vQga2!B)xXj(6A0j+g3GLvl2YG@d zuz#@#@V7W3jskC=Qxh5B=g1z;dQS=h@-+Mo^ z<`jhd-^=wMxt-gy4%kyI41@0eiUK?q|9j3~Z8@rC6bag*>_TQ*73Zz-;}j45`w|Y$ zBC+mq3~4N@0n_%0SSj_0Lzs&oynmnPDd<;S|0`Vnn)XurzsCHpVO-dA_SpWfar!gr zwex0Vy*>74SpOB+8uakYl&(|I&c<)nGQVBd{ImR3$VvX$Knh;@@zDgpypni6{5JiY z2b9J$?J#ZsGXu)le`Y+vgpDU2N9XYdA_hJMDZvW6hf_x!7gNh)SjCCJ#ULZk37g1Y zHV|N6qu%PmBWF>E={@fq%8RS3O?%R26GC#*!PZAQe2%XRyQ$u!;?^222tCqs65MFz z5N{l;K4Wm!Fado|yija{JqlXDxox}~?&IT7g77o=HgG-a4nY@LxW#nbO7<86Fl|Ik z_M$xJ{fqQV)od;^KX$Jk`)j;&6`!4{^VisaALGoHzJK>faNeGIcYna}zh}zYuKt-N z^<%rR#n}%h>}AgLF5UGLL?W;S%DFgI&cA6M=EL9HEi3Uu-sFV-Vdt#v_iTQQ$q&W*e}dK0y`)+U zt|pO9*pdhXSl9DL0J@4J;wtcVDV#kn6+{Ywnpy(RM!_jB(A^75CP+EeK@5_RN`i<0Seh5*S{b<2sOx&Y*dbftYY9|U35#q2gmb4LQwIxTaQnjr3ohr^ ze5>ypR&*HKZxVrR`9L6*oRF$kwltg?Ug%U-E15WN*O17$IKIcADJ)htQC?19LkL%4 zchm~P*(AEVSI;Ew+(_HX9L$A{xi??bM54gE1BLLk@icj#%Ol2Cz!0@MqIBDhw`t>o zh#VbtNuZVaKC-YzDaaOcX4^niRS*R>S-RwHLPZW~PeAALxI&0^-GChk3>yfRm@Own zY>AEY#=^J3t{ehR6@o6MHIXTiE|DonR_j_I0){w9{6ZY1Ah65S1%Zh0K+0R$JUPFJ zk!x5X%B$$OlF!H~V=>8U!d@sV^kcGjmRE+aC zlx%i51x>u5^!_=K0?$3hj)5=KxNt%6a0u{M;E_Pc7vHG?AGYBT!c!wsadIhN(Zj>P zDSr160S(VHE6?Y&+`#S&3LHEf92l6l$fwJ69&X*o7ro=XERRo4_|2bo!SK6OxM<$6 z7uA}9A?>mxZZ`l*bg59tuolK@fAFM#Xz}g~Y7~uIl4zaCrquWCC;@oz6I{Ax9+U70 z`Cxs9+u`Bhg)idyjlzF+@I2Okw{y}Uv!L9wO8t;_-VlsUevg<=AzT=(rOTp@hAj{5 z7xLkuVW;8k_A-}XF9s;x2?ba-^n2>|?Qos@e|DfGq>_cm(1w4Vw!ZQAGmDa>?k>M7 zX`i5JTt4HE^WgWj~F-f?eASwTlDyVhU;Um_jd?X!?!g$E-W0o`X z-ofJl{=N0rmxT1$|H3hKtn*tTM<1kKH*y8*lbm0K<=qv<5mL)2}lbspS)j-nkov}v^k-50AnP;)rS{-+VJ zISEwDNr@7*4%k+t5S!KXgEIWtKNY_>+)*OUQOUFN@36TIjGZD`CEDJ|Qaow-w#)rd zv@Hi#>GI|QlBVA-cRaW(YT~#p1AEoL(St$7FOpdTpqy^GyJ^7W1VAI1>-V{%L;-~9 z=5J?pQ6d1ZSrxyssN_`xCYOssWD*Sj3qX3K7ZEp~}hA z84us;o)~QH#cte+UyAr-ZuiOc!JJDn%B@A<-!u?WR#qUe#CL#yG`JZWIe1fLwHuVe z5(yh)@0K3CddbOdJa-Gn=j#h8-cA?OZG0x2p#AkDx%s41i|$ zgc?PMml{ReA1Jf{G+(!>7nSxFoV(34PW;bMY-y-&;gpria%p@t>FMu#;2W68s`0Wi z`u##|H75DgvgtUhao2`Cf#cKYjD4z{`tDS4JK}+xqXn8a`GbNDlV#;(A=bvg@Zm{8 zwAd{9`IiA*TrBpl0aMNdwk9uFoq|4`ft@ zafQUF$}x3iDEaqXlf}=FE_=(xGd;1j5GeGi4N`YVrY5tVpQ3Nmy=dB%%ll>7%7fcv zVPD5TaEw2(ov)|w=!r!CRO%_niI@d&AbWMXhlcaRq9LW@Lclq(KvlAqTtGm8Dx%fV zE&0IMDa|jtd>fy#O!f@znKt;C>bmF^S>7q|WT=(0YW8V7%rszU&k=m^B!MPvX+Kgp zQcD|li(P!0nLXJve2bWS0f<#K@Yb=4N0VGH^+J~@9pK{kmlo#&HqPI|$y+Emf_~q= zhOZGaG6|G(eSl!V0`Y>{8WuI``zQcK#|vjW=B{)*$XLb~XT!kA4dZCK19eLV_L732 z+{a6=%V)WP+=s_YB;})x+<67TAM{yCS{k`8GU@y!-lX>$d$~RZo9q8$hWuWWI#*Ay zoj#D!xXb`G@^-4^YCz4lyqhYeT2n9AcCed1<-N;{T2l|#w$eDu>w|Q$%+PFlN=5&e z-1|dYy9e4H22)$iGr%K-2NwhfJWvpj;gNuEoX-w8TzEu6YGni*Do*h$NO)W~?>?f@ z!zbW{JyLjXJv)1-0N+r9K_^!^M+1Cj#lEI+Fy>LKdC2;`_6jZ0>8%S)Ert|f*-=bF z36=`)DLbmvE&J1$1=OTE8^L}Yc8hly*l`A|^I$Bu-eIZit6pvv58&>1cBT@QDw8^J#_nZRuO?^4;PRGHs=%ty^? z$BMvR|5*vVe`*POepVfOhu{%<%ThVIJJO^YnLVMSYqzq1qO66tMLZfK8q<3>2$z&X zltN#0pTl%jYmvn;b2%d#Bem3|8iCqEIh8+C#r55XR{;OOZJ4lne%YK535xCHaIU@HeD z8=X#js~stkNOm7rC1IJ3gyzF+KL05RP$8*v;UCGG7wQo3N>%HEox#paw~Jr(##cQEr}(NK9+=$evhf z$~Fe)s1M8~TjUeoM|T@`XB=EQz)Roq>z#>!TGFmv@2!Qy&58iKDKWM^XB!E;Oo6l!t=D<)R6R#fjE zo7H0oBm?o%|H${~`?q_asBIUY$~K?iEg3I5s#nIp>Fw=}@M1cOkMk8(5L6LNlAP~Q zSd<~?zIHocN)oDC)XVbXA-$AF1-W-}21S&z6nggCxIN~a%zfVtunK*&4yI*m^%zoA zam3E!rilvX0kmvJ^aBQx#|?fQMdTC5LheVotH3Yn+a0hrQ-*shoY9xsg+leq39c>g zr1yHyb#J-CceW`T^_b0X$Wf!Ex@A;lie|QGHWunyr5J>T9zF(KHtee; zO-|n_d7$R2d572I3jPAGEmh}ps^?enU)5vWvuX`awHB^Fusz<_8PUz+or1~ytdGIj z;73-~4q3xH1$hwwp`b7%zv>&*EDZ}!Pkz0*UK2a_f&17SKUHPk&Q-g-$olzvZeQTYS6-_SidV{}flmGrrqEgk3G@Z^G8 zwTX2k)yFno5h>nDbwu?1VV!5s5a17nfZ@ z8jeo%G3lT$JEf<;`qT{H$+uq|<21B(y;|U~u25*IQvhtBPB#xB*B%kL2<6@_)pMm3 zs8v9W+O(K4L!V8?Z5}4mLs%cX*6ZPn+#>d9 zoi)1JG)5-&sBW$EZC5e5Dk@mq;i%e{M_ayq^0nT;$9og=K{im^A&>8KO7kal5=h_E z+T>FA3b~$f6E8x%aA;i-wLiD{V+c=9tywhTS$tTsqc6FqG3TczMa55t*giXwgYlyO zqv1*dC9w^CiABfox)$;P-I2$D#-Zye=w(+>iH5yKB)=foa8{dZB;o4NfUhWiQHh2U zhL>QaVS554;hT~5C(5@m1El3g%?Aqf8_VY?yjEL{HQVnny-+TA+&{U9>-4l9cUrnX z>mV{Mql&X^qyCEKjc!O~*gZLh{anbZZ0X=jkLD=5nGn1poUC#yBoqBrA>YR@R>{D; z22N#QO)1u{&IWw}Uc*h&xNSSt8>;uXiuAl38b+Y^gtshT`jVH5kxUH8vZ+&j=`YMQ zyyk6aMp}}!p(WFcTrv4*-&LM-Y2%s0{K&gs`9%Jn&v6IDgr~#{-`hmF!Vb^Ee6mHs z>lCC3K5l!Lk(Z3M|Iz8W^<8u&mFsM;3czWSi?RnoB=___0_$aDwVr1yO#T#NZzZG6 z!RQ)R0!mkSBsZpb_`DZv)D9IB(#^jOLneakq{!(px7pbQPeHk|vE>Qn<7BR1-k1w% zyvw%7n&4|IR)k>o;q&lJjTo(~n16+rljf$jBoq}PRg7_=AU+2A{=wUPSIVL%caavd z=*aFImhLh0x3gIfEWDzi8IHoljQjfR<1zc~D(zEH-^rqbFV~oYFI?wXtvP0flB%?o zj2*lOS3)dqzSQ8H zU)tLJ`7stTa}<=>*S-_G$2jH`+~g|3#edX4F5jHWZFUcJXt+ehqk?1H3;%{Mn<$Oz zjrWu315tfow$_Gj(g(5W3VmsUTV>@rc}0y3lQd??ly_dW=LFtM%^ z%C2To!7OLeC;#>RBUH6E911p)ugihAa-xut4Cp504g{qNEOHlxlHcyyc-zb`I+k#k z?m04yd?GWVeO%W%fnQO|Kb@5dACN^mf2>X@F^n_bZle_?>YlI;WLWZHW) z-OgK>L3Xx;2c}OvdjsZU2B8}{K`W=At@5GK zPU4BX_t(uF90U|X*EN@Qz+a<;C5%*k!U*LiRl&;mUO_0c@Xotk*GHb|twZ5*fq| z0uOaGzVEYZ6&i?tqDoIgc^NX>l*-}9Y}VWz5#{py>Q@OUNjmT)BR`JFpVO3y~EJnW!L;Y|?zTWvwMKNV=YrxPrZ>ysjDD zryt695PBqAc=vTfJ-za#D>z2qHR>Vortk6 zq3)+^SvyQ%j)j4bE}e(fw&cbOpkta237;U}MmVw5)w?hx`(i;74EhqgntOlPnU?wjjI(jQf4CGG5i zNw0riE;!*UaKC#Bde=L;<6n(7pd|?}MqyMhS1$Tej(U{t7G#{GS)C9Qg9t;J;I*}p znJ#o?K=|f0{*0)c`tsGZ0p5t_%Gx1{50GZnRyZ93tbUo+2#y%=_9>`*sHzSOB|QKe zwhwYOql&wB$_|#s{(nE8$!SFel*((ZUynP@|5JV)fbID<$E=?k!F? zCnjcQBJ;dMDWnCN6@`o`NXGEobKi?*Y^vTOlwP~ab9?yhTbbM6#5VOQ9-^g~Z^d z9KU4wUUOVHxS=$^Zmyo=rInr!#LuvKUy%}WaJ~9_;z{0eE~6P)B6je!QqRaK$a-?6 z^Mqe;`#4mpZ*p>f=dO#=box#J7(c_+`AA!*9Nh0eE%-8+65f)KPrir5QlS;=^|3bs z(-^KRx$xv(wdN_PU%PI##HoT+ON&+Jf75@&1Awc!85FU`_=sk#Y^7+c6%}^26dJ6< z3oNR63>&)hI>wL=-RVsyUzQ!77*M)_H4i;KG{D@UpLY?quS1zC#-4#y#<+w2RgoeP zWmexwgoqTZ3Vd(s3+V>lYz2K$=P%Qv;AmH*!WWj-#Bw}(%5=*~TPYnS4#6I}|AI4W z&R8#m1gh8@S*%+Ui|~B#=*G+rcu{Ls*`Z3TV;dMDD0Y-Fo;4^D%^HQ0J=;(!3dOA{ z6Si@ULBO@X=4-9;O<!Qt}aY?y+|3uU`bf> zWzAIWQ;Q=O!pCaH6<_~g4(>;kd^K<-^BH)q;X%&@BOfhiE}+?0kV^2?e9`twj*kkD zk{)+cQ2%7W@eJ+WE?AI8xdxDgkvA+*s&@)94*DI5d%5QDY?<(|iMOX%R#Zq}L^xb=W#ALUW-)x!~0;iAbAb)yBQ ziRi-?;)xwA*^x;dU1BYMO_x*;EVDB&;m1!n(qKJgufk3|p`6V2*%Mq_E80;-+Dy7~ z@Hl(MIsuZxbf@OE&$_)j{sv-$1=jOL7Aj-UYmFNU;&&lI48MH3GUw;Jw7M->ricjCXffj_xg>?9K2jA}zB%%A8EcIZC^Wm>+G8r?e- z?r9&AS+P3oaVVyoIVS2{tzC6*360d-E%Yfl6il&L&kgm?iNpW&y*iXZW(j?4z^1+k zF?I1+$C93LEw1{r2Kq}xGaV~47v@lhyNJhJ>KlR(k4`$8S$UfNimcay)_PTQyDX7R zCN#!nJbQ)ku@ZXt0uE3uxv;v?tuZ=q_2JI)n2AT-yX}m%yTzvITq$Oa#wON z>;eYmL(226Mv;)=hi}SN0V%#Jpb{_d<<@?No#iQ6q|#IQjrYm$z(z&! zweO((hG5q|3aYUbPbsLR;*)8mpmN_&Q=#^c8d0)e%b@7tK-CFFZh8w&ScZsd;vb~e zTF!-<@rd9(WO-WqikrNZr#gg%Sr)&PGIlDJ-C8v$A|Zdy)FnC6zFD<09mTwEjLd~9 zOHjy$?5$O&Kw5XZb*xQ%0k}CzUHCm+$gt%`Q(*R6=;4^<6lr2J{^Ti$2k(=J znNHu)tK~usq8N==|C|0J9spcTbSI^IkCFWzwo;t-es(LLlVKiP&rED(k>;zXo9z$p z|8LbFJaMx4TBmnSz(C*Bz}T(~cBrJEyehopqJvKrUYyL)I5|?v37ti1{0Q^iRM&7%tbWN3PT< zb#B<{-csXvu;x!}TfuMN68OM;MNur#uAtVO#c?5ywm{w2yNmmSjhqRPZigl#wbjNevCK#$$kLEGzB$0Dp=% z=c!@hwoIc4IPptLMu@%e5uv$Yy{VtwAxOL3r@SIH`MKxYHyehK9j!u?rJtU{iW67c5*t7C_BnTaPX1B!&cjxzPX7zzD6`6B*K zKzU)-`=?&S#lr+qL*d4D6cqWuscvzt`(33^LlVlB2#;fpTQtROpq&{IpS@n87UCK!#Aw8d`hs zxR`GY^-7r-t8oxTZNU7%>o7Z}n z<0l$ziN6(I9s9xCH#>p@$^GFp`28|6yPp8CM?hb2qLh6KdWeSl@M@j7p*8Vz$GhY~ z!cEIGBr<27Ds+Qy>dM{1aUsibaXhJF#80pKeTzN?WetV%?+Asher?tJGPJ;gUV)Q2 z<1}|ecR@_Fg2yN*lvuR|ced?>)woa8Y_Ei=DXb9tq^mG~tgofmLG&H+6tJH}b98+J z{#NiRacv(3(pb9BTh2~3F$917%yAZ{>mJQLGR^siLj_g0IXrf?c&Zu8*Aviy$Jkn3w=A@~8sHmu=Mw8-i}M_p4bXR}!zXgD1#W3JB$dfhwETm%|RnW3s-`ron2u&J*2E7kgkm6+*dkA`5Jz zX9<1+^OYEur%_RamjGK}{R`keucU1Q20*g+3it2|ju1{}R58Mrkx|!eFgQEOoyV^=~ni(~DuVs&_x$|oSqp;DN8%A1B;}NIchgNXt1{8hPZZ*1N)`9))jS|J8 zHO5n#0Qp>>W`gvHj#58(D4I_MUVr0&3cSf2=#M8|5;)Q5~4G za}c^8daMQvkUT*j!nR}%5^8Y|Y^p8V2UY}@2q}UQfTx0%)MBdxWh!XPtec^;18F4) zm1iP0(p=(r~vxibp|6)5V{(LDsj_>Qn>|AOSKMs?P%)j&PsD zJy7s|Uz5gjyRydABauVssQj7uHGm2JE=$2 zDgCNiDtBY-rB6(al-EDmsU+I7%P*QdGzpPeR0_75T#BtF0zxyD8y0Ws@Fhizrwr8X^2& z-kKMZOk|`RH8fN)09z_lj0le{uV`YO+?IEyr2nY2<&AGkq$bubl+2Ho-n3w?ZY84n zN~t|cnczDo+8_gj$=i)cosyw`&7yIi>ND{Bd2vwx95Cjfo_P(CiKcSD<@o9yYFP`( zqeD*nbv27FRdV6&b?GZtOCujG`XWad3Rp_2KdBh>v!5S+BX!V&w6x%5Z{vNP;p0|!J$k)cmB6En=(+eA_O!h$ z(d0VSR-)CFxtAjZ)&wdTo_jc3x)>6yJl)FA;~ew+Fw==iUh7e28>u!|CEVex+jOWk zyQ9szyv|?SJCx|6RbdfcOqpnG-isnFMoeT_UwtAs@%0o`6M8&0owbwH-Bqa|TO0bl z>8OHvc484;oBh*9twxVkGzZ_S&4my!4n``g&${i9ctHLuBDzB+a62y3k2jyB^}tcf_HrNDPt6S-ULP3?|Z&h^i8*0AIz zKo)ukT+9YHLZ;l^!urT0^!O$djX6u^ll5#3Rq(^b*nuT*)*zVz9j8Uk$lg?6gs-OW zqcG>7$*kh8?k+L~%oxX!Py>r^lM{iWBNf$TRtt?46lZDCttue-tfQ@lNa5j{Q&2L)U{b`G>EfLY6x2?eSC9J3sXnZqrPNAwY@502i-MV zT@$ZKND|w_j62mbYo9xrcFf*il7uu*4;ph8#FF+ORJmqKG8L5}kz@3eO z&zN&IlybwV9|mk4n-!5Z^_s6%`bz8E+3?6G5FyTJ}&4H zi_F$#{P$G%V#f@=GE+qmP_HD?>V*E!q*~i?NT;ygPAvaSAWzv*wcYc<6Itn*&_Saa z%9+DYWlj5T>0k|D6p-jC88i_NJuDWRa!Zda1#0wH1z?sy94Fa+48fu%RX)3n{ue;nn#{<#<9y|GSMeN89U1ZKpao>R7G3tD`PB|MJQvm&k797iK6*h0b7y4ICa0-dwFM_6vM?wBC>Iy0vjZ78GK6mADCmlkDE zp`-B$n(=N!{nXe|kEEKODoFz~8ONyI$M>;;$^|1eago&qZWwB|>jzR*VmafPJG%Hq zgZD;ubMNVe(rvkLCvMqvM>bD+We=E>iAeFreXY)TBFrv0VUley5_C+!D=d`Vnf{(3 zvRw(TOMc__@PNv4f2jpSt`@C(uy2r|+5wIl{+)@y_ZdPa3ABr2*DfPKs$W}```CTi zJ!0@~k6P~=UOd5)6p>e71Je`ox?c2S<~!JHNn=qIiyv^bziQbRDjGSCdiYUv$prR5+1NHm#dg!edw2>@=*-sY|P+HME$TVxD zrc}_MHdPGVMUU>FYj>C~M*a{t{59Zfd}8)a4DebOa`h=FM(^Zv{_Cnj%I#yKc5e?I zl0AX6B~zuUJW=ZT!gp`RfgcxI#25P~j$HW3@~R?`XAuR*GpURQr_i_XJ*JC&A=%lP z)<)z*4);f}8gt+u6C>Hr?_MvZOw97!*E$KeRAcw|G{HLOYK3r@3q>a-hXt5a(jJG z6YG}IZ1Lx-mZk4w=i3V$EUzdj(8~wo2304>8FxwC;gQ0{6{C2?ly?eR*xPgUm@L_U zpB!OR$Z2i&iDuZ$|M7SEvF6^I?a0Hp$p4L_X`{*=EumP}_=!J>PTEo+ z@y9ot>UI$tx0`6uh50E$&Dp!&rfjb$j9_O2Ru@^!SmDb-PdZqAFyaZ{}aO`WmgH9UZ~-wXUPx zFD01<)ziBY(rmu49wJEE^xggINYimVqfu>t^*+N18W)7w%M6t}a~rX8-K_?cx=Ql2j7(*|e}30Up+XBX?f6 z#zO^0s}l#LZ>he(TW)HN;ubrYJ?h=jIub3~g=Q7kO0^6`>GN!=bs;+&yQC|;2|1@A z`=-UA6)xxMk<`VLE@V#0-s;r?)3=mVhw}8Md6EMbuDuIKujVWN(+Lnd*X~U9HE`}w z+3EUJ&eZ<(|8&Z3Jp~!#sC;!-n2q)0Ig#_ilq_Z1#v4yCkFc$B{3di7gNgf3g+ggoVoL}3n zy6?Z=ZrEWooG_Wq9#q&BFRO2aEwUZj;n*#RP1`JFG74i;2wD3E@ah1% z_Gno8>>hWh(c}KF_nf#R4L84F?`4=#A}_uJzQfYxToL7D})>MElfgE8dZ6#T7GUF1F>mR|nkuZejkWEWPnss8oTK`|Y~iq#J`6nbfby z?ls4|61wQAoIFjD354fg{F+72cE5f9{nez@$D3uH-7Q9TtE1DT;w2hi1%#4za__V3 zcL?94#8Af+udFC!yARd6Q+*pd&V4trkGMY;6nK+?+K54&K$=^)Q84gbdW*JHcS-$ z+0=uspPBX#`|6}B8zD0E%KRsCE42!`RRQ%5zG7}+9nW6T9^spdX;00qsrHhsSlU{v zK`rR2hSe2haO5>6{9?Uy+CDfw&U)(TM=3olatd;RECsD9dT8rf+c;0|zuwgv*@ffC z;doP{c?(>>3O7dVu>Y3#RdinG$ykRYCa}1?_tZV$6hr_CRVs;)uWv3Z2Nu*Gaa=fC zuJ~%rv6E?=+V`hfgG79+jKXr+HVGRNZj}~&)g*F4n}ksROHe4aw(>M<;IC8^XH*0L z)vq9#uv{j9AplDKs`-CE2oF$RiCX*4G;0807h_m1-6r7|z#EZYHUBf9S7)+t+~wR& z+Jdr=zX>RnaS(`#P1mnVmfPEUl;OF%r2v0-aeB{HgY9HWJAZF^k2P+Ukw{UusG*l* z7dfyD*q;PGsNlc>zUGEQx`J@!m;ZJY4tIl^OI%rR9mguUp!4SQaH>boY9~1FCWU$a z^2r4CDX76B%*hZ#xkyzUt6=_K_$ulB(Ba`H;G4zJyWw&Mbxx@}H+ElQJOH-eIl>#U z(&@!UU%B5NBV%8x9GnP5dl4RhNx$%x6{qoq$@G`Ppqt%-4hM{xTq9lcLKM_5QM#G~ z3^bm~JKcbjwR z{7th5_n!$Hvb=e4=bB&`)BQGygwgK}j+FFUX+tm4%)WJL?@gg}2(r>Ls7`-*B~if6 z%>N?p32$b4Vi%DrQ-|O?cAS9^e2@jr=bPF(MTGJ>bitLk3D{<-A(kc{8)J^K%A8cR z_G#%oDaY@N7DiSn5WAy;dj{K5>Ed?Hc0UP}6V(h3iM;+k+v6im|3UGNH8#bHsT^6@ z^FrCWPbh&@H;lPsqHX2-YkDOg=w3;XPe-u{)5pcU*M~@$TM_Vj##KUj7aui_(XQwd z>$8=+Z;Uxn!(OwJY|Git^YvGzq}<-WPiJNqrcuNHsa07|Jr3<9vHxNPf(>5^YT&X8 zIa2`D<3P0k)82c4Mb#{eqHBg37=|hRN8I+8mAVEM- zM4|-A8AK&0f+CV6gLnAD-uvA5&U@$G`tq(h{G^x!Oz~kdD9mRMIQx8r3lxLt@olie5pMi;fMRP zaAnJh6EsljllecIol0?GFXM-D@49AGpuZ-X-hG?ShHVJD$6pQM{5bL(=ua8QaO(YR zWXCG6P^tQ+RPdh1N?vfI2E?u~vx2L()C10*G4=xP)Fu_#Nxb@Sit+1dO4$;2F_x%f z&^GyP$CUjSOPlpkA?!^K!YdkM#oZSVt3zW!iq)+}t2CJh%L%tTrnsak+rUExGt6W2 z$rVqHb>a@Y2yTFqi`5b*`b~DA+2SEk#jncV%25p){SJY zrs6G$XZ=04#Xp0kMF}(~_7c`BWLOUr=^YkaR%TLDQ0-jb{8^u8kdNf#wyPoMwyj_r z>IqrP^OP$1rtb4GDeev9I}%^dW6Ov$_Z(K`sPNtiR#7&Z@b`G!8-3h}Pwgwi`t&~j zU~y{Vi#vnPS?k~PCJtM19Lug-V3}iRX`CO2Z@4%=p}tmUQrfwqup1l`YlvjMe!S*%u3x-wmb) zgTH#1sQbA=mlpVlz6-vd_G%+~#j%-9>2t=#(q{MSxK}-UIa51!hwIH6M7?uA^Z!R<%7g(k{eq$Ik= zcjR+TaeQ3XIDz#OID)Bv{2@@v&s@PCTk<6*bt zWMu~W@VP3dp4+I_JU9)WH~n?DS`wE@_epI+tw_Z(#LlXB&2_&s640a=z({ zy5q`41J~r1?Dwa)ne{Svwv}BIeW?RJ$y8WD2QShNj~qI;=!3tM2D<#Z z6L@ky1^WMYT-iUN>m5?uj^5u)I(mM-&s%uFf0OU~L?`c{-P!pa-TQ9etrFPqa@>!> zQx~r(j;?h|{RWf*-|diukCnE6ImUbQb;9b-InJm3jk+6Qpr0oPw+cI&jz8qa4L?36 z{|$g=V!4!ZtyJ>jn@-d0T_x%4Vwl^64v4aqmV;>*8P94xkrjvGf0Xs>;OS96nWm;D zHT0P>G-~NBCqd>fmGleakIwx!8 zeawQXEhf8s_4~b)h32&VxUTDgc!TpYDil&`8=R_IHRP|-WSpAYOBx8oTcZ0TIwtyS zk5r7d?VU1Te;t^zqj5WU`{~Jpfc!jZ+I5{M4<1Yj(`5JaH}~m<;|$Ma{t%DTGn;hm z$oBT;LF=?}sqQsseH6EI&=Gperx;L!I{AKkhXb_*Utxi>jp3GCCxV{6S5BP}%}cNj zV>tJ|=W0c(_9%7ya<+1~I3s{4`5H4dFC~2?NP*V(r`D2@?jxd`dfRJ9wnz>IsT1P> zX7UdA6eh!c-sK-vjeA=1U!QjgK5K15DbTbgvcw`yy1GBKuw25`=<=mU{}9(Vz|r|B z&!{yf_%f8@>mLGsHM+|Tzkwp9P^_f;PTXWd&h6key+(R}WtLnzyPWaA#_K`aV*KL)K7DwDihrd>-&?I-PZl5A_$B4l>!hv8|I6L% zU8lllXKMKwe&3T^&7$K>{ng9==6^LW0GvHnhAoW_=lQI7S*3uSG^jqU zWkeg?GCNS?Dc<`L4w!|%DDAj1d;c_d|Bn33&e`2VbSUPoxGk7>Z}UI;+}5?Pa@^FC zsGl1f5<_W{)_Ac+ag9t^8?OGqm2qM$LugJs6fTB(k-;E$Yw&*~jmz~Vc^kK+r~O9+1WZety|(PwOL9*Cr`4RBI3)b) zzi{gPmE+r`S{}&&+qk^K?G?6!cM4MPXF z?xQX@YV-KWm>%JeBiMr)6_xx)G9KK2>G))Y9H5)MNYBBA_nYIgd*OBfuv))Nv^CXN30TL z&tF+lWwnLWk`i$dJvUe6AU0Z4b(c;ZJJCpg{gfck{gS|F)p_VQASbpuJL`TzC-%CA zo@zs0G&%Oq8!Xth_rcAIRu(AiG9>M&L4(Z{#L+{wCVm>C=K1@vDbleASCgaj=S@15Rf z|D-c;-+~1)F?EJmg^wc1dXkk)6VyNdI6eEr*wvM7ky-tu;rqe%z~8azFp4O@#KacoN1UO4+hWB7Y^mGksdr8$w0W?(k_(W8Zr4F)iqF_0hwDTiVS@t_79W@|h0m+0SaXxoyQ5yKRTw2x6ZOv1@XZxFviC&3AJ=ctRqTC8Z3PfJ-29@>v<+iW^r%1wmD67^X2S_?!sHAyKP*)#h(*q23qdc0`1 zHq{VHur5?~5$C>1ka+$I)_W&;`y+8FjhjZuB-{R^S4DJM_c_qt>zy&L^V7>C$;?bIlm*H${x{Wz5RH_{gE0Y{8a7RHV7T17)0J0s7`6Gc)yT-gOiX zrXG$qn--E&L1S@h+aAr5Ka^wj72Ms$c_O_sZv2o|U>MW4L`BCgeok~6vpj`+#~ni{ z#P%N`%sd9XGY2BR^AYrS zbd_{qSAC*vmKAONvcoa0vBt-t_F+9A!iSS@=wCH)JNAmhJzd^8*UWN$89o^rGE}NI zW0hA@%3F?nX=I#6nS=^7WF94jCt0pq%k2!EXC zOxV|URJna)eTR8KVCI%4l@QkU;(l7z*EP(DVnP*8%ESzohzglZr{>JByzeq&_CvGR zCD5^D_n%AtVPcNnX5SMv6MwF6aMtRlza|=PBXlFiar?P{{Y>MQ(@pHWPSL-DBATnBq>fhIJ>yS}6SY^>#( z|FeR{_^toqeR+KLVhN-j_^7;hJ-t2of32JNt5)ay>3yBs$8IY>UoIoJOVYb9sLa`i z`VWJmMG(=Tvyx<(wOE{8QIx7H5mUKHY}zD^!v6-~B2XJw9W8dqk|MiZAld|Qk9$d(#JbjLMyp@Nk`Fl)pqH4_S@ z^=IEYslT2Rx52Xb%qXmd@=hW%?Mljcj&`3`)JR+Vo%#pG^I0#N#^iDJJS)s^o2I=g z*m3f?@U~S?@YQWx5+)7IWy?#7MNnkd7d3i28?~D`*WqTrWcxLqmBeduk*|11n&SzL zacz7&oYJ|&SVz_-riTU9$Pzf6fgkiba5^h#W}bA#mMENDVlX* z^vj7%>wyPpKVlLJNF9d!?7N7*8ft8ln%!Vts(2Y#|M7Xaf{r~`w>DGLjb^2)UEB3H z88;*ExLI_awNf_}zHqlIT%@>JxG;U!)Mn=VIUV<)gHLOe!6S_~t&5c$?v%jRCNBt2R!nf&m)-0&tl%nx-|zhf3LFDNhle-@ZeP1J zw^iWGxz<(-su|x4N%pA`jjf-h=`nF9vi*;PF$K$P8GIydQ*P^Qc6UYuD@ESsPSMzF zx+edJ!R+gH*r(BN08N!pQLd=;rrvP%Njrt#a&;ZlGJyMg*DWjf%N?V+$Y)o`r*%-% zzmTUpx8@={{zCp6N-Xl%a$OxXG=S%Om$eoAZ_58$Wb+l}zsdf0MgQ-}%751G-equA zk?~w)OFKpLRXZ*JQTyM>+B&F50FQf@xfOizAC!M1vDyDCB=;X>=IsDwxdmNKIe0ZoD-i}&V*EAw{>6gyTbh8JqL9+{)Gve@ z27$*y`wWK*?pB@C##Fb5s9WjE-4y$h6nir)_}Q2618p>0(e>KHb4dO z%83k4kL=h9A$pWa^y`C>fyvluf9s|n$GD+Xw(lpN=B$WMo=@%Ij0sQdQGHrp(deA* zmrnMTwr+ALI(eE{{cf`J!R<~awg;wlZ;&mAy8S}t2V;)skvC4t{K(~#hJ-nX%Y^=I z5oMC? z(r4dghRj-LDgBql7V@KC1fOCLK=|kPMhG@|=hcNh504bM`rJ6Nvx`96`ZPqOmvjlp z+IZ`(6l2o<@x2j&oiuMbOHoti08MxE;K4g)G>+EFm6;YLQYV~O=H);};-n-Lm1c0# zQ_!2pQX&jU-=-Krn;cAu5M}`u(Pbp<+!7YD=vyWbX85ou1+$DHb&5G$lJCcLP$BsU zCjrjKQG%QB2wvSd{&Ih{tg;qS_sv36_JrLvGFE7{{Y|svpl~#|KNP;nu z+I}O%k>wrvoH`H_QJ^kkgVt;8RFGba={(6K$(uTB^>LMDhRFhh0vAS6c+)A@3nT=W zps;u*ggkohuJ`t^nZajf+elR#kiHoYFK>Wv4LyqC9Aq2{tLU_tv!ND1zqUcMTd9(9 z+--BgvFVb+qzGE3-eHO(P@)jEB3ZT_x0` z;GR|M0W&Gt>~5Du12r1F#d;ZQi^NRP2t5etRC+vVtFeSIU?Hb#)eyW>K2sfKK1^JO zL6Fl0*#guUJus)d@R##FaLsGGqg7cT!;@6^8PH}m?n8)jg22HHEFqyx-NtDOUM?WlXF>&Toox1vC#c?)`k;MZ=Ed7YI$b* zy%e__I<2nfE~a0<3x($*-FA@CR?Fjdi{K|(v;LI|^3oNy8>h0p{I*=BwX7x{Zcjos zWWj~F#q(6@zfPjZwP14stlGHm(OkvM;M=4&wXoilde(GXn^42KBHf}>W~2u;hP%s0 zTU@-QYqec++xDPS-F^gjAyJFm9Ym86rj<;d*Uh$IP&WSMbf5L}$x^<6DEvh3Vs)WS zQ>|p0b;y0`Skhi80}zeO%#6V5bu>*ql(thAFV&o!VHjiusfs78zT;mP55~zNJV#X;< zzrRajS+zQ-#M!}nk_}u;!5TGv>1O(!I>$!(R0 zSg1x$B6+m0zDLzcLwRltoV*1tKT`yO7a5K8e^oq8R7ukSjM75&8nh%)_lF5wbfq@= z5^f_Q{GSlgIG!j`Ei1Lg6)zhXkJ!N_id{giTXII@+Y_Qo(bpw$ER?82S}yJSG-Xv? z_J~L|`mSe>+=ML5Kc@=*e4eM;rUlcn@8oL{fGQ)sUe z#Z^V|n}0SisEvG)r=m&&#Ww*RqTuqccAJzV7ONV~-C7WG=*nEuT!(sN2ZWs>K2kte z7xt@K%a_isSXPZkV?>fi!i73k6=DMtp@T?AKOE4^fQypfgovspe$SdYf45B8IkNOD zE2z!>S-h-(Dg>`8J+iWR7U8z0dsM!8>oFVxwmN)Gdl0MCe9W^-tr(p;<&mC;G8 zi<;mZWx$Sdh#aMnkmy+J*SK*ZO|?C{HG22WaO&5jd|Z}GImbl0 zX=tRBO)|uVimD-$TiVem^!d;mp@)^YG1~}wHBl+evMxCnO%ytmt+g@=A7?2)$t&6Z z=@38;0e4m$PQUY{d6bt5t*M+5RAAV*_hHRlFpzh-UrMwV{7@l2gJBISWo1bSdTcg5 zFG0%0l|RmpTdOo}>*l zM$>u_7wZmG88oxM6i*=pL+`qA!i&N0g3mXMn-~kw*nge7Kj$fDaz7AUdZQBsVphY#!cC0>P^V zrJ5gOT0nBmeOBB_l4$}m#R!6238&XUvz^XkgZzQ(`leS>_#4OsFuxKPx{s+KO>{W; zPh~J^UtQ6Ra@-mr7&R}d6=wm)*6^4@>ANyQIAW?I5t~E!XgPE|t5D~iH>-0dtISB% z3^k54A)uW?V!kvy?g;{_5=}ekoJnN+o$3XFl$Zh_M*;d4ooAu8_qu8orpA_`Cezcf zI3)&Qj8vqd2nD%{#L1o06XdmXhAP6_Gq5ogPLbHHAlOj%nePKpAIQVX1YhqKe!%s; zOFc5~d|mGUfd74b=s9}pO<26%m29E1iKpI3y1B#`+@n9DS^hujdJOg(^JW&frhAze z_fgN0hNtdm%Pl)eX@~rP19Xy#M+g##^J3Y*xENnr5;?xO_Hd7>HoTKaWA5{1&d>?+ z=MISsHmb(0ft+wm;AOblUWekVbDXjLQ=P0mwol6p;YE`nb?N-;sQe`e%w5vw%it&> zWqhM1mtQHN4J0aqx~ihRpHk3K^;NrFHGPD2u_F*!jP=E5IjM~7p^T#zN%jwe=v~GC5aHK%as-M}o;ZB^M3paIsOzDBg8XvPFZJgZwsod9$wpeFDI@&{8}+ zJ(4>-i&Pk9w*l>3g#2PN!C5_vUe$Ahae?eDBLa{Q?k>Kba~c%)acw$9RWuUx^b;NV zKo<_aVRM2Bnx#kA(2WGATdYD~O}4innj58zJhI4PgRs- zBLw$xye-iuaS%;>tvsQiEdfB@7lvJ>xPQJ&GIquLT;+N@iV`2iguRazC?T9@eCR_S z;tM&5Tx-0ZZXdLUW1Z5QfS{`3usW`DI99DUSHCRxwup*ThtunXWgA1)Xu_Gsdj84y zhPmH>KnG6S?b$j*642zhQ8BAjBqlHh?4p;U@+4jr@XF#i?`^XDh}Z!v)mPP&E7G9# z($P>w-j{5;$_HMo-M-%7@kpz#iR7!2uJvL1n6n{Yc$Q{xI!YT1Y3_9NL ze1_|65?KHV@vh8H0SZtpdujr7Fls97ejK7@J%lWb@KK*XF*HpVCqCc~uvp%$zf}@} zQ*kaDq8Q#Eco`7)^~9Rla)>=Kj`3Zfx4l`n4%DlaEqi+YY+7O20gPT&SAn)b zXFZ*`Xw0|x`tIULv=R+-*9K7MzbIyB1_IROOlKE7q$f+)N?#-#qG?gt394!3FiLze zfw$}x@-Umu#9nTgvhb1Zwp}$*Wn#&F_YbXuC+OtWuZV7Eiz zQy{yK3cp=M&*5-Aqt@~sa*s8z5XxW4oFlAkbrkLSKgj<_hRj@XN#czyQQ0InsRKv# zMHac9(tpl>Uy`YoJ;9geQp$G0Yd2bx*0+QU>PJ0$ZoIkz^H&N##QOSEkIXxZVo`+a z+gHE3$NJu*9$9j}Zd*7lM|9w^tdw|9?;{w#Tc& zxnaknpiH`X!Kc6Ih_f76rdrL8Vk`3V)c!|Z+K}&$mKANRN-l&++DnH-Zdw1FpW#C# zE^}?@Oy23#WO_ew^<(H($6 zlIrXjf>DeWfyG$c<0rbw?^K#R1bJD1bsBbk+tXq=2p)P^+8q$dFCe7r zkZX`5ufJiWw!xc?elT;F{LyT0sa)}Ml;d$y_Ln<=i%)G)Y`3**q-t>Pq8o(`4WphF zE_RFd9YG6#Xi2^ZDx3g-$$lO~zbr2*me6Gm1Xby;s~$9xvi)M71Il-h64dSkr65H{ z6P{OKN|d~jmv_Ho>038meWJw2EGt7p676yw15`FoL*+Rcvfw`!suohLXG0)ex^7(jzkzy(*t zM{X*79GDL>k`qxhkj|)$W#cBoDWS+#jBggJ*bv^7=xg3UR*-sLg3VRAXh<8LTNU!% za1XkF`toHDIeBeYRloze48;Zk2lFqF6Y&|k;pL)~WQ0X_cpgp;$I}Wj&q>86SRdNUnFG3GjubQ*MJj^W&eXn{|L)TO2(^xUr{Bp?tCu(_o z$_Ki?n3=`Eyoxs0r(efKluIu%hLhf;SAaGM_NHRELs!~WjR^@aS?2bjXflk4)st-z z?ivae&Nyl?FPs4$y3#-iVDM(iH|AWjd`omO(@%#bD9a3OZQ!;c^?fWVFb{SOg@1#{6X!{ zX?SkfqbgJnAxK6)qEJ!IO)E?kN30i-} z1TZ8d;LMw-3sE*8aK6!jl#S>yl%aSZsDCkQMY?Xld>oY*Kq2p-+u4|#ie_@6{1tEu zfZlEyNdb-&pw!W?LzF}!4Idcm@GF0^6;JGzhcMV`qd>RqoP0iUoPKLNs_(@a(E2Z|7LTdhcRT9Y7^gWL=`^urJBKzL7~HQ?vLv1o_qslR zO)iz3Nmm!Vf_e2iYBC@hL-tMG3Thow{iF*|vU}1FQDhG_ChCZQG)wMT@O8^Z!=dCM z-Mof!dxG>fj%6j&1#u?c#p*9gWf{>uOi{Q`zy=MW#SnnC3LveoY-zwq8IJo1Z8|?2 ztXNyASkCvX(p}w=q!0RlH%p&^Tq#@L%kxn6eujZob}@{?xVf8kYz<1|JgackHqgXq z8&G?lOlq{oOZ54_j)QARjFL9;mGOyUF_v;AIuvdx>0qMbL-yAxhp z-~tvh#9vTIPY7$B{$aeRrle~eadGHC_q>Xp%{_1-y&WhfKD7Z(Bmdlfx)TV4P$scN zo8muJ&!JJdP{pxv|3(9 z5*5%&hz=32hiHKu5Ztwtij6aJ*DC=$WnTM+=x$4BS0FzQO2pQu6Ibe7JfU<&4E%A2 zAGn<*P+xUTCt4Mdm+#by8VCIpN%D4MFR4eyp8&FI3)&bB9mKRyI#W!D?sG6Qhxt*b z56A9#ma=p)4(k#GYlB0hDmlje9K0-mR*?5{V}l)++Pry$Qp!wFw}OZqBMa#)wQNlv zjtxU|!gRzgcv3=0lQv&noA4CottFp;u(7IuJ6d(p--)diu^J~o295oT>*C|k1(=L2 zI1{jQ4%?~XZ@Ilk4>jS)yWjzcgPb}A8Xm$Q_u}b5lxv6lx8gH5xJb7M70LuT2KcCf zon&1dmJfLj%26%T=nv>GooQimFbfL)Vrz%hX9~Iyz%9bw0ukk0JcwgwmetOGf5&fx zV#~Qyt5>UJEiG~;$_TN17D?5BH!R>05VHkDGbiOYa>P(KaUeVeGGmN^6kfsOh?lG& zSqzA`Qs`R=e}BVr)cS{DWxt%WDy~mngiI8bi3UsK`5B9-Ubsu0yxQZti%9NPSv6a? zOag1CP=Xdq1=e?}V!x*6LgH1xmOL}KVJxcCAXyt|4+aC%mF=2IiPlipbpUMvVLQ=! zX`95@5Uxy|@B)m&+Kx5<%o{?b?o=g1MVVqO-KP z6lzC1loQF{AjV}#r7Knx&%pvhz&zVO?v%x>vCe}TI)A|YY2<}M$3Ru`lKQw4#w$RS z92jiX&Z*L0+83!|PX^=LF7Lx+z<%8#<8fz}y{)S7OK2||@xd=b4-Oi5<`(YZWKfz znN)k;bs#~)!zKu?X_ z2v@c0yw%Qzi&#a80Q7ioaQw=dm$5StV7jX1xQSS4XOpSt5e5C~B>(iE_E8AXicxeMlf zdHIzmXWEI9YvI(H{5Faxkia3;!D$ye<&f9mbW*VXzfV$W64t7_HkIaPWB20cX|ZZm zHdV*hTU+c^)LObjZm|Z2Lir_`*ca+5M^Oc?C;tce?=ocNA6%L_gLyDyF;U6~VfS)g z+s018rp6RYRg!cR#Cqnv#Liz;{x>Joi9bu4aNhg^m>q@)nOw#26-iAvE@i1lYMjLy z3K!q{y8kDG*pqc|ZWtm0*lagZnOFa3CpENXc;!FnAXY(<9^5+cFT)vai7CS_RjPC4>}mrM_sk^WeG9u z8M1y51@iP5{Ubb_(#}Zj$5q~*kqQCmN zcED7Kq+fkKZ@~+Izo>$75Efs8o@v7^ztv#;3Fa(S;sBf{U4-R$CaOAZfQP zAitU*9k3*QLfumuTPW|DoUIsg$IsN%uC*1PmDUqLDp{C%8>Rbecgm%l%d;8D-eutl zZ2ya^zDBmydYn=pY*x^Si1IFDkZu zcGA4Qj`F58>eMklT+WBjB*ieMZ71<9RXmZxSC}>Z>`i<9)p&_CIdWWHMbD54i8NyU zM8Xw_0ptv7wY%YcciryabadsMW+u#Vc%u{zKcWVB(LJwXZ)c=Q;Oyl_NkP@yNsVb5 z@i~O-_tC6Sl zL#E*%ZA-O!;!4SARTDhYi4HvoK{p!Hq-&Fl!i5I)qGWmF6RgVVUE#S17%wW4#M{<~ zTve2H(Z*Heo>BS%Oe?pBUdhG}p^z8v9;q&dl!aFOA}npHm?4ij;L^JhruYPRoM?W` zNe6*v((6JZwVX#k&Elw}FI@_c>w>95Xh=nE@>yV`MtqND9EF3y@!r0s_#9{Ikj#9R&0a!@NN3ow6m*Eec~X=)7`LP}1i z7xzdVXLkTKeAFJTLi{x0VggvwbRV49scLU*J=!G)-W701s@4~r$|pQpDeaAT;9=~T|^6TfSN?W)k1xdXUFrH1YU z3KH(6wrBlZujwie?i5T6d8z#8g1MKwL)252$S5miY7C}6Tw5uPAr@CXIAH=C=zr@y zvG460BlTa!dJ6wj!O_$gzX2PEvU==W%D3?22iQdcCLlDxT2S2eZC_<^o=szk~L^jujE#3|Q$=NtF(n-(BlZWw?2=@ANL4T{Dl_VekQj5LK+Ck;AT;fP}zE zITaJ`d;!P6$xVO~#RTTNgK22h^eZxUO?)IP7F5jpBhf?&*hGL?wLrf_@TR92Eh_j? zf~C@xnA6y=YLm2eA&#&{hSIQ=dZ3l^cjBeXY8nd|m;#=hK{fo@b*m2p&j zO>pNi-Z5Rsu(!`{xzF3cPo(6pE*UrP!#ZJW>puNmJJ{;zoU^W4FYU~@9AbgJXOtxE zKd}fIR%S_&<`*B6Jm<#&`JXdZdM&T5u3_t0`*Ou0EUVVOJjSE@qs7<`i?6L{lkA9} z*BAntXA)}d(-HS<(B0PlcTtB1aISIC#>og|7bsPKNKpH(!AZQB~eIeU3 zO}XNo%aqd9{~KW0ytoB#*F}JZ`JZgaf zaDkKR$$KL{%RUxCAurLGGh@oqMJ9%oIK5rCY%yS%f&KcdNdsb;!_kk`lPheAIv;i3Xi}_$5)^Iwwk|Mj%C~zEYq(B+GoFYt%InLl1vvZdkuQi z=s0%I#KGi}P!f~LwQr<+tG3}8N`bw zS4XeNrgIJ203Sl^O<6S~Tb2un<3qhzhVkeSlp#GQ zB2JeX_Cz)sOs~g@uGfK0-a2%-b)`Tv|eIB*bbhM^q_nn#?vv zM6ONE!g3>6VCYO}NFROfqgm9A0kW0eOd^&wUm&q*F)RoP==CnReEC_;#Y={U#IXsF@iRSN9c0b@R= zuXE8!SY#$jhz@0z1Sae0RhCX$b^6%Q zf*Ha~Z<<+x$7;Eoyi=Z?QFxRjy?R3R7S>Gdrz=RaLGfVfN=ZXTWjashlT_%O6H}x; zI8-)a8YCI%Sh2eaBigpSSim>98iIFg#`-+s*KZeXP0)53Hr`UfD90 ztNvlD>6Ahf9wupMZOc>|qebz>6jG!9?5;C*?66XH)pLKW0ld7&lA4KaYT;FhK0DzF z`&Pb0x#s0qQ zbC6md;)+TNgR3jEkRGH+|M3?q8s(uZUwn3I4zM*gb`=t`nGt`!3C`o#73V$ogn!ee zv6|xq`JC=!u3xsVSDxiR*ml)vtB_vLJj*xu3yeO?C);-U16z2uT&5p7qIM+8u{2)f zD}M!wa4bz1`TmXlx1e(9NMGTS=?MOji1X6K-x(e+O&0m8hmLR^iTnYYg^t)BiN-C> z6!{v5j<_C)KK={5BKSl_{3q4FWKazqVLB3tTbg9_#ez5fD&}v3sUlzPE3D|(rMV(s ztG^1LW%N}H9U(mu>0g>*^fe3}(YpdC{>G~OJK*26qALH$_J08_LPxp^Ia+@?;~2Cs zwnB+J^Mk`wxH)uo(Qg8+Z-*yXQ{`@F$@HozYEA5BX?!|YTJA`}11v!Rto}C;uOCyW zZAmNBvkTfCs^9KE7Xc!r5gCdZYB#6dm60F#X2C?*eIBqDxdRLZuh&BzMr~Uc(nV0B fcb9cEq=6dyg_N~(>sa3U{9R}8BQS%`?}h&YP9W-g diff --git a/ios/Flutter/Profile.xcconfig b/ios/Flutter/Profile.xcconfig new file mode 100644 index 0000000..73272fc --- /dev/null +++ b/ios/Flutter/Profile.xcconfig @@ -0,0 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile index 620e46e..7eff7c9 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,4 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '13.0' +platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..fb2ec56 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,35 @@ +PODS: + - Flutter (1.0.0) + - flutter_secure_storage (6.0.0): + - Flutter + - image_picker_ios (0.0.1): + - Flutter + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13 + image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + +PODFILE CHECKSUM: f8c2dcdfb50bb67645580d28a6bf814fca30bdec + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 6f5cdd9..3fbd1ef 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -15,6 +15,8 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + 995A594C2412F61895CB2016 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DF55B8E259192E16A3811CC1 /* Pods_Runner.framework */; }; + F42061B85542064B2CAE079F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0055849AAFA1815289813A3B /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -41,14 +43,18 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0055849AAFA1815289813A3B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 2B8CB5C8A4FB36AEE69DD7FA /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 6CBEB391E648AB4FC44FCDFE /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 79F48B122F8D6BC8002A1A11 /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Flutter/Profile.xcconfig; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -57,6 +63,11 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A7206B50C42F65222935848D /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + AA12F1DB701472A907C0C6B9 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + B893C3A8BB7D1BA341320415 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + DF55B8E259192E16A3811CC1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FBDCE86BA6755F0F93243B5B /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -64,6 +75,15 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 995A594C2412F61895CB2016 /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + EC4F91958B1958510DF60AFF /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + F42061B85542064B2CAE079F /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -83,6 +103,7 @@ children = ( 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 79F48B122F8D6BC8002A1A11 /* Profile.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, 9740EEB31CF90195004384FC /* Generated.xcconfig */, ); @@ -96,6 +117,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + D394CDA7A7BD6BDE41D8494A /* Pods */, + ACB44088637E3B4C0CAEDA11 /* Frameworks */, ); sourceTree = ""; }; @@ -124,6 +147,29 @@ path = Runner; sourceTree = ""; }; + ACB44088637E3B4C0CAEDA11 /* Frameworks */ = { + isa = PBXGroup; + children = ( + DF55B8E259192E16A3811CC1 /* Pods_Runner.framework */, + 0055849AAFA1815289813A3B /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + D394CDA7A7BD6BDE41D8494A /* Pods */ = { + isa = PBXGroup; + children = ( + 2B8CB5C8A4FB36AEE69DD7FA /* Pods-Runner.debug.xcconfig */, + 6CBEB391E648AB4FC44FCDFE /* Pods-Runner.release.xcconfig */, + FBDCE86BA6755F0F93243B5B /* Pods-Runner.profile.xcconfig */, + A7206B50C42F65222935848D /* Pods-RunnerTests.debug.xcconfig */, + AA12F1DB701472A907C0C6B9 /* Pods-RunnerTests.release.xcconfig */, + B893C3A8BB7D1BA341320415 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -131,8 +177,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + F3650807002DE913D3C084C2 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + EC4F91958B1958510DF60AFF /* Frameworks */, ); buildRules = ( ); @@ -148,12 +196,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 8ADD842875B4146703974D33 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 0A3C24793BEDEF7D086CCEB8 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -225,6 +275,23 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 0A3C24793BEDEF7D086CCEB8 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -241,6 +308,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 8ADD842875B4146703974D33 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -256,6 +345,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + F3650807002DE913D3C084C2 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -361,13 +472,14 @@ }; 249021D4217E4FDB00AE95B9 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + baseConfigurationReference = 79F48B122F8D6BC8002A1A11 /* Profile.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -382,6 +494,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = A7206B50C42F65222935848D /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -399,6 +512,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AA12F1DB701472A907C0C6B9 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -414,6 +528,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B893C3A8BB7D1BA341320415 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -547,6 +662,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -569,6 +685,7 @@ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/core/constants/app_constants.dart b/lib/core/constants/app_constants.dart index eb4e2f2..0455d99 100644 --- a/lib/core/constants/app_constants.dart +++ b/lib/core/constants/app_constants.dart @@ -1,10 +1,24 @@ import 'dart:io'; class AppConstants { - // Android emulator routes localhost as 10.0.2.2; iOS simulator uses localhost fine - static String get baseUrl => Platform.isAndroid - ? 'http://10.0.2.2:4000/api/v1' - : 'http://localhost:4000/api/v1'; + static const String apiBaseUrlOverride = + String.fromEnvironment('API_BASE_URL'); + static const String developmentAndroidApiUrl = 'http://10.0.2.2:4000/api/v1'; + static const String developmentLocalApiUrl = 'http://localhost:4000/api/v1'; + + // Dev builds stay local by default. Real phones still need a LAN-reachable + // override because their localhost is the phone itself, not the dev machine. + static String get baseUrl { + if (apiBaseUrlOverride.isNotEmpty) { + return apiBaseUrlOverride; + } + + if (Platform.isAndroid) { + return developmentAndroidApiUrl; + } + + return developmentLocalApiUrl; + } static const String tokenKey = 'auth_token'; static const String userTypeKey = 'user_type'; diff --git a/lib/core/models/marketplace.dart b/lib/core/models/marketplace.dart index d253cbb..35e1041 100644 --- a/lib/core/models/marketplace.dart +++ b/lib/core/models/marketplace.dart @@ -193,7 +193,7 @@ class MarketplaceOffer { String get discountLabel { switch (type) { case 'PERCENTAGE': - return '${discountValue}% off'; + return '$discountValue% off'; case 'FIXED_AMOUNT': return '\$${(discountValue / 100).toStringAsFixed(0)} off'; case 'FREE_DAY': diff --git a/lib/core/models/notification.dart b/lib/core/models/notification.dart new file mode 100644 index 0000000..388a562 --- /dev/null +++ b/lib/core/models/notification.dart @@ -0,0 +1,31 @@ +class AppNotification { + final String id; + final String type; + final String title; + final String body; + final bool isRead; + final DateTime createdAt; + final Map? data; + + const AppNotification({ + required this.id, + required this.type, + required this.title, + required this.body, + required this.isRead, + required this.createdAt, + this.data, + }); + + factory AppNotification.fromJson(Map json) => + AppNotification( + id: json['id'] as String, + type: json['type'] as String? ?? '', + title: json['title'] as String? ?? '', + body: json['body'] as String? ?? '', + isRead: json['isRead'] as bool? ?? false, + createdAt: DateTime.parse( + json['createdAt'] as String? ?? DateTime.now().toIso8601String()), + data: json['data'] as Map?, + ); +} diff --git a/lib/core/models/offer.dart b/lib/core/models/offer.dart new file mode 100644 index 0000000..1e1245a --- /dev/null +++ b/lib/core/models/offer.dart @@ -0,0 +1,68 @@ +class CompanyOffer { + final String id; + final String title; + final String? description; + final String type; // PERCENTAGE | FIXED_AMOUNT | FREE_DAY | SPECIAL_RATE + final int discountValue; + final String? promoCode; + final bool isActive; + final bool isPublic; + final bool isFeatured; + final DateTime validFrom; + final DateTime validUntil; + final int? maxRedemptions; + final int redemptionCount; + final List categories; + + const CompanyOffer({ + required this.id, + required this.title, + this.description, + required this.type, + required this.discountValue, + this.promoCode, + required this.isActive, + required this.isPublic, + required this.isFeatured, + required this.validFrom, + required this.validUntil, + this.maxRedemptions, + required this.redemptionCount, + required this.categories, + }); + + String get discountLabel { + switch (type) { + case 'PERCENTAGE': + return '$discountValue% off'; + case 'FIXED_AMOUNT': + return '\$${(discountValue / 100).toStringAsFixed(0)} off'; + case 'FREE_DAY': + return '$discountValue free day${discountValue == 1 ? '' : 's'}'; + case 'SPECIAL_RATE': + return 'Special rate'; + default: + return 'Offer'; + } + } + + factory CompanyOffer.fromJson(Map json) => CompanyOffer( + id: json['id'] as String, + title: json['title'] as String, + description: json['description'] as String?, + type: json['type'] as String? ?? 'PERCENTAGE', + discountValue: json['discountValue'] as int? ?? 0, + promoCode: json['promoCode'] as String?, + isActive: json['isActive'] as bool? ?? true, + isPublic: json['isPublic'] as bool? ?? false, + isFeatured: json['isFeatured'] as bool? ?? false, + validFrom: DateTime.parse(json['validFrom'] as String), + validUntil: DateTime.parse(json['validUntil'] as String), + maxRedemptions: json['maxRedemptions'] as int?, + redemptionCount: json['redemptionCount'] as int? ?? 0, + categories: (json['categories'] as List?) + ?.map((e) => e as String) + .toList() ?? + [], + ); +} diff --git a/lib/core/models/report.dart b/lib/core/models/report.dart new file mode 100644 index 0000000..389b929 --- /dev/null +++ b/lib/core/models/report.dart @@ -0,0 +1,62 @@ +class ReportSummary { + final int totalReservations; + final double rentalRevenue; + final double totalPaid; + final double totalOutstanding; + final List items; + + const ReportSummary({ + required this.totalReservations, + required this.rentalRevenue, + required this.totalPaid, + required this.totalOutstanding, + required this.items, + }); + + factory ReportSummary.fromJson(Map json) => ReportSummary( + totalReservations: json['totalReservations'] as int? ?? 0, + rentalRevenue: (json['rentalRevenue'] as num?)?.toDouble() ?? 0, + totalPaid: (json['totalPaid'] as num?)?.toDouble() ?? 0, + totalOutstanding: + (json['totalOutstanding'] as num?)?.toDouble() ?? 0, + items: (json['reservations'] as List? ?? []) + .map((e) => ReportItem.fromJson(e as Map)) + .toList(), + ); +} + +class ReportItem { + final String id; + final String? customerName; + final String? vehicleName; + final DateTime startDate; + final DateTime endDate; + final double totalAmount; + final String status; + final String paymentStatus; + final String? source; + + const ReportItem({ + required this.id, + this.customerName, + this.vehicleName, + required this.startDate, + required this.endDate, + required this.totalAmount, + required this.status, + required this.paymentStatus, + this.source, + }); + + factory ReportItem.fromJson(Map json) => ReportItem( + id: json['id'] as String, + customerName: json['customerName'] as String?, + vehicleName: json['vehicleName'] as String?, + startDate: DateTime.parse(json['startDate'] as String), + endDate: DateTime.parse(json['endDate'] as String), + totalAmount: (json['totalAmount'] as num?)?.toDouble() ?? 0, + status: json['status'] as String? ?? '', + paymentStatus: json['paymentStatus'] as String? ?? '', + source: json['source'] as String?, + ); +} diff --git a/lib/core/models/reservation.dart b/lib/core/models/reservation.dart index a0b0744..3581380 100644 --- a/lib/core/models/reservation.dart +++ b/lib/core/models/reservation.dart @@ -11,6 +11,8 @@ class Reservation { final CustomerSummary? customer; final DateTime createdAt; final String? source; + final String? contractNumber; + final String? invoiceNumber; const Reservation({ required this.id, @@ -25,6 +27,8 @@ class Reservation { this.customer, required this.createdAt, this.source, + this.contractNumber, + this.invoiceNumber, }); int get days => endDate.difference(startDate).inDays; @@ -49,6 +53,8 @@ class Reservation { createdAt: DateTime.parse( json['createdAt'] as String? ?? DateTime.now().toIso8601String()), source: json['source'] as String?, + contractNumber: json['contractNumber'] as String?, + invoiceNumber: json['invoiceNumber'] as String?, ); } diff --git a/lib/core/models/settings.dart b/lib/core/models/settings.dart new file mode 100644 index 0000000..2917bbf --- /dev/null +++ b/lib/core/models/settings.dart @@ -0,0 +1,63 @@ +class BrandSettings { + final String displayName; + final String? tagline; + final String? publicEmail; + final String? publicPhone; + final String? publicCity; + final String? publicCountry; + final String? websiteUrl; + final String? whatsappNumber; + final String? logoUrl; + final String primaryColor; + final bool isListedOnMarketplace; + final String? defaultLocale; + final String? defaultCurrency; + + const BrandSettings({ + required this.displayName, + this.tagline, + this.publicEmail, + this.publicPhone, + this.publicCity, + this.publicCountry, + this.websiteUrl, + this.whatsappNumber, + this.logoUrl, + required this.primaryColor, + required this.isListedOnMarketplace, + this.defaultLocale, + this.defaultCurrency, + }); + + factory BrandSettings.fromJson(Map json) => BrandSettings( + displayName: json['displayName'] as String? ?? '', + tagline: json['tagline'] as String?, + publicEmail: json['publicEmail'] as String?, + publicPhone: json['publicPhone'] as String?, + publicCity: json['publicCity'] as String?, + publicCountry: json['publicCountry'] as String?, + websiteUrl: json['websiteUrl'] as String?, + whatsappNumber: json['whatsappNumber'] as String?, + logoUrl: json['logoUrl'] as String?, + primaryColor: json['primaryColor'] as String? ?? '#1A56DB', + isListedOnMarketplace: + json['isListedOnMarketplace'] as bool? ?? false, + defaultLocale: json['defaultLocale'] as String?, + defaultCurrency: json['defaultCurrency'] as String?, + ); + + Map toJson() => { + 'displayName': displayName, + if (tagline != null) 'tagline': tagline, + if (publicEmail != null) 'publicEmail': publicEmail, + if (publicPhone != null) 'publicPhone': publicPhone, + if (publicCity != null) 'publicCity': publicCity, + if (publicCountry != null) 'publicCountry': publicCountry, + if (websiteUrl != null) 'websiteUrl': websiteUrl, + if (whatsappNumber != null) 'whatsappNumber': whatsappNumber, + 'primaryColor': primaryColor, + 'isListedOnMarketplace': isListedOnMarketplace, + if (defaultLocale != null) 'defaultLocale': defaultLocale, + if (defaultCurrency != null) 'defaultCurrency': defaultCurrency, + }; +} diff --git a/lib/core/models/vehicle.dart b/lib/core/models/vehicle.dart index 339f728..35056c8 100644 --- a/lib/core/models/vehicle.dart +++ b/lib/core/models/vehicle.dart @@ -12,6 +12,7 @@ class Vehicle { final int? seats; final String? transmission; final String? fuelType; + final String? color; final bool isPublished; const Vehicle({ @@ -28,6 +29,7 @@ class Vehicle { this.seats, this.transmission, this.fuelType, + this.color, required this.isPublished, }); @@ -52,6 +54,7 @@ class Vehicle { seats: json['seats'] as int?, transmission: json['transmission'] as String?, fuelType: json['fuelType'] as String?, + color: json['color'] as String?, isPublished: json['isPublished'] as bool? ?? false, ); } diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index fb5305a..93a8956 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -7,7 +7,6 @@ import '../../features/auth/screens/splash_screen.dart'; import '../../features/auth/screens/landing_screen.dart'; import '../../features/auth/screens/role_screen.dart'; import '../../features/auth/screens/employee_login_screen.dart'; -import '../../features/dashboard/screens/dashboard_shell.dart'; import '../../features/dashboard/screens/home_screen.dart'; import '../../features/dashboard/screens/vehicles_screen.dart'; import '../../features/dashboard/screens/vehicle_detail_screen.dart'; @@ -15,7 +14,14 @@ import '../../features/dashboard/screens/reservations_screen.dart'; import '../../features/dashboard/screens/reservation_detail_screen.dart'; import '../../features/dashboard/screens/customers_screen.dart'; import '../../features/dashboard/screens/customer_detail_screen.dart'; -import '../../features/client/screens/client_shell.dart'; +import '../../features/dashboard/screens/notifications_screen.dart'; +import '../../features/dashboard/screens/online_reservations_screen.dart'; +import '../../features/dashboard/screens/contract_screen.dart'; +import '../../features/dashboard/screens/contracts_screen.dart'; +import '../../features/dashboard/screens/offers_screen.dart'; +import '../../features/dashboard/screens/reports_screen.dart'; +import '../../features/dashboard/screens/settings_screen.dart'; +import '../../features/dashboard/screens/team_screen.dart'; import '../../features/client/screens/client_home_screen.dart'; import '../../features/client/screens/client_vehicle_detail_screen.dart'; import '../../features/client/screens/booking_screen.dart'; @@ -25,7 +31,6 @@ final routerProvider = Provider((ref) { final router = GoRouter( initialLocation: '/splash', redirect: (context, state) { - // Read (not watch) so redirect is evaluated on router.refresh() final auth = ref.read(authProvider); final status = auth.status; final loc = state.matchedLocation; @@ -33,12 +38,10 @@ final routerProvider = Provider((ref) { final isLanding = loc == '/landing'; final isLogin = loc.startsWith('/login'); - // Still initialising — stay on splash if (status == AuthStatus.unknown) { return isSplash ? null : '/splash'; } - // Auth resolved — always leave splash if (isSplash) { if (status == AuthStatus.authenticated) { return auth.userType == AppConstants.userTypeEmployee @@ -49,7 +52,6 @@ final routerProvider = Provider((ref) { } if (status == AuthStatus.unauthenticated) { - // Allow landing, login routes, and the entire client section without auth if (isLanding || isLogin || loc.startsWith('/client')) return null; return '/landing'; } @@ -64,95 +66,99 @@ final routerProvider = Provider((ref) { return null; }, routes: [ - GoRoute( - path: '/splash', - builder: (context, _) => const SplashScreen(), - ), - GoRoute( - path: '/landing', - builder: (context, _) => const LandingScreen(), - ), - GoRoute( - path: '/role', - builder: (context, _) => const RoleScreen(), - ), + GoRoute(path: '/splash', builder: (context, _) => const SplashScreen()), + GoRoute(path: '/landing', builder: (context, _) => const LandingScreen()), + GoRoute(path: '/role', builder: (context, _) => const RoleScreen()), GoRoute( path: '/login/employee', builder: (context, _) => const EmployeeLoginScreen(), ), - ShellRoute( - builder: (context, state, child) => DashboardShell(child: child), - routes: [ - GoRoute( - path: '/dashboard', - builder: (context, _) => const HomeScreen(), - ), - GoRoute( - path: '/dashboard/vehicles', - builder: (context, _) => const VehiclesScreen(), - routes: [ - GoRoute( - path: ':id', - builder: (_, state) => - VehicleDetailScreen(id: state.pathParameters['id']!), - ), - ], - ), - GoRoute( - path: '/dashboard/reservations', - builder: (context, _) => const ReservationsScreen(), - routes: [ - GoRoute( - path: ':id', - builder: (_, state) => - ReservationDetailScreen(id: state.pathParameters['id']!), - ), - ], - ), - GoRoute( - path: '/dashboard/customers', - builder: (context, _) => const CustomersScreen(), - routes: [ - GoRoute( - path: ':id', - builder: (_, state) => - CustomerDetailScreen(id: state.pathParameters['id']!), - ), - ], - ), - ], + GoRoute(path: '/dashboard', builder: (context, _) => const HomeScreen()), + GoRoute( + path: '/dashboard/vehicles', + builder: (context, _) => const VehiclesScreen(), ), - ShellRoute( - builder: (context, state, child) => ClientShell(child: child), - routes: [ - GoRoute( - path: '/client', - builder: (context, _) => const ClientHomeScreen(), - ), - GoRoute( - path: '/client/vehicles/:id', - builder: (_, state) => ClientVehicleDetailScreen( - id: state.pathParameters['id']!, - vehicle: state.extra as MarketplaceVehicle?, - ), - ), - GoRoute( - path: '/client/book/:vehicleId', - builder: (_, state) => BookingScreen( - vehicleId: state.pathParameters['vehicleId']!, - vehicle: state.extra as MarketplaceVehicle?, - ), - ), - GoRoute( - path: '/client/bookings', - builder: (context, _) => const MyBookingsScreen(), - ), - ], + GoRoute( + path: '/dashboard/reservations', + builder: (context, _) => const ReservationsScreen(), + ), + GoRoute( + path: '/dashboard/customers', + builder: (context, _) => const CustomersScreen(), + ), + GoRoute( + path: '/dashboard/vehicles/:id', + builder: (_, state) => + VehicleDetailScreen(id: state.pathParameters['id']!), + ), + GoRoute( + path: '/dashboard/reservations/:id', + builder: (_, state) => + ReservationDetailScreen(id: state.pathParameters['id']!), + ), + GoRoute( + path: '/dashboard/reservations/:id/contract', + builder: (_, state) => + ContractScreen(reservationId: state.pathParameters['id']!), + ), + GoRoute( + path: '/dashboard/customers/:id', + builder: (_, state) => + CustomerDetailScreen(id: state.pathParameters['id']!), + ), + GoRoute( + path: '/dashboard/notifications', + builder: (context, _) => const NotificationsScreen(), + ), + GoRoute( + path: '/dashboard/online-reservations', + builder: (context, _) => const OnlineReservationsScreen(), + ), + GoRoute( + path: '/dashboard/contracts', + builder: (context, _) => const ContractsScreen(), + ), + GoRoute( + path: '/dashboard/offers', + builder: (context, _) => const OffersScreen(), + ), + GoRoute( + path: '/dashboard/reports', + builder: (context, _) => const ReportsScreen(), + ), + GoRoute( + path: '/dashboard/settings', + builder: (context, _) => const SettingsScreen(), + ), + GoRoute( + path: '/dashboard/team', + builder: (context, _) => const TeamScreen(), + ), + GoRoute( + path: '/client', + builder: (context, _) => const ClientHomeScreen(), + ), + GoRoute( + path: '/client/bookings', + builder: (context, _) => const MyBookingsScreen(), + ), + GoRoute( + path: '/client/vehicles/:id', + builder: (_, state) => ClientVehicleDetailScreen( + id: state.pathParameters['id']!, + vehicle: state.extra as MarketplaceVehicle?, + ), + ), + GoRoute( + path: '/client/book/:vehicleId', + builder: (_, state) => BookingScreen( + vehicleId: state.pathParameters['vehicleId']!, + vehicle: state.extra as MarketplaceVehicle?, + ), ), ], ); - // Trigger redirect re-evaluation whenever auth state changes ref.listen(authProvider, (_, _) => router.refresh()); return router; diff --git a/lib/core/services/analytics_service.dart b/lib/core/services/analytics_service.dart index 4ff17d9..ea0c24f 100644 --- a/lib/core/services/analytics_service.dart +++ b/lib/core/services/analytics_service.dart @@ -1,4 +1,5 @@ import '../models/analytics.dart'; +import '../models/report.dart'; import 'api_client.dart'; class AnalyticsService { @@ -8,4 +9,18 @@ class AnalyticsService { final res = await _client.get('/analytics/dashboard'); return DashboardMetrics.fromJson(res.data as Map); } + + Future getReport({ + required String startDate, + required String endDate, + String? status, + }) async { + final res = await _client.get('/analytics/report', params: { + 'startDate': startDate, + 'endDate': endDate, + 'status': ?status, + 'format': 'JSON', + }); + return ReportSummary.fromJson(res.data as Map); + } } diff --git a/lib/core/services/api_client.dart b/lib/core/services/api_client.dart index d8297fa..6037de2 100644 --- a/lib/core/services/api_client.dart +++ b/lib/core/services/api_client.dart @@ -17,7 +17,7 @@ class ApiClient { _dio.interceptors.add(InterceptorsWrapper( onRequest: (options, handler) async { final token = await TokenStorage.getToken(); - if (token != null) { + if (token != null && !_shouldSkipAuthHeader(options.path)) { options.headers['Authorization'] = 'Bearer $token'; } handler.next(options); @@ -41,6 +41,11 @@ class ApiClient { static ApiClient get instance => _instance; + bool _shouldSkipAuthHeader(String path) { + return path.endsWith('/auth/employee/login') || + path.endsWith('/auth/renter/login'); + } + // baseUrl is dynamic (platform-aware), so recreate when it may have changed. // In practice this is called once and cached via the singleton. Dio get dio => _dio; @@ -54,5 +59,8 @@ class ApiClient { Future patch(String path, {dynamic data}) => _dio.patch(path, data: data); + Future put(String path, {dynamic data}) => + _dio.put(path, data: data); + Future delete(String path) => _dio.delete(path); } diff --git a/lib/core/services/auth_service.dart b/lib/core/services/auth_service.dart index 3f36f6b..36c286d 100644 --- a/lib/core/services/auth_service.dart +++ b/lib/core/services/auth_service.dart @@ -11,7 +11,7 @@ class AuthService { required String password, }) async { final res = await _client.post('/auth/employee/login', data: { - 'email': email, + 'email': email.trim().toLowerCase(), 'password': password, }); final data = res.data as Map; diff --git a/lib/core/services/contract_service.dart b/lib/core/services/contract_service.dart new file mode 100644 index 0000000..f673f63 --- /dev/null +++ b/lib/core/services/contract_service.dart @@ -0,0 +1,293 @@ +import 'api_client.dart'; + +class ContractCompany { + final String name; + final String? email; + final String? phone; + final String? address; + + const ContractCompany( + {required this.name, this.email, this.phone, this.address}); + + factory ContractCompany.fromJson(Map j) { + final parts = []; + final addr = j['address']; + if (addr is String && addr.trim().isNotEmpty) parts.add(addr.trim()); + if (addr is Map) { + for (final v in addr.values) { + if (v is String && v.trim().isNotEmpty) parts.add(v.trim()); + } + } + if (j['city'] is String) parts.add(j['city'] as String); + if (j['country'] is String) parts.add(j['country'] as String); + return ContractCompany( + name: (j['legalName'] as String?)?.isNotEmpty == true + ? j['legalName'] as String + : j['name'] as String, + email: j['email'] as String?, + phone: j['phone'] as String?, + address: parts.isEmpty ? null : parts.join(', '), + ); + } +} + +class ContractDriver { + final String firstName; + final String lastName; + final String email; + final String? phone; + final String? driverLicense; + final String? licenseCountry; + final String? licenseExpiry; + final String? nationality; + + const ContractDriver({ + required this.firstName, + required this.lastName, + required this.email, + this.phone, + this.driverLicense, + this.licenseCountry, + this.licenseExpiry, + this.nationality, + }); + + String get fullName => '$firstName $lastName'; + + factory ContractDriver.fromJson(Map j) => ContractDriver( + firstName: j['firstName'] as String, + lastName: j['lastName'] as String, + email: j['email'] as String, + phone: j['phone'] as String?, + driverLicense: j['driverLicense'] as String?, + licenseCountry: j['licenseCountry'] as String?, + licenseExpiry: j['licenseExpiry'] as String?, + nationality: j['nationality'] as String?, + ); +} + +class ContractVehicle { + final String make; + final String model; + final int year; + final String? color; + final String licensePlate; + final String? vin; + final String category; + + const ContractVehicle({ + required this.make, + required this.model, + required this.year, + this.color, + required this.licensePlate, + this.vin, + required this.category, + }); + + String get displayName => '$year $make $model'; + + factory ContractVehicle.fromJson(Map j) => ContractVehicle( + make: j['make'] as String, + model: j['model'] as String, + year: j['year'] as int, + color: j['color'] as String?, + licensePlate: j['licensePlate'] as String, + vin: j['vin'] as String?, + category: j['category'] as String, + ); +} + +class ContractLineItem { + final String description; + final int qty; + final int unitPrice; + final int total; + + const ContractLineItem({ + required this.description, + required this.qty, + required this.unitPrice, + required this.total, + }); + + factory ContractLineItem.fromJson(Map j) => ContractLineItem( + description: j['description'] as String, + qty: (j['qty'] as num).toInt(), + unitPrice: (j['unitPrice'] as num).toInt(), + total: (j['total'] as num).toInt(), + ); +} + +class ContractPayment { + final String id; + final int amount; + final String currency; + final String type; + final String status; + final String? paymentMethod; + final DateTime? paidAt; + + const ContractPayment({ + required this.id, + required this.amount, + required this.currency, + required this.type, + required this.status, + this.paymentMethod, + this.paidAt, + }); + + factory ContractPayment.fromJson(Map j) => ContractPayment( + id: j['id'] as String, + amount: (j['amount'] as num).toInt(), + currency: j['currency'] as String? ?? 'USD', + type: j['type'] as String, + status: j['status'] as String, + paymentMethod: j['paymentMethod'] as String?, + paidAt: j['paidAt'] != null + ? DateTime.tryParse(j['paidAt'] as String) + : null, + ); +} + +class ContractInspection { + final int? mileage; + final String? fuelLevel; + final String? generalCondition; + final String? employeeNotes; + + const ContractInspection( + {this.mileage, this.fuelLevel, this.generalCondition, this.employeeNotes}); + + factory ContractInspection.fromJson(Map j) => + ContractInspection( + mileage: j['mileage'] as int?, + fuelLevel: j['fuelLevel'] as String?, + generalCondition: j['generalCondition'] as String?, + employeeNotes: j['employeeNotes'] as String?, + ); +} + +class ContractPayload { + final String reservationId; + final String? contractNumber; + final String? invoiceNumber; + final String status; + final String paymentStatus; + final String? notes; + final DateTime generatedAt; + final ContractCompany company; + final ContractDriver driver; + final ContractVehicle vehicle; + final DateTime startDate; + final DateTime endDate; + final int totalDays; + final String? pickupLocation; + final String? returnLocation; + final List lineItems; + final int subtotal; + final int taxTotal; + final int total; + final int amountPaid; + final int balanceDue; + final String currency; + final List payments; + final ContractInspection? checkIn; + final ContractInspection? checkOut; + final String? terms; + final String? fuelPolicy; + final String? depositPolicy; + + const ContractPayload({ + required this.reservationId, + this.contractNumber, + this.invoiceNumber, + required this.status, + required this.paymentStatus, + this.notes, + required this.generatedAt, + required this.company, + required this.driver, + required this.vehicle, + required this.startDate, + required this.endDate, + required this.totalDays, + this.pickupLocation, + this.returnLocation, + required this.lineItems, + required this.subtotal, + required this.taxTotal, + required this.total, + required this.amountPaid, + required this.balanceDue, + required this.currency, + required this.payments, + this.checkIn, + this.checkOut, + this.terms, + this.fuelPolicy, + this.depositPolicy, + }); + + factory ContractPayload.fromJson(Map j) { + final period = j['rentalPeriod'] as Map; + final invoice = j['invoice'] as Map; + final inspections = + j['inspections'] as Map? ?? {}; + final termsData = j['terms'] as Map? ?? {}; + + return ContractPayload( + reservationId: j['reservationId'] as String, + contractNumber: j['contractNumber'] as String?, + invoiceNumber: j['invoiceNumber'] as String?, + status: j['status'] as String, + paymentStatus: j['paymentStatus'] as String, + notes: j['notes'] as String?, + generatedAt: DateTime.parse(j['generatedAt'] as String), + company: + ContractCompany.fromJson(j['company'] as Map), + driver: + ContractDriver.fromJson(j['driver'] as Map), + vehicle: + ContractVehicle.fromJson(j['vehicle'] as Map), + startDate: DateTime.parse(period['startDate'] as String), + endDate: DateTime.parse(period['endDate'] as String), + totalDays: (period['totalDays'] as num).toInt(), + pickupLocation: period['pickupLocation'] as String?, + returnLocation: period['returnLocation'] as String?, + lineItems: (invoice['lineItems'] as List? ?? []) + .map((e) => ContractLineItem.fromJson(e as Map)) + .toList(), + subtotal: (invoice['subtotal'] as num).toInt(), + taxTotal: (invoice['taxTotal'] as num).toInt(), + total: (invoice['total'] as num).toInt(), + amountPaid: (invoice['amountPaid'] as num).toInt(), + balanceDue: (invoice['balanceDue'] as num).toInt(), + currency: invoice['currency'] as String? ?? 'USD', + payments: (invoice['payments'] as List? ?? []) + .map((e) => ContractPayment.fromJson(e as Map)) + .toList(), + checkIn: inspections['checkIn'] != null + ? ContractInspection.fromJson( + inspections['checkIn'] as Map) + : null, + checkOut: inspections['checkOut'] != null + ? ContractInspection.fromJson( + inspections['checkOut'] as Map) + : null, + terms: termsData['terms'] as String?, + fuelPolicy: termsData['fuelPolicy'] as String?, + depositPolicy: termsData['depositPolicy'] as String?, + ); + } +} + +class ContractService { + final _client = ApiClient.instance; + + Future getContract(String reservationId) async { + final res = await _client.get('/reservations/$reservationId/contract'); + return ContractPayload.fromJson(res.data as Map); + } +} diff --git a/lib/core/services/customer_service.dart b/lib/core/services/customer_service.dart index 5421ed6..ec22a39 100644 --- a/lib/core/services/customer_service.dart +++ b/lib/core/services/customer_service.dart @@ -1,3 +1,4 @@ +import 'package:dio/dio.dart'; import '../models/customer.dart'; import 'api_client.dart'; @@ -26,4 +27,40 @@ class CustomerService { final res = await _client.post('/customers', data: data); return Customer.fromJson(res.data as Map); } + + Future updateCustomer(String id, Map data) async { + final res = await _client.patch('/customers/$id', data: data); + return Customer.fromJson(res.data as Map); + } + + Future flagCustomer(String id, String reason) async { + await _client.post('/customers/$id/flag', data: {'reason': reason}); + } + + Future unflagCustomer(String id) async { + await _client.delete('/customers/$id/flag'); + } + + Future approveLicense( + String id, String decision, {String? note}) async { + final res = await _client.post('/customers/$id/approve-license', + data: {'decision': decision, 'note': ?note}); + return Customer.fromJson(res.data as Map); + } + + Future uploadLicenseImage(String id, String filePath) async { + final formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath), + }); + final res = await _client.dio.post( + '/customers/$id/license-image', + data: formData, + options: Options(headers: {'Content-Type': 'multipart/form-data'}), + ); + final body = res.data; + final payload = (body is Map && body.containsKey('data')) + ? body['data'] + : body; + return Customer.fromJson(payload as Map); + } } diff --git a/lib/core/services/marketplace_service.dart b/lib/core/services/marketplace_service.dart index c6dd716..c1f5e5f 100644 --- a/lib/core/services/marketplace_service.dart +++ b/lib/core/services/marketplace_service.dart @@ -46,10 +46,10 @@ class MarketplaceService { 'startDate': '${startDate.toIso8601String().substring(0, 10)}T00:00:00.000Z', if (endDate != null) 'endDate': '${endDate.toIso8601String().substring(0, 10)}T00:00:00.000Z', - if (category != null) 'category': category, - if (transmission != null) 'transmission': transmission, + 'category': ?category, + 'transmission': ?transmission, if (make != null && make.isNotEmpty) 'make': make, - if (maxPriceCents != null) 'maxPrice': maxPriceCents, + 'maxPrice': ?maxPriceCents, }); final data = res.data; diff --git a/lib/core/services/notification_service.dart b/lib/core/services/notification_service.dart new file mode 100644 index 0000000..30f7170 --- /dev/null +++ b/lib/core/services/notification_service.dart @@ -0,0 +1,29 @@ +import '../models/notification.dart'; +import 'api_client.dart'; + +class NotificationService { + final _client = ApiClient.instance; + + Future> getNotifications({bool? unreadOnly}) async { + final res = await _client.get('/notifications/company', params: { + if (unreadOnly == true) 'unread': true, + }); + final list = res.data as List; + return list + .map((e) => AppNotification.fromJson(e as Map)) + .toList(); + } + + Future getUnreadCount() async { + final res = await _client.get('/notifications/unread-count'); + return (res.data as Map)['unread'] as int? ?? 0; + } + + Future markRead(String id) async { + await _client.post('/notifications/company/$id/read'); + } + + Future markAllRead() async { + await _client.post('/notifications/company/read-all'); + } +} diff --git a/lib/core/services/offer_service.dart b/lib/core/services/offer_service.dart new file mode 100644 index 0000000..aa9c53d --- /dev/null +++ b/lib/core/services/offer_service.dart @@ -0,0 +1,41 @@ +import '../models/offer.dart'; +import 'api_client.dart'; + +class OfferService { + final _client = ApiClient.instance; + + Future> getOffers() async { + final res = await _client.get('/offers'); + final list = res.data as List; + return list + .map((e) => CompanyOffer.fromJson(e as Map)) + .toList(); + } + + Future getOffer(String id) async { + final res = await _client.get('/offers/$id'); + return CompanyOffer.fromJson(res.data as Map); + } + + Future createOffer(Map data) async { + final res = await _client.post('/offers', data: data); + return CompanyOffer.fromJson(res.data as Map); + } + + Future updateOffer(String id, Map data) async { + final res = await _client.patch('/offers/$id', data: data); + return CompanyOffer.fromJson(res.data as Map); + } + + Future deleteOffer(String id) async { + await _client.delete('/offers/$id'); + } + + Future activateOffer(String id) async { + await _client.post('/offers/$id/activate'); + } + + Future deactivateOffer(String id) async { + await _client.post('/offers/$id/deactivate'); + } +} diff --git a/lib/core/services/payment_service.dart b/lib/core/services/payment_service.dart new file mode 100644 index 0000000..93781ae --- /dev/null +++ b/lib/core/services/payment_service.dart @@ -0,0 +1,52 @@ +import 'api_client.dart'; + +class Payment { + final String id; + final int amount; + final String currency; + final String type; + final String paymentMethod; + final String status; + final DateTime createdAt; + + const Payment({ + required this.id, + required this.amount, + required this.currency, + required this.type, + required this.paymentMethod, + required this.status, + required this.createdAt, + }); + + factory Payment.fromJson(Map json) => Payment( + id: json['id'] as String, + amount: json['amount'] as int? ?? 0, + currency: json['currency'] as String? ?? 'USD', + type: json['type'] as String? ?? 'CHARGE', + paymentMethod: json['paymentMethod'] as String? ?? 'CASH', + status: json['status'] as String? ?? 'COMPLETED', + createdAt: DateTime.parse( + json['createdAt'] as String? ?? DateTime.now().toIso8601String()), + ); +} + +class PaymentService { + final _client = ApiClient.instance; + + Future> getPayments(String reservationId) async { + final res = await _client.get('/payments/reservations/$reservationId'); + final list = res.data as List; + return list + .map((e) => Payment.fromJson(e as Map)) + .toList(); + } + + Future recordManualPayment( + String reservationId, Map data) async { + final res = await _client.post( + '/payments/reservations/$reservationId/manual', + data: data); + return Payment.fromJson(res.data as Map); + } +} diff --git a/lib/core/services/reservation_service.dart b/lib/core/services/reservation_service.dart index d5bb887..d6a2802 100644 --- a/lib/core/services/reservation_service.dart +++ b/lib/core/services/reservation_service.dart @@ -1,6 +1,38 @@ import '../models/reservation.dart'; import 'api_client.dart'; +class ReservationInspection { + final String id; + final String type; // CHECKIN | CHECKOUT + final int? mileage; + final String? fuelLevel; + final String? generalCondition; + final String? employeeNotes; + final DateTime createdAt; + + const ReservationInspection({ + required this.id, + required this.type, + this.mileage, + this.fuelLevel, + this.generalCondition, + this.employeeNotes, + required this.createdAt, + }); + + factory ReservationInspection.fromJson(Map json) => + ReservationInspection( + id: json['id'] as String, + type: json['type'] as String, + mileage: json['mileage'] as int?, + fuelLevel: json['fuelLevel'] as String?, + generalCondition: json['generalCondition'] as String?, + employeeNotes: json['employeeNotes'] as String?, + createdAt: DateTime.parse( + json['createdAt'] as String? ?? DateTime.now().toIso8601String()), + ); +} + class ReservationService { final _client = ApiClient.instance; @@ -8,13 +40,17 @@ class ReservationService { int page = 1, int pageSize = 20, String? status, + String? source, String? search, + String? customerId, }) async { final res = await _client.get('/reservations', params: { 'page': page, 'pageSize': pageSize, - if (status != null) 'status': status, + 'status': ?status, + 'source': ?source, if (search != null && search.isNotEmpty) 'search': search, + 'customerId': ?customerId, }); return ReservationListResponse.fromJson(res.data as Map); } @@ -44,4 +80,26 @@ class ReservationService { Future checkout(String id, int mileage) async { await _client.post('/reservations/$id/checkout', data: {'mileage': mileage}); } + + Future closeReservation(String id) async { + final res = await _client.post('/reservations/$id/close'); + return Reservation.fromJson(res.data as Map); + } + + Future> getInspections(String id) async { + final res = await _client.get('/reservations/$id/inspections'); + final list = res.data as List; + return list + .map((e) => + ReservationInspection.fromJson(e as Map)) + .toList(); + } + + Future upsertInspection( + String id, String type, Map data) async { + final res = await _client.put( + '/reservations/$id/inspections/$type', + data: data); + return ReservationInspection.fromJson(res.data as Map); + } } diff --git a/lib/core/services/settings_service.dart b/lib/core/services/settings_service.dart new file mode 100644 index 0000000..bf03f6f --- /dev/null +++ b/lib/core/services/settings_service.dart @@ -0,0 +1,16 @@ +import '../models/settings.dart'; +import 'api_client.dart'; + +class SettingsService { + final _client = ApiClient.instance; + + Future getBrand() async { + final res = await _client.get('/companies/me/brand'); + return BrandSettings.fromJson(res.data as Map); + } + + Future updateBrand(Map data) async { + final res = await _client.patch('/companies/me/brand', data: data); + return BrandSettings.fromJson(res.data as Map); + } +} diff --git a/lib/core/services/team_service.dart b/lib/core/services/team_service.dart new file mode 100644 index 0000000..236ed19 --- /dev/null +++ b/lib/core/services/team_service.dart @@ -0,0 +1,62 @@ +import 'api_client.dart'; + +class TeamMember { + final String id; + final String firstName; + final String lastName; + final String email; + final String role; + final bool isActive; + final DateTime createdAt; + + const TeamMember({ + required this.id, + required this.firstName, + required this.lastName, + required this.email, + required this.role, + required this.isActive, + required this.createdAt, + }); + + String get fullName => '$firstName $lastName'; + + factory TeamMember.fromJson(Map json) => TeamMember( + id: json['id'] as String, + firstName: json['firstName'] as String, + lastName: json['lastName'] as String, + email: json['email'] as String, + role: json['role'] as String? ?? 'AGENT', + isActive: json['isActive'] as bool? ?? true, + createdAt: DateTime.parse( + json['createdAt'] as String? ?? DateTime.now().toIso8601String()), + ); +} + +class TeamService { + final _client = ApiClient.instance; + + Future> getMembers() async { + final res = await _client.get('/team'); + final list = res.data as List; + return list + .map((e) => TeamMember.fromJson(e as Map)) + .toList(); + } + + Future inviteMember(Map data) async { + await _client.post('/team/invite', data: data); + } + + Future updateRole(String id, String role) async { + await _client.patch('/team/$id/role', data: {'role': role}); + } + + Future deactivateMember(String id) async { + await _client.post('/team/$id/deactivate'); + } + + Future reactivateMember(String id) async { + await _client.post('/team/$id/reactivate'); + } +} diff --git a/lib/core/services/vehicle_service.dart b/lib/core/services/vehicle_service.dart index 2504a64..a8301c7 100644 --- a/lib/core/services/vehicle_service.dart +++ b/lib/core/services/vehicle_service.dart @@ -1,6 +1,69 @@ +import 'package:dio/dio.dart'; import '../models/vehicle.dart'; import 'api_client.dart'; +class MaintenanceLog { + final String id; + final String type; + final String? description; + final int? cost; + final int? mileage; + final DateTime performedAt; + final DateTime? nextDueAt; + final int? nextDueMileage; + + const MaintenanceLog({ + required this.id, + required this.type, + this.description, + this.cost, + this.mileage, + required this.performedAt, + this.nextDueAt, + this.nextDueMileage, + }); + + factory MaintenanceLog.fromJson(Map json) => MaintenanceLog( + id: json['id'] as String, + type: json['type'] as String, + description: json['description'] as String?, + cost: json['cost'] as int?, + mileage: json['mileage'] as int?, + performedAt: DateTime.parse(json['performedAt'] as String), + nextDueAt: json['nextDueAt'] != null + ? DateTime.parse(json['nextDueAt'] as String) + : null, + nextDueMileage: json['nextDueMileage'] as int?, + ); +} + +class CalendarEvent { + final String id; + final String type; // RESERVATION | MAINTENANCE | BLOCK + final DateTime startDate; + final DateTime endDate; + final String? status; + final String label; + + const CalendarEvent({ + required this.id, + required this.type, + required this.startDate, + required this.endDate, + this.status, + required this.label, + }); + + factory CalendarEvent.fromJson(Map json) => CalendarEvent( + id: json['id'] as String, + type: json['type'] as String, + startDate: DateTime.parse(json['startDate'] as String), + endDate: DateTime.parse(json['endDate'] as String), + status: json['status'] as String?, + label: json['label'] as String, + ); +} + class VehicleService { final _client = ApiClient.instance; @@ -13,7 +76,7 @@ class VehicleService { final res = await _client.get('/vehicles', params: { 'page': page, 'pageSize': pageSize, - if (status != null) 'status': status, + 'status': ?status, if (search != null && search.isNotEmpty) 'search': search, }); return VehicleListResponse.fromJson(res.data as Map); @@ -34,7 +97,70 @@ class VehicleService { return Vehicle.fromJson(res.data as Map); } + Future deleteVehicle(String id) async { + await _client.delete('/vehicles/$id'); + } + Future togglePublish(String id, bool publish) async { await _client.patch('/vehicles/$id/publish', data: {'isPublished': publish}); } + + Future uploadPhotos(String id, List filePaths) async { + final formData = FormData.fromMap({ + 'photos': [ + for (final path in filePaths) + await MultipartFile.fromFile(path), + ], + }); + final res = await _client.dio.post( + '/vehicles/$id/photos', + data: formData, + options: Options(headers: {'Content-Type': 'multipart/form-data'}), + ); + final body = res.data; + final payload = (body is Map && body.containsKey('data')) + ? body['data'] + : body; + return Vehicle.fromJson(payload as Map); + } + + Future deletePhoto(String id, int idx) async { + final res = await _client.delete('/vehicles/$id/photos/$idx'); + return Vehicle.fromJson(res.data as Map); + } + + Future> getMaintenance(String id) async { + final res = await _client.get('/vehicles/$id/maintenance'); + final list = res.data as List; + return list + .map((e) => MaintenanceLog.fromJson(e as Map)) + .toList(); + } + + Future addMaintenance( + String id, Map data) async { + final res = await _client.post('/vehicles/$id/maintenance', data: data); + return MaintenanceLog.fromJson(res.data as Map); + } + + Future> getCalendar( + String id, int year, int month) async { + final res = await _client.get('/vehicles/$id/calendar', + params: {'year': year, 'month': month}); + final list = res.data as List; + return list + .map((e) => CalendarEvent.fromJson(e as Map)) + .toList(); + } + + Future createCalendarBlock( + String id, Map data) async { + final res = + await _client.post('/vehicles/$id/calendar/blocks', data: data); + return CalendarEvent.fromJson(res.data as Map); + } + + Future deleteCalendarBlock(String id, String blockId) async { + await _client.delete('/vehicles/$id/calendar/blocks/$blockId'); + } } diff --git a/lib/features/auth/screens/employee_login_screen.dart b/lib/features/auth/screens/employee_login_screen.dart index 33cfe97..3a231ca 100644 --- a/lib/features/auth/screens/employee_login_screen.dart +++ b/lib/features/auth/screens/employee_login_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:dio/dio.dart'; import '../../../core/providers/auth_provider.dart'; class EmployeeLoginScreen extends ConsumerStatefulWidget { @@ -36,15 +37,36 @@ class _EmployeeLoginScreenState extends ConsumerState { _emailController.text.trim(), _passwordController.text, ); - } catch (e) { + } on DioException catch (e) { setState(() { - _error = 'Invalid email or password. Please try again.'; + _error = _messageForDioError(e); + }); + } catch (_) { + setState(() { + _error = 'Sign in failed. Please try again.'; }); } finally { if (mounted) setState(() => _loading = false); } } + String _messageForDioError(DioException error) { + final statusCode = error.response?.statusCode; + if (statusCode == 401 || statusCode == 403) { + return 'Invalid email or password. Please try again.'; + } + + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + case DioExceptionType.connectionError: + return 'Unable to reach the server. Check the API URL or your connection and try again.'; + default: + return 'Sign in failed. Please try again.'; + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -107,6 +129,10 @@ class _EmployeeLoginScreenState extends ConsumerState { controller: _emailController, keyboardType: TextInputType.emailAddress, textInputAction: TextInputAction.next, + textCapitalization: TextCapitalization.none, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.username, AutofillHints.email], decoration: const InputDecoration( labelText: 'Email', prefixIcon: Icon(Icons.email_outlined), @@ -120,6 +146,9 @@ class _EmployeeLoginScreenState extends ConsumerState { controller: _passwordController, obscureText: _obscurePassword, textInputAction: TextInputAction.done, + autocorrect: false, + enableSuggestions: false, + autofillHints: const [AutofillHints.password], onFieldSubmitted: (_) => _login(), decoration: InputDecoration( labelText: 'Password', diff --git a/lib/features/client/screens/client_home_screen.dart b/lib/features/client/screens/client_home_screen.dart index 352a803..ca5604c 100644 --- a/lib/features/client/screens/client_home_screen.dart +++ b/lib/features/client/screens/client_home_screen.dart @@ -7,14 +7,22 @@ import '../providers/client_providers.dart'; import '../../../core/models/marketplace.dart'; import '../../../l10n/app_localizations.dart'; import '../../../widgets/preferences_bar.dart'; +import 'client_shell.dart'; import 'widgets/marketplace_vehicle_card.dart'; +import 'widgets/offer_banner.dart'; const _orange = Color(0xFFFF6B00); const _blue = Color(0xFF1A56DB); const _allCarTypes = [ - 'ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', - 'SUV', 'LUXURY', 'VAN', 'TRUCK', + 'ECONOMY', + 'COMPACT', + 'MIDSIZE', + 'FULLSIZE', + 'SUV', + 'LUXURY', + 'VAN', + 'TRUCK', ]; String _capitalize(String s) => @@ -62,8 +70,9 @@ class _ClientHomeScreenState extends ConsumerState { endDate: _endDate, categories: _selectedTypes.isEmpty ? null : Set.from(_selectedTypes), maxPriceCents: _priceFilterActive ? _maxPriceCents : null, - minPriceCents: - _priceFilterActive && _minPriceCents > 0 ? _minPriceCents : null, + minPriceCents: _priceFilterActive && _minPriceCents > 0 + ? _minPriceCents + : null, ); } @@ -78,9 +87,9 @@ class _ClientHomeScreenState extends ConsumerState { ? DateTimeRange(start: _startDate!, end: _endDate!) : DateTimeRange(start: now, end: now.add(const Duration(days: 1))), builder: (context, child) => Theme( - data: Theme.of(context).copyWith( - colorScheme: cs.copyWith(primary: _orange), - ), + data: Theme.of( + context, + ).copyWith(colorScheme: cs.copyWith(primary: _orange)), child: child!, ), ); @@ -174,7 +183,9 @@ class _ClientHomeScreenState extends ConsumerState { } List _applyClientFilters( - List all, SearchParams params) { + List all, + SearchParams params, + ) { var result = all; final cats = params.categories; if (cats != null && cats.length > 1) { @@ -199,14 +210,14 @@ class _ClientHomeScreenState extends ConsumerState { final typeLabel = _selectedTypes.isEmpty ? l10n.allCarTypes : _selectedTypes.length == 1 - ? _capitalize(_selectedTypes.first) - : '${_selectedTypes.length} types'; + ? _capitalize(_selectedTypes.first) + : '${_selectedTypes.length} types'; final priceLabel = _priceFilterActive ? '\$${(_minPriceCents / 100).toStringAsFixed(0)} – \$${(_maxPriceCents / 100).toStringAsFixed(0)}/day' : l10n.anyPrice; - return Scaffold( + return ClientShell( backgroundColor: cs.surface, body: CustomScrollView( slivers: [ @@ -268,12 +279,14 @@ class _ClientHomeScreenState extends ConsumerState { children: [ // Pickup _SearchInputBox( - onTap: () => citiesAsync - .whenData((c) => _showCitySheet(c, l10n)), + onTap: () => citiesAsync.whenData( + (c) => _showCitySheet(c, l10n), + ), child: _LocationRow( icon: Icons.radio_button_checked, iconColor: _orange, - text: _city ?? + text: + _city ?? (_sameDropoff ? l10n.findCarToRent : l10n.pickupLocation), @@ -307,17 +320,20 @@ class _ClientHomeScreenState extends ConsumerState { // Drop-off box _SearchInputBox( onTap: () => citiesAsync.whenData( - (c) => _showDropoffCitySheet(c, l10n)), + (c) => _showDropoffCitySheet(c, l10n), + ), child: _LocationRow( icon: Icons.location_on, iconColor: _blue, - text: _dropoffCity ?? + text: + _dropoffCity ?? l10n.dropoffLocation, placeholder: _dropoffCity == null, cs: cs, onClear: _dropoffCity != null ? () => setState( - () => _dropoffCity = null) + () => _dropoffCity = null, + ) : null, ), ), @@ -343,13 +359,18 @@ class _ClientHomeScreenState extends ConsumerState { ) : Row( children: [ - Icon(Icons.calendar_today_outlined, - size: 18, color: cs.onSurfaceVariant), + Icon( + Icons.calendar_today_outlined, + size: 18, + color: cs.onSurfaceVariant, + ), const SizedBox(width: 12), Text( l10n.pickDates, style: TextStyle( - color: cs.onSurfaceVariant, fontSize: 15), + color: cs.onSurfaceVariant, + fontSize: 15, + ), ), ], ), @@ -382,10 +403,10 @@ class _ClientHomeScreenState extends ConsumerState { onTap: () => _showPriceSheet(l10n), onClear: _priceFilterActive ? () => setState(() { - _priceFilterActive = false; - _minPriceCents = 0; - _maxPriceCents = 50000; - }) + _priceFilterActive = false; + _minPriceCents = 0; + _maxPriceCents = 50000; + }) : null, ), ), @@ -426,28 +447,38 @@ class _ClientHomeScreenState extends ConsumerState { Padding( padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), child: searchAsync.when( - loading: () => Row(children: [ - SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: cs.onSurfaceVariant, + loading: () => Row( + children: [ + SizedBox( + width: 12, + height: 12, + child: CircularProgressIndicator( + strokeWidth: 1.5, + color: cs.onSurfaceVariant, + ), ), - ), - const SizedBox(width: 8), - Text(l10n.searching, + const SizedBox(width: 8), + Text( + l10n.searching, style: TextStyle( - color: cs.onSurfaceVariant, fontSize: 13)), - ]), + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), error: (_, _) => const SizedBox.shrink(), data: (r) { - final count = - _applyClientFilters(r.data, params).length; + final count = _applyClientFilters( + r.data, + params, + ).length; return Text( l10n.vehiclesFound(count), style: TextStyle( - color: cs.onSurfaceVariant, fontSize: 13), + color: cs.onSurfaceVariant, + fontSize: 13, + ), ); }, ), @@ -458,12 +489,60 @@ class _ClientHomeScreenState extends ConsumerState { ), ), + // ── Active offers ──────────────────────────────────────────────── + ref + .watch(offersProvider) + .when( + loading: () => + const SliverToBoxAdapter(child: SizedBox.shrink()), + error: (_, _) => + const SliverToBoxAdapter(child: SizedBox.shrink()), + data: (offers) { + if (offers.isEmpty) { + return const SliverToBoxAdapter(child: SizedBox.shrink()); + } + return SliverToBoxAdapter( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 10), + child: Text( + 'Special Offers', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), + ), + ), + SizedBox( + height: 110, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: offers.length, + separatorBuilder: (_, _) => + const SizedBox(width: 10), + itemBuilder: (_, i) => + OfferBanner(offer: offers[i]), + ), + ), + const SizedBox(height: 4), + ], + ), + ); + }, + ), + // ── Results ────────────────────────────────────────────────────── searchAsync.when( loading: () => const SliverFillRemaining( child: Center( child: CircularProgressIndicator( - color: _orange, strokeWidth: 2.5), + color: _orange, + strokeWidth: 2.5, + ), ), ), error: (_, _) => SliverFillRemaining( @@ -477,7 +556,8 @@ class _ClientHomeScreenState extends ConsumerState { final vehicles = _applyClientFilters(result.data, params); if (vehicles.isEmpty) { return SliverFillRemaining( - child: _EmptyBody(l10n: l10n, cs: cs)); + child: _EmptyBody(l10n: l10n, cs: cs), + ); } return SliverPadding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 32), @@ -511,8 +591,11 @@ class _DropoffTab extends StatelessWidget { final bool active; final VoidCallback onTap; - const _DropoffTab( - {required this.label, required this.active, required this.onTap}); + const _DropoffTab({ + required this.label, + required this.active, + required this.onTap, + }); @override Widget build(BuildContext context) { @@ -579,26 +662,25 @@ class _LocationRow extends StatelessWidget { @override Widget build(BuildContext context) => Row( - children: [ - Icon(icon, size: 18, color: iconColor), - const SizedBox(width: 12), - Expanded( - child: Text( - text, - style: TextStyle( - color: placeholder ? cs.onSurfaceVariant : cs.onSurface, - fontSize: 15, - ), - ), + children: [ + Icon(icon, size: 18, color: iconColor), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: TextStyle( + color: placeholder ? cs.onSurfaceVariant : cs.onSurface, + fontSize: 15, ), - if (onClear != null) - GestureDetector( - onTap: onClear, - child: - Icon(Icons.close, size: 16, color: cs.onSurfaceVariant), - ), - ], - ); + ), + ), + if (onClear != null) + GestureDetector( + onTap: onClear, + child: Icon(Icons.close, size: 16, color: cs.onSurfaceVariant), + ), + ], + ); } // ── Date range display ──────────────────────────────────────────────────────── @@ -629,8 +711,11 @@ class _DateRangeRow extends StatelessWidget { ), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), - child: Icon(Icons.arrow_forward, - size: 14, color: cs.onSurfaceVariant), + child: Icon( + Icons.arrow_forward, + size: 14, + color: cs.onSurfaceVariant, + ), ), _DateChunk( date: dayFmt.format(end), @@ -647,23 +732,23 @@ class _DateChunk extends StatelessWidget { final String sub; final ColorScheme cs; - const _DateChunk( - {required this.date, required this.sub, required this.cs}); + const _DateChunk({required this.date, required this.sub, required this.cs}); @override Widget build(BuildContext context) => Row( - children: [ - Text(date, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600)), - const SizedBox(width: 6), - Text(sub, - style: - TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), - ], - ); + children: [ + Text( + date, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 6), + Text(sub, style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13)), + ], + ); } // ── Option tile (car type / price) ──────────────────────────────────────────── @@ -696,15 +781,11 @@ class _OptionTile extends StatelessWidget { ? _blue.withValues(alpha: 0.12) : cs.surfaceContainerHigh, borderRadius: BorderRadius.circular(14), - border: Border.all( - color: active ? _blue : cs.outlineVariant, - ), + border: Border.all(color: active ? _blue : cs.outlineVariant), ), child: Row( children: [ - Icon(icon, - size: 16, - color: active ? _blue : cs.onSurfaceVariant), + Icon(icon, size: 16, color: active ? _blue : cs.onSurfaceVariant), const SizedBox(width: 8), Expanded( child: Text( @@ -712,8 +793,7 @@ class _OptionTile extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 13, - fontWeight: - active ? FontWeight.w600 : FontWeight.normal, + fontWeight: active ? FontWeight.w600 : FontWeight.normal, color: cs.onSurface, ), ), @@ -722,9 +802,11 @@ class _OptionTile extends StatelessWidget { const SizedBox(width: 4), GestureDetector( onTap: onClear, - child: Icon(Icons.close, - size: 14, - color: active ? _blue : cs.onSurfaceVariant), + child: Icon( + Icons.close, + size: 14, + color: active ? _blue : cs.onSurfaceVariant, + ), ), ], ], @@ -843,7 +925,11 @@ class _CarTypeSheetState extends State<_CarTypeSheet> { ), Padding( padding: EdgeInsets.fromLTRB( - 16, 10, 16, 16 + MediaQuery.of(context).padding.bottom), + 16, + 10, + 16, + 16 + MediaQuery.of(context).padding.bottom, + ), child: SizedBox( width: double.infinity, height: 50, @@ -853,12 +939,17 @@ class _CarTypeSheetState extends State<_CarTypeSheet> { backgroundColor: _orange, foregroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14)), + borderRadius: BorderRadius.circular(14), + ), elevation: 0, ), - child: Text(l10n.apply, - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.bold)), + child: Text( + l10n.apply, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), ), ), ), @@ -903,8 +994,9 @@ class _PriceSheetState extends State<_PriceSheet> { ); } - String _fmt(double v) => - v >= _PriceSheet._cap ? '\$${v.toStringAsFixed(0)}+' : '\$${v.toStringAsFixed(0)}'; + String _fmt(double v) => v >= _PriceSheet._cap + ? '\$${v.toStringAsFixed(0)}+' + : '\$${v.toStringAsFixed(0)}'; @override Widget build(BuildContext context) { @@ -912,8 +1004,9 @@ class _PriceSheetState extends State<_PriceSheet> { final l10n = widget.l10n; return Padding( - padding: - EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -941,8 +1034,10 @@ class _PriceSheetState extends State<_PriceSheet> { minimumSize: Size.zero, tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), - child: Text(l10n.clearAll, - style: const TextStyle(fontSize: 14)), + child: Text( + l10n.clearAll, + style: const TextStyle(fontSize: 14), + ), ), ], ), @@ -955,8 +1050,7 @@ class _PriceSheetState extends State<_PriceSheet> { mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ _PriceTag(label: _fmt(_range.start), cs: cs), - Icon(Icons.arrow_forward, - size: 14, color: cs.onSurfaceVariant), + Icon(Icons.arrow_forward, size: 14, color: cs.onSurfaceVariant), _PriceTag(label: '${_fmt(_range.end)}/day', cs: cs), ], ), @@ -987,7 +1081,11 @@ class _PriceSheetState extends State<_PriceSheet> { Padding( padding: EdgeInsets.fromLTRB( - 16, 4, 16, 16 + MediaQuery.of(context).padding.bottom), + 16, + 4, + 16, + 16 + MediaQuery.of(context).padding.bottom, + ), child: SizedBox( width: double.infinity, height: 50, @@ -1001,12 +1099,17 @@ class _PriceSheetState extends State<_PriceSheet> { backgroundColor: _orange, foregroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14)), + borderRadius: BorderRadius.circular(14), + ), elevation: 0, ), - child: Text(l10n.apply, - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.bold)), + child: Text( + l10n.apply, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), ), ), ), @@ -1024,21 +1127,21 @@ class _PriceTag extends StatelessWidget { @override Widget build(BuildContext context) => Container( - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: cs.surfaceContainerHigh, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: cs.outlineVariant), - ), - child: Text( - label, - style: TextStyle( - color: cs.onSurface, - fontWeight: FontWeight.w600, - fontSize: 15), - ), - ); + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.outlineVariant), + ), + child: Text( + label, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + ); } // ── City Sheet ──────────────────────────────────────────────────────────────── @@ -1089,9 +1192,10 @@ class _CitySheetState extends State<_CitySheet> { Text( widget.titleOverride ?? widget.l10n.selectCity, style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.bold, - color: cs.onSurface), + fontSize: 17, + fontWeight: FontWeight.bold, + color: cs.onSurface, + ), ), const SizedBox(height: 12), TextField( @@ -1099,10 +1203,12 @@ class _CitySheetState extends State<_CitySheet> { style: TextStyle(color: cs.onSurface), decoration: InputDecoration( hintText: widget.l10n.searchCities, - prefixIcon: Icon(Icons.search, - size: 20, color: cs.onSurfaceVariant), - contentPadding: - const EdgeInsets.symmetric(vertical: 8), + prefixIcon: Icon( + Icons.search, + size: 20, + color: cs.onSurfaceVariant, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 8), ), onChanged: (v) => setState(() => _query = v), ), @@ -1114,10 +1220,14 @@ class _CitySheetState extends State<_CitySheet> { controller: controller, children: [ ListTile( - leading: Icon(Icons.public_outlined, - color: cs.onSurfaceVariant), - title: Text(widget.l10n.allCities, - style: TextStyle(color: cs.onSurface)), + leading: Icon( + Icons.public_outlined, + color: cs.onSurfaceVariant, + ), + title: Text( + widget.l10n.allCities, + style: TextStyle(color: cs.onSurface), + ), selected: widget.selected == null, selectedColor: _orange, trailing: widget.selected == null @@ -1125,19 +1235,21 @@ class _CitySheetState extends State<_CitySheet> { : null, onTap: () => widget.onSelect(null), ), - ...filtered.map((city) => ListTile( - leading: Icon(Icons.location_city_outlined, - color: cs.onSurfaceVariant), - title: Text(city, - style: TextStyle(color: cs.onSurface)), - selected: widget.selected == city, - selectedColor: _orange, - trailing: widget.selected == city - ? const Icon(Icons.check, - color: _orange, size: 18) - : null, - onTap: () => widget.onSelect(city), - )), + ...filtered.map( + (city) => ListTile( + leading: Icon( + Icons.location_city_outlined, + color: cs.onSurfaceVariant, + ), + title: Text(city, style: TextStyle(color: cs.onSurface)), + selected: widget.selected == city, + selectedColor: _orange, + trailing: widget.selected == city + ? const Icon(Icons.check, color: _orange, size: 18) + : null, + onTap: () => widget.onSelect(city), + ), + ), ], ), ), @@ -1155,13 +1267,13 @@ class _SheetHandle extends StatelessWidget { @override Widget build(BuildContext context) => Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: cs.outlineVariant, - borderRadius: BorderRadius.circular(2), - ), - ); + width: 40, + height: 4, + decoration: BoxDecoration( + color: cs.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ); } // ── Error / Empty states ───────────────────────────────────────────────────── @@ -1171,46 +1283,48 @@ class _ErrorBody extends StatelessWidget { final AppLocalizations l10n; final ColorScheme cs; - const _ErrorBody( - {required this.onRetry, required this.l10n, required this.cs}); + const _ErrorBody({ + required this.onRetry, + required this.l10n, + required this.cs, + }); @override Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.wifi_off_outlined, - size: 48, color: cs.onSurfaceVariant), - const SizedBox(height: 12), - Text( - l10n.couldNotLoadVehicles, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Text( - l10n.checkConnectionRetry, - style: - TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - textAlign: TextAlign.center, - ), - const SizedBox(height: 20), - OutlinedButton( - onPressed: onRetry, - style: OutlinedButton.styleFrom( - foregroundColor: _orange, - side: const BorderSide(color: _orange), - ), - child: Text(l10n.retry), - ), - ], + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wifi_off_outlined, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 12), + Text( + l10n.couldNotLoadVehicles, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - ), - ); + const SizedBox(height: 8), + Text( + l10n.checkConnectionRetry, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + OutlinedButton( + onPressed: onRetry, + style: OutlinedButton.styleFrom( + foregroundColor: _orange, + side: const BorderSide(color: _orange), + ), + child: Text(l10n.retry), + ), + ], + ), + ), + ); } class _EmptyBody extends StatelessWidget { @@ -1221,29 +1335,29 @@ class _EmptyBody extends StatelessWidget { @override Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.search_off, size: 56, color: cs.outlineVariant), - const SizedBox(height: 16), - Text( - l10n.noVehiclesFound, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - Text( - l10n.tryDifferentSearch, - style: - TextStyle(color: cs.onSurfaceVariant, fontSize: 13), - textAlign: TextAlign.center, - ), - ], + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.search_off, size: 56, color: cs.outlineVariant), + const SizedBox(height: 16), + Text( + l10n.noVehiclesFound, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), ), - ), - ); + const SizedBox(height: 8), + Text( + l10n.tryDifferentSearch, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); } diff --git a/lib/features/client/screens/client_shell.dart b/lib/features/client/screens/client_shell.dart index 1bdba26..08dbe49 100644 --- a/lib/features/client/screens/client_shell.dart +++ b/lib/features/client/screens/client_shell.dart @@ -1,15 +1,23 @@ import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../l10n/app_localizations.dart'; -class ClientShell extends ConsumerWidget { - final Widget child; +class ClientShell extends StatelessWidget { + final PreferredSizeWidget? appBar; + final Widget body; + final Widget? floatingActionButton; + final Color? backgroundColor; - const ClientShell({super.key, required this.child}); + const ClientShell({ + super.key, + this.appBar, + required this.body, + this.floatingActionButton, + this.backgroundColor, + }); @override - Widget build(BuildContext context, WidgetRef ref) { + Widget build(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; final l10n = AppLocalizations.of(context); @@ -17,7 +25,10 @@ class ClientShell extends ConsumerWidget { if (location == '/client/bookings') currentIndex = 1; return Scaffold( - body: child, + appBar: appBar, + backgroundColor: backgroundColor, + body: body, + floatingActionButton: floatingActionButton, bottomNavigationBar: NavigationBar( selectedIndex: currentIndex, onDestinationSelected: (i) { diff --git a/lib/features/client/screens/my_bookings_screen.dart b/lib/features/client/screens/my_bookings_screen.dart index d750277..bf0b45c 100644 --- a/lib/features/client/screens/my_bookings_screen.dart +++ b/lib/features/client/screens/my_bookings_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/providers/auth_provider.dart'; +import 'client_shell.dart'; class MyBookingsScreen extends ConsumerWidget { const MyBookingsScreen({super.key}); @@ -11,7 +12,7 @@ class MyBookingsScreen extends ConsumerWidget { final auth = ref.watch(authProvider); if (auth.status != AuthStatus.authenticated) { - return Scaffold( + return ClientShell( appBar: AppBar(title: const Text('My Bookings')), body: Center( child: Padding( @@ -19,8 +20,11 @@ class MyBookingsScreen extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.bookmark_outline, - size: 64, color: Color(0xFFD1D5DB)), + const Icon( + Icons.bookmark_outline, + size: 64, + color: Color(0xFFD1D5DB), + ), const SizedBox(height: 20), const Text( 'Sign in to view your bookings', @@ -48,7 +52,7 @@ class MyBookingsScreen extends ConsumerWidget { ); } - return Scaffold( + return ClientShell( appBar: AppBar(title: const Text('My Bookings')), body: const Center( child: Column( @@ -58,10 +62,7 @@ class MyBookingsScreen extends ConsumerWidget { SizedBox(height: 16), Text( 'No bookings yet', - style: TextStyle( - fontSize: 16, - color: Color(0xFF6B7280), - ), + style: TextStyle(fontSize: 16, color: Color(0xFF6B7280)), ), ], ), diff --git a/lib/features/dashboard/providers/dashboard_providers.dart b/lib/features/dashboard/providers/dashboard_providers.dart index e029bcc..544a9c2 100644 --- a/lib/features/dashboard/providers/dashboard_providers.dart +++ b/lib/features/dashboard/providers/dashboard_providers.dart @@ -3,20 +3,48 @@ import '../../../core/models/analytics.dart'; import '../../../core/models/vehicle.dart'; import '../../../core/models/reservation.dart'; import '../../../core/models/customer.dart'; +import '../../../core/models/notification.dart'; +import '../../../core/models/offer.dart'; +import '../../../core/models/settings.dart'; +import '../../../core/models/report.dart'; import '../../../core/services/analytics_service.dart'; import '../../../core/services/vehicle_service.dart'; import '../../../core/services/reservation_service.dart'; import '../../../core/services/customer_service.dart'; +import '../../../core/services/notification_service.dart'; +import '../../../core/services/offer_service.dart'; +import '../../../core/services/settings_service.dart'; +import '../../../core/services/payment_service.dart'; +import '../../../core/services/team_service.dart'; +import '../../../core/services/contract_service.dart'; + +// ─── Service providers ──────────────────────────────────────── final analyticsServiceProvider = Provider((_) => AnalyticsService()); final vehicleServiceProvider = Provider((_) => VehicleService()); final reservationServiceProvider = Provider((_) => ReservationService()); final customerServiceProvider = Provider((_) => CustomerService()); +final notificationServiceProvider = Provider((_) => NotificationService()); +final offerServiceProvider = Provider((_) => OfferService()); +final settingsServiceProvider = Provider((_) => SettingsService()); + +// ─── Analytics ──────────────────────────────────────────────── final dashboardMetricsProvider = FutureProvider((ref) async { return ref.read(analyticsServiceProvider).getDashboard(); }); +final reportProvider = + FutureProvider.family>((ref, params) async { + return ref.read(analyticsServiceProvider).getReport( + startDate: params['startDate']!, + endDate: params['endDate']!, + status: params['status'], + ); +}); + +// ─── Vehicles ───────────────────────────────────────────────── + final vehiclesProvider = FutureProvider.family>( (ref, params) async { return ref.read(vehicleServiceProvider).getVehicles( @@ -32,6 +60,21 @@ final vehicleDetailProvider = FutureProvider.family((ref, id) { return ref.read(vehicleServiceProvider).getVehicle(id); }); +final maintenanceProvider = + FutureProvider.family, String>((ref, vehicleId) { + return ref.read(vehicleServiceProvider).getMaintenance(vehicleId); +}); + +typedef CalendarKey = ({String vehicleId, int year, int month}); + +final calendarProvider = + FutureProvider.family, CalendarKey>((ref, key) { + return ref.read(vehicleServiceProvider) + .getCalendar(key.vehicleId, key.year, key.month); +}); + +// ─── Reservations ───────────────────────────────────────────── + final reservationsProvider = FutureProvider.family>( (ref, params) async { @@ -39,7 +82,9 @@ final reservationsProvider = page: params['page'] as int? ?? 1, pageSize: params['pageSize'] as int? ?? 20, status: params['status'] as String?, + source: params['source'] as String?, search: params['search'] as String?, + customerId: params['customerId'] as String?, ); }, ); @@ -49,6 +94,13 @@ final reservationDetailProvider = return ref.read(reservationServiceProvider).getReservation(id); }); +final inspectionsProvider = + FutureProvider.family, String>((ref, id) { + return ref.read(reservationServiceProvider).getInspections(id); +}); + +// ─── Customers ──────────────────────────────────────────────── + final customersProvider = FutureProvider.family>( (ref, params) async { @@ -64,3 +116,57 @@ final customerDetailProvider = FutureProvider.family((ref, id) { return ref.read(customerServiceProvider).getCustomer(id); }); + +// ─── Notifications ──────────────────────────────────────────── + +final notificationsProvider = + FutureProvider>((ref) async { + return ref.read(notificationServiceProvider).getNotifications(); +}); + +final unreadCountProvider = FutureProvider((ref) async { + return ref.read(notificationServiceProvider).getUnreadCount(); +}); + +// ─── Offers ─────────────────────────────────────────────────── + +final offersProvider = FutureProvider>((ref) async { + return ref.read(offerServiceProvider).getOffers(); +}); + +final offerDetailProvider = + FutureProvider.family((ref, id) { + return ref.read(offerServiceProvider).getOffer(id); +}); + +// ─── Settings ───────────────────────────────────────────────── + +final brandSettingsProvider = FutureProvider((ref) async { + return ref.read(settingsServiceProvider).getBrand(); +}); + +// ─── Payments ───────────────────────────────────────────────── + +final paymentServiceProvider = Provider((_) => PaymentService()); + +final reservationPaymentsProvider = + FutureProvider.family, String>((ref, reservationId) { + return ref.read(paymentServiceProvider).getPayments(reservationId); +}); + +// ─── Team ───────────────────────────────────────────────────── + +final teamServiceProvider = Provider((_) => TeamService()); + +final teamProvider = FutureProvider>((ref) { + return ref.read(teamServiceProvider).getMembers(); +}); + +// ─── Contracts ──────────────────────────────────────────────── + +final contractServiceProvider = Provider((_) => ContractService()); + +final contractProvider = + FutureProvider.family((ref, reservationId) { + return ref.read(contractServiceProvider).getContract(reservationId); +}); diff --git a/lib/features/dashboard/screens/contract_screen.dart b/lib/features/dashboard/screens/contract_screen.dart new file mode 100644 index 0000000..e79180a --- /dev/null +++ b/lib/features/dashboard/screens/contract_screen.dart @@ -0,0 +1,665 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../providers/dashboard_providers.dart'; +import '../../../core/services/contract_service.dart'; +import '../../../widgets/error_view.dart'; +import '../../../widgets/status_badge.dart'; + +class ContractScreen extends ConsumerWidget { + final String reservationId; + + const ContractScreen({super.key, required this.reservationId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(contractProvider(reservationId)); + + return Scaffold( + appBar: AppBar( + title: const Text('Rental Contract'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => ref.invalidate(contractProvider(reservationId)), + ), + ], + ), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Could not load contract', + onRetry: () => ref.invalidate(contractProvider(reservationId)), + ), + data: (c) => _ContractView(contract: c), + ), + ); + } +} + +class _ContractView extends StatelessWidget { + final ContractPayload contract; + + const _ContractView({required this.contract}); + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + final fmtMoney = + NumberFormat.currency(symbol: '\$', decimalDigits: 2); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Header ────────────────────────────────────────────── + _Section( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (contract.contractNumber != null) + Text( + 'Contract ${contract.contractNumber}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF111928), + ), + ), + if (contract.invoiceNumber != null) + Text( + 'Invoice ${contract.invoiceNumber}', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF6B7280)), + ), + ], + ), + ), + StatusBadge(status: contract.status), + ], + ), + const SizedBox(height: 8), + Text( + 'Generated ${fmt.format(contract.generatedAt)}', + style: const TextStyle( + fontSize: 12, color: Color(0xFF9CA3AF)), + ), + if (contract.notes != null && contract.notes!.isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.notes_outlined, + size: 14, color: Color(0xFF92400E)), + const SizedBox(width: 6), + Expanded( + child: Text( + contract.notes!, + style: const TextStyle( + fontSize: 12, color: Color(0xFF92400E)), + ), + ), + ], + ), + ), + ], + ], + ), + ), + const SizedBox(height: 12), + + // ── Rental Period ──────────────────────────────────────── + _Section( + title: 'Rental Period', + icon: Icons.calendar_today_outlined, + child: Column( + children: [ + _Row2( + left: _LabelValue( + label: 'Check-out', + value: fmt.format(contract.startDate)), + right: _LabelValue( + label: 'Check-in', + value: fmt.format(contract.endDate)), + ), + const SizedBox(height: 10), + _Row2( + left: _LabelValue( + label: 'Duration', + value: + '${contract.totalDays} day${contract.totalDays == 1 ? '' : 's'}'), + right: _LabelValue( + label: 'Payment', + value: _paymentLabel(contract.paymentStatus)), + ), + if (contract.pickupLocation != null || + contract.returnLocation != null) ...[ + const SizedBox(height: 10), + if (contract.pickupLocation != null) + _LabelValue( + label: 'Pick-up location', + value: contract.pickupLocation!), + if (contract.returnLocation != null) ...[ + const SizedBox(height: 6), + _LabelValue( + label: 'Return location', + value: contract.returnLocation!), + ], + ], + ], + ), + ), + const SizedBox(height: 12), + + // ── Vehicle ────────────────────────────────────────────── + _Section( + title: 'Vehicle', + icon: Icons.directions_car_outlined, + child: Column( + children: [ + _Row2( + left: _LabelValue( + label: 'Vehicle', + value: contract.vehicle.displayName), + right: _LabelValue( + label: 'Plate', + value: contract.vehicle.licensePlate), + ), + const SizedBox(height: 10), + _Row2( + left: _LabelValue( + label: 'Category', + value: contract.vehicle.category), + right: _LabelValue( + label: 'Color', + value: contract.vehicle.color ?? '—'), + ), + if (contract.vehicle.vin != null) ...[ + const SizedBox(height: 10), + _LabelValue( + label: 'VIN', value: contract.vehicle.vin!), + ], + ], + ), + ), + const SizedBox(height: 12), + + // ── Customer ───────────────────────────────────────────── + _Section( + title: 'Customer', + icon: Icons.person_outline, + child: Column( + children: [ + _Row2( + left: _LabelValue( + label: 'Name', + value: contract.driver.fullName), + right: _LabelValue( + label: 'Email', value: contract.driver.email), + ), + if (contract.driver.phone != null) ...[ + const SizedBox(height: 10), + _LabelValue( + label: 'Phone', value: contract.driver.phone!), + ], + if (contract.driver.driverLicense != null) ...[ + const SizedBox(height: 10), + _Row2( + left: _LabelValue( + label: 'License #', + value: contract.driver.driverLicense!), + right: _LabelValue( + label: 'Country', + value: + contract.driver.licenseCountry ?? '—'), + ), + ], + if (contract.driver.licenseExpiry != null) ...[ + const SizedBox(height: 10), + _LabelValue( + label: 'License expiry', + value: contract.driver.licenseExpiry!), + ], + ], + ), + ), + const SizedBox(height: 12), + + // ── Company ────────────────────────────────────────────── + _Section( + title: 'Rental Company', + icon: Icons.business_outlined, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + contract.company.name, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF111928)), + ), + if (contract.company.address != null) ...[ + const SizedBox(height: 4), + Text( + contract.company.address!, + style: const TextStyle( + fontSize: 13, color: Color(0xFF6B7280)), + ), + ], + if (contract.company.email != null || + contract.company.phone != null) ...[ + const SizedBox(height: 8), + _Row2( + left: _LabelValue( + label: 'Email', + value: contract.company.email ?? '—'), + right: _LabelValue( + label: 'Phone', + value: contract.company.phone ?? '—'), + ), + ], + ], + ), + ), + const SizedBox(height: 12), + + // ── Invoice ────────────────────────────────────────────── + _Section( + title: 'Invoice', + icon: Icons.receipt_outlined, + child: Column( + children: [ + ...contract.lineItems.map((item) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text(item.description, + style: const TextStyle( + fontSize: 13, + color: Color(0xFF374151))), + if (item.qty > 1) + Text( + '${item.qty} × ${fmtMoney.format(item.unitPrice / 100)}', + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + Text( + fmtMoney.format(item.total / 100), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF111928)), + ), + ], + ), + )), + const Divider(height: 16), + if (contract.taxTotal > 0) ...[ + _AmountRow( + label: 'Subtotal', + amount: contract.subtotal, + currency: contract.currency), + const SizedBox(height: 4), + _AmountRow( + label: 'Taxes', + amount: contract.taxTotal, + currency: contract.currency), + const Divider(height: 12), + ], + _AmountRow( + label: 'Total', + amount: contract.total, + currency: contract.currency, + bold: true, + color: const Color(0xFF1A56DB), + ), + const SizedBox(height: 4), + _AmountRow( + label: 'Amount paid', + amount: contract.amountPaid, + currency: contract.currency, + color: const Color(0xFF057A55), + ), + if (contract.balanceDue > 0) ...[ + const SizedBox(height: 4), + _AmountRow( + label: 'Balance due', + amount: contract.balanceDue, + currency: contract.currency, + bold: true, + color: const Color(0xFFE02424), + ), + ], + ], + ), + ), + + // ── Payments ───────────────────────────────────────────── + if (contract.payments.isNotEmpty) ...[ + const SizedBox(height: 12), + _Section( + title: 'Payments', + icon: Icons.payments_outlined, + child: Column( + children: contract.payments + .map((p) => _PaymentRow(payment: p)) + .toList(), + ), + ), + ], + + // ── Inspections ────────────────────────────────────────── + if (contract.checkIn != null || contract.checkOut != null) ...[ + const SizedBox(height: 12), + _Section( + title: 'Vehicle Inspections', + icon: Icons.fact_check_outlined, + child: Column( + children: [ + if (contract.checkIn != null) ...[ + _InspectionRow( + label: 'Check-out', + inspection: contract.checkIn!), + if (contract.checkOut != null) + const SizedBox(height: 10), + ], + if (contract.checkOut != null) + _InspectionRow( + label: 'Check-in', + inspection: contract.checkOut!), + ], + ), + ), + ], + + // ── Terms ──────────────────────────────────────────────── + if (contract.terms != null && contract.terms!.isNotEmpty) ...[ + const SizedBox(height: 12), + _Section( + title: 'Terms & Conditions', + icon: Icons.gavel_outlined, + child: Text( + contract.terms!, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + height: 1.5), + ), + ), + ], + + if (contract.fuelPolicy != null && + contract.fuelPolicy!.isNotEmpty) ...[ + const SizedBox(height: 12), + _Section( + title: 'Fuel Policy', + icon: Icons.local_gas_station_outlined, + child: Text( + contract.fuelPolicy!, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + height: 1.5), + ), + ), + ], + + const SizedBox(height: 24), + ], + ); + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +class _Section extends StatelessWidget { + final String? title; + final IconData? icon; + final Widget child; + + const _Section({this.title, this.icon, required this.child}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (title != null) ...[ + Row( + children: [ + if (icon != null) ...[ + Icon(icon, size: 16, color: const Color(0xFF6B7280)), + const SizedBox(width: 6), + ], + Text( + title!, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: Color(0xFF374151), + ), + ), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + ], + child, + ], + ), + ), + ); + } +} + +class _LabelValue extends StatelessWidget { + final String label; + final String value; + + const _LabelValue({required this.label, required this.value}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 10, color: Color(0xFF9CA3AF))), + Text(value, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF111928))), + ], + ); +} + +class _Row2 extends StatelessWidget { + final Widget left; + final Widget right; + + const _Row2({required this.left, required this.right}); + + @override + Widget build(BuildContext context) => Row( + children: [ + Expanded(child: left), + const SizedBox(width: 8), + Expanded(child: right), + ], + ); +} + +class _AmountRow extends StatelessWidget { + final String label; + final int amount; + final String currency; + final bool bold; + final Color? color; + + const _AmountRow({ + required this.label, + required this.amount, + required this.currency, + this.bold = false, + this.color, + }); + + @override + Widget build(BuildContext context) { + final fmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2); + final style = TextStyle( + fontWeight: bold ? FontWeight.bold : FontWeight.normal, + color: color ?? const Color(0xFF374151), + fontSize: bold ? 15 : 13, + ); + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: style), + Text(fmt.format(amount / 100), style: style), + ], + ); + } +} + +class _PaymentRow extends StatelessWidget { + final ContractPayment payment; + + const _PaymentRow({required this.payment}); + + @override + Widget build(BuildContext context) { + final fmt = NumberFormat.currency(symbol: '\$', decimalDigits: 2); + final fmtDate = DateFormat('MMM d, yyyy'); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + payment.paymentMethod ?? payment.type, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF374151)), + ), + if (payment.paidAt != null) + Text( + fmtDate.format(payment.paidAt!), + style: const TextStyle( + fontSize: 11, color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + Text( + fmt.format(payment.amount / 100), + style: TextStyle( + fontWeight: FontWeight.w600, + color: payment.status == 'PAID' + ? const Color(0xFF057A55) + : const Color(0xFF374151), + ), + ), + ], + ), + ); + } +} + +class _InspectionRow extends StatelessWidget { + final String label; + final ContractInspection inspection; + + const _InspectionRow( + {required this.label, required this.inspection}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + color: Color(0xFF374151)), + ), + const SizedBox(height: 6), + Row( + children: [ + if (inspection.mileage != null) + Expanded( + child: _LabelValue( + label: 'Mileage', + value: '${inspection.mileage} km'), + ), + if (inspection.fuelLevel != null) + Expanded( + child: _LabelValue( + label: 'Fuel', value: inspection.fuelLevel!), + ), + ], + ), + if (inspection.generalCondition != null) ...[ + const SizedBox(height: 6), + _LabelValue( + label: 'Condition', + value: inspection.generalCondition!), + ], + if (inspection.employeeNotes != null && + inspection.employeeNotes!.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + inspection.employeeNotes!, + style: const TextStyle( + fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ], + ); + } +} + +String _paymentLabel(String status) { + switch (status.toUpperCase()) { + case 'PAID': + return 'Paid'; + case 'PARTIAL': + return 'Partial'; + case 'PENDING': + return 'Pending'; + default: + return status[0] + status.substring(1).toLowerCase(); + } +} diff --git a/lib/features/dashboard/screens/contracts_screen.dart b/lib/features/dashboard/screens/contracts_screen.dart new file mode 100644 index 0000000..f6b2ced --- /dev/null +++ b/lib/features/dashboard/screens/contracts_screen.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../providers/dashboard_providers.dart'; +import '../../../core/models/reservation.dart'; + +class ContractsScreen extends ConsumerStatefulWidget { + const ContractsScreen({super.key}); + + @override + ConsumerState createState() => _ContractsScreenState(); +} + +class _ContractsScreenState extends ConsumerState { + final _searchCtrl = TextEditingController(); + String _search = ''; + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final params = { + 'page': 1, + 'pageSize': 100, + 'status': 'CONFIRMED,ACTIVE,COMPLETED,CLOSED', + if (_search.isNotEmpty) 'search': _search, + }; + + final asyncData = ref.watch(reservationsProvider(params)); + + return Scaffold( + appBar: AppBar( + title: const Text('Contracts'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(56), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: TextField( + controller: _searchCtrl, + decoration: InputDecoration( + hintText: 'Search by customer, vehicle, contract…', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _search.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 20), + onPressed: () { + _searchCtrl.clear(); + setState(() => _search = ''); + }, + ) + : null, + isDense: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: Theme.of(context).colorScheme.surface, + ), + onChanged: (v) => setState(() => _search = v), + ), + ), + ), + ), + body: asyncData.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Error: $e')), + data: (response) { + final contracts = response.data + .where((r) => r.contractNumber != null) + .toList(); + + if (contracts.isEmpty) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.description_outlined, + size: 64, color: Color(0xFF9CA3AF)), + SizedBox(height: 12), + Text( + 'No contracts yet', + style: TextStyle( + fontSize: 16, + color: Color(0xFF6B7280), + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () async => + ref.invalidate(reservationsProvider(params)), + child: ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: contracts.length, + separatorBuilder: (context, index) => const SizedBox(height: 8), + itemBuilder: (_, i) => _ContractCard(reservation: contracts[i]), + ), + ); + }, + ), + ); + } +} + +class _ContractCard extends StatelessWidget { + final Reservation reservation; + + const _ContractCard({required this.reservation}); + + @override + Widget build(BuildContext context) { + final r = reservation; + final vehicle = r.vehicle; + final customer = r.customer; + + return Card( + margin: EdgeInsets.zero, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + child: InkWell( + borderRadius: BorderRadius.circular(10), + onTap: () => + context.push('/dashboard/reservations/${r.id}/contract'), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.description_outlined, + size: 18, color: Color(0xFF1A56DB)), + const SizedBox(width: 6), + Text( + r.contractNumber!, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 15, + color: Color(0xFF1A56DB), + ), + ), + const Spacer(), + _StatusChip(status: r.status), + ], + ), + if (r.invoiceNumber != null) ...[ + const SizedBox(height: 4), + Text( + 'Invoice: ${r.invoiceNumber}', + style: const TextStyle( + fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + const SizedBox(height: 10), + Row( + children: [ + const Icon(Icons.person_outline, + size: 16, color: Color(0xFF6B7280)), + const SizedBox(width: 4), + Expanded( + child: Text( + customer?.fullName ?? '—', + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + const Icon(Icons.directions_car_outlined, + size: 16, color: Color(0xFF6B7280)), + const SizedBox(width: 4), + Expanded( + child: Text( + vehicle?.displayName ?? '—', + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + const SizedBox(height: 10), + Row( + children: [ + const Icon(Icons.calendar_today_outlined, + size: 14, color: Color(0xFF9CA3AF)), + const SizedBox(width: 4), + Text( + '${_fmt(r.startDate)} → ${_fmt(r.endDate)}', + style: const TextStyle( + fontSize: 12, color: Color(0xFF6B7280)), + ), + const Spacer(), + Text( + '${(r.totalAmount / 100).toStringAsFixed(2)} DA', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + String _fmt(DateTime d) => + '${d.day.toString().padLeft(2, '0')}/${d.month.toString().padLeft(2, '0')}/${d.year}'; +} + +class _StatusChip extends StatelessWidget { + final String status; + + const _StatusChip({required this.status}); + + @override + Widget build(BuildContext context) { + final (label, bg, fg) = switch (status) { + 'CONFIRMED' => ('Confirmed', const Color(0xFFEBF5FB), const Color(0xFF1A56DB)), + 'ACTIVE' => ('Active', const Color(0xFFECFDF5), const Color(0xFF059669)), + 'COMPLETED' => ('Completed', const Color(0xFFF0FDF4), const Color(0xFF16A34A)), + 'CLOSED' => ('Closed', const Color(0xFFF9FAFB), const Color(0xFF6B7280)), + _ => (status, const Color(0xFFF9FAFB), const Color(0xFF6B7280)), + }; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(6), + ), + child: Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: fg, + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/customer_detail_screen.dart b/lib/features/dashboard/screens/customer_detail_screen.dart index aecadf8..bf2b52c 100644 --- a/lib/features/dashboard/screens/customer_detail_screen.dart +++ b/lib/features/dashboard/screens/customer_detail_screen.dart @@ -1,9 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../providers/dashboard_providers.dart'; import '../../../widgets/status_badge.dart'; import '../../../widgets/error_view.dart'; +import '../../../widgets/reservation_card.dart'; +import 'customer_form_screen.dart'; class CustomerDetailScreen extends ConsumerWidget { final String id; @@ -13,6 +16,9 @@ class CustomerDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final async = ref.watch(customerDetailProvider(id)); + final reservationsAsync = ref.watch( + reservationsProvider({'customerId': id, 'page': 1, 'pageSize': 10}), + ); return async.when( loading: () => @@ -27,7 +33,62 @@ class CustomerDetailScreen extends ConsumerWidget { data: (customer) { final fmt = DateFormat('MMM d, yyyy'); return Scaffold( - appBar: AppBar(title: Text(customer.fullName)), + appBar: AppBar( + title: Text(customer.fullName), + actions: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + CustomerFormScreen(customer: customer)), + ); + if (result == true) { + ref.invalidate(customerDetailProvider(id)); + } + }, + ), + PopupMenuButton( + onSelected: (v) async { + if (v == 'flag') { + await _showFlagDialog(context, ref, customer.isFlagged); + } else if (v == 'approve') { + await _showApproveLicenseDialog(context, ref); + } + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'flag', + child: Row( + children: [ + Icon( + customer.isFlagged ? Icons.flag_outlined : Icons.flag, + size: 18, + color: const Color(0xFFE02424), + ), + const SizedBox(width: 8), + Text( + customer.isFlagged ? 'Remove Flag' : 'Flag Customer', + style: const TextStyle(color: Color(0xFFE02424)), + ), + ], + ), + ), + const PopupMenuItem( + value: 'approve', + child: Row( + children: [ + Icon(Icons.badge_outlined, size: 18), + SizedBox(width: 8), + Text('Review License'), + ], + ), + ), + ], + ), + ], + ), body: ListView( padding: const EdgeInsets.all(16), children: [ @@ -128,12 +189,213 @@ class CustomerDetailScreen extends ConsumerWidget { ), ), ), + const SizedBox(height: 16), + if (customer.licenseStatus == 'PENDING') + _ActionButton( + label: 'Approve License', + color: const Color(0xFF0E9F6E), + icon: Icons.check_circle_outline, + onTap: () => _showApproveLicenseDialog(context, ref), + ), + if (customer.licenseStatus == 'PENDING') + _ActionButton( + label: 'Reject License', + color: const Color(0xFFE02424), + icon: Icons.cancel_outlined, + onTap: () => _showRejectLicenseDialog(context, ref), + ), + if (!customer.isFlagged) + _ActionButton( + label: 'Flag Customer', + color: const Color(0xFFE02424), + icon: Icons.flag_outlined, + onTap: () => _showFlagDialog(context, ref, false), + ) + else + _ActionButton( + label: 'Remove Flag', + color: const Color(0xFF6B7280), + icon: Icons.flag_outlined, + onTap: () => _showFlagDialog(context, ref, true), + ), + const SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Reservations', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + color: Color(0xFF111928), + ), + ), + TextButton( + onPressed: () => + context.push('/dashboard/reservations'), + child: const Text('View all'), + ), + ], + ), + const SizedBox(height: 8), + reservationsAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (_, _) => const SizedBox.shrink(), + data: (res) => res.data.isEmpty + ? const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text( + 'No reservations yet', + style: TextStyle(color: Color(0xFF9CA3AF)), + ), + ), + ) + : Column( + children: res.data + .map( + (r) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: ReservationCard( + reservation: r, + onTap: () => context.push( + '/dashboard/reservations/${r.id}', + ), + ), + ), + ) + .toList(), + ), + ), ], ), ); }, ); } + + Future _showFlagDialog( + BuildContext context, WidgetRef ref, bool isFlagged) async { + if (isFlagged) { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Remove Flag'), + content: const Text('Remove the flag from this customer?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Remove Flag')), + ], + ), + ); + if (confirmed == true && context.mounted) { + await ref.read(customerServiceProvider).unflagCustomer(id); + ref.invalidate(customerDetailProvider(id)); + } + } else { + final ctrl = TextEditingController(); + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Flag Customer'), + content: TextField( + controller: ctrl, + decoration: const InputDecoration(labelText: 'Reason'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel')), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE02424)), + onPressed: () => Navigator.pop(context, true), + child: const Text('Flag'), + ), + ], + ), + ); + if (confirmed == true && ctrl.text.isNotEmpty && context.mounted) { + await ref + .read(customerServiceProvider) + .flagCustomer(id, ctrl.text.trim()); + ref.invalidate(customerDetailProvider(id)); + } + } + } + + Future _showApproveLicenseDialog( + BuildContext context, WidgetRef ref) async { + final noteCtrl = TextEditingController(); + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Approve License'), + content: TextField( + controller: noteCtrl, + decoration: const InputDecoration(labelText: 'Note (optional)'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel')), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0E9F6E)), + onPressed: () => Navigator.pop(context, true), + child: const Text('Approve'), + ), + ], + ), + ); + if (confirmed == true && context.mounted) { + await ref.read(customerServiceProvider).approveLicense( + id, + 'APPROVED', + note: noteCtrl.text.isNotEmpty ? noteCtrl.text : null, + ); + ref.invalidate(customerDetailProvider(id)); + } + } + + Future _showRejectLicenseDialog( + BuildContext context, WidgetRef ref) async { + final noteCtrl = TextEditingController(); + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Reject License'), + content: TextField( + controller: noteCtrl, + decoration: const InputDecoration(labelText: 'Reason'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel')), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE02424)), + onPressed: () => Navigator.pop(context, true), + child: const Text('Reject'), + ), + ], + ), + ); + if (confirmed == true && context.mounted) { + await ref.read(customerServiceProvider).approveLicense( + id, + 'REJECTED', + note: noteCtrl.text.isNotEmpty ? noteCtrl.text : null, + ); + ref.invalidate(customerDetailProvider(id)); + } + } } class _InfoRow extends StatelessWidget { @@ -151,11 +413,40 @@ class _InfoRow extends StatelessWidget { Icon(icon, size: 18, color: const Color(0xFF6B7280)), const SizedBox(width: 10), Expanded( - child: Text(value, - style: const TextStyle(fontSize: 14)), + child: Text(value, style: const TextStyle(fontSize: 14)), ), ], ), ); } } + +class _ActionButton extends StatelessWidget { + final String label; + final Color color; + final IconData icon; + final VoidCallback onTap; + + const _ActionButton({ + required this.label, + required this.color, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: ElevatedButton.icon( + style: ElevatedButton.styleFrom( + backgroundColor: color, + minimumSize: const Size.fromHeight(48), + ), + icon: Icon(icon), + label: Text(label), + onPressed: onTap, + ), + ); + } +} diff --git a/lib/features/dashboard/screens/customer_form_screen.dart b/lib/features/dashboard/screens/customer_form_screen.dart new file mode 100644 index 0000000..3248411 --- /dev/null +++ b/lib/features/dashboard/screens/customer_form_screen.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../../../core/models/customer.dart'; +import '../providers/dashboard_providers.dart'; + +class CustomerFormScreen extends ConsumerStatefulWidget { + final Customer? customer; + + const CustomerFormScreen({super.key, this.customer}); + + @override + ConsumerState createState() => _CustomerFormScreenState(); +} + +class _CustomerFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + bool _loading = false; + + final _firstName = TextEditingController(); + final _lastName = TextEditingController(); + final _email = TextEditingController(); + final _phone = TextEditingController(); + final _nationality = TextEditingController(); + DateTime? _dob; + + @override + void initState() { + super.initState(); + final c = widget.customer; + if (c != null) { + _firstName.text = c.firstName; + _lastName.text = c.lastName; + _email.text = c.email ?? ''; + _phone.text = c.phone ?? ''; + _nationality.text = c.nationality ?? ''; + _dob = c.dateOfBirth; + } + } + + @override + void dispose() { + for (final c in [_firstName, _lastName, _email, _phone, _nationality]) { + c.dispose(); + } + super.dispose(); + } + + Future _pickDob() async { + final picked = await showDatePicker( + context: context, + initialDate: _dob ?? DateTime(1990), + firstDate: DateTime(1920), + lastDate: DateTime.now().subtract(const Duration(days: 365 * 16)), + ); + if (picked != null) setState(() => _dob = picked); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _loading = true); + try { + final data = { + 'firstName': _firstName.text.trim(), + 'lastName': _lastName.text.trim(), + if (_email.text.isNotEmpty) 'email': _email.text.trim(), + if (_phone.text.isNotEmpty) 'phone': _phone.text.trim(), + if (_nationality.text.isNotEmpty) + 'nationality': _nationality.text.trim(), + if (_dob != null) + 'dateOfBirth': + '${_dob!.toIso8601String().substring(0, 10)}T00:00:00.000Z', + }; + final svc = ref.read(customerServiceProvider); + if (widget.customer == null) { + await svc.createCustomer(data); + } else { + await svc.updateCustomer(widget.customer!.id, data); + } + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.customer != null; + return Scaffold( + appBar: AppBar( + title: Text(isEdit ? 'Edit Customer' : 'New Customer'), + actions: [ + if (_loading) + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)), + ) + else + TextButton( + onPressed: _submit, + child: const Text('Save'), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + _field('First Name', _firstName, required: true), + _field('Last Name', _lastName, required: true), + _field('Email', _email, + inputType: TextInputType.emailAddress), + _field('Phone', _phone, inputType: TextInputType.phone), + _field('Nationality', _nationality), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: InkWell( + onTap: _pickDob, + child: InputDecorator( + decoration: + const InputDecoration(labelText: 'Date of Birth'), + child: Text( + _dob != null + ? DateFormat('MMM d, yyyy').format(_dob!) + : 'Tap to select', + style: TextStyle( + color: _dob != null + ? null + : const Color(0xFF9CA3AF), + ), + ), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _loading ? null : _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: Text(isEdit ? 'Save Changes' : 'Create Customer'), + ), + ], + ), + ), + ); + } + + Widget _field(String label, TextEditingController ctrl, + {bool required = false, TextInputType? inputType}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: TextFormField( + controller: ctrl, + keyboardType: inputType, + decoration: InputDecoration(labelText: label), + validator: required + ? (v) => v == null || v.isEmpty ? 'Required' : null + : null, + ), + ); + } +} diff --git a/lib/features/dashboard/screens/customers_screen.dart b/lib/features/dashboard/screens/customers_screen.dart index 4498c9a..45562f8 100644 --- a/lib/features/dashboard/screens/customers_screen.dart +++ b/lib/features/dashboard/screens/customers_screen.dart @@ -5,6 +5,8 @@ import '../providers/dashboard_providers.dart'; import '../../../widgets/loading_list.dart'; import '../../../widgets/error_view.dart'; import '../../../widgets/status_badge.dart'; +import 'dashboard_shell.dart'; +import 'customer_form_screen.dart'; class CustomersScreen extends ConsumerStatefulWidget { const CustomersScreen({super.key}); @@ -24,16 +26,25 @@ class _CustomersScreenState extends ConsumerState { } Map get _params => { - 'page': 1, - 'pageSize': 50, - if (_search.isNotEmpty) 'search': _search, - }; + 'page': 1, + 'pageSize': 50, + if (_search.isNotEmpty) 'search': _search, + }; @override Widget build(BuildContext context) { final customersAsync = ref.watch(customersProvider(_params)); - return Scaffold( + return DashboardShell( + floatingActionButton: FloatingActionButton( + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const CustomerFormScreen()), + ); + if (result == true) ref.invalidate(customersProvider(_params)); + }, + child: const Icon(Icons.person_add_outlined), + ), appBar: AppBar( title: const Text('Customers'), bottom: PreferredSize( @@ -60,8 +71,11 @@ class _CustomersScreenState extends ConsumerState { ), data: (res) => res.data.isEmpty ? const Center( - child: Text('No customers found', - style: TextStyle(color: Color(0xFF9CA3AF)))) + child: Text( + 'No customers found', + style: TextStyle(color: Color(0xFF9CA3AF)), + ), + ) : RefreshIndicator( onRefresh: () async => ref.invalidate(customersProvider(_params)), @@ -98,8 +112,7 @@ class _CustomersScreenState extends ConsumerState { const SizedBox(width: 12), Expanded( child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ @@ -111,9 +124,11 @@ class _CustomersScreenState extends ConsumerState { ), if (c.isFlagged) ...[ const SizedBox(width: 6), - const Icon(Icons.flag, - size: 14, - color: Color(0xFFE02424)), + const Icon( + Icons.flag, + size: 14, + color: Color(0xFFE02424), + ), ], ], ), diff --git a/lib/features/dashboard/screens/dashboard_shell.dart b/lib/features/dashboard/screens/dashboard_shell.dart index 3ee8b81..911941e 100644 --- a/lib/features/dashboard/screens/dashboard_shell.dart +++ b/lib/features/dashboard/screens/dashboard_shell.dart @@ -2,11 +2,21 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../core/providers/auth_provider.dart'; +import '../providers/dashboard_providers.dart'; class DashboardShell extends ConsumerWidget { - final Widget child; + final PreferredSizeWidget? appBar; + final Widget body; + final Widget? floatingActionButton; + final Color? backgroundColor; - const DashboardShell({super.key, required this.child}); + const DashboardShell({ + super.key, + this.appBar, + required this.body, + this.floatingActionButton, + this.backgroundColor, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -19,8 +29,11 @@ class DashboardShell extends ConsumerWidget { if (location.startsWith('/dashboard/customers')) currentIndex = 3; return Scaffold( - drawer: _Drawer(employee: employee, ref: ref), - body: child, + appBar: appBar, + backgroundColor: backgroundColor, + drawer: _Drawer(employee: employee), + body: body, + floatingActionButton: floatingActionButton, bottomNavigationBar: NavigationBar( selectedIndex: currentIndex, onDestinationSelected: (i) { @@ -62,14 +75,16 @@ class DashboardShell extends ConsumerWidget { } } -class _Drawer extends StatelessWidget { +class _Drawer extends ConsumerWidget { final dynamic employee; - final WidgetRef ref; - const _Drawer({required this.employee, required this.ref}); + const _Drawer({required this.employee}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final unreadAsync = ref.watch(unreadCountProvider); + final unread = unreadAsync.valueOrNull ?? 0; + return Drawer( child: SafeArea( child: Column( @@ -84,7 +99,9 @@ class _Drawer extends StatelessWidget { child: Text( employee?.firstName.substring(0, 1).toUpperCase() ?? 'U', style: const TextStyle( - color: Colors.white, fontWeight: FontWeight.bold), + color: Colors.white, + fontWeight: FontWeight.bold, + ), ), ), const SizedBox(width: 12), @@ -99,7 +116,9 @@ class _Drawer extends StatelessWidget { Text( employee?.role ?? '', style: const TextStyle( - fontSize: 12, color: Color(0xFF6B7280)), + fontSize: 12, + color: Color(0xFF6B7280), + ), ), ], ), @@ -108,14 +127,115 @@ class _Drawer extends StatelessWidget { ), ), const Divider(), - const Spacer(), + Expanded( + child: ListView( + padding: EdgeInsets.zero, + children: [ + _NavItem( + icon: Icons.dashboard_outlined, + label: 'Dashboard', + onTap: () { + Navigator.pop(context); + context.go('/dashboard'); + }, + ), + _NavItem( + icon: Icons.directions_car_outlined, + label: 'Vehicles', + onTap: () { + Navigator.pop(context); + context.go('/dashboard/vehicles'); + }, + ), + _NavItem( + icon: Icons.event_note_outlined, + label: 'Reservations', + onTap: () { + Navigator.pop(context); + context.go('/dashboard/reservations'); + }, + ), + _NavItem( + icon: Icons.people_outlined, + label: 'Customers', + onTap: () { + Navigator.pop(context); + context.go('/dashboard/customers'); + }, + ), + const Divider(), + _NavItem( + icon: Icons.public_outlined, + label: 'Online Requests', + onTap: () { + Navigator.pop(context); + context.push('/dashboard/online-reservations'); + }, + ), + _NavItem( + icon: Icons.description_outlined, + label: 'Contracts', + onTap: () { + Navigator.pop(context); + context.push('/dashboard/contracts'); + }, + ), + _NavItem( + icon: Icons.notifications_outlined, + label: 'Notifications', + badge: unread > 0 ? '$unread' : null, + onTap: () { + Navigator.pop(context); + context.push('/dashboard/notifications'); + }, + ), + _NavItem( + icon: Icons.local_offer_outlined, + label: 'Offers', + onTap: () { + Navigator.pop(context); + context.push('/dashboard/offers'); + }, + ), + _NavItem( + icon: Icons.bar_chart_outlined, + label: 'Reports', + onTap: () { + Navigator.pop(context); + context.push('/dashboard/reports'); + }, + ), + _NavItem( + icon: Icons.people_outline, + label: 'Team', + onTap: () { + Navigator.pop(context); + context.push('/dashboard/team'); + }, + ), + _NavItem( + icon: Icons.settings_outlined, + label: 'Settings', + onTap: () { + Navigator.pop(context); + context.push('/dashboard/settings'); + }, + ), + ], + ), + ), + const Divider(), ListTile( leading: const Icon(Icons.logout, color: Color(0xFFE02424)), - title: const Text('Sign Out', - style: TextStyle(color: Color(0xFFE02424))), + title: const Text( + 'Sign Out', + style: TextStyle(color: Color(0xFFE02424)), + ), onTap: () async { + final router = GoRouter.of(context); Navigator.of(context).pop(); await ref.read(authProvider.notifier).logout(); + router.go('/landing'); }, ), const SizedBox(height: 8), @@ -125,3 +245,28 @@ class _Drawer extends StatelessWidget { ); } } + +class _NavItem extends StatelessWidget { + final IconData icon; + final String label; + final String? badge; + final VoidCallback onTap; + + const _NavItem({ + required this.icon, + required this.label, + this.badge, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + leading: badge != null + ? Badge(label: Text(badge!), child: Icon(icon)) + : Icon(icon), + title: Text(label), + onTap: onTap, + ); + } +} diff --git a/lib/features/dashboard/screens/home_screen.dart b/lib/features/dashboard/screens/home_screen.dart index 8dc8bb1..f966d46 100644 --- a/lib/features/dashboard/screens/home_screen.dart +++ b/lib/features/dashboard/screens/home_screen.dart @@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../../../core/providers/auth_provider.dart'; import '../providers/dashboard_providers.dart'; +import 'dashboard_shell.dart'; import '../../../widgets/stat_card.dart'; import '../../../widgets/error_view.dart'; import '../../../widgets/reservation_card.dart'; @@ -18,8 +19,18 @@ class HomeScreen extends ConsumerWidget { final recentAsync = ref.watch( reservationsProvider({'page': 1, 'pageSize': 5}), ); + final onlineAsync = ref.watch( + reservationsProvider({ + 'status': 'DRAFT', + 'source': 'MARKETPLACE', + 'page': 1, + 'pageSize': 100, + }), + ); + final pendingOnline = onlineAsync.valueOrNull?.data.length ?? 0; + final unreadCount = ref.watch(unreadCountProvider).valueOrNull ?? 0; - return Scaffold( + return DashboardShell( appBar: AppBar( title: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -30,18 +41,21 @@ class HomeScreen extends ConsumerWidget { ), Text( DateFormat('EEEE, MMMM d').format(DateTime.now()), - style: - const TextStyle(fontSize: 12, color: Color(0xFF6B7280)), + style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280)), ), ], ), actions: [ - Builder( - builder: (ctx) => IconButton( - icon: const Icon(Icons.menu), - onPressed: () => Scaffold.of(ctx).openDrawer(), - ), + IconButton( + icon: unreadCount > 0 + ? Badge( + label: Text('$unreadCount'), + child: const Icon(Icons.notifications_outlined), + ) + : const Icon(Icons.notifications_outlined), + onPressed: () => context.push('/dashboard/notifications'), ), + _AvatarMenu(employee: employee), ], ), body: RefreshIndicator( @@ -92,7 +106,7 @@ class HomeScreen extends ConsumerWidget { child: StatCard( title: 'Revenue', value: - '\$${NumberFormat.compact().format(metrics.totalRevenue)}', + '\$${NumberFormat.compact().format(metrics.totalRevenue / 100)}', icon: Icons.attach_money, color: const Color(0xFF5521B5), ), @@ -127,7 +141,8 @@ class HomeScreen extends ConsumerWidget { value: metrics.occupancyRate / 100, backgroundColor: const Color(0xFFE5E7EB), valueColor: const AlwaysStoppedAnimation( - Color(0xFF1A56DB)), + Color(0xFF1A56DB), + ), minHeight: 8, borderRadius: BorderRadius.circular(4), ), @@ -146,6 +161,44 @@ class HomeScreen extends ConsumerWidget { ], ), ), + if (pendingOnline > 0) ...[ + const SizedBox(height: 16), + InkWell( + onTap: () => context.push('/dashboard/online-reservations'), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + decoration: BoxDecoration( + color: const Color(0xFFFFF3CD), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFFF6B00)), + ), + child: Row( + children: [ + const Icon( + Icons.public, + color: Color(0xFFFF6B00), + size: 22, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '$pendingOnline online request${pendingOnline == 1 ? '' : 's'} pending approval', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF92400E), + ), + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFFFF6B00)), + ], + ), + ), + ), + ], const SizedBox(height: 24), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -168,7 +221,8 @@ class HomeScreen extends ConsumerWidget { recentAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => const ErrorView( - message: 'Could not load recent reservations'), + message: 'Could not load recent reservations', + ), data: (res) => res.data.isEmpty ? const Center( child: Padding( @@ -181,15 +235,17 @@ class HomeScreen extends ConsumerWidget { ) : Column( children: res.data - .map((r) => Padding( - padding: - const EdgeInsets.only(bottom: 10), - child: ReservationCard( - reservation: r, - onTap: () => context.push( - '/dashboard/reservations/${r.id}'), + .map( + (r) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: ReservationCard( + reservation: r, + onTap: () => context.push( + '/dashboard/reservations/${r.id}', ), - )) + ), + ), + ) .toList(), ), ), @@ -199,3 +255,103 @@ class HomeScreen extends ConsumerWidget { ); } } + +class _AvatarMenu extends ConsumerWidget { + final dynamic employee; + + const _AvatarMenu({required this.employee}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final initial = employee?.firstName.substring(0, 1).toUpperCase() ?? 'U'; + final name = employee?.fullName ?? 'Employee'; + final role = employee?.role ?? ''; + + return Padding( + padding: const EdgeInsets.only(right: 8), + child: PopupMenuButton( + onSelected: (value) async { + if (value == 'logout') { + final confirm = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Sign out'), + content: const Text('Are you sure you want to sign out?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.pop(context, true), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFE02424), + ), + child: const Text('Sign out'), + ), + ], + ), + ); + if (confirm == true) { + await ref.read(authProvider.notifier).logout(); + if (context.mounted) context.go('/landing'); + } + } + }, + offset: const Offset(0, 44), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + itemBuilder: (_) => [ + PopupMenuItem( + enabled: false, + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF111928), + fontSize: 14, + ), + ), + if (role.isNotEmpty) + Text( + role, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + ), + const SizedBox(height: 4), + const Divider(height: 1), + ], + ), + ), + const PopupMenuItem( + value: 'logout', + child: Row( + children: [ + Icon(Icons.logout, size: 18, color: Color(0xFFE02424)), + SizedBox(width: 10), + Text('Sign out', style: TextStyle(color: Color(0xFFE02424))), + ], + ), + ), + ], + child: CircleAvatar( + backgroundColor: const Color(0xFF1A56DB), + radius: 18, + child: Text( + initial, + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/new_reservation_screen.dart b/lib/features/dashboard/screens/new_reservation_screen.dart new file mode 100644 index 0000000..3c9bee7 --- /dev/null +++ b/lib/features/dashboard/screens/new_reservation_screen.dart @@ -0,0 +1,320 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../providers/dashboard_providers.dart'; + +class NewReservationScreen extends ConsumerStatefulWidget { + const NewReservationScreen({super.key}); + + @override + ConsumerState createState() => + _NewReservationScreenState(); +} + +class _NewReservationScreenState extends ConsumerState { + final _formKey = GlobalKey(); + bool _loading = false; + + String? _vehicleId; + String? _vehicleName; + String? _customerId; + String? _customerName; + DateTime? _startDate; + DateTime? _endDate; + final _pickup = TextEditingController(); + final _returnLoc = TextEditingController(); + final _notes = TextEditingController(); + final _deposit = TextEditingController(text: '0'); + + @override + void dispose() { + for (final c in [_pickup, _returnLoc, _notes, _deposit]) { + c.dispose(); + } + super.dispose(); + } + + Future _pickDateRange() async { + final range = await showDateRangePicker( + context: context, + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + initialDateRange: _startDate != null && _endDate != null + ? DateTimeRange(start: _startDate!, end: _endDate!) + : null, + ); + if (range != null) { + setState(() { + _startDate = range.start; + _endDate = range.end; + }); + } + } + + Future _pickVehicle() async { + final vehicles = await ref + .read(vehicleServiceProvider) + .getVehicles(status: 'AVAILABLE', pageSize: 100); + if (!mounted) return; + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.6, + expand: false, + builder: (_, scroll) => ListView.builder( + controller: scroll, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: vehicles.data.length, + itemBuilder: (_, i) { + final v = vehicles.data[i]; + return ListTile( + title: Text(v.displayName), + subtitle: Text(v.licensePlate), + onTap: () => Navigator.pop( + context, {'id': v.id, 'name': v.displayName}), + ); + }, + ), + ), + ); + if (result != null) { + setState(() { + _vehicleId = result['id']; + _vehicleName = result['name']; + }); + } + } + + Future _pickCustomer() async { + final customers = + await ref.read(customerServiceProvider).getCustomers(pageSize: 100); + if (!mounted) return; + final result = await showModalBottomSheet>( + context: context, + isScrollControlled: true, + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.6, + expand: false, + builder: (_, scroll) => ListView.builder( + controller: scroll, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: customers.data.length, + itemBuilder: (_, i) { + final c = customers.data[i]; + return ListTile( + title: Text(c.fullName), + subtitle: Text(c.email ?? c.phone ?? ''), + onTap: () => Navigator.pop( + context, {'id': c.id, 'name': c.fullName}), + ); + }, + ), + ), + ); + if (result != null) { + setState(() { + _customerId = result['id']; + _customerName = result['name']; + }); + } + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + if (_vehicleId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select a vehicle'))); + return; + } + if (_customerId == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select a customer'))); + return; + } + if (_startDate == null || _endDate == null) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please select dates'))); + return; + } + setState(() => _loading = true); + try { + final data = { + 'vehicleId': _vehicleId!, + 'customerId': _customerId!, + 'startDate': + '${_startDate!.toIso8601String().substring(0, 10)}T00:00:00.000Z', + 'endDate': + '${_endDate!.toIso8601String().substring(0, 10)}T00:00:00.000Z', + if (_pickup.text.isNotEmpty) 'pickupLocation': _pickup.text.trim(), + if (_returnLoc.text.isNotEmpty) + 'returnLocation': _returnLoc.text.trim(), + if (_notes.text.isNotEmpty) 'notes': _notes.text.trim(), + 'depositAmount': ((double.tryParse(_deposit.text) ?? 0) * 100).round(), + }; + await ref.read(reservationServiceProvider).createReservation(data); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + return Scaffold( + appBar: AppBar( + title: const Text('New Reservation'), + actions: [ + if (_loading) + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)), + ) + else + TextButton( + onPressed: _submit, + child: const Text('Create'), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _PickerTile( + label: 'Vehicle', + value: _vehicleName, + icon: Icons.directions_car_outlined, + onTap: _pickVehicle, + placeholder: 'Select available vehicle', + ), + const SizedBox(height: 12), + _PickerTile( + label: 'Customer', + value: _customerName, + icon: Icons.person_outlined, + onTap: _pickCustomer, + placeholder: 'Select customer', + ), + const SizedBox(height: 12), + _PickerTile( + label: 'Rental Period', + value: _startDate != null && _endDate != null + ? '${fmt.format(_startDate!)} → ${fmt.format(_endDate!)}' + : null, + icon: Icons.date_range_outlined, + onTap: _pickDateRange, + placeholder: 'Select start & end dates', + ), + const SizedBox(height: 16), + Card( + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + children: [ + _textField('Pickup Location', _pickup), + _textField('Return Location', _returnLoc), + _textField('Deposit (\$)', _deposit, + inputType: TextInputType.number), + _textField('Notes', _notes, maxLines: 3), + ], + ), + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _loading ? null : _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: const Text('Create Reservation'), + ), + ], + ), + ), + ); + } + + Widget _textField(String label, TextEditingController ctrl, + {TextInputType? inputType, int maxLines = 1}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: TextFormField( + controller: ctrl, + keyboardType: inputType, + maxLines: maxLines, + decoration: InputDecoration(labelText: label), + ), + ); + } +} + +class _PickerTile extends StatelessWidget { + final String label; + final String? value; + final String placeholder; + final IconData icon; + final VoidCallback onTap; + + const _PickerTile({ + required this.label, + required this.value, + required this.placeholder, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all(color: const Color(0xFFE5E7EB)), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon(icon, color: const Color(0xFF6B7280)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 11, color: Color(0xFF9CA3AF))), + const SizedBox(height: 2), + Text( + value ?? placeholder, + style: TextStyle( + fontWeight: + value != null ? FontWeight.w500 : FontWeight.normal, + color: value != null + ? const Color(0xFF111928) + : const Color(0xFF9CA3AF), + ), + ), + ], + ), + ), + const Icon(Icons.chevron_right, color: Color(0xFF9CA3AF)), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/notifications_screen.dart b/lib/features/dashboard/screens/notifications_screen.dart new file mode 100644 index 0000000..ef26766 --- /dev/null +++ b/lib/features/dashboard/screens/notifications_screen.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../providers/dashboard_providers.dart'; +import '../../../widgets/error_view.dart'; + +class NotificationsScreen extends ConsumerWidget { + const NotificationsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(notificationsProvider); + + return Scaffold( + appBar: AppBar( + title: const Text('Notifications'), + actions: [ + IconButton( + icon: const Icon(Icons.done_all), + tooltip: 'Mark all read', + onPressed: () async { + await ref.read(notificationServiceProvider).markAllRead(); + ref.invalidate(notificationsProvider); + ref.invalidate(unreadCountProvider); + }, + ), + ], + ), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Failed to load notifications', + onRetry: () => ref.invalidate(notificationsProvider), + ), + data: (notifications) { + if (notifications.isEmpty) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.notifications_none, + size: 64, color: Color(0xFFD1D5DB)), + SizedBox(height: 16), + Text('No notifications', + style: TextStyle(color: Color(0xFF9CA3AF))), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(notificationsProvider), + child: ListView.separated( + itemCount: notifications.length, + separatorBuilder: (_, _) => + const Divider(height: 1, indent: 72), + itemBuilder: (_, i) { + final n = notifications[i]; + return _NotificationTile( + notification: n, + onRead: () async { + if (!n.isRead) { + await ref + .read(notificationServiceProvider) + .markRead(n.id); + ref.invalidate(notificationsProvider); + ref.invalidate(unreadCountProvider); + } + }, + ); + }, + ), + ); + }, + ), + ); + } +} + +class _NotificationTile extends StatelessWidget { + final dynamic notification; + final VoidCallback onRead; + + const _NotificationTile( + {required this.notification, required this.onRead}); + + IconData _icon(String type) { + switch (type) { + case 'RESERVATION_CREATED': + case 'RESERVATION_CONFIRMED': + return Icons.event_available; + case 'RESERVATION_CANCELLED': + return Icons.event_busy; + case 'PAYMENT_RECEIVED': + return Icons.payments_outlined; + case 'LICENSE_UPLOADED': + return Icons.badge_outlined; + default: + return Icons.notifications_outlined; + } + } + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, h:mm a'); + final isRead = notification.isRead as bool; + return InkWell( + onTap: onRead, + child: Container( + color: isRead ? null : const Color(0xFFF0F7FF), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isRead + ? const Color(0xFFF3F4F6) + : const Color(0xFFE1EFFE), + shape: BoxShape.circle, + ), + child: Icon( + _icon(notification.type as String), + size: 20, + color: isRead + ? const Color(0xFF6B7280) + : const Color(0xFF1A56DB), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + notification.title as String, + style: TextStyle( + fontWeight: + isRead ? FontWeight.normal : FontWeight.w600, + fontSize: 14, + ), + ), + const SizedBox(height: 2), + Text( + notification.body as String, + style: const TextStyle( + fontSize: 13, color: Color(0xFF6B7280)), + ), + const SizedBox(height: 4), + Text( + fmt.format(notification.createdAt as DateTime), + style: const TextStyle( + fontSize: 11, color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + if (!isRead) + Container( + width: 8, + height: 8, + margin: const EdgeInsets.only(top: 6), + decoration: const BoxDecoration( + color: Color(0xFF1A56DB), + shape: BoxShape.circle, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/offers_screen.dart b/lib/features/dashboard/screens/offers_screen.dart new file mode 100644 index 0000000..e821726 --- /dev/null +++ b/lib/features/dashboard/screens/offers_screen.dart @@ -0,0 +1,452 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../../../core/models/offer.dart'; +import '../providers/dashboard_providers.dart'; +import '../../../widgets/error_view.dart'; + +class OffersScreen extends ConsumerWidget { + const OffersScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(offersProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Offers & Promotions')), + floatingActionButton: FloatingActionButton.extended( + onPressed: () async { + final created = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => const _OfferFormSheet(), + ); + if (created == true) ref.invalidate(offersProvider); + }, + icon: const Icon(Icons.add), + label: const Text('New Offer'), + ), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Failed to load offers', + onRetry: () => ref.invalidate(offersProvider), + ), + data: (offers) { + if (offers.isEmpty) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.local_offer_outlined, + size: 64, color: Color(0xFFD1D5DB)), + SizedBox(height: 16), + Text('No offers yet', + style: TextStyle(color: Color(0xFF9CA3AF))), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(offersProvider), + child: ListView.separated( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: offers.length, + separatorBuilder: (_, _) => const SizedBox(height: 10), + itemBuilder: (_, i) => _OfferCard( + offer: offers[i], + onToggle: () async { + final svc = ref.read(offerServiceProvider); + if (offers[i].isActive) { + await svc.deactivateOffer(offers[i].id); + } else { + await svc.activateOffer(offers[i].id); + } + ref.invalidate(offersProvider); + }, + onDelete: () async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete Offer'), + content: Text( + 'Delete "${offers[i].title}"? This cannot be undone.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel')), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE02424)), + onPressed: () => Navigator.pop(context, true), + child: const Text('Delete'), + ), + ], + ), + ); + if (confirmed == true) { + await ref.read(offerServiceProvider).deleteOffer(offers[i].id); + ref.invalidate(offersProvider); + } + }, + ), + ), + ); + }, + ), + ); + } +} + +class _OfferCard extends StatelessWidget { + final CompanyOffer offer; + final VoidCallback onToggle; + final VoidCallback onDelete; + + const _OfferCard({ + required this.offer, + required this.onToggle, + required this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + final isExpired = offer.validUntil.isBefore(DateTime.now()); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + offer.title, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 15), + ), + ), + _StatusChip( + active: offer.isActive && !isExpired, + expired: isExpired), + const SizedBox(width: 8), + PopupMenuButton( + onSelected: (v) { + if (v == 'toggle') onToggle(); + if (v == 'delete') onDelete(); + }, + itemBuilder: (_) => [ + PopupMenuItem( + value: 'toggle', + child: Text(offer.isActive ? 'Deactivate' : 'Activate'), + ), + const PopupMenuItem( + value: 'delete', + child: Text('Delete', + style: TextStyle(color: Color(0xFFE02424))), + ), + ], + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF0F7FF), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + offer.discountLabel, + style: const TextStyle( + color: Color(0xFF1A56DB), + fontWeight: FontWeight.w600, + fontSize: 13), + ), + ), + if (offer.promoCode != null) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + offer.promoCode!, + style: const TextStyle( + fontFamily: 'monospace', + fontSize: 12, + fontWeight: FontWeight.w600), + ), + ), + ], + ], + ), + const SizedBox(height: 8), + Text( + '${fmt.format(offer.validFrom)} → ${fmt.format(offer.validUntil)}', + style: + const TextStyle(fontSize: 12, color: Color(0xFF6B7280)), + ), + if (offer.maxRedemptions != null) ...[ + const SizedBox(height: 4), + Text( + '${offer.redemptionCount} / ${offer.maxRedemptions} uses', + style: const TextStyle( + fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ], + ), + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + final bool active; + final bool expired; + + const _StatusChip({required this.active, required this.expired}); + + @override + Widget build(BuildContext context) { + Color bg; + Color fg; + String label; + if (expired) { + bg = const Color(0xFFF3F4F6); + fg = const Color(0xFF6B7280); + label = 'Expired'; + } else if (active) { + bg = const Color(0xFFDEF7EC); + fg = const Color(0xFF03543F); + label = 'Active'; + } else { + bg = const Color(0xFFFDE8E8); + fg = const Color(0xFF9B1C1C); + label = 'Inactive'; + } + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: + BoxDecoration(color: bg, borderRadius: BorderRadius.circular(20)), + child: Text(label, + style: TextStyle(fontSize: 11, color: fg, fontWeight: FontWeight.w600)), + ); + } +} + +class _OfferFormSheet extends ConsumerStatefulWidget { + const _OfferFormSheet(); + + @override + ConsumerState<_OfferFormSheet> createState() => _OfferFormSheetState(); +} + +class _OfferFormSheetState extends ConsumerState<_OfferFormSheet> { + final _formKey = GlobalKey(); + bool _loading = false; + + final _title = TextEditingController(); + final _description = TextEditingController(); + final _promoCode = TextEditingController(); + final _discountValue = TextEditingController(text: '10'); + final _maxRedemptions = TextEditingController(); + + String _type = 'PERCENTAGE'; + bool _isPublic = false; + DateTime _validFrom = DateTime.now(); + DateTime _validUntil = DateTime.now().add(const Duration(days: 30)); + + static const _types = ['PERCENTAGE', 'FIXED_AMOUNT', 'FREE_DAY']; + + @override + void dispose() { + for (final c in [ + _title, _description, _promoCode, _discountValue, _maxRedemptions + ]) { + c.dispose(); + } + super.dispose(); + } + + Future _pickDateRange() async { + final range = await showDateRangePicker( + context: context, + firstDate: DateTime.now().subtract(const Duration(days: 1)), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + initialDateRange: DateTimeRange(start: _validFrom, end: _validUntil), + ); + if (range != null) { + setState(() { + _validFrom = range.start; + _validUntil = range.end; + }); + } + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _loading = true); + try { + int discVal = int.tryParse(_discountValue.text) ?? 0; + if (_type == 'FIXED_AMOUNT') discVal = discVal * 100; + + await ref.read(offerServiceProvider).createOffer({ + 'title': _title.text.trim(), + if (_description.text.isNotEmpty) + 'description': _description.text.trim(), + 'type': _type, + 'discountValue': discVal, + if (_promoCode.text.isNotEmpty) 'promoCode': _promoCode.text.trim(), + 'isPublic': _isPublic, + 'isActive': true, + 'isFeatured': false, + 'validFrom': + '${_validFrom.toIso8601String().substring(0, 10)}T00:00:00.000Z', + 'validUntil': + '${_validUntil.toIso8601String().substring(0, 10)}T23:59:59.000Z', + if (_maxRedemptions.text.isNotEmpty) + 'maxRedemptions': int.parse(_maxRedemptions.text), + 'categories': [], + }); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Text('New Offer', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + const Spacer(), + if (_loading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + else + TextButton( + onPressed: _submit, + child: const Text('Create')), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _title, + decoration: const InputDecoration(labelText: 'Title'), + validator: (v) => + v == null || v.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _description, + decoration: const InputDecoration(labelText: 'Description'), + maxLines: 2, + ), + const SizedBox(height: 12), + InputDecorator( + decoration: const InputDecoration(labelText: 'Type'), + child: DropdownButton( + value: _type, + isExpanded: true, + underline: const SizedBox(), + items: _types + .map((t) => DropdownMenuItem(value: t, child: Text(t))) + .toList(), + onChanged: (v) => setState(() => _type = v!), + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _discountValue, + decoration: InputDecoration( + labelText: _type == 'PERCENTAGE' + ? 'Discount (%)' + : _type == 'FIXED_AMOUNT' + ? 'Amount (\$)' + : 'Free Days', + ), + keyboardType: TextInputType.number, + validator: (v) => + v == null || v.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _promoCode, + decoration: const InputDecoration( + labelText: 'Promo Code (optional)'), + textCapitalization: TextCapitalization.characters, + ), + const SizedBox(height: 12), + TextFormField( + controller: _maxRedemptions, + decoration: const InputDecoration( + labelText: 'Max Redemptions (optional)'), + keyboardType: TextInputType.number, + ), + const SizedBox(height: 12), + InkWell( + onTap: _pickDateRange, + child: InputDecorator( + decoration: + const InputDecoration(labelText: 'Valid Period'), + child: Text( + '${fmt.format(_validFrom)} → ${fmt.format(_validUntil)}'), + ), + ), + const SizedBox(height: 12), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Public offer'), + subtitle: const Text('Visible to customers on marketplace'), + value: _isPublic, + onChanged: (v) => setState(() => _isPublic = v), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _loading ? null : _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: const Text('Create Offer'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/online_reservations_screen.dart b/lib/features/dashboard/screens/online_reservations_screen.dart new file mode 100644 index 0000000..85eb29b --- /dev/null +++ b/lib/features/dashboard/screens/online_reservations_screen.dart @@ -0,0 +1,342 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../providers/dashboard_providers.dart'; +import '../../../core/models/reservation.dart'; +import '../../../widgets/error_view.dart'; +import '../../../widgets/status_badge.dart'; + +const _params = { + 'status': 'DRAFT', + 'source': 'MARKETPLACE', + 'page': 1, + 'pageSize': 100, +}; + +class OnlineReservationsScreen extends ConsumerWidget { + const OnlineReservationsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(reservationsProvider(_params)); + + return Scaffold( + appBar: AppBar( + title: const Text('Online Reservations'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: () => ref.invalidate(reservationsProvider(_params)), + ), + ], + ), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Could not load online reservations', + onRetry: () => ref.invalidate(reservationsProvider(_params)), + ), + data: (result) { + final items = result.data; + if (items.isEmpty) { + return const Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.check_circle_outline, + size: 64, color: Color(0xFF6EE7B7)), + SizedBox(height: 16), + Text( + 'All caught up!', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF111928)), + ), + SizedBox(height: 6), + Text( + 'No pending marketplace requests.', + style: TextStyle(color: Color(0xFF6B7280)), + ), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: () async => + ref.invalidate(reservationsProvider(_params)), + child: ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 12), + itemBuilder: (_, i) => _ReservationCard( + reservation: items[i], + onAction: () => ref.invalidate(reservationsProvider(_params)), + ), + ), + ); + }, + ), + ); + } +} + +class _ReservationCard extends ConsumerWidget { + final Reservation reservation; + final VoidCallback onAction; + + const _ReservationCard({ + required this.reservation, + required this.onAction, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final r = reservation; + final fmt = DateFormat('MMM d, yyyy'); + final fmtMoney = + NumberFormat.currency(symbol: '\$', decimalDigits: 2); + + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header: vehicle + status + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (r.vehicle != null) + Text( + r.vehicle!.displayName, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: Color(0xFF111928), + ), + ) + else + const Text('Vehicle unavailable', + style: + TextStyle(color: Color(0xFF9CA3AF))), + if (r.vehicle != null) + Text( + r.vehicle!.licensePlate, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280)), + ), + ], + ), + ), + StatusBadge(status: r.status), + ], + ), + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + // Customer + _InfoRow( + icon: Icons.person_outline, + label: r.customer != null + ? '${r.customer!.firstName} ${r.customer!.lastName}' + : 'Unknown customer', + sub: r.customer?.email, + ), + const SizedBox(height: 8), + // Dates + _InfoRow( + icon: Icons.calendar_today_outlined, + label: + '${fmt.format(r.startDate)} → ${fmt.format(r.endDate)}', + sub: '${r.days} day${r.days == 1 ? '' : 's'}', + ), + const SizedBox(height: 8), + // Amount + _InfoRow( + icon: Icons.attach_money, + label: fmtMoney.format(r.totalAmount / 100), + sub: 'Total', + ), + const SizedBox(height: 14), + // Actions + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.close, size: 16), + label: const Text('Decline'), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFE02424), + side: const BorderSide(color: Color(0xFFE02424)), + ), + onPressed: () => + _showDeclineDialog(context, ref, r.id), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.check, size: 16), + label: const Text('Confirm'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF057A55), + ), + onPressed: () => _confirm(context, ref, r.id), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _confirm( + BuildContext context, WidgetRef ref, String id) async { + try { + await ref.read(reservationServiceProvider).confirmReservation(id); + onAction(); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Reservation confirmed'), + backgroundColor: Color(0xFF057A55), + ), + ); + } + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to confirm: $e')), + ); + } + } + } + + void _showDeclineDialog( + BuildContext context, WidgetRef ref, String id) { + final reasonCtrl = TextEditingController(); + bool saving = false; + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setState) => AlertDialog( + title: const Text('Decline Reservation'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Optionally provide a reason — it will be recorded on the reservation.', + style: + TextStyle(fontSize: 13, color: Color(0xFF6B7280)), + ), + const SizedBox(height: 12), + TextField( + controller: reasonCtrl, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Reason (optional)', + hintText: + 'e.g. Vehicle not available for these dates', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: saving ? null : () => Navigator.pop(ctx), + child: const Text('Cancel'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFE02424)), + onPressed: saving + ? null + : () async { + setState(() => saving = true); + try { + await ref + .read(reservationServiceProvider) + .cancelReservation( + id, + reasonCtrl.text.trim().isEmpty + ? 'Declined by company' + : reasonCtrl.text.trim()); + onAction(); + if (ctx.mounted) Navigator.pop(ctx); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Reservation declined')), + ); + } + } catch (e) { + setState(() => saving = false); + if (ctx.mounted) { + ScaffoldMessenger.of(ctx).showSnackBar( + SnackBar( + content: + Text('Failed to decline: $e')), + ); + } + } + }, + child: saving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white)), + ) + : const Text('Decline'), + ), + ], + ), + ), + ); + } +} + +class _InfoRow extends StatelessWidget { + final IconData icon; + final String label; + final String? sub; + + const _InfoRow({required this.icon, required this.label, this.sub}); + + @override + Widget build(BuildContext context) => Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: const Color(0xFF9CA3AF)), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Color(0xFF374151))), + if (sub != null) + Text(sub!, + style: const TextStyle( + fontSize: 11, color: Color(0xFF9CA3AF))), + ], + ), + ), + ], + ); +} diff --git a/lib/features/dashboard/screens/reports_screen.dart b/lib/features/dashboard/screens/reports_screen.dart new file mode 100644 index 0000000..9f91cfe --- /dev/null +++ b/lib/features/dashboard/screens/reports_screen.dart @@ -0,0 +1,296 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../providers/dashboard_providers.dart'; +import '../../../widgets/error_view.dart'; + +class ReportsScreen extends ConsumerStatefulWidget { + const ReportsScreen({super.key}); + + @override + ConsumerState createState() => _ReportsScreenState(); +} + +class _ReportsScreenState extends ConsumerState { + DateTime _start = DateTime.now().subtract(const Duration(days: 30)); + DateTime _end = DateTime.now(); + String? _statusFilter; + + Map get _params => { + 'startDate': + '${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z', + 'endDate': '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z', + 'status': ?_statusFilter, + }; + + Future _pickRange() async { + final range = await showDateRangePicker( + context: context, + firstDate: DateTime(2020), + lastDate: DateTime.now(), + initialDateRange: DateTimeRange(start: _start, end: _end), + ); + if (range != null) { + setState(() { + _start = range.start; + _end = range.end; + }); + } + } + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + final fmtMoney = NumberFormat.currency(symbol: '\$', decimalDigits: 2); + final reportAsync = ref.watch(reportProvider(_params)); + + return Scaffold( + appBar: AppBar( + title: const Text('Reports'), + actions: [ + PopupMenuButton( + icon: Icon( + Icons.filter_list, + color: _statusFilter != null ? const Color(0xFF1A56DB) : null, + ), + onSelected: (v) => setState(() => _statusFilter = v), + itemBuilder: (_) => const [ + PopupMenuItem(value: null, child: Text('All statuses')), + PopupMenuItem(value: 'CONFIRMED', child: Text('Confirmed')), + PopupMenuItem(value: 'ACTIVE', child: Text('Active')), + PopupMenuItem(value: 'CLOSED', child: Text('Closed')), + PopupMenuItem(value: 'CANCELLED', child: Text('Cancelled')), + ], + ), + ], + ), + body: Column( + children: [ + _DateRangeBar( + start: _start, + end: _end, + fmt: fmt, + onTap: _pickRange, + ), + Expanded( + child: reportAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Failed to load report', + onRetry: () => ref.invalidate(reportProvider(_params)), + ), + data: (report) => CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverToBoxAdapter( + child: Column( + children: [ + Row( + children: [ + Expanded( + child: _MetricCard( + label: 'Reservations', + value: + report.totalReservations.toString(), + icon: Icons.event_note, + color: const Color(0xFF1A56DB), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MetricCard( + label: 'Revenue', + value: fmtMoney + .format(report.rentalRevenue / 100), + icon: Icons.attach_money, + color: const Color(0xFF0E9F6E), + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _MetricCard( + label: 'Collected', + value: fmtMoney + .format(report.totalPaid / 100), + icon: Icons.check_circle_outline, + color: const Color(0xFF0E9F6E), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _MetricCard( + label: 'Outstanding', + value: fmtMoney + .format(report.totalOutstanding / 100), + icon: Icons.pending_outlined, + color: const Color(0xFFE02424), + ), + ), + ], + ), + ], + ), + ), + ), + if (report.items.isNotEmpty) ...[ + const SliverPadding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 8), + sliver: SliverToBoxAdapter( + child: Text( + 'Reservations', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 16), + sliver: SliverList.separated( + itemCount: report.items.length, + separatorBuilder: (_, _) => + const Divider(height: 1), + itemBuilder: (_, i) { + final item = report.items[i]; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text( + item.customerName ?? 'Walk-in', + style: const TextStyle( + fontWeight: FontWeight.w500), + ), + subtitle: Text( + '${item.vehicleName ?? 'Vehicle'} • ${fmt.format(item.startDate)} – ${fmt.format(item.endDate)}', + style: const TextStyle(fontSize: 12), + ), + trailing: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + fmtMoney.format(item.totalAmount / 100), + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + Text( + item.paymentStatus, + style: TextStyle( + fontSize: 11, + color: item.paymentStatus == 'PAID' + ? const Color(0xFF0E9F6E) + : const Color(0xFF9CA3AF), + ), + ), + ], + ), + ); + }, + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } +} + +class _DateRangeBar extends StatelessWidget { + final DateTime start; + final DateTime end; + final DateFormat fmt; + final VoidCallback onTap; + + const _DateRangeBar({ + required this.start, + required this.end, + required this.fmt, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + color: Color(0xFFF9FAFB), + border: Border(bottom: BorderSide(color: Color(0xFFE5E7EB))), + ), + child: Row( + children: [ + const Icon(Icons.date_range_outlined, + size: 18, color: Color(0xFF6B7280)), + const SizedBox(width: 8), + Text( + '${fmt.format(start)} → ${fmt.format(end)}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const Spacer(), + const Text('Change', + style: TextStyle( + color: Color(0xFF1A56DB), fontSize: 13)), + ], + ), + ), + ); + } +} + +class _MetricCard extends StatelessWidget { + final String label; + final String value; + final IconData icon; + final Color color; + + const _MetricCard({ + required this.label, + required this.value, + required this.icon, + required this.color, + }); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 20, color: color), + const SizedBox(height: 8), + Text( + value, + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: color, + ), + ), + const SizedBox(height: 4), + Text( + label, + style: const TextStyle( + fontSize: 12, + color: Color(0xFF6B7280), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/reservation_detail_screen.dart b/lib/features/dashboard/screens/reservation_detail_screen.dart index be48a53..53ab8c9 100644 --- a/lib/features/dashboard/screens/reservation_detail_screen.dart +++ b/lib/features/dashboard/screens/reservation_detail_screen.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import '../providers/dashboard_providers.dart'; +import '../../../core/services/reservation_service.dart'; import '../../../widgets/status_badge.dart'; import '../../../widgets/error_view.dart'; @@ -13,6 +15,8 @@ class ReservationDetailScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final async = ref.watch(reservationDetailProvider(id)); + final paymentsAsync = ref.watch(reservationPaymentsProvider(id)); + final inspectionsAsync = ref.watch(inspectionsProvider(id)); return async.when( loading: () => @@ -31,13 +35,22 @@ class ReservationDetailScreen extends ConsumerWidget { res.status == 'DRAFT' || res.status == 'CONFIRMED'; final canCheckin = res.status == 'CONFIRMED'; final canCheckout = res.status == 'ACTIVE'; + final canClose = res.status == 'COMPLETED'; + final canRecordPayment = (res.status != 'CANCELLED') && + res.paymentStatus != 'PAID'; return Scaffold( appBar: AppBar( title: Text('Reservation #${id.substring(0, 8)}'), actions: [ + IconButton( + icon: const Icon(Icons.description_outlined), + tooltip: 'View Contract', + onPressed: () => context.push( + '/dashboard/reservations/$id/contract'), + ), StatusBadge(status: res.status), - const SizedBox(width: 16), + const SizedBox(width: 8), ], ), body: ListView( @@ -82,7 +95,7 @@ class ReservationDetailScreen extends ConsumerWidget { children: [ _InfoRow( label: 'Total', - value: '\$${res.totalAmount.toStringAsFixed(2)}'), + value: '\$${(res.totalAmount / 100).toStringAsFixed(2)}'), Row( children: [ const Text('Status: ', @@ -94,7 +107,105 @@ class ReservationDetailScreen extends ConsumerWidget { ], ), ), + paymentsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (payments) { + if (payments.isEmpty) return const SizedBox.shrink(); + return _Section( + title: 'Payment History', + child: Column( + children: payments + .map((p) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + p.paymentMethod, + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 13), + ), + Text( + DateFormat('MMM d, yyyy') + .format(p.createdAt), + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + Text( + '\$${(p.amount / 100).toStringAsFixed(2)}', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF0E9F6E), + ), + ), + ], + ), + )) + .toList(), + ), + ); + }, + ), + // Inspections + inspectionsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (inspections) { + final checkin = inspections + .where((i) => i.type == 'CHECKIN') + .firstOrNull; + final checkout = inspections + .where((i) => i.type == 'CHECKOUT') + .firstOrNull; + final canEditCheckin = res.status == 'CONFIRMED' || + res.status == 'ACTIVE'; + final canEditCheckout = res.status == 'ACTIVE' || + res.status == 'COMPLETED'; + if (!canEditCheckin && !canEditCheckout && inspections.isEmpty) { + return const SizedBox.shrink(); + } + return _Section( + title: 'Inspections', + child: Column( + children: [ + _InspectionCard( + label: 'Departure', + inspection: checkin, + canEdit: canEditCheckin, + onEdit: () => _showInspectionSheet( + context, ref, 'CHECKIN', checkin), + ), + const SizedBox(height: 10), + _InspectionCard( + label: 'Return', + inspection: checkout, + canEdit: canEditCheckout, + onEdit: () => _showInspectionSheet( + context, ref, 'CHECKOUT', checkout), + ), + ], + ), + ); + }, + ), const SizedBox(height: 16), + if (canRecordPayment) + _ActionButton( + label: 'Record Payment', + color: const Color(0xFF0E9F6E), + icon: Icons.payments_outlined, + onTap: () => _showPaymentDialog(context, ref), + ), if (canConfirm) _ActionButton( label: 'Confirm Reservation', @@ -121,6 +232,37 @@ class ReservationDetailScreen extends ConsumerWidget { icon: Icons.logout, onTap: () => _showMileageDialog(context, ref, 'checkout'), ), + if (canClose) + _ActionButton( + label: 'Close Reservation', + color: const Color(0xFF5521B5), + icon: Icons.lock_outline, + onTap: () async { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Close Reservation'), + content: const Text( + 'Mark this reservation as closed? This finalises the rental.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Cancel')), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Close'), + ), + ], + ), + ); + if (confirmed == true && context.mounted) { + await ref + .read(reservationServiceProvider) + .closeReservation(id); + ref.invalidate(reservationDetailProvider(id)); + } + }, + ), if (canCancel) _ActionButton( label: 'Cancel Reservation', @@ -135,6 +277,91 @@ class ReservationDetailScreen extends ConsumerWidget { ); } + void _showPaymentDialog(BuildContext context, WidgetRef ref) { + final amountCtrl = TextEditingController(); + String method = 'CASH'; + String type = 'CHARGE'; + const methods = ['CASH', 'CHECK', 'BANK_TRANSFER', 'CARD']; + const types = ['CHARGE', 'DEPOSIT']; + + showDialog( + context: context, + builder: (_) => StatefulBuilder( + builder: (ctx, setState) => AlertDialog( + title: const Text('Record Payment'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: amountCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration(labelText: 'Amount (\$)'), + ), + const SizedBox(height: 12), + InputDecorator( + decoration: const InputDecoration(labelText: 'Method'), + child: DropdownButton( + value: method, + isExpanded: true, + underline: const SizedBox(), + items: methods + .map((m) => DropdownMenuItem(value: m, child: Text(m))) + .toList(), + onChanged: (v) => setState(() => method = v!), + ), + ), + const SizedBox(height: 12), + InputDecorator( + decoration: const InputDecoration(labelText: 'Type'), + child: DropdownButton( + value: type, + isExpanded: true, + underline: const SizedBox(), + items: types + .map((t) => DropdownMenuItem(value: t, child: Text(t))) + .toList(), + onChanged: (v) => setState(() => type = v!), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Cancel')), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0E9F6E)), + onPressed: () async { + final dollars = double.tryParse(amountCtrl.text); + if (dollars == null || dollars <= 0) return; + Navigator.pop(ctx); + try { + await ref.read(paymentServiceProvider).recordManualPayment( + id, + { + 'amount': (dollars * 100).round(), + 'currency': 'USD', + 'type': type, + 'paymentMethod': method, + }, + ); + ref.invalidate(reservationDetailProvider(id)); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Payment failed: $e'))); + } + } + }, + child: const Text('Record'), + ), + ], + ), + ), + ); + } + void _showMileageDialog( BuildContext context, WidgetRef ref, String type) { final ctrl = TextEditingController(); @@ -178,6 +405,24 @@ class ReservationDetailScreen extends ConsumerWidget { ); } + void _showInspectionSheet( + BuildContext context, + WidgetRef ref, + String type, + ReservationInspection? existing, + ) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _InspectionSheet( + reservationId: id, + type: type, + existing: existing, + onSaved: () => ref.invalidate(inspectionsProvider(id)), + ), + ); + } + void _showCancelDialog(BuildContext context, WidgetRef ref) { final ctrl = TextEditingController(); showDialog( @@ -293,3 +538,364 @@ class _ActionButton extends StatelessWidget { ); } } + +// ── Fuel level helpers ──────────────────────────────────────────────────────── + +const _fuelLevels = [ + 'FULL', + 'SEVEN_EIGHTHS', + 'THREE_QUARTERS', + 'FIVE_EIGHTHS', + 'HALF', + 'THREE_EIGHTHS', + 'QUARTER', + 'ONE_EIGHTH', + 'EMPTY', +]; + +const _fuelLabels = { + 'FULL': 'Full', + 'SEVEN_EIGHTHS': '7/8', + 'THREE_QUARTERS': '3/4', + 'FIVE_EIGHTHS': '5/8', + 'HALF': '1/2', + 'THREE_EIGHTHS': '3/8', + 'QUARTER': '1/4', + 'ONE_EIGHTH': '1/8', + 'EMPTY': 'Empty', +}; + +double _fuelFraction(String level) { + const fractions = { + 'FULL': 1.0, + 'SEVEN_EIGHTHS': 0.875, + 'THREE_QUARTERS': 0.75, + 'FIVE_EIGHTHS': 0.625, + 'HALF': 0.5, + 'THREE_EIGHTHS': 0.375, + 'QUARTER': 0.25, + 'ONE_EIGHTH': 0.125, + 'EMPTY': 0.0, + }; + return fractions[level] ?? 0; +} + +// ── InspectionCard ──────────────────────────────────────────────────────────── + +class _InspectionCard extends StatelessWidget { + final String label; + final ReservationInspection? inspection; + final bool canEdit; + final VoidCallback onEdit; + + const _InspectionCard({ + required this.label, + required this.inspection, + required this.canEdit, + required this.onEdit, + }); + + @override + Widget build(BuildContext context) { + final i = inspection; + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF9FAFB), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFE5E7EB)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 13, + color: Color(0xFF374151))), + const SizedBox(height: 6), + if (i == null) + const Text('Not recorded', + style: TextStyle( + fontSize: 12, color: Color(0xFF9CA3AF))) + else ...[ + if (i.fuelLevel != null) + _FuelBar(level: i.fuelLevel!), + if (i.mileage != null) ...[ + const SizedBox(height: 4), + Text('${i.mileage} km', + style: const TextStyle( + fontSize: 12, color: Color(0xFF6B7280))), + ], + if (i.generalCondition != null && + i.generalCondition!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(i.generalCondition!, + style: const TextStyle( + fontSize: 12, color: Color(0xFF6B7280))), + ], + if (i.employeeNotes != null && + i.employeeNotes!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text(i.employeeNotes!, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + color: Color(0xFF9CA3AF), + fontStyle: FontStyle.italic)), + ], + ], + ], + ), + ), + if (canEdit) + IconButton( + icon: Icon( + i == null ? Icons.add_circle_outline : Icons.edit_outlined, + size: 20, + color: const Color(0xFF1A56DB), + ), + onPressed: onEdit, + ), + ], + ), + ); + } +} + +class _FuelBar extends StatelessWidget { + final String level; + + const _FuelBar({required this.level}); + + @override + Widget build(BuildContext context) { + final fraction = _fuelFraction(level); + final label = _fuelLabels[level] ?? level; + final color = fraction > 0.5 + ? const Color(0xFF057A55) + : fraction > 0.25 + ? const Color(0xFFFF6B00) + : const Color(0xFFE02424); + + return Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: fraction, + minHeight: 8, + backgroundColor: const Color(0xFFE5E7EB), + valueColor: AlwaysStoppedAnimation(color), + ), + ), + ), + const SizedBox(width: 8), + Text(label, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color)), + ], + ); + } +} + +// ── InspectionSheet ─────────────────────────────────────────────────────────── + +class _InspectionSheet extends ConsumerStatefulWidget { + final String reservationId; + final String type; // CHECKIN | CHECKOUT + final ReservationInspection? existing; + final VoidCallback onSaved; + + const _InspectionSheet({ + required this.reservationId, + required this.type, + this.existing, + required this.onSaved, + }); + + @override + ConsumerState<_InspectionSheet> createState() => _InspectionSheetState(); +} + +class _InspectionSheetState extends ConsumerState<_InspectionSheet> { + late String _fuelLevel; + final _mileageCtrl = TextEditingController(); + final _conditionCtrl = TextEditingController(); + final _notesCtrl = TextEditingController(); + bool _saving = false; + String? _error; + + @override + void initState() { + super.initState(); + final e = widget.existing; + _fuelLevel = e?.fuelLevel ?? 'FULL'; + if (e?.mileage != null) _mileageCtrl.text = '${e!.mileage}'; + _conditionCtrl.text = e?.generalCondition ?? ''; + _notesCtrl.text = e?.employeeNotes ?? ''; + } + + @override + void dispose() { + _mileageCtrl.dispose(); + _conditionCtrl.dispose(); + _notesCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + try { + await ref.read(reservationServiceProvider).upsertInspection( + widget.reservationId, + widget.type, + { + 'fuelLevel': _fuelLevel, + if (_mileageCtrl.text.isNotEmpty) + 'mileage': int.parse(_mileageCtrl.text), + if (_conditionCtrl.text.trim().isNotEmpty) + 'generalCondition': _conditionCtrl.text.trim(), + if (_notesCtrl.text.trim().isNotEmpty) + 'employeeNotes': _notesCtrl.text.trim(), + }, + ); + widget.onSaved(); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + setState(() { + _saving = false; + _error = 'Failed to save. Please try again.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final title = widget.type == 'CHECKIN' + ? 'Departure Inspection' + : 'Return Inspection'; + + return Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + // Fuel level + const Text('Fuel Level', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF374151))), + const SizedBox(height: 8), + _FuelBar(level: _fuelLevel), + const SizedBox(height: 8), + SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _fuelLevels.length, + separatorBuilder: (_, _) => const SizedBox(width: 6), + itemBuilder: (_, i) { + final level = _fuelLevels[i]; + final selected = level == _fuelLevel; + return GestureDetector( + onTap: () => setState(() => _fuelLevel = level), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: selected + ? const Color(0xFF1A56DB) + : const Color(0xFFF3F4F6), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + _fuelLabels[level] ?? level, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: selected + ? Colors.white + : const Color(0xFF374151), + ), + ), + ), + ); + }, + ), + ), + const SizedBox(height: 14), + // Mileage + TextField( + controller: _mileageCtrl, + keyboardType: TextInputType.number, + decoration: + const InputDecoration(labelText: 'Mileage (km)'), + ), + const SizedBox(height: 12), + // General condition + TextField( + controller: _conditionCtrl, + decoration: const InputDecoration( + labelText: 'General Condition (optional)'), + ), + const SizedBox(height: 12), + // Notes + TextField( + controller: _notesCtrl, + maxLines: 3, + decoration: const InputDecoration( + labelText: 'Notes (optional)', + alignLabelWithHint: true, + ), + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Text(_error!, + style: + const TextStyle(color: Color(0xFF9B1C1C))), + ], + const SizedBox(height: 20), + ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48)), + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white)), + ) + : Text(widget.existing == null + ? 'Record Inspection' + : 'Update Inspection'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/reservations_screen.dart b/lib/features/dashboard/screens/reservations_screen.dart index a55e719..4a5ad26 100644 --- a/lib/features/dashboard/screens/reservations_screen.dart +++ b/lib/features/dashboard/screens/reservations_screen.dart @@ -5,6 +5,8 @@ import '../providers/dashboard_providers.dart'; import '../../../widgets/reservation_card.dart'; import '../../../widgets/loading_list.dart'; import '../../../widgets/error_view.dart'; +import 'dashboard_shell.dart'; +import 'new_reservation_screen.dart'; class ReservationsScreen extends ConsumerStatefulWidget { const ReservationsScreen({super.key}); @@ -16,8 +18,24 @@ class ReservationsScreen extends ConsumerStatefulWidget { class _ReservationsScreenState extends ConsumerState with SingleTickerProviderStateMixin { late final TabController _tabs; - final _statuses = [null, 'CONFIRMED', 'ACTIVE', 'COMPLETED', 'CANCELLED']; - final _labels = ['All', 'Confirmed', 'Active', 'Completed', 'Cancelled']; + final _statuses = [ + null, + 'DRAFT', + 'CONFIRMED', + 'ACTIVE', + 'COMPLETED', + 'CLOSED', + 'CANCELLED', + ]; + final _labels = [ + 'All', + 'Pending', + 'Confirmed', + 'Active', + 'Completed', + 'Closed', + 'Cancelled', + ]; final _searchController = TextEditingController(); String _search = ''; @@ -35,15 +53,27 @@ class _ReservationsScreenState extends ConsumerState } Map _params(int tabIndex) => { - 'page': 1, - 'pageSize': 50, - if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex], - if (_search.isNotEmpty) 'search': _search, - }; + 'page': 1, + 'pageSize': 50, + if (_statuses[tabIndex] != null) 'status': _statuses[tabIndex], + if (_search.isNotEmpty) 'search': _search, + }; @override Widget build(BuildContext context) { - return Scaffold( + return DashboardShell( + floatingActionButton: FloatingActionButton( + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const NewReservationScreen()), + ); + if (result == true) { + ref.invalidate(reservationsProvider); + setState(() {}); + } + }, + child: const Icon(Icons.add), + ), appBar: AppBar( title: const Text('Reservations'), bottom: TabBar( @@ -74,8 +104,7 @@ class _ReservationsScreenState extends ConsumerState _statuses.length, (i) => _ReservationTab( params: _params(i), - onTap: (id) => - context.push('/dashboard/reservations/$id'), + onTap: (id) => context.push('/dashboard/reservations/$id'), ), ), ), @@ -103,8 +132,11 @@ class _ReservationTab extends ConsumerWidget { ), data: (res) => res.data.isEmpty ? const Center( - child: Text('No reservations found', - style: TextStyle(color: Color(0xFF9CA3AF)))) + child: Text( + 'No reservations found', + style: TextStyle(color: Color(0xFF9CA3AF)), + ), + ) : RefreshIndicator( onRefresh: () async => ref.invalidate(reservationsProvider(params)), diff --git a/lib/features/dashboard/screens/settings_screen.dart b/lib/features/dashboard/screens/settings_screen.dart new file mode 100644 index 0000000..00af9ba --- /dev/null +++ b/lib/features/dashboard/screens/settings_screen.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/dashboard_providers.dart'; +import '../../../widgets/error_view.dart'; + +class SettingsScreen extends ConsumerWidget { + const SettingsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(brandSettingsProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Brand Settings')), + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Failed to load settings', + onRetry: () => ref.invalidate(brandSettingsProvider), + ), + data: (settings) => _SettingsForm(settings: settings), + ), + ); + } +} + +class _SettingsForm extends ConsumerStatefulWidget { + final dynamic settings; + + const _SettingsForm({required this.settings}); + + @override + ConsumerState<_SettingsForm> createState() => _SettingsFormState(); +} + +class _SettingsFormState extends ConsumerState<_SettingsForm> { + final _formKey = GlobalKey(); + bool _loading = false; + + late final _displayName = + TextEditingController(text: widget.settings.displayName as String); + late final _tagline = + TextEditingController(text: widget.settings.tagline as String?); + late final _email = + TextEditingController(text: widget.settings.publicEmail as String?); + late final _phone = + TextEditingController(text: widget.settings.publicPhone as String?); + late final _city = + TextEditingController(text: widget.settings.publicCity as String?); + late final _country = + TextEditingController(text: widget.settings.publicCountry as String?); + late final _website = + TextEditingController(text: widget.settings.websiteUrl as String?); + late final _whatsapp = + TextEditingController(text: widget.settings.whatsappNumber as String?); + late bool _listed = widget.settings.isListedOnMarketplace as bool; + + @override + void dispose() { + for (final c in [ + _displayName, _tagline, _email, _phone, _city, _country, _website, _whatsapp + ]) { + c.dispose(); + } + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _loading = true); + try { + await ref.read(settingsServiceProvider).updateBrand({ + 'displayName': _displayName.text.trim(), + if (_tagline.text.isNotEmpty) 'tagline': _tagline.text.trim(), + if (_email.text.isNotEmpty) 'publicEmail': _email.text.trim(), + if (_phone.text.isNotEmpty) 'publicPhone': _phone.text.trim(), + if (_city.text.isNotEmpty) 'publicCity': _city.text.trim(), + if (_country.text.isNotEmpty) 'publicCountry': _country.text.trim(), + if (_website.text.isNotEmpty) 'websiteUrl': _website.text.trim(), + if (_whatsapp.text.isNotEmpty) 'whatsappNumber': _whatsapp.text.trim(), + 'isListedOnMarketplace': _listed, + }); + ref.invalidate(brandSettingsProvider); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Settings saved'))); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _section('Brand', [ + _field('Display Name', _displayName, required: true), + _field('Tagline', _tagline), + ]), + const SizedBox(height: 16), + _section('Contact', [ + _field('Public Email', _email, + inputType: TextInputType.emailAddress), + _field('Public Phone', _phone, inputType: TextInputType.phone), + _field('WhatsApp Number', _whatsapp, + inputType: TextInputType.phone), + _field('Website URL', _website, inputType: TextInputType.url), + ]), + const SizedBox(height: 16), + _section('Location', [ + _field('City', _city), + _field('Country', _country), + ]), + const SizedBox(height: 16), + Card( + child: SwitchListTile( + title: const Text('Listed on Marketplace'), + subtitle: const Text( + 'Show your fleet on the public marketplace', + ), + value: _listed, + onChanged: (v) => setState(() => _listed = v), + ), + ), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _loading ? null : _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: _loading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Text('Save Settings'), + ), + ], + ), + ); + } + + Widget _section(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF6B7280))), + const SizedBox(height: 10), + Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column(children: children), + ), + ), + ], + ); + } + + Widget _field(String label, TextEditingController ctrl, + {bool required = false, TextInputType? inputType}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: TextFormField( + controller: ctrl, + keyboardType: inputType, + decoration: InputDecoration(labelText: label), + validator: required + ? (v) => v == null || v.isEmpty ? 'Required' : null + : null, + ), + ); + } +} diff --git a/lib/features/dashboard/screens/team_screen.dart b/lib/features/dashboard/screens/team_screen.dart new file mode 100644 index 0000000..db965dd --- /dev/null +++ b/lib/features/dashboard/screens/team_screen.dart @@ -0,0 +1,341 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../providers/dashboard_providers.dart'; +import '../../../widgets/error_view.dart'; +import '../../../core/providers/auth_provider.dart'; + +class TeamScreen extends ConsumerWidget { + const TeamScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(teamProvider); + final myRole = ref.watch(authProvider).employee?.role ?? ''; + final isOwner = myRole == 'OWNER'; + + return Scaffold( + appBar: AppBar(title: const Text('Team')), + floatingActionButton: isOwner + ? FloatingActionButton.extended( + onPressed: () async { + final invited = await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + builder: (_) => const _InviteSheet(), + ); + if (invited == true) ref.invalidate(teamProvider); + }, + icon: const Icon(Icons.person_add_outlined), + label: const Text('Invite'), + ) + : null, + body: async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Failed to load team', + onRetry: () => ref.invalidate(teamProvider), + ), + data: (members) { + if (members.isEmpty) { + return const Center( + child: Text('No team members', + style: TextStyle(color: Color(0xFF9CA3AF))), + ); + } + return RefreshIndicator( + onRefresh: () async => ref.invalidate(teamProvider), + child: ListView.separated( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + itemCount: members.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) => _MemberCard( + member: members[i], + isOwner: isOwner, + onDeactivate: () async { + await ref + .read(teamServiceProvider) + .deactivateMember(members[i].id); + ref.invalidate(teamProvider); + }, + onReactivate: () async { + await ref + .read(teamServiceProvider) + .reactivateMember(members[i].id); + ref.invalidate(teamProvider); + }, + onRoleChange: (role) async { + await ref + .read(teamServiceProvider) + .updateRole(members[i].id, role); + ref.invalidate(teamProvider); + }, + ), + ), + ); + }, + ), + ); + } +} + +class _MemberCard extends StatelessWidget { + final dynamic member; + final bool isOwner; + final VoidCallback onDeactivate; + final VoidCallback onReactivate; + final ValueChanged onRoleChange; + + const _MemberCard({ + required this.member, + required this.isOwner, + required this.onDeactivate, + required this.onReactivate, + required this.onRoleChange, + }); + + @override + Widget build(BuildContext context) { + final isActive = member.isActive as bool; + final role = member.role as String; + + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + CircleAvatar( + backgroundColor: isActive + ? const Color(0xFFE1EFFE) + : const Color(0xFFF3F4F6), + radius: 22, + child: Text( + (member.firstName as String) + .substring(0, 1) + .toUpperCase(), + style: TextStyle( + color: isActive + ? const Color(0xFF1A56DB) + : const Color(0xFF9CA3AF), + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + member.fullName as String, + style: TextStyle( + fontWeight: FontWeight.w600, + color: isActive ? null : const Color(0xFF9CA3AF), + ), + ), + const SizedBox(width: 8), + _RoleBadge(role: role), + ], + ), + Text( + member.email as String, + style: const TextStyle( + fontSize: 12, color: Color(0xFF6B7280)), + ), + if (!isActive) + const Text( + 'Inactive', + style: TextStyle( + fontSize: 11, color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + if (isOwner && role != 'OWNER') + PopupMenuButton( + onSelected: (v) { + if (v == 'deactivate') onDeactivate(); + if (v == 'reactivate') onReactivate(); + if (v == 'MANAGER' || v == 'AGENT') onRoleChange(v); + }, + itemBuilder: (_) => [ + if (role != 'MANAGER') + const PopupMenuItem( + value: 'MANAGER', child: Text('Make Manager')), + if (role != 'AGENT') + const PopupMenuItem( + value: 'AGENT', child: Text('Make Agent')), + const PopupMenuDivider(), + if (isActive) + const PopupMenuItem( + value: 'deactivate', + child: Text('Deactivate', + style: TextStyle(color: Color(0xFFE02424))), + ) + else + const PopupMenuItem( + value: 'reactivate', + child: Text('Reactivate', + style: TextStyle(color: Color(0xFF0E9F6E))), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _RoleBadge extends StatelessWidget { + final String role; + + const _RoleBadge({required this.role}); + + @override + Widget build(BuildContext context) { + final colors = { + 'OWNER': (const Color(0xFFFFF3CD), const Color(0xFF92400E)), + 'MANAGER': (const Color(0xFFE1EFFE), const Color(0xFF1E40AF)), + 'AGENT': (const Color(0xFFF3F4F6), const Color(0xFF374151)), + }; + final (bg, fg) = colors[role] ?? (const Color(0xFFF3F4F6), const Color(0xFF374151)); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: + BoxDecoration(color: bg, borderRadius: BorderRadius.circular(4)), + child: Text(role, + style: TextStyle( + fontSize: 10, color: fg, fontWeight: FontWeight.w600)), + ); + } +} + +class _InviteSheet extends ConsumerStatefulWidget { + const _InviteSheet(); + + @override + ConsumerState<_InviteSheet> createState() => _InviteSheetState(); +} + +class _InviteSheetState extends ConsumerState<_InviteSheet> { + final _formKey = GlobalKey(); + bool _loading = false; + + final _firstName = TextEditingController(); + final _lastName = TextEditingController(); + final _email = TextEditingController(); + String _role = 'AGENT'; + + @override + void dispose() { + for (final c in [_firstName, _lastName, _email]) { + c.dispose(); + } + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _loading = true); + try { + await ref.read(teamServiceProvider).inviteMember({ + 'firstName': _firstName.text.trim(), + 'lastName': _lastName.text.trim(), + 'email': _email.text.trim(), + 'role': _role, + }); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('Invite Team Member', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + const Spacer(), + if (_loading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + else + TextButton( + onPressed: _submit, child: const Text('Invite')), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _firstName, + decoration: const InputDecoration(labelText: 'First Name'), + validator: (v) => + v == null || v.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _lastName, + decoration: const InputDecoration(labelText: 'Last Name'), + validator: (v) => + v == null || v.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 12), + TextFormField( + controller: _email, + keyboardType: TextInputType.emailAddress, + decoration: const InputDecoration(labelText: 'Email'), + validator: (v) => + v == null || v.isEmpty ? 'Required' : null, + ), + const SizedBox(height: 12), + InputDecorator( + decoration: const InputDecoration(labelText: 'Role'), + child: DropdownButton( + value: _role, + isExpanded: true, + underline: const SizedBox(), + items: const [ + DropdownMenuItem(value: 'MANAGER', child: Text('Manager')), + DropdownMenuItem(value: 'AGENT', child: Text('Agent')), + ], + onChanged: (v) => setState(() => _role = v!), + ), + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _loading ? null : _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48)), + child: const Text('Send Invitation'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/vehicle_detail_screen.dart b/lib/features/dashboard/screens/vehicle_detail_screen.dart index c9ad330..48c2e11 100644 --- a/lib/features/dashboard/screens/vehicle_detail_screen.dart +++ b/lib/features/dashboard/screens/vehicle_detail_screen.dart @@ -1,16 +1,30 @@ +import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; import '../providers/dashboard_providers.dart'; import '../../../core/constants/app_constants.dart'; +import '../../../core/services/vehicle_service.dart'; import '../../../widgets/status_badge.dart'; import '../../../widgets/error_view.dart'; +import 'vehicle_form_screen.dart'; class VehicleDetailScreen extends ConsumerWidget { final String id; const VehicleDetailScreen({super.key, required this.id}); + String _resolveUrl(String photo) { + if (!photo.startsWith('http')) { + return '${AppConstants.baseUrl.replaceAll('/api/v1', '')}$photo'; + } + if (Platform.isAndroid) { + return photo.replaceFirst('localhost', '10.0.2.2'); + } + return photo; + } + @override Widget build(BuildContext context, WidgetRef ref) { final vehicleAsync = ref.watch(vehicleDetailProvider(id)); @@ -27,128 +41,414 @@ class VehicleDetailScreen extends ConsumerWidget { ), ), data: (vehicle) { - final baseUrl = - AppConstants.baseUrl.replaceAll('/api/v1', ''); - return Scaffold( - body: CustomScrollView( - slivers: [ - SliverAppBar( - expandedHeight: 240, - pinned: true, - flexibleSpace: FlexibleSpaceBar( - background: vehicle.primaryPhoto != null - ? CachedNetworkImage( - imageUrl: vehicle.primaryPhoto!.startsWith('http') - ? vehicle.primaryPhoto! - : '$baseUrl${vehicle.primaryPhoto!}', - fit: BoxFit.cover, - errorWidget: (_, _, _) => Container( - color: const Color(0xFFF3F4F6), - child: const Icon(Icons.directions_car, - size: 72, color: Color(0xFFD1D5DB)), + return DefaultTabController( + length: 3, + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverAppBar( + expandedHeight: 240, + pinned: true, + actions: [ + IconButton( + icon: const Icon(Icons.add_photo_alternate_outlined), + onPressed: () async { + final result = await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => VehiclePhotosSheet( + vehicleId: id, + currentPhotos: vehicle.photos + .map((p) => _resolveUrl(p)) + .toList(), ), - ) - : Container( - color: const Color(0xFFF3F4F6), - child: const Icon(Icons.directions_car, - size: 72, color: Color(0xFFD1D5DB)), - ), - ), - ), - SliverPadding( - padding: const EdgeInsets.all(20), - sliver: SliverList( - delegate: SliverChildListDelegate([ - Row( - children: [ - Expanded( - child: Text( - vehicle.displayName, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - color: Color(0xFF111928), - ), - ), - ), - StatusBadge(status: vehicle.status), - ], + ); + if (result == true) { + ref.invalidate(vehicleDetailProvider(id)); + } + }, ), - const SizedBox(height: 8), - Text( - vehicle.licensePlate, - style: const TextStyle( - fontSize: 15, color: Color(0xFF6B7280)), + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + VehicleFormScreen(vehicle: vehicle)), + ); + if (result == true) { + ref.invalidate(vehicleDetailProvider(id)); + } + }, ), - const SizedBox(height: 20), - _InfoGrid(vehicle: vehicle), - const SizedBox(height: 20), - const Text( - 'Pricing', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF111928), - ), - ), - const SizedBox(height: 12), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - const Icon(Icons.attach_money, - color: Color(0xFF1A56DB)), - const SizedBox(width: 8), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '\$${vehicle.dailyRate.toStringAsFixed(2)}/day', - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.bold, - color: Color(0xFF1A56DB), - ), - ), - const Text( - 'Daily rate', - style: TextStyle( - fontSize: 12, - color: Color(0xFF6B7280)), + PopupMenuButton( + onSelected: (v) async { + if (v == 'delete') { + final confirmed = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Delete Vehicle'), + content: Text( + 'Delete ${vehicle.displayName}? This cannot be undone.'), + actions: [ + TextButton( + onPressed: () => + Navigator.pop(context, false), + child: const Text('Cancel')), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: + const Color(0xFFE02424)), + onPressed: () => + Navigator.pop(context, true), + child: const Text('Delete'), ), ], ), - const Spacer(), - Switch( - value: vehicle.isPublished, - onChanged: (v) async { - try { - await ref - .read(vehicleServiceProvider) - .togglePublish(id, v); - ref.invalidate(vehicleDetailProvider(id)); - } catch (_) {} - }, - ), - const Text( - 'Published', - style: TextStyle( - fontSize: 12, color: Color(0xFF6B7280)), - ), - ], + ); + if (confirmed == true && context.mounted) { + await ref + .read(vehicleServiceProvider) + .deleteVehicle(id); + if (context.mounted) Navigator.of(context).pop(); + } + } + }, + itemBuilder: (_) => const [ + PopupMenuItem( + value: 'delete', + child: Text('Delete', + style: TextStyle(color: Color(0xFFE02424))), ), - ), + ], ), - ]), + ], + flexibleSpace: FlexibleSpaceBar( + background: vehicle.primaryPhoto != null + ? CachedNetworkImage( + imageUrl: _resolveUrl(vehicle.primaryPhoto!), + fit: BoxFit.cover, + errorWidget: (context, url, _) => _placeholder(), + ) + : _placeholder(), + ), ), + SliverPersistentHeader( + pinned: true, + delegate: _TabBarDelegate( + TabBar( + tabs: const [ + Tab(text: 'Details'), + Tab(text: 'Calendar'), + Tab(text: 'Maintenance'), + ], + ), + ), + ), + ], + body: TabBarView( + children: [ + _DetailsTab( + vehicle: vehicle, + onPublishChanged: (v) async { + await ref + .read(vehicleServiceProvider) + .togglePublish(id, v); + ref.invalidate(vehicleDetailProvider(id)); + }, + ), + _CalendarTab(vehicleId: id), + _MaintenanceTab(vehicleId: id), + ], ), - ], + ), ), ); }, ); } + + Widget _placeholder() => Container( + color: const Color(0xFFF3F4F6), + child: const Icon(Icons.directions_car, + size: 72, color: Color(0xFFD1D5DB)), + ); +} + +class _TabBarDelegate extends SliverPersistentHeaderDelegate { + final TabBar tabBar; + + _TabBarDelegate(this.tabBar); + + @override + double get minExtent => tabBar.preferredSize.height; + @override + double get maxExtent => tabBar.preferredSize.height; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) => + Container( + color: Theme.of(context).scaffoldBackgroundColor, + child: tabBar, + ); + + @override + bool shouldRebuild(_TabBarDelegate old) => false; +} + +class _DetailsTab extends StatelessWidget { + final dynamic vehicle; + final ValueChanged onPublishChanged; + + const _DetailsTab({ + required this.vehicle, + required this.onPublishChanged, + }); + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(20), + children: [ + Row( + children: [ + Expanded( + child: Text( + vehicle.displayName, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Color(0xFF111928), + ), + ), + ), + StatusBadge(status: vehicle.status), + ], + ), + const SizedBox(height: 8), + Text( + vehicle.licensePlate, + style: + const TextStyle(fontSize: 15, color: Color(0xFF6B7280)), + ), + const SizedBox(height: 20), + _InfoGrid(vehicle: vehicle), + const SizedBox(height: 20), + const Text( + 'Pricing', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF111928), + ), + ), + const SizedBox(height: 12), + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Icon(Icons.attach_money, color: Color(0xFF1A56DB)), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '\$${(vehicle.dailyRate / 100).toStringAsFixed(2)}/day', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFF1A56DB), + ), + ), + const Text( + 'Daily rate', + style: TextStyle( + fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ), + const Spacer(), + Switch( + value: vehicle.isPublished, + onChanged: onPublishChanged, + ), + const Text( + 'Published', + style: TextStyle( + fontSize: 12, color: Color(0xFF6B7280)), + ), + ], + ), + ), + ), + ], + ); + } +} + +class _MaintenanceTab extends ConsumerWidget { + final String vehicleId; + + const _MaintenanceTab({required this.vehicleId}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final async = ref.watch(maintenanceProvider(vehicleId)); + + return async.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Could not load maintenance logs', + onRetry: () => ref.invalidate(maintenanceProvider(vehicleId)), + ), + data: (logs) => Stack( + children: [ + logs.isEmpty + ? const Center( + child: Text('No maintenance records', + style: TextStyle(color: Color(0xFF9CA3AF))), + ) + : ListView.separated( + padding: const EdgeInsets.all(16), + itemCount: logs.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (_, i) => _MaintenanceCard(log: logs[i]), + ), + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.small( + onPressed: () => + _showAddMaintenance(context, ref, vehicleId), + child: const Icon(Icons.add), + ), + ), + ], + ), + ); + } + + void _showAddMaintenance( + BuildContext context, WidgetRef ref, String vehicleId) { + final typeCtrl = TextEditingController(); + final descCtrl = TextEditingController(); + final costCtrl = TextEditingController(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Add Maintenance Log', + style: TextStyle( + fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + TextField( + controller: typeCtrl, + decoration: + const InputDecoration(labelText: 'Type (e.g. Oil Change)')), + const SizedBox(height: 12), + TextField( + controller: descCtrl, + decoration: + const InputDecoration(labelText: 'Description'), + maxLines: 2), + const SizedBox(height: 12), + TextField( + controller: costCtrl, + decoration: + const InputDecoration(labelText: 'Cost (\$)'), + keyboardType: TextInputType.number), + const SizedBox(height: 20), + ElevatedButton( + onPressed: () async { + if (typeCtrl.text.isEmpty) return; + await ref.read(vehicleServiceProvider).addMaintenance( + vehicleId, + { + 'type': typeCtrl.text.trim(), + if (descCtrl.text.isNotEmpty) + 'description': descCtrl.text.trim(), + if (costCtrl.text.isNotEmpty) + 'cost': ((double.tryParse(costCtrl.text) ?? 0) * + 100) + .round(), + 'performedAt': DateTime.now().toIso8601String(), + }, + ); + ref.invalidate(maintenanceProvider(vehicleId)); + if (context.mounted) Navigator.of(context).pop(); + }, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48)), + child: const Text('Add Log'), + ), + ], + ), + ), + ), + ); + } +} + +class _MaintenanceCard extends StatelessWidget { + final dynamic log; + + const _MaintenanceCard({required this.log}); + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + return Card( + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + log.type as String, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + if (log.cost != null) + Text( + '\$${((log.cost as int) / 100).toStringAsFixed(2)}', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: Color(0xFF1A56DB)), + ), + ], + ), + if (log.description != null) ...[ + const SizedBox(height: 4), + Text( + log.description as String, + style: const TextStyle( + fontSize: 13, color: Color(0xFF6B7280)), + ), + ], + const SizedBox(height: 6), + Text( + fmt.format(log.performedAt as DateTime), + style: + const TextStyle(fontSize: 12, color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + ); + } } class _InfoGrid extends StatelessWidget { @@ -161,10 +461,10 @@ class _InfoGrid extends StatelessWidget { final items = [ ('Category', vehicle.category), if (vehicle.seats != null) ('Seats', '${vehicle.seats}'), - if (vehicle.transmission != null) ('Transmission', vehicle.transmission!), + if (vehicle.transmission != null) + ('Transmission', vehicle.transmission!), if (vehicle.fuelType != null) ('Fuel', vehicle.fuelType!), - if (vehicle.mileage != null) - ('Mileage', '${vehicle.mileage} km'), + if (vehicle.mileage != null) ('Mileage', '${vehicle.mileage} km'), ]; return Wrap( @@ -201,3 +501,563 @@ class _InfoGrid extends StatelessWidget { ); } } + +// ─── Calendar Tab ───────────────────────────────────────────────────────────── + +class _CalendarTab extends ConsumerStatefulWidget { + final String vehicleId; + + const _CalendarTab({required this.vehicleId}); + + @override + ConsumerState<_CalendarTab> createState() => _CalendarTabState(); +} + +class _CalendarTabState extends ConsumerState<_CalendarTab> + with AutomaticKeepAliveClientMixin { + late int _year; + late int _month; + DateTime? _selectedDay; + + static const _eventColors = { + 'RESERVATION': Color(0xFF1A56DB), + 'MAINTENANCE': Color(0xFFFF6B00), + 'BLOCK': Color(0xFFE02424), + }; + + static const _eventBg = { + 'RESERVATION': Color(0xFFEBF5FF), + 'MAINTENANCE': Color(0xFFFFF3E0), + 'BLOCK': Color(0xFFFDE8E8), + }; + + @override + bool get wantKeepAlive => true; + + @override + void initState() { + super.initState(); + final now = DateTime.now(); + _year = now.year; + _month = now.month; + } + + void _prevMonth() => setState(() { + if (_month == 1) { + _month = 12; + _year--; + } else { + _month--; + } + _selectedDay = null; + }); + + void _nextMonth() => setState(() { + if (_month == 12) { + _month = 1; + _year++; + } else { + _month++; + } + _selectedDay = null; + }); + + bool _isSameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; + + List _eventsForDay( + DateTime day, List events) { + final d = DateTime(day.year, day.month, day.day); + return events.where((e) { + final s = DateTime(e.startDate.year, e.startDate.month, e.startDate.day); + final en = DateTime(e.endDate.year, e.endDate.month, e.endDate.day); + return !d.isBefore(s) && !d.isAfter(en); + }).toList(); + } + + @override + Widget build(BuildContext context) { + super.build(context); + final key = (vehicleId: widget.vehicleId, year: _year, month: _month); + final eventsAsync = ref.watch(calendarProvider(key)); + + return Stack( + children: [ + eventsAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => ErrorView( + message: 'Could not load calendar', + onRetry: () => ref.invalidate(calendarProvider(key)), + ), + data: (events) => ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 80), + children: [ + // Month navigation + Row( + children: [ + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: _prevMonth, + ), + Expanded( + child: Text( + DateFormat('MMMM yyyy').format(DateTime(_year, _month)), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFF111928), + ), + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: _nextMonth, + ), + ], + ), + const SizedBox(height: 4), + // Day-of-week headers + Row( + children: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'] + .map((d) => Expanded( + child: Center( + child: Text(d, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: Color(0xFF9CA3AF), + )), + ), + )) + .toList(), + ), + const SizedBox(height: 4), + // Calendar grid + _buildGrid(events), + const SizedBox(height: 12), + // Legend + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: _eventColors.entries.expand((entry) => [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: entry.value, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 4), + Text( + entry.key[0] + entry.key.substring(1).toLowerCase(), + style: const TextStyle( + fontSize: 11, color: Color(0xFF6B7280)), + ), + const SizedBox(width: 14), + ]).toList(), + ), + const SizedBox(height: 16), + // Selected-day events + if (_selectedDay != null) ...[ + Text( + DateFormat('EEEE, MMMM d').format(_selectedDay!), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Color(0xFF374151), + ), + ), + const SizedBox(height: 10), + ..._eventsForDay(_selectedDay!, events).map((e) => _EventTile( + event: e, + color: _eventColors[e.type] ?? Colors.grey, + bg: _eventBg[e.type] ?? const Color(0xFFF3F4F6), + onDelete: e.type == 'BLOCK' + ? () async { + await ref + .read(vehicleServiceProvider) + .deleteCalendarBlock( + widget.vehicleId, e.id); + ref.invalidate(calendarProvider(key)); + } + : null, + )), + if (_eventsForDay(_selectedDay!, events).isEmpty) + const Text( + 'No events on this day', + style: TextStyle(color: Color(0xFF9CA3AF)), + ), + ], + ], + ), + ), + Positioned( + bottom: 16, + right: 16, + child: FloatingActionButton.small( + heroTag: 'cal_add', + tooltip: 'Block dates', + onPressed: () => _showAddBlock(context), + child: const Icon(Icons.block), + ), + ), + ], + ); + } + + Widget _buildGrid(List events) { + final daysInMonth = DateTime(_year, _month + 1, 0).day; + final firstDay = DateTime(_year, _month, 1); + final startOffset = (firstDay.weekday - 1) % 7; + + final cells = [ + for (int i = 0; i < startOffset; i++) const SizedBox(), + for (int day = 1; day <= daysInMonth; day++) + _buildDayCell(day, events), + ]; + + while (cells.length % 7 != 0) { + cells.add(const SizedBox()); + } + + return GridView.count( + crossAxisCount: 7, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + children: cells, + ); + } + + Widget _buildDayCell(int day, List events) { + final date = DateTime(_year, _month, day); + final dayEvents = _eventsForDay(date, events); + final isSelected = + _selectedDay != null && _isSameDay(_selectedDay!, date); + final isToday = _isSameDay(DateTime.now(), date); + + return GestureDetector( + onTap: () => setState( + () => _selectedDay = isSelected ? null : date), + child: Container( + margin: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF1A56DB) + : isToday + ? const Color(0xFFEBF5FF) + : null, + borderRadius: BorderRadius.circular(6), + border: isToday && !isSelected + ? Border.all(color: const Color(0xFF1A56DB)) + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '$day', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected + ? Colors.white + : isToday + ? const Color(0xFF1A56DB) + : const Color(0xFF374151), + ), + ), + if (dayEvents.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: dayEvents + .take(3) + .map((e) => Container( + width: 4, + height: 4, + margin: + const EdgeInsets.symmetric(horizontal: 1), + decoration: BoxDecoration( + color: isSelected + ? Colors.white70 + : (_eventColors[e.type] ?? + Colors.grey), + shape: BoxShape.circle, + ), + )) + .toList(), + ), + ), + ], + ), + ), + ); + } + + void _showAddBlock(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _AddBlockSheet( + vehicleId: widget.vehicleId, + initialStart: _selectedDay ?? DateTime.now(), + onSaved: () { + final key = + (vehicleId: widget.vehicleId, year: _year, month: _month); + ref.invalidate(calendarProvider(key)); + }, + ), + ); + } +} + +class _EventTile extends StatelessWidget { + final CalendarEvent event; + final Color color; + final Color bg; + final VoidCallback? onDelete; + + const _EventTile({ + required this.event, + required this.color, + required this.bg, + this.onDelete, + }); + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d'); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + Container( + width: 4, + height: 36, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.label, + style: TextStyle( + fontWeight: FontWeight.w600, + color: color, + fontSize: 13, + ), + ), + Text( + '${fmt.format(event.startDate)} → ${fmt.format(event.endDate)}', + style: const TextStyle( + fontSize: 11, color: Color(0xFF6B7280)), + ), + if (event.status != null) + Text( + event.status!, + style: const TextStyle( + fontSize: 11, color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + if (onDelete != null) + IconButton( + icon: const Icon(Icons.delete_outline, size: 18), + color: const Color(0xFFE02424), + onPressed: onDelete, + ), + ], + ), + ); + } +} + +class _AddBlockSheet extends ConsumerStatefulWidget { + final String vehicleId; + final DateTime initialStart; + final VoidCallback onSaved; + + const _AddBlockSheet({ + required this.vehicleId, + required this.initialStart, + required this.onSaved, + }); + + @override + ConsumerState<_AddBlockSheet> createState() => _AddBlockSheetState(); +} + +class _AddBlockSheetState extends ConsumerState<_AddBlockSheet> { + late DateTime _start; + late DateTime _end; + final _reasonCtrl = TextEditingController(); + String _blockType = 'MANUAL'; + bool _saving = false; + String? _error; + + @override + void initState() { + super.initState(); + _start = widget.initialStart; + _end = widget.initialStart.add(const Duration(days: 1)); + } + + @override + void dispose() { + _reasonCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() { + _saving = true; + _error = null; + }); + try { + await ref.read(vehicleServiceProvider).createCalendarBlock( + widget.vehicleId, + { + 'startDate': + '${_start.toIso8601String().substring(0, 10)}T00:00:00.000Z', + 'endDate': + '${_end.toIso8601String().substring(0, 10)}T23:59:59.000Z', + 'type': _blockType, + if (_reasonCtrl.text.trim().isNotEmpty) + 'reason': _reasonCtrl.text.trim(), + }, + ); + widget.onSaved(); + if (mounted) Navigator.of(context).pop(); + } catch (e) { + setState(() { + _saving = false; + _error = 'Failed to create block. Please try again.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final fmt = DateFormat('MMM d, yyyy'); + final far = DateTime.now().add(const Duration(days: 365 * 2)); + final near = DateTime.now().subtract(const Duration(days: 365)); + + return Padding( + padding: + EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Block Dates', + style: + TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 14), + label: Text(fmt.format(_start)), + onPressed: () async { + final d = await showDatePicker( + context: context, + initialDate: _start, + firstDate: near, + lastDate: far, + ); + if (d != null) { + setState(() { + _start = d; + if (_end.isBefore(_start)) _end = _start; + }); + } + }, + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 8), + child: Text('→'), + ), + Expanded( + child: OutlinedButton.icon( + icon: const Icon(Icons.calendar_today, size: 14), + label: Text(fmt.format(_end)), + onPressed: () async { + final d = await showDatePicker( + context: context, + initialDate: _end, + firstDate: _start, + lastDate: far, + ); + if (d != null) setState(() => _end = d); + }, + ), + ), + ], + ), + const SizedBox(height: 12), + InputDecorator( + decoration: const InputDecoration(labelText: 'Block Type'), + child: DropdownButton( + value: _blockType, + isExpanded: true, + underline: const SizedBox(), + items: const [ + DropdownMenuItem( + value: 'MANUAL', child: Text('Manual Block')), + DropdownMenuItem( + value: 'MAINTENANCE', child: Text('Maintenance')), + ], + onChanged: (v) => setState(() => _blockType = v!), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _reasonCtrl, + decoration: + const InputDecoration(labelText: 'Reason (optional)'), + ), + if (_error != null) ...[ + const SizedBox(height: 10), + Text(_error!, + style: const TextStyle(color: Color(0xFF9B1C1C))), + ], + const SizedBox(height: 20), + ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48)), + child: _saving + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: + AlwaysStoppedAnimation(Colors.white)), + ) + : const Text('Block Dates'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/vehicle_form_screen.dart b/lib/features/dashboard/screens/vehicle_form_screen.dart new file mode 100644 index 0000000..79f4b2a --- /dev/null +++ b/lib/features/dashboard/screens/vehicle_form_screen.dart @@ -0,0 +1,347 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:image_picker/image_picker.dart'; +import '../../../core/models/vehicle.dart'; +import '../providers/dashboard_providers.dart'; + +class VehicleFormScreen extends ConsumerStatefulWidget { + final Vehicle? vehicle; + + const VehicleFormScreen({super.key, this.vehicle}); + + @override + ConsumerState createState() => _VehicleFormScreenState(); +} + +class _VehicleFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + bool _loading = false; + + late final _make = TextEditingController(text: widget.vehicle?.make); + late final _model = TextEditingController(text: widget.vehicle?.model); + late final _year = TextEditingController( + text: widget.vehicle?.year.toString() ?? DateTime.now().year.toString()); + late final _plate = + TextEditingController(text: widget.vehicle?.licensePlate); + late final _rate = TextEditingController( + text: widget.vehicle != null + ? (widget.vehicle!.dailyRate / 100).toStringAsFixed(2) + : ''); + late final _mileage = TextEditingController( + text: widget.vehicle?.mileage?.toString()); + late final _seats = TextEditingController( + text: widget.vehicle?.seats?.toString() ?? '5'); + late final _color = TextEditingController(text: widget.vehicle?.color ?? ''); + + String _category = 'ECONOMY'; + String _transmission = 'AUTOMATIC'; + String _fuelType = 'GASOLINE'; + + static const _categories = [ + 'ECONOMY', 'COMPACT', 'MIDSIZE', 'FULLSIZE', 'SUV', 'LUXURY', 'VAN', 'TRUCK', + ]; + static const _transmissions = ['AUTOMATIC', 'MANUAL']; + static const _fuels = ['GASOLINE', 'DIESEL', 'ELECTRIC', 'HYBRID']; + + @override + void initState() { + super.initState(); + if (widget.vehicle != null) { + _category = widget.vehicle!.category; + _transmission = widget.vehicle!.transmission ?? 'AUTOMATIC'; + _fuelType = widget.vehicle!.fuelType ?? 'GASOLINE'; + } + } + + @override + void dispose() { + for (final c in [_make, _model, _year, _plate, _rate, _mileage, _seats, _color]) { + c.dispose(); + } + super.dispose(); + } + + Future _submit() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _loading = true); + try { + final data = { + 'make': _make.text.trim(), + 'model': _model.text.trim(), + 'year': int.parse(_year.text), + 'licensePlate': _plate.text.trim(), + 'dailyRate': (double.parse(_rate.text) * 100).round(), + 'category': _category, + 'transmission': _transmission, + 'fuelType': _fuelType, + 'seats': int.tryParse(_seats.text) ?? 5, + if (_mileage.text.isNotEmpty) 'mileage': int.parse(_mileage.text), + if (_color.text.isNotEmpty) 'color': _color.text.trim(), + }; + final svc = ref.read(vehicleServiceProvider); + if (widget.vehicle == null) { + await svc.createVehicle(data); + } else { + await svc.updateVehicle(widget.vehicle!.id, data); + } + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e'))); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + @override + Widget build(BuildContext context) { + final isEdit = widget.vehicle != null; + return Scaffold( + appBar: AppBar( + title: Text(isEdit ? 'Edit Vehicle' : 'New Vehicle'), + actions: [ + if (_loading) + const Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)), + ) + else + TextButton( + onPressed: _submit, + child: const Text('Save'), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _section('Basic Info', [ + _field('Make', _make, required: true), + _field('Model', _model, required: true), + _field('Year', _year, required: true, + inputType: TextInputType.number), + _field('License Plate', _plate, required: true), + _field('Color', _color), + ]), + const SizedBox(height: 16), + _section('Specs', [ + _dropdown('Category', _category, _categories, + (v) => setState(() => _category = v!)), + _dropdown('Transmission', _transmission, _transmissions, + (v) => setState(() => _transmission = v!)), + _dropdown('Fuel Type', _fuelType, _fuels, + (v) => setState(() => _fuelType = v!)), + _field('Seats', _seats, inputType: TextInputType.number), + _field('Mileage (km)', _mileage, + inputType: TextInputType.number), + ]), + const SizedBox(height: 16), + _section('Pricing', [ + _field('Daily Rate (\$)', _rate, + required: true, inputType: TextInputType.number), + ]), + const SizedBox(height: 24), + ElevatedButton( + onPressed: _loading ? null : _submit, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), + child: Text(isEdit ? 'Save Changes' : 'Create Vehicle'), + ), + ], + ), + ), + ); + } + + Widget _section(String title, List children) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: Color(0xFF6B7280))), + const SizedBox(height: 10), + Card( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column(children: children), + ), + ), + ], + ); + } + + Widget _field(String label, TextEditingController ctrl, + {bool required = false, TextInputType? inputType}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: TextFormField( + controller: ctrl, + keyboardType: inputType, + decoration: InputDecoration(labelText: label), + validator: required + ? (v) => v == null || v.isEmpty ? 'Required' : null + : null, + ), + ); + } + + Widget _dropdown(String label, String value, List items, + ValueChanged onChanged) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: InputDecorator( + decoration: InputDecoration(labelText: label), + child: DropdownButton( + value: value, + isExpanded: true, + underline: const SizedBox(), + items: items + .map((e) => DropdownMenuItem(value: e, child: Text(e))) + .toList(), + onChanged: onChanged, + ), + ), + ); + } +} + +class VehiclePhotosSheet extends ConsumerStatefulWidget { + final String vehicleId; + final List currentPhotos; + + const VehiclePhotosSheet({ + super.key, + required this.vehicleId, + required this.currentPhotos, + }); + + @override + ConsumerState createState() => _VehiclePhotosSheetState(); +} + +class _VehiclePhotosSheetState extends ConsumerState { + bool _uploading = false; + + Future _pickAndUpload() async { + final picker = ImagePicker(); + final files = await picker.pickMultiImage(imageQuality: 80); + if (files.isEmpty) return; + setState(() => _uploading = true); + try { + await ref + .read(vehicleServiceProvider) + .uploadPhotos(widget.vehicleId, files.map((f) => f.path).toList()); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Upload failed: $e'))); + } + } finally { + if (mounted) setState(() => _uploading = false); + } + } + + Future _deletePhoto(int idx) async { + try { + await ref + .read(vehicleServiceProvider) + .deletePhoto(widget.vehicleId, idx); + if (mounted) Navigator.of(context).pop(true); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text('Delete failed: $e'))); + } + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Text('Photos', + style: + TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const Spacer(), + if (_uploading) + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2)) + else + IconButton( + icon: const Icon(Icons.add_photo_alternate_outlined), + onPressed: _pickAndUpload, + ), + ], + ), + const SizedBox(height: 12), + if (widget.currentPhotos.isEmpty) + const Padding( + padding: EdgeInsets.all(24), + child: Text('No photos yet', + style: TextStyle(color: Color(0xFF9CA3AF))), + ) + else + SizedBox( + height: 100, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: widget.currentPhotos.length, + separatorBuilder: (_, _) => const SizedBox(width: 8), + itemBuilder: (_, i) => Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + widget.currentPhotos[i], + width: 100, + height: 100, + fit: BoxFit.cover, + ), + ), + Positioned( + top: 2, + right: 2, + child: GestureDetector( + onTap: () => _deletePhoto(i), + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: Color(0xFFE02424), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close, + size: 14, color: Colors.white), + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ), + ); + } +} diff --git a/lib/features/dashboard/screens/vehicles_screen.dart b/lib/features/dashboard/screens/vehicles_screen.dart index 6233bff..1bd9969 100644 --- a/lib/features/dashboard/screens/vehicles_screen.dart +++ b/lib/features/dashboard/screens/vehicles_screen.dart @@ -5,6 +5,8 @@ import '../providers/dashboard_providers.dart'; import '../../../widgets/vehicle_card.dart'; import '../../../widgets/loading_list.dart'; import '../../../widgets/error_view.dart'; +import 'dashboard_shell.dart'; +import 'vehicle_form_screen.dart'; class VehiclesScreen extends ConsumerStatefulWidget { const VehiclesScreen({super.key}); @@ -25,17 +27,26 @@ class _VehiclesScreenState extends ConsumerState { } Map get _params => { - 'page': 1, - 'pageSize': 50, - if (_statusFilter != null) 'status': _statusFilter, - if (_search.isNotEmpty) 'search': _search, - }; + 'page': 1, + 'pageSize': 50, + if (_statusFilter != null) 'status': _statusFilter, + if (_search.isNotEmpty) 'search': _search, + }; @override Widget build(BuildContext context) { final vehiclesAsync = ref.watch(vehiclesProvider(_params)); - return Scaffold( + return DashboardShell( + floatingActionButton: FloatingActionButton( + onPressed: () async { + final result = await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const VehicleFormScreen()), + ); + if (result == true) ref.invalidate(vehiclesProvider(_params)); + }, + child: const Icon(Icons.add), + ), appBar: AppBar( title: const Text('Vehicles'), bottom: PreferredSize( @@ -57,9 +68,7 @@ class _VehiclesScreenState extends ConsumerState { PopupMenuButton( icon: Icon( Icons.filter_list, - color: _statusFilter != null - ? const Color(0xFF1A56DB) - : null, + color: _statusFilter != null ? const Color(0xFF1A56DB) : null, ), onSelected: (v) => setState(() => _statusFilter = v), itemBuilder: (_) => [ @@ -67,7 +76,9 @@ class _VehiclesScreenState extends ConsumerState { const PopupMenuItem(value: 'AVAILABLE', child: Text('Available')), const PopupMenuItem(value: 'RENTED', child: Text('Rented')), const PopupMenuItem( - value: 'MAINTENANCE', child: Text('Maintenance')), + value: 'MAINTENANCE', + child: Text('Maintenance'), + ), ], ), ], @@ -80,15 +91,17 @@ class _VehiclesScreenState extends ConsumerState { ), data: (res) => res.data.isEmpty ? const Center( - child: Text('No vehicles found', - style: TextStyle(color: Color(0xFF9CA3AF)))) + child: Text( + 'No vehicles found', + style: TextStyle(color: Color(0xFF9CA3AF)), + ), + ) : RefreshIndicator( onRefresh: () async => ref.invalidate(vehiclesProvider(_params)), child: GridView.builder( padding: const EdgeInsets.all(16), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 1, mainAxisExtent: 240, mainAxisSpacing: 12, @@ -96,8 +109,8 @@ class _VehiclesScreenState extends ConsumerState { itemCount: res.data.length, itemBuilder: (_, i) => VehicleCard( vehicle: res.data[i], - onTap: () => context - .push('/dashboard/vehicles/${res.data[i].id}'), + onTap: () => + context.push('/dashboard/vehicles/${res.data[i].id}'), ), ), ), diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 671acaa..0ef463d 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -3,7 +3,7 @@ "appName": "RentalDriveGo", "tagline": "أسهل طريقة للعثور\nوحجز سيارتك المثالية", "findYourCar": "ابحث عن سيارتك", - "companySignIn": "تسجيل دخول لوحة تحكم الشركة", + "companySignIn": "وكالة السيارات", "sameDropoff": "إرجاع نفس المكان", "differentDropoff": "إرجاع مكان آخر", "findCarToRent": "ابحث عن سيارة للإيجار", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1a912dc..f8189cd 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -3,7 +3,7 @@ "appName": "RentalDriveGo", "tagline": "The easiest way to find\nand reserve your perfect car", "findYourCar": "Find your car", - "companySignIn": "Company dashboard sign in", + "companySignIn": "Space Agency", "sameDropoff": "Same drop-off", "differentDropoff": "Different drop-off", "findCarToRent": "Find a car to rent", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 41efe30..cbf495a 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -3,7 +3,7 @@ "appName": "RentalDriveGo", "tagline": "La façon la plus simple de trouver\net réserver la voiture parfaite", "findYourCar": "Trouver ma voiture", - "companySignIn": "Connexion tableau de bord entreprise", + "companySignIn": "Espace Agence", "sameDropoff": "Retour même lieu", "differentDropoff": "Retour autre lieu", "findCarToRent": "Trouver une voiture à louer", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index de5ed8a..d48db3c 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -121,7 +121,7 @@ abstract class AppLocalizations { /// No description provided for @companySignIn. /// /// In en, this message translates to: - /// **'Company dashboard sign in'** + /// **'Space Agency'** String get companySignIn; /// No description provided for @sameDropoff. diff --git a/lib/l10n/app_localizations_ar.dart b/lib/l10n/app_localizations_ar.dart index 3eb23ac..a2f6a45 100644 --- a/lib/l10n/app_localizations_ar.dart +++ b/lib/l10n/app_localizations_ar.dart @@ -18,7 +18,7 @@ class AppLocalizationsAr extends AppLocalizations { String get findYourCar => 'ابحث عن سيارتك'; @override - String get companySignIn => 'تسجيل دخول لوحة تحكم الشركة'; + String get companySignIn => 'وكالة السيارات'; @override String get sameDropoff => 'إرجاع نفس المكان'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 02658f5..c4b0a38 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -18,7 +18,7 @@ class AppLocalizationsEn extends AppLocalizations { String get findYourCar => 'Find your car'; @override - String get companySignIn => 'Company dashboard sign in'; + String get companySignIn => 'Space Agency'; @override String get sameDropoff => 'Same drop-off'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 6fd86d8..7df8f66 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -19,7 +19,7 @@ class AppLocalizationsFr extends AppLocalizations { String get findYourCar => 'Trouver ma voiture'; @override - String get companySignIn => 'Connexion tableau de bord entreprise'; + String get companySignIn => 'Espace Agence'; @override String get sameDropoff => 'Retour même lieu'; diff --git a/lib/widgets/preferences_bar.dart b/lib/widgets/preferences_bar.dart index 906cb60..161e9ee 100644 --- a/lib/widgets/preferences_bar.dart +++ b/lib/widgets/preferences_bar.dart @@ -5,7 +5,13 @@ import '../core/providers/theme_provider.dart'; const _orange = Color(0xFFFF6B00); -/// Compact row of language pills + theme toggle. +const _langs = [ + ('EN', Locale('en')), + ('FR', Locale('fr')), + ('AR', Locale('ar')), +]; + +/// Compact language dropdown + theme toggle. /// [onDark] = true when rendered on a dark background (landing screen). class PreferencesBar extends ConsumerWidget { final bool onDark; @@ -17,69 +23,100 @@ class PreferencesBar extends ConsumerWidget { final currentLocale = ref.watch(localeProvider); final themeMode = ref.watch(themeProvider); final isDark = themeMode == ThemeMode.dark; + final cs = Theme.of(context).colorScheme; - final inactiveText = onDark - ? Colors.white.withValues(alpha: 0.55) - : Theme.of(context).colorScheme.onSurfaceVariant; - final activeText = Colors.white; - final inactiveBg = onDark + final chipBg = onDark ? Colors.white.withValues(alpha: 0.12) - : Theme.of(context).colorScheme.surfaceContainerHighest; - - const langs = [ - ('EN', Locale('en')), - ('FR', Locale('fr')), - ('AR', Locale('ar')), - ]; + : cs.surfaceContainerHighest; + final iconColor = onDark + ? Colors.white.withValues(alpha: 0.8) + : cs.onSurfaceVariant; + final langCode = currentLocale.languageCode.toUpperCase(); return Row( mainAxisSize: MainAxisSize.min, children: [ - // Language pills - ...langs.map((item) { - final (code, locale) = item; - final selected = - currentLocale.languageCode == locale.languageCode; - return GestureDetector( - onTap: () => ref.read(localeProvider.notifier).setLocale(locale), - child: Container( - margin: const EdgeInsets.only(right: 6), - padding: - const EdgeInsets.symmetric(horizontal: 11, vertical: 6), - decoration: BoxDecoration( - color: selected ? _orange : inactiveBg, - borderRadius: BorderRadius.circular(20), - ), - child: Text( - code, - style: TextStyle( - color: selected ? activeText : inactiveText, - fontSize: 12, - fontWeight: - selected ? FontWeight.bold : FontWeight.normal, - ), + // ── Language dropdown ────────────────────────────────────────── + PopupMenuButton( + onSelected: (locale) => + ref.read(localeProvider.notifier).setLocale(locale), + color: cs.surfaceContainerHigh, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: cs.outlineVariant), + ), + offset: const Offset(0, 36), + itemBuilder: (_) => _langs.map((item) { + final (code, locale) = item; + final selected = + currentLocale.languageCode == locale.languageCode; + return PopupMenuItem( + value: locale, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Expanded( + child: Text( + code, + style: TextStyle( + color: selected ? _orange : cs.onSurface, + fontWeight: selected + ? FontWeight.bold + : FontWeight.normal, + fontSize: 14, + ), + ), + ), + if (selected) + const Icon(Icons.check, size: 16, color: _orange), + ], ), + ); + }).toList(), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: chipBg, + borderRadius: BorderRadius.circular(20), ), - ); - }), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + langCode, + style: TextStyle( + color: onDark ? Colors.white : cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 2), + Icon( + Icons.expand_more, + size: 14, + color: iconColor, + ), + ], + ), + ), + ), - const SizedBox(width: 2), + const SizedBox(width: 6), - // Theme toggle icon + // ── Theme toggle ─────────────────────────────────────────────── GestureDetector( onTap: () => ref.read(themeProvider.notifier).toggle(), child: Container( padding: const EdgeInsets.all(7), decoration: BoxDecoration( - color: inactiveBg, + color: chipBg, borderRadius: BorderRadius.circular(20), ), child: Icon( isDark ? Icons.light_mode_outlined : Icons.dark_mode_outlined, size: 15, - color: onDark - ? Colors.white.withValues(alpha: 0.8) - : Theme.of(context).colorScheme.onSurfaceVariant, + color: iconColor, ), ), ), diff --git a/lib/widgets/reservation_card.dart b/lib/widgets/reservation_card.dart index 5b8dce6..ba9d806 100644 --- a/lib/widgets/reservation_card.dart +++ b/lib/widgets/reservation_card.dart @@ -63,7 +63,7 @@ class ReservationCard extends StatelessWidget { size: 14, color: Color(0xFF9CA3AF)), const SizedBox(width: 4), Text( - '\$${reservation.totalAmount.toStringAsFixed(2)}', + '\$${(reservation.totalAmount / 100).toStringAsFixed(2)}', style: const TextStyle( fontWeight: FontWeight.w600, color: Color(0xFF111928), diff --git a/lib/widgets/status_badge.dart b/lib/widgets/status_badge.dart index a45e467..f4b9470 100644 --- a/lib/widgets/status_badge.dart +++ b/lib/widgets/status_badge.dart @@ -41,6 +41,8 @@ class StatusBadge extends StatelessWidget { return (const Color(0xFF1A56DB), const Color(0xFFE1EFFE), 'Active'); case 'COMPLETED': return (const Color(0xFF5521B5), const Color(0xFFEDEBFE), 'Completed'); + case 'CLOSED': + return (const Color(0xFF374151), const Color(0xFFF3F4F6), 'Closed'); case 'CANCELLED': return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'Cancelled'); case 'DRAFT': @@ -49,6 +51,10 @@ class StatusBadge extends StatelessWidget { return (const Color(0xFF9B1C1C), const Color(0xFFFDE8E8), 'No Show'); case 'PENDING': return (const Color(0xFFB45309), const Color(0xFFFDF6B2), 'Pending'); + case 'PARTIAL': + return (const Color(0xFFB45309), const Color(0xFFFDF6B2), 'Partial'); + case 'PAID': + return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Paid'); case 'SUCCEEDED': return (const Color(0xFF057A55), const Color(0xFFDEF7EC), 'Paid'); case 'FAILED': diff --git a/lib/widgets/vehicle_card.dart b/lib/widgets/vehicle_card.dart index 4dc67ee..d79ed49 100644 --- a/lib/widgets/vehicle_card.dart +++ b/lib/widgets/vehicle_card.dart @@ -55,7 +55,7 @@ class VehicleCard extends StatelessWidget { const Icon(Icons.attach_money, size: 16, color: Color(0xFF1A56DB)), Text( - '${vehicle.dailyRate.toStringAsFixed(0)}/day', + '\$${(vehicle.dailyRate / 100).toStringAsFixed(0)}/day', style: const TextStyle( fontWeight: FontWeight.w600, color: Color(0xFF1A56DB), diff --git a/pubspec.lock b/pubspec.lock index 61e18c9..0bfdd2d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -105,6 +105,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -169,6 +177,38 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -219,6 +259,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0" + url: "https://pub.dev" + source: hosted + version: "2.0.34" flutter_riverpod: dependency: "direct main" description: @@ -325,6 +373,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.8.0" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "91c025426c2881c551100bce834e201c835a170151545f58d17da5180ca7d9ac" + url: "https://pub.dev" + source: hosted + version: "1.2.2" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f + url: "https://pub.dev" + source: hosted + version: "0.8.13+17" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 98592f8..468b9ba 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: cached_network_image: ^3.4.1 shimmer: ^3.0.0 fl_chart: ^0.69.0 + image_picker: ^1.1.2 dev_dependencies: flutter_test: