From 078cf90efe2fbc87a28fee7170294d033a8a6de5 Mon Sep 17 00:00:00 2001 From: abu <3109389044@qq.com> Date: Thu, 21 Aug 2025 10:50:38 +0800 Subject: [PATCH] init --- .../default.png} | Bin assets/images/bk.jpg | Bin 0 -> 121923 bytes assets/images/notify/dd.png | Bin 0 -> 2110 bytes assets/images/notify/guanzhu.png | Bin 0 -> 7882 bytes assets/images/notify/hudong.png | Bin 0 -> 8006 bytes assets/images/notify/msr.png | Bin 0 -> 2613 bytes assets/images/notify/qun.png | Bin 0 -> 7882 bytes assets/images/notify/xitong.png | Bin 0 -> 8341 bytes assets/images/svg/report.svg | 1 + ios/Podfile | 6 +- ios/Podfile.lock | 85 +- ios/Runner.xcodeproj/project.pbxproj | 32 +- ios/Runner/AppDelegate.swift | 48 +- ios/Runner/Base.lproj/Main.storyboard | 13 +- ios/Runner/Info.plist | 44 +- ios/Runner/Runner.entitlements | 9 + lib/IM/controller/chat_controller.dart | 106 +- lib/IM/controller/chat_detail_controller.dart | 46 +- .../controller/im_user_info_controller.dart | 137 ++ lib/IM/global_badge.dart | 98 +- lib/IM/im_core.dart | 9 +- lib/IM/im_friend_listeners.dart | 23 +- lib/IM/im_message.dart | 253 +- lib/IM/im_message_listeners.dart | 39 +- lib/IM/im_result.dart | 20 + lib/IM/im_service.dart | 437 +++- lib/IM/push_service.dart | 208 ++ lib/api/common_api.dart | 18 +- lib/api/shop_api.dart | 17 + lib/api/video_api.dart | 29 +- lib/bings/chat_binding.dart | 11 + lib/components/custom_sticky_header.dart | 26 +- lib/components/my_toast.dart | 25 + lib/components/network_or_asset_image.dart | 47 + lib/components/preview_video.dart | 76 + lib/components/scan_util.dart | 24 + lib/components/shark_video.dart | 2062 +++++++++++++++++ lib/controller/shop_index_controller.dart | 145 ++ lib/layouts/index.dart | 12 +- lib/main.dart | 23 +- lib/models/conversation_type.dart | 48 + lib/models/conversation_view_model.dart | 6 +- lib/models/notify_message.type.dart | 97 + lib/models/summary_type.dart | 6 + lib/pages/auth/login.dart | 16 +- lib/pages/chat/chat.dart | 1357 ++++++++--- lib/pages/chat/chat_group.dart | 1813 +++++++++++++++ lib/pages/chat/chat_no_friend.dart | 2052 ++++++++++++++++ lib/pages/chat/components/redpacket.dart | 186 +- lib/pages/chat/index.dart | 63 +- lib/pages/goods/detail.dart | 597 +++-- lib/pages/index/index.dart | 597 ++--- lib/pages/index/indexcopy.dart | 497 ++++ lib/pages/my/des.dart | 88 + lib/pages/my/index.dart | 600 +++-- lib/pages/my/nick_name.dart | 86 + lib/pages/my/setting.dart | 70 + lib/pages/my/user_info.dart | 490 ++++ lib/pages/my/vloger.dart | 648 ++++++ lib/pages/video/index.dart | 76 +- lib/pages/video/module/recommend.dart | 325 ++- lib/router/index.dart | 50 +- lib/service/http.dart | 17 +- lib/service/http_config.dart | 14 +- lib/update/upgrade_dialog.dart | 2 +- lib/update/upgrade_service.dart | 92 +- lib/utils/audio_player_service.dart | 57 + lib/utils/common.dart | 2 +- lib/utils/index.dart | 5 +- lib/utils/notification_banner.dart | 29 + lib/utils/parse_message_summary.dart | 34 +- lib/utils/snapshot.dart | 15 + lib/utils/voice_service.dart | 80 + lib/utils/wxsdk.dart | 144 ++ pubspec.lock | 256 ++ pubspec.yaml | 18 +- 76 files changed, 13043 insertions(+), 1619 deletions(-) rename assets/images/{default_avatar.png => avatar/default.png} (100%) create mode 100644 assets/images/bk.jpg create mode 100644 assets/images/notify/dd.png create mode 100644 assets/images/notify/guanzhu.png create mode 100644 assets/images/notify/hudong.png create mode 100644 assets/images/notify/msr.png create mode 100644 assets/images/notify/qun.png create mode 100644 assets/images/notify/xitong.png create mode 100644 assets/images/svg/report.svg create mode 100644 ios/Runner/Runner.entitlements create mode 100644 lib/IM/controller/im_user_info_controller.dart create mode 100644 lib/IM/push_service.dart create mode 100644 lib/api/shop_api.dart create mode 100644 lib/bings/chat_binding.dart create mode 100644 lib/components/my_toast.dart create mode 100644 lib/components/network_or_asset_image.dart create mode 100644 lib/components/preview_video.dart create mode 100644 lib/components/shark_video.dart create mode 100644 lib/controller/shop_index_controller.dart create mode 100644 lib/models/conversation_type.dart create mode 100644 lib/models/notify_message.type.dart create mode 100644 lib/models/summary_type.dart create mode 100644 lib/pages/chat/chat_group.dart create mode 100644 lib/pages/chat/chat_no_friend.dart create mode 100644 lib/pages/index/indexcopy.dart create mode 100644 lib/pages/my/des.dart create mode 100644 lib/pages/my/nick_name.dart create mode 100644 lib/pages/my/setting.dart create mode 100644 lib/pages/my/user_info.dart create mode 100644 lib/pages/my/vloger.dart create mode 100644 lib/utils/audio_player_service.dart create mode 100644 lib/utils/snapshot.dart create mode 100644 lib/utils/voice_service.dart create mode 100644 lib/utils/wxsdk.dart diff --git a/assets/images/default_avatar.png b/assets/images/avatar/default.png similarity index 100% rename from assets/images/default_avatar.png rename to assets/images/avatar/default.png diff --git a/assets/images/bk.jpg b/assets/images/bk.jpg new file mode 100644 index 0000000000000000000000000000000000000000..eb27d19b4b3d72329e1a6065ed81d4ac27a5818d GIT binary patch literal 121923 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

du@x4{*9;fzLZhDr*(8D0j&A%I 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 ibsaabsFkhk9N~DUd{xb25e=bhpW9`B| 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 aFQi}i^=ZRnY2`1;*i@fkwEpD-x&PS-$>C4{ literal 0 HcmV?d00001 diff --git a/assets/images/notify/dd.png b/assets/images/notify/dd.png new file mode 100644 index 0000000000000000000000000000000000000000..79d4bedd5c1ed9ec261913dacf591f4901137ad4 GIT binary patch literal 2110 zcmV-E2*LM>P)zCJi95YWo6o!VA#60G&KR6CMBpFOYPFRN7?n z0&Th~5)yR<5=gDospz86#Fulf?aW7Pk3IIizGL5?G?`>3o;dgKbIv{Iei*@U#Lv)z zlh6i1&j^?3r6xW2`=cB>}F3UIF58r7-`&8h&}?=Cq<+j6j5&Bti#FMM!H|)9uEzi7^)F$GU>*1irI$t*??Bdlwhh9kO^9R8T)kW69(y{28Tq+vk4tfefy ztkf|><`I5YZAnLydRbet-BOZD3|VJ-qtaQmDq8Sn`U^OnWype`6=Ei6_*j;RQZO}U zh&#|FbF=O#FCN8@DaFM^F{HnR~M;mEV)y)Jf=p6{09w;3X%7v6eyZsZH+b z0a%LVkgOx7yBlvACu9}YQZ$YDggH_S8MLi?QUmv{IC+ks5qZD~EPNCzk~36wK1+{N zG#=F`r~j5Eu3GA;3X~3Epp=r$-BN!GgSU%g)rfd)W`m zA(xsM`SD99C!viL$$OLu>M5CrT=qa2+>nDkm^(bN>QruV%g49W zmE=`|?4|%_bk>H#J*!XeQ(z|-_0Np!SGZ%K+f4zo82BMnjkjCAh0Mxxa&r%sKr0AM zc2m`|jQQm}=NWZf{$(76Eg$DQi};`M@~uU&$9!)X*|Vj7KEIOHCW4 zaWeQ$J2_$0k86&bI(QCshsDs~7gC^e#t4_)%c(&n(f|Q3hMO;z$hjN-l$rw#*GAqk z5H&pEn8E(xJeUBNG3K$9Q3L`85cpO|u27Y?cv>uh*8oa@)FU4K5jk@uMBKw0D+DxFsZ_SrQt!>a9}m|;qvB{#x5YWJZS4cG*mjq8P7 z)X051j>M1~j0n)v#d!4ujONv2(1LVPL&gV}gjfe1{Iv;2^GuV+cD%7^_hVwnsE!)$ z6~W0JuZb`bKDVb@#MmQm#YzvSP76v*w5 zgzR0lz>NQkq7%ZHnggWO^;;x1eTlqL82t#m*KwF&Ih=Ta*~?)+#aNt-db1m!KKSBh{;lPFmSE|Wtp160mR zq%|z45JG-BC`JZ%z8CC#Tge`HqNhrP5anWo%&F_rB0`8ZF+%1<6s;nJXcr^Izj2B~ z!U!{VQ{jBUqZlW=ZQUXaPoZSwz?H|_e`A+^)VrYv%E{gj=`1#>E3=(+cS${`V-Ql9 z189@QP)09!&Q`JFgpDt7pDln_Y?Up*C`O49MQ|UerkqfR*bX+GY0146MP&IUTyNk~ oqtnB4>8f{my?d4$rCVyw|6Aj2*5ShJ#sB~S07*qoM6N<$g0TG1{Qv*} literal 0 HcmV?d00001 diff --git a/assets/images/notify/guanzhu.png b/assets/images/notify/guanzhu.png new file mode 100644 index 0000000000000000000000000000000000000000..a5c88f1eba7630e10bc40f672d4b9d5f2d763390 GIT binary patch literal 7882 zcmV;*9yQ^KP)0ZH1afPLg5+Nu?~VWurQ+61RybX1ZV?!9?#`0|~{ zeyqJ7zqR(-=Q}sx7%yA?PRM-XAOM?iR2s3S|$DLOJ5Xh?cN1tirZ9`HiZjg%Y=bay$k%R1}eNH~C=Nd~#j2 z7?AahRTj*}p03{g`fwwTU^!yKh(g#LCb%l9=DOfaueltS07*+J2db~*lCTG3$Mbk^ zK3>>y)1_e-jzBr=!bor=9k!!bf@ym|V;BWK^jfiuaxL>u2Gm^lWbIdt?ZeZWai)#s#OmcLaLuQ& zB#=X&68744vYa*yuIj;QHd?I;=414={qNs!=%MS73Zu6MU|R>gBQSDPSXl|i&x6%d z!iI2G*C`NFMP4oRFhrH{OMhG#vOuj1Ax(8vj)0vL^j31n!r?Ml%yEKS+f}id z`4s1@Yk;vj(ySe(^)Xgb{iwkf$~N$oU@OFcVyr)2q6|W6z7`MXpLhc2-+cL@fvpJ+ zL3zb>vhlbMcSb5_`U~RmKJvs7op{h|lCSmGjTz~z9hw*Zc9(%yGwI?<=pDi*N|aK?~!R*U1%=NlY;a)~55s1d3!* zQFzfCnaNUgwOlh^b%l}eiGu`yhM`4(xqa7L0};>&N`+m%dpbH-1``a?+cVyUWaGgF zz9#mw`xPIPZ8*qsP%!nXL_l5XHEkl9zG|XU z`w1PRbE|^-KCK!RV_V`6R#2908D*gDO)qWoE^Dl6js7!SZ#ew0W-D}w_eE~rmkG@( zo$6V$Vp-sE$(li$PtzHp!}}V*BLZ4VMV`bvzwnl@6U!*e62{A~lZz%Db_1B28D*k} zmwn=baV*DDR@7V=wwlEqnr{}wD9zqyb{kVa;erlC8+eNLaHK?ti2UP zWqKhKhB)nnAgi(>)>I>=2x|;;0AFu)3ZHn1A#HtlzVQ zmHLt#_(@OnQPWIvRy}7tHs>hE)((L~LcibF5&w3_Kf*wcYOGIj!SDi&v2V~1nzcr0 z*LnL~I7}$(k22p-i9_esY3I;@q+UCjq%m{iV|~K(faQRevFRGw8uz4cK$}w+g|I;jwxG zSP0bTqW6dW?0@tpICIaH)8T4yn0iWvCxs?5`9QICjS_+?ogsnN4V|w=EYGBEsb3Jx z2|NDMZ*Zun)*vFD|I+$KGmTfS1zn$%mA^7ixDRXKbc*#Y?yo2q@%g~(#Lub6KV@;a znray@zeZ+bM|-E-?r9k8s7VZku4&6IN+0Ge=N6 zQ*ohJ%u`2iR)fZAH$>BdkC_XDD?W#h#mB5PIwvVs&-`qHhEfePk29IXgQSlR~(Fc4(11-QnSS^CUP>jU31bYY=pCbA%c z(M{hrU-Z6=eF@VoEC@o3P$0YSzcQCF^c|HHleD_f?_*c9(d#l;Jg0^eDepuEq4L;KZGh$B7quHFhP~I zokl|ul%@s_uT+qC^^rfH$WrTs>YGD&I4 zqxP|RVR9%A5_VoRV{C(lB9BUusjhG|^ZZ`~-gP0uX-`E}l0eeE^m*NRz$fm2{OujE zTu>RnAFBy)_~fzcP`3(ug%}GDc|lAJ)Q>}JsUZyyly`lF2Ewp_E%D#msJmP?UnOfE z72F-KtYHE*!H2T2u~4pFO^1;-hB$%85;_tDVo87flJhd7ms)PR6S($v(p)sOjjc`m zT!*gL7-i0*w3jooLcsZ8h#}0Qx?dLj{-45E<>oUU>99H8Jk{`$Cal_K&RU}q6kj5y zF-DMTQ*qF?v(|r0Wv&(lo&EIX2_ls(=SD@E^$@b}%AI(X0EO3LQBM$# zC}H$FFQmy#pzLS{)DfHaYyK*JpZb)@cfD^g6{*}Tj9^_l3U(T<~1rnNQ1b_i*w*|C@c!KmDi8 zceql|gc{8N9kcmJwfe4dWyOcoFd6LlQl!U5L6#94Y%r535d?>_N|b zMPSBvzngbZ+X z((6N_xsL-f>_81$;HM3M17T>7rvSjR%DvZ^D)Is8aP0+A4wKc#VI4V@o=HK7=gxeoo&qmK z1YbFe)EcB378Le`HKGuvPw{>6TR5n)@B64Cww1!($tSq=(wsu5l~BXr7W@0cbp{_+ zP-+wR$Y&?fd9SaoN2V2AY_q8~^3)yrj@(ZKl#4Ib&ANMp4x&EU_xyUj=V@^J`|3;#_{TMjc=}?@h}mDB94p z0!Caerd;u#eAO8w)?|yogHNa3x0VLdp(u4fI8-w*tG>fvpDhvOwkK+p zC;!FB)bA|QI?|Xd3~BwGYq^kc+zMR55Im#}sD&hBe2ewhg&0lU9ZzTaP9`3@iRl73 zFPncWhmnkJlWMBO;}^U|ISZPEq)BSFb5jiXHa$N^IHzTYoCNTl`?9nC%-8$#oNeXo zE75UURI{sIyR`L262z6C$Wa;fBY+)wKCaF^Stcyh`6(!0m<39lZnTy4Ou955HoPZV zNb>+!uc^v1Ph&{wXPCC|&6&adK^nOT)S8xsQnUIW{Pei(kNd6qeSZfx9n6KINpzw!Ay(&Mhgqc%AR?8ZDk3TZr2AUi09 zv%vTbkE(65Y{r3B?n!H3^lZp8PeV9;P5hkNFT`xZ_mwAAlUDD&CqvAA4?;bw(c;}+6YYGC{Js8!9pNT_+0FDvWI_AEb55?;I`MOCPrQ>(~Rvk zt2GT?Z_GEO5}7Tp{!N7S>wwuAEW0GZd^$#9F*KI@_E+%4mV+q*s%e$sap}u{oG++; zXruM>5X0+2G#DQSgU5b|#(B}N<~k{%m@F(=*0=A^j(ZeF>9kmoJ!@O$Wo#Zi6VO7q z`@-?v&KOOtPL;Z1Rs=htB81G^C@gT+z-k-fv0mzhevFr$gYec(z$u4F2wFDH_SO|{ z*wZ^^dpdk}U9`NJy6&sjPzX^=NA%~{Jdn9GY>N7j2m~X0{ttFxKjq(ydt9@4stq`OsgYf%tPwV1kJ^`}Qa@huiKiZNR zkYMS(yCDZ^^Rj4JS8t6Wa7n@I&&8DC&4>Mp&e<@{FoAxj-d7Cphocq$qGhCX$hX%i&r12m#ZJtl=l9T8Lb3^vwf2YTK91NMR+#~7+c_nBFOd6P!xuY#*vW`&Dg89Np zQmvPAb90l01n$**OhEVW(qaqV1%na%GUDZ&!oTd{?qxG@z7%RG+%COPD% zLt-MdU|T>fMXlWT!S8ZnGbM)+3Ra4mW*YRx&&4rOlD|rHKk`@%XYXBlyNh2)C(Hv? zR^kKsOTPw(Ft#XL019qT=lSW74*M#MFPg7!!m$pd!b624hAJ&4)Ks2zI*y4lduIRD z({=k@OK+FL;h6meM^KC&D4MQ*12xn;)8SCXd<*4=tob3f@^Ti&vc6KVr$R!7iHw&{ zR6b-_E9F^d;Fu_9to7rEmIzZ?PG4KEISl{%lYA{u-~wm9bRAu5d6(L)LjPm^UKl5w zfb^#f_AA8bT7)4scqI?8D1VvkV@7@P+Oq8CELeuGMm)`E7 z$6^laf69qkMEz~YI`>Wm!TfTIuxHfbg77vS$A8nefL;AqwWH~ws(I6y5K*ws%ZnaK zNS2fO$8X@6D0hC(kCy|TU%t!cWzkd{QI(?4SB0|5ZX>VIhW#<~c#}3ZVVrOhc1H)K z@3jzyB_%)yG}DBc8iQaTVc7ln&nLecHES8s%i$==Dn53LTl=}sSX#T}TR!^F{b8Ut zi-sn6k>;EgSF^lAiL3rF@%x0wU@vzuVI-)EC%aUF>APU0g{Wv?N*t0v&{REznC?CJ zDCG4!fp>qZ*3KhQ5;H&fW#BCzF_@Bb&q7$LWqdRW@ZKK*ErfH%8k8CVeW$wZIdbpR{%R#$HFz5Z1oclIr(;^^3rGe#Xz{1`kGD ze)zGv|H!<)ZfciO?I)d>GX#mLI_}fg)*C&kzf|rke(Hxm8qgxY^7Oc)cw#kFH9rkt zdSCIwoPl zL&LW(Ja z3Z3Yc&g)l@s~XMlETFntpL``KtZ(WwE%rcNP(LchgY*mUt6SEO={tVoopCGHU*u9f zjG*)c)Q`(JE1>&WJzvely%Il@Ws?b0XaVV63ybB3>a>m$#vb#jXl%wB{K&=UT-Oa* z3BvICn6~dRnwf_Aum5Ejgb?GeI10G3ZzW}tQW)%6-P~*a3!c16SF0%6(RDRCn6|^U zdrT1AF9i{z_f-3Adax9XRAWlTh9DGjm%c2yS>~1>)$z*dT8jEeHuM?70d?1`TGUoJ`f5|HBApDb#u$3{q3}-Jn>FIFHuct>o zz#u?1Q6bB|`6PpmaR{%q$(u+#UaYb}``%!KB?L8%pe;zqFR-*!&_lctNlFmeI?pJVtj{HX@@ z8_Y-Exne@}k$+T#w%W?VMa4){{9^XmN;;aORO55xqc92hK7AX2RLTL?<}CLhH66-O zskC3f;KvGBMVaw4zX}m;7AcDgV=k@^V?UQkJ>c+n8$`Gev4))rMbuukxN~i?M=Gtb z**i3<#h6nU_$H6Q#&?fK-=f(CsUXMeFY=K_VDxRFabO_|4ZnaLckEpJHQWJ9DCfRI zu8OL1Md{aF62`;G&@6QN%5@#Fs=-aWx>jm6_oq4CF*(Bw zo|cZW>sNo{gW(Dspe*V2+g<3*YaVTg3!vI|1T<9aC&L(9?s=5qxU0n5lZ;(weF$qA zLrbIehWk`1E0r3KP{y(v4W;;1QEl4jf>IR~<7<={Z9gh@piC=KK?U@kSSh4!>VvHydI)o(rN0yG3e25S)6~ z{Ai740y_Afzs^ZaL3S)h5V?aDlyl!I+aqDyv;*ZA!6;x!$O>9oEwB0?p`Y&sr$hV6d=PBE^TW&kBUmdEMk>8PI)5Fg6uJWS zTOumB!?-(Gv>1F0A+sMvY3VIXY(AVQojMK?vSaSUVLMh( zRxFHEX5S*)qjf8=!w2i|$c0)DD)Np-_fa znO8$tXc`%t!<2ebdNHQfu%wrq2n=A>1BhCI^d(>hoX$fZYef3r1JtV|~@W*I@}hlDBIVW0*x4LhayMfxG$mIe+fj zW9Y9Qr;-Q?Ct`gx_xy(fOX$fPJ+yj-)?p@4C~PbAjvk_JHmd4hQt=pF6)v|5rz?F= z)3=*ZQqf!ev;huMSK|b%KU9M7jt_B(ROh2 zwv1h4h(hf!4K`ZXscOp9@VACGV6d}Ii$Se>_2eXMpWAuhzqz-Pa@d5SW%f-n8yBkS z5ylyEik3xXmPm`qdhTfkjD*O-j{xb~rqcVG?ZOcgjfoK-q=s_)s?|{lhrmfMRt~!` zw4D7W*^0DC3=UnT43dtjr^L&AMV&Z?6A-q6+THRpb2#79h9fHK`tIme-}$Y>07+xz zhzUc>*>C9oa!tzZpQG1!4N!%cs_}Ngp`$e@g26FJ$`w(m`}9{C0ee=b1>8puuO+nQ zhzmo@^L|G*M($l24=;+`)Ccq#E=;HOd~6k7K^_{x4p-eM_u4k;IPGfgiX-m+k0UGu zEk{ilT4t}1HQhtFs7tS)4*kpVvtS&u@&Yf=)7wC{#tHLA`?v>Ziw{_r;FpV#xcs z)lm>fTL4^+IbpQrxm#s+RgAsBMWR8XM|5dyh{4D3$=l}o!07*qoM6N<$f;pl>g#Z8m literal 0 HcmV?d00001 diff --git a/assets/images/notify/hudong.png b/assets/images/notify/hudong.png new file mode 100644 index 0000000000000000000000000000000000000000..d9be6f15f13421a184123bb95655630f828f9341 GIT binary patch literal 8006 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 IM6N<$f?TTB2LJ#7 literal 0 HcmV?d00001 diff --git a/assets/images/notify/msr.png b/assets/images/notify/msr.png new file mode 100644 index 0000000000000000000000000000000000000000..ae4145c802424e107e96845d23980487532e553b GIT binary patch literal 2613 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
FN(0kU$Pd7*!CnLk$upIjxgPR>*8H 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 literal 0 HcmV?d00001 diff --git a/assets/images/notify/xitong.png b/assets/images/notify/xitong.png new file mode 100644 index 0000000000000000000000000000000000000000..53c761ccb434f752c0c6bdc044964b69288d9c42 GIT binary patch literal 8341 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 \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile index e549ee2..2c53880 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,9 +1,11 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' - +platform :ios, '12.0' +# 允许拉取http资源 +# ENV['COCOAPODS_ALLOW_INSECURE_SOURCES'] = 'true' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' + project 'Runner', { 'Debug' => :debug, 'Profile' => :release, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 9d1f391..9f0155d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,11 +1,25 @@ PODS: + - audioplayers_darwin (0.0.1): + - Flutter + - FlutterMacOS - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) + - flutter_image_compress_common (1.0.0): + - Flutter + - Mantle + - SDWebImage + - SDWebImageWebPCoder - flutter_native_splash (2.4.3): - Flutter - flutter_upgrader (1.0.7): - Flutter + - fluwx (0.0.1): + - Flutter + - fluwx/pay (= 0.0.1) + - fluwx/pay (0.0.1): + - Flutter + - WechatOpenSDK-XCFramework (~> 2.0.4) - geolocator_apple (1.2.0): - Flutter - FlutterMacOS @@ -14,6 +28,21 @@ PODS: - Flutter - install_plugin (2.0.0): - Flutter + - libwebp (1.3.2): + - libwebp/demux (= 1.3.2) + - libwebp/mux (= 1.3.2) + - libwebp/sharpyuv (= 1.3.2) + - libwebp/webp (= 1.3.2) + - libwebp/demux (1.3.2): + - libwebp/webp + - libwebp/mux (1.3.2): + - libwebp/demux + - libwebp/sharpyuv (1.3.2) + - libwebp/webp (1.3.2): + - libwebp/sharpyuv + - Mantle (2.2.0): + - Mantle/extobjc (= 2.2.0) + - Mantle/extobjc (2.2.0) - media_kit_libs_ios_video (1.0.4): - Flutter - media_kit_video (0.0.1): @@ -31,26 +60,47 @@ PODS: - photo_manager (3.7.1): - Flutter - FlutterMacOS + - record_ios (1.0.0): + - Flutter + - SDWebImage (5.20.0): + - SDWebImage/Core (= 5.20.0) + - SDWebImage/Core (5.20.0) + - SDWebImageWebPCoder (0.14.6): + - libwebp (~> 1.0) + - SDWebImage/Core (~> 5.17) + - tencent_cloud_chat_push (8.6.7019): + - Flutter + - TIMPush (= 8.6.7019) + - TXIMSDK_Plus_iOS_XCFramework - tencent_cloud_chat_sdk (8.0.0): - Flutter - HydraAsync - TXIMSDK_Plus_iOS_XCFramework (~> 8.6.7019) + - TIMPush (8.6.7019): + - TXIMSDK_Plus_iOS_XCFramework (>= 8.6.7019) - TXIMSDK_Plus_iOS_XCFramework (8.6.7019) - url_launcher_ios (0.0.1): - Flutter - video_player_avfoundation (0.0.1): - Flutter - FlutterMacOS + - video_thumbnail (0.0.1): + - Flutter + - libwebp - volume_controller (0.0.1): - Flutter - wakelock_plus (0.0.1): - Flutter + - WechatOpenSDK-XCFramework (2.0.4) DEPENDENCIES: + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) + - flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`) - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) - flutter_upgrader (from `.symlinks/plugins/flutter_upgrader/ios`) + - fluwx (from `.symlinks/plugins/fluwx/ios`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - install_plugin (from `.symlinks/plugins/install_plugin/ios`) @@ -61,26 +111,41 @@ DEPENDENCIES: - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - photo_manager (from `.symlinks/plugins/photo_manager/ios`) + - record_ios (from `.symlinks/plugins/record_ios/ios`) + - tencent_cloud_chat_push (from `.symlinks/plugins/tencent_cloud_chat_push/ios`) - tencent_cloud_chat_sdk (from `.symlinks/plugins/tencent_cloud_chat_sdk/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/darwin`) + - video_thumbnail (from `.symlinks/plugins/video_thumbnail/ios`) - volume_controller (from `.symlinks/plugins/volume_controller/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) SPEC REPOS: trunk: - HydraAsync + - libwebp + - Mantle + - SDWebImage + - SDWebImageWebPCoder + - TIMPush - TXIMSDK_Plus_iOS_XCFramework + - WechatOpenSDK-XCFramework EXTERNAL SOURCES: + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/darwin" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter + flutter_image_compress_common: + :path: ".symlinks/plugins/flutter_image_compress_common/ios" flutter_native_splash: :path: ".symlinks/plugins/flutter_native_splash/ios" flutter_upgrader: :path: ".symlinks/plugins/flutter_upgrader/ios" + fluwx: + :path: ".symlinks/plugins/fluwx/ios" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/darwin" image_picker_ios: @@ -101,26 +166,37 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" photo_manager: :path: ".symlinks/plugins/photo_manager/ios" + record_ios: + :path: ".symlinks/plugins/record_ios/ios" + tencent_cloud_chat_push: + :path: ".symlinks/plugins/tencent_cloud_chat_push/ios" tencent_cloud_chat_sdk: :path: ".symlinks/plugins/tencent_cloud_chat_sdk/ios" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" video_player_avfoundation: :path: ".symlinks/plugins/video_player_avfoundation/darwin" + video_thumbnail: + :path: ".symlinks/plugins/video_thumbnail/ios" volume_controller: :path: ".symlinks/plugins/volume_controller/ios" wakelock_plus: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1 flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf flutter_upgrader: 16a975eb987fc210cdf6bebffe0069a480f80523 + fluwx: 6bf9c5a3a99ad31b0de137dd92370a0d10a60f4b geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e HydraAsync: 8d589bd725b0224f899afafc9a396327405f8063 image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a install_plugin: e17e38d6f504857748a3ec1299d8a2bbeeeea854 + libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009 + Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854 media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474 mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 @@ -128,13 +204,20 @@ SPEC CHECKSUMS: path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d photo_manager: 1d80ae07a89a67dfbcae95953a1e5a24af7c3e62 + record_ios: fee1c924aa4879b882ebca2b4bce6011bcfc3d8b + SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8 + SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380 + tencent_cloud_chat_push: f87ae58098c2062b06e81f39fc53afc528395916 tencent_cloud_chat_sdk: 0a406f1854a65aad2f853494c02a2e084a027ab2 + TIMPush: d0dfe96355ee413a7cacb2576f8aaa66f6073ab2 TXIMSDK_Plus_iOS_XCFramework: cb54f7de6e30e1368c6831c6eff31c25393bbb98 url_launcher_ios: 694010445543906933d732453a59da0a173ae33d video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b + video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140 volume_controller: 3657a1f65bedb98fa41ff7dc5793537919f31b12 wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556 + WechatOpenSDK-XCFramework: 36fb2bea0754266c17184adf4963d7e6ff98b69f -PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 +PODFILE CHECKSUM: 866435f3a12ad92d8fb66fa46b52776da7e16ce5 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index fa53fd0..c95476e 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 */; }; + C8092B212E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = C8092B202E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework */; }; + C8092B222E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = C8092B202E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; ECDFBB33253E89949730F7D8 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 898AE91CA73F2F6E910D884D /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -35,6 +37,7 @@ dstPath = ""; dstSubfolderSpec = 10; files = ( + C8092B222E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework in Embed Frameworks */, ); name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; @@ -63,6 +66,8 @@ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; A9774DDA95C7FD895F6925A4 /* 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 = ""; }; BB84C2FA9C50ACAF0C376254 /* 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 = ""; }; + C8092B202E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = "WechatOpenSDK-XCFramework.xcframework"; path = "Pods/WechatOpenSDK-XCFramework/WechatOpenSDK-XCFramework.xcframework"; sourceTree = ""; }; + C891EF1D2E43F9730021EB39 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; DCA23AF172275D04ECB63EFB /* 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 = ""; }; E3EC116A6CCDD06C6D4615E2 /* 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 */ @@ -72,6 +77,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + C8092B212E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework in Frameworks */, ECDFBB33253E89949730F7D8 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -143,6 +149,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + C891EF1D2E43F9730021EB39 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -158,6 +165,7 @@ 9CE4C2341F34F9A85A1D3EED /* Frameworks */ = { isa = PBXGroup; children = ( + C8092B202E34A78000D25A0B /* WechatOpenSDK-XCFramework.xcframework */, 898AE91CA73F2F6E910D884D /* Pods_Runner.framework */, 1AE799326ED7557212A901E0 /* Pods_RunnerTests.framework */, ); @@ -487,8 +495,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = VZ6V44Q3T4; + DEVELOPMENT_TEAM = 9C9VWBX77X; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -496,8 +507,9 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 4.1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.wzj41.test1; + PRODUCT_BUNDLE_IDENTIFIER = cn.net.wzj.mall; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; @@ -671,8 +683,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = VZ6V44Q3T4; + DEVELOPMENT_TEAM = 9C9VWBX77X; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -680,8 +695,9 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 4.1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.wzj41.test1; + PRODUCT_BUNDLE_IDENTIFIER = cn.net.wzj.mall; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -695,8 +711,11 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = VZ6V44Q3T4; + DEVELOPMENT_TEAM = 9C9VWBX77X; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -704,8 +723,9 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 4.1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.wzj41.test1; + PRODUCT_BUNDLE_IDENTIFIER = cn.net.wzj.mall; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; VERSIONING_SYSTEM = "apple-generic"; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 8be1cec..5cc1db3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,13 +1,39 @@ -import Flutter import UIKit +import Flutter -@main -@objc class AppDelegate: FlutterAppDelegate { - override func application( - _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? - ) -> Bool { - GeneratedPluginRegistrant.register(with: self) - return super.application(application, didFinishLaunchingWithOptions: launchOptions) - } -} +// Add these two import lines +import TIMPush +import tencent_cloud_chat_push + +// Add `, TIMPushDelegate` to the following line +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate, TIMPushDelegate { + override func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) -> Bool { + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + // To be deprecated,please use the new field businessID below. + @objc func offlinePushCertificateID() -> Int32 { + return TencentCloudChatPushFlutterModal.shared.offlinePushCertificateID(); + } + + // Add this function + @objc func businessID() -> Int32 { + return TencentCloudChatPushFlutterModal.shared.businessID(); + } + + // Add this function + @objc func applicationGroupID() -> String { + return TencentCloudChatPushFlutterModal.shared.applicationGroupID() + } + + // Add this function + @objc func onRemoteNotificationReceived(_ notice: String?) -> Bool { + TencentCloudChatPushPlugin.shared.tryNotifyDartOnNotificationClickEvent(notice) + return true + } +} \ No newline at end of file diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c2851..0fb20ec 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 36250c5..2b101d6 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - 无终见41 + 无终街 CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -22,20 +22,44 @@ $(FLUTTER_BUILD_NAME) CFBundleSignature ???? + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLName + weixin + CFBundleURLSchemes + + wxebcdaea31881caab + + + CFBundleVersion $(FLUTTER_BUILD_NUMBER) + LSApplicationQueriesSchemes + + weixin + wechat + weixinULAPI + LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSCameraUsageDescription App需要使用您的相机进行拍摄 NSMicrophoneUsageDescription - App需要访问麦克风用于视频录制 + App需要访问麦克风用于发送语音消息 NSPhotoLibraryAddUsageDescription - App需要权限以保存视频到您的相册 + App需要权限以保存图片或视频到您的相册 NSPhotoLibraryLimitedUsageDescription - App需要访问部分照片用于选择视频 + App需要访问部分照片用于选择图片或视频 NSPhotoLibraryUsageDescription - App需要访问您的相册用于选择视频 + App需要访问您的相册用于选择图片或视频 UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName @@ -59,5 +83,15 @@ UIViewControllerBasedStatusBarAppearance + + UIBackgroundModes + + remote-notification + + + com.apple.developer.associated-domains + + applinks:wuzhongjie.com.cn + diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements new file mode 100644 index 0000000..cf469c6 --- /dev/null +++ b/ios/Runner/Runner.entitlements @@ -0,0 +1,9 @@ + + + + + aps-environment + development + + + diff --git a/lib/IM/controller/chat_controller.dart b/lib/IM/controller/chat_controller.dart index 665fb5c..cda9069 100644 --- a/lib/IM/controller/chat_controller.dart +++ b/lib/IM/controller/chat_controller.dart @@ -1,24 +1,118 @@ import 'package:get/get.dart'; import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/models/conversation_type.dart' as myConversationType; import 'package:loopin/models/conversation_view_model.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_filter.dart'; class ChatController extends GetxController { - RxInt count = 100.obs; // 每页条数 + RxInt count = 20.obs; // 每页条数 RxString nextSeq = '0'.obs; // 页码 + RxBool isFinished = false.obs; // 是否拉取完?默认未拉取完 final chatList = [].obs; - // 获取会话列表 + void initChatData() { + chatList.value = []; + nextSeq.value = '0'; + isFinished.value = false; + } + + // 获取所有会话列表 void getConversationList() async { + if (isFinished.value) { + // 拉取完数据了,直接结束 + return; + } final res = await ImService.instance.getConversationList(nextSeq.value, count.value); if (!res.success || res.data == null) return; final List convList = res.data; - // for (var conv in convList) { - // logger.i('基本会话: ${conv.conversation.toLogString()}, 头像: ${conv.faceUrl}'); - // } + for (var conv in convList) { + logger.i('基本会话: ${conv.conversation.toJson()}, 会话ID: ${conv.conversation.conversationID}'); + } - chatList.value = convList; + chatList.addAll(convList); + // 不包含noFriend才执行加载数据逻辑,分页加载时候过滤 + final hasNoFriend = chatList.any((item) => item.conversation.conversationGroupList?.contains(myConversationType.ConversationType.noFriend.name) ?? false); + if (!hasNoFriend) { + getNoFriendData(); + } } + + ///构建陌生人消息菜单入口 + void getNoFriendData({V2TimConversation? csion}) async { + // 检测会话列表是否已有陌生人消息菜单 + final hasNoFriend = chatList.any((item) => item.conversation.conversationGroupList?.contains(myConversationType.ConversationType.noFriend.name) ?? false); + if (hasNoFriend) { + // 已经有了入口 + final ConversationViewModel matchItem = chatList.firstWhere( + (item) => item.conversation.conversationGroupList?.contains(myConversationType.ConversationType.noFriend.name) ?? false, + ); + // 获取陌生人未读总数 + final unreadTotal = await ImService.instance.getUnreadMessageCountByFilter( + filter: V2TimConversationFilter( + conversationGroup: myConversationType.ConversationType.noFriend.name, + hasUnreadCount: true, + ), + ); + matchItem.conversation.lastMessage = csion!.lastMessage; + matchItem.conversation.unreadCount = unreadTotal.data; + chatList.refresh(); + return; + } + // 没有则执行创建逻辑 + final res = await ImService.instance.getConversationListByFilter( + filter: V2TimConversationFilter(conversationGroup: myConversationType.ConversationType.noFriend.name), + nextSeq: 0, + count: 1, + ); + if (res.success && res.data != null) { + final convList = res.data!.conversationList ?? []; + if (convList.isNotEmpty) { + // logger.i(res.data!.toJson()); + // 有陌生人消息,1.获取未读数,2.组装converstaionviewmodel + final unread = await ImService.instance.getUnreadMessageCountByFilter( + filter: V2TimConversationFilter( + conversationGroup: myConversationType.ConversationType.noFriend.name, + hasUnreadCount: true, + ), + ); + if (unread.success) { + final conv = convList.first; + final faceUrl = 'assets/images/notify/msr.png'; + conv.showName = '陌生人消息'; + conv.unreadCount = unread.data; + final createItem = ConversationViewModel( + conversation: conv, + faceUrl: faceUrl, + ); + final newList = List.from(chatList); + newList.add(createItem); + newList.sort((a, b) { + final atime = a.conversation.lastMessage?.timestamp ?? 0; + final btime = b.conversation.lastMessage?.timestamp ?? 0; + return btime.compareTo(atime); // 降序 + }); + chatList.value = newList; + } + } + } + } + + /// 按会话分组查询 getConversationListByFilter + // void getConversationList() async { + // final res = await ImService.instance.getConversationListByFilter( + // filter: V2TimConversationFilter(conversationGroup: null), + // nextSeq: nextSeq.value, + // ); + // final convList = res.data!.conversationList; + // logger.i(res.data!.toJson()); + // chatList.value = convList; + // // for (var element in convList ?? []) { + // // logger.i(element.toJson()); + // // // 你可以在这里继续处理 element + // // } + // } } diff --git a/lib/IM/controller/chat_detail_controller.dart b/lib/IM/controller/chat_detail_controller.dart index 1a29b49..566fc13 100644 --- a/lib/IM/controller/chat_detail_controller.dart +++ b/lib/IM/controller/chat_detail_controller.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:loopin/IM/im_message.dart'; import 'package:loopin/IM/im_service.dart'; @@ -8,8 +9,10 @@ class ChatDetailController extends GetxController { final String userID; ChatDetailController({required this.userID}); + final ScrollController chatController = ScrollController(); final RxList chatList = [].obs; + final RxBool isFriend = true.obs; void updateChatListWithTimeLabels(List originMessages) async { final idRes = await ImService.instance.selfUserId(); @@ -37,17 +40,54 @@ class ChatDetailController extends GetxController { } } - // 把当前消息先插入后插入标签 - displayMessages.add(current); + // if (i == 0) { + // // 第一条一定插时间 + // needInsertLabel = true; + // } else { + // final prev = originMessages[i - 1]; + // final prevTimestamp = prev.timestamp ?? 0; + // final diff = currentTimestamp - prevTimestamp; + // if (diff > 180) { + // needInsertLabel = true; + // } + // } + // 把当前消息先插入,label后插入 + displayMessages.add(current); if (needInsertLabel) { final labelTime = Utils().formatChatTime(currentTimestamp); final timeLabel = await IMMessage().insertTimeLabel(labelTime, selfUserId); displayMessages.add(timeLabel.data); } } - + // 新加载的记录放在最上面 chatList.addAll(displayMessages); } } + + ///滚动 + void scrollToBottom() { + if (chatController.hasClients) { + chatController.animateTo( + 0, + duration: Duration(milliseconds: 200), + curve: Curves.easeOut, + ); + } + // Future.delayed(Duration(milliseconds: 300), () { + // if (chatController.hasClients) { + // chatController.animateTo( + // chatController.position.maxScrollExtent, + // duration: Duration(milliseconds: 200), + // curve: Curves.easeOut, + // ); + // } + // }); + } + + @override + void onClose() { + chatController.dispose(); + super.onClose(); + } } diff --git a/lib/IM/controller/im_user_info_controller.dart b/lib/IM/controller/im_user_info_controller.dart new file mode 100644 index 0000000..5485fb3 --- /dev/null +++ b/lib/IM/controller/im_user_info_controller.dart @@ -0,0 +1,137 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart'; + +class ImUserInfoController extends GetxController { + @override + void onInit() { + super.onInit(); + refreshUserInfo(); + logger.i('IM用户信息初始化'); + } + + V2TimUserFullInfo? rawUserInfo; + + final userID = ''.obs; + final nickname = ''.obs; + final faceUrl = ''.obs; + final signature = ''.obs; + final gender = 0.obs; + final allowType = 0.obs; + final customInfo = { + "coverBg": "", + "area": "", + "areaCode": "", + "openId": "", + }.obs; + final role = 0.obs; + final level = 0.obs; + final birthday = 0.obs; + + void init(V2TimUserFullInfo userInfo) { + logger.i(userInfo.toJson()); + rawUserInfo = userInfo; + userID.value = userInfo.userID ?? ''; + nickname.value = userInfo.nickName ?? ''; + faceUrl.value = userInfo.faceUrl ?? ''; + signature.value = userInfo.selfSignature ?? ''; + gender.value = userInfo.gender ?? 0; + allowType.value = userInfo.allowType ?? 0; + customInfo.assignAll(userInfo.customInfo ?? + { + "coverBg": "", + "area": "", + "areaCode": "", + "openId": "", + }); + + role.value = userInfo.role ?? 0; + level.value = userInfo.level ?? 0; + birthday.value = userInfo.birthday ?? 0; + } + + void refreshUserInfo() async { + try { + final updatedUserInfo = await ImService.instance.selfInfo(); + if (updatedUserInfo.success) { + init(updatedUserInfo.data); + } + } catch (e) { + print('刷新用户信息失败: $e'); + } + } + + /// 更新昵称 + Future updateNickname(newnickname) async { + final res = await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(nickName: newnickname)); + if (res.success) { + nickname.value = newnickname; + } else { + logger.i(res.desc); + if (res.code == 80001) { + MyDialog.toast('昵称违规', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + } + return res.success; + } + + /// 更新简介 + Future updateSignature(newsignature) async { + final res = await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(selfSignature: newsignature)); + if (res.success) { + signature.value = newsignature; + } else { + logger.i(res.desc); + if (res.code == 80001) { + MyDialog.toast('简介内容违规', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + } + return res.success; + } + + /// 更新头像 + Future updateFaceUrl() async { + if (faceUrl.value.trim().isEmpty) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(faceUrl: faceUrl.value)); + } + + /// 更新背景图 + Future updateCover() async { + final coverBg = customInfo['coverBg']; + if (coverBg == null || coverBg.trim().isEmpty) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(customInfo: customInfo)); + } + + /// 更新openId + Future updateOpenId() async { + final openId = customInfo['openId']; + if (openId == null || openId.trim().isEmpty) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(customInfo: customInfo)); + } + // customInfo.update("coverBg", (value) => coverBgUrl); + + /// 更新所在地 + Future updateArea() async { + final area = customInfo['area']; + if (area == null || area.trim().isEmpty) return; + final areaCode = customInfo['areaCode']; + if (areaCode == null || areaCode.trim().isEmpty) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(customInfo: customInfo)); + } + + ///更新生日 + Future updateBirthday() async { + if (birthday.value < 0) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(birthday: birthday.value)); + } + + ///更新性别 + Future updateGender() async { + if (gender.value < 0) return; + await ImService.instance.setSelfInfo(userFullInfo: V2TimUserFullInfo(gender: gender.value)); + } + + /// updateAvatar、updateSignature 等方法 +} diff --git a/lib/IM/global_badge.dart b/lib/IM/global_badge.dart index e677432..aef3fe0 100644 --- a/lib/IM/global_badge.dart +++ b/lib/IM/global_badge.dart @@ -1,9 +1,12 @@ import 'package:get/get.dart'; import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/controller/tab_bar_controller.dart'; -import 'package:loopin/IM/im_core.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/models/conversation_type.dart'; import 'package:loopin/models/tab_type.dart'; import 'package:tencent_cloud_chat_sdk/enum/V2TimConversationListener.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_filter.dart'; import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; class GlobalBadge extends GetxController { @@ -21,14 +24,103 @@ class GlobalBadge extends GetxController { logger.i('未读数发生变化$count'); totalUnread.value = count; Get.find().setBadge(TabType.chat, totalUnread.value); - // 更新会话列表 - Get.find().getConversationList(); + }, + onNewConversation: (List conversationList) { + for (var conv in conversationList) { + logger.i("新会话创建:${conv.conversationGroupList}"); + handleCoverstion(conv); + } + }, + onConversationChanged: (List conversationList) async { + logger.w('会话变更:会话分组:${conversationList.first.conversationGroupList},会话内容${conversationList.first.toLogString()}'); + final ctl = Get.find(); + final updatedIds = conversationList.map((e) => e.conversationID).toSet(); + logger.w('要变更的会话id:$updatedIds'); + for (int i = 0; i < ctl.chatList.length; i++) { + final chatItem = ctl.chatList[i]; + logger.w('需要更新的ID:${chatItem.conversation.conversationID}'); + if (updatedIds.contains(chatItem.conversation.conversationID)) { + final updatedConv = conversationList.firstWhere( + (c) => c.conversationID == chatItem.conversation.conversationID, + orElse: () => V2TimConversation(conversationID: ''), + ); + + if (updatedConv.conversationID != '' && (updatedConv.conversationGroupList?.contains(ConversationType.noFriend.name) ?? false)) { + // 单独处理陌生人会话 + final unread = await ImService.instance.getUnreadMessageCountByFilter( + filter: V2TimConversationFilter( + conversationGroup: ConversationType.noFriend.name, + hasUnreadCount: true, + ), + ); + chatItem.conversation.lastMessage = updatedConv.lastMessage; + chatItem.conversation.unreadCount = unread.data; // 获取陌生人未读总数 + } else { + // 其他类型统一更新处理 + chatItem.conversation = updatedConv; + } + } + } + //重新排序 + ctl.chatList.sort((a, b) { + final atime = a.conversation.lastMessage?.timestamp ?? 0; + final btime = b.conversation.lastMessage?.timestamp ?? 0; + return btime.compareTo(atime); // 降序 + }); + ctl.chatList.refresh(); }, ); + final ctl = Get.find(); + ctl.getConversationList(); _initUnreadCount(); _addListener(); } + // final rr = await ImService.instance.deleteConversationsFromGroup( + // conversationIDList: [cov.conversationID], + // groupName: 'noFriend', + // ); + // logger.w(rr.desc); + + /// 新建会话时候,根据消息的自定义属性给会话分组 + void handleCoverstion(V2TimConversation cov) async { + final message = cov.lastMessage; + final isSelfSend = message!.isSelf; // 是否本人发送的消息 + final typeEnum = conversationTypeFromString(message.cloudCustomData); // 会话类型 + final needAdd = cov.conversationGroupList!.isEmpty == true; // 当前会话是否已加入了分组中 + if (typeEnum != null && needAdd && isSelfSend == false) { + logger.i('当前会话的类型要加入的组是:$typeEnum'); + // 当前会话需要进行分组,检测 组 是否存在 + final hasGroupRes = await ImService.instance.getConversationGroupList(); + if (hasGroupRes.success) { + final exists = hasGroupRes.data?.any((item) => item == typeEnum) ?? false; + if (!exists) { + // 组不存在,创建组并把会话加入group中 + await ImService.instance.createConversationGroup( + groupName: typeEnum, + conversationIDList: ['c2c_${message.sender}'], + ); + logger.i('首次创建会话分组$typeEnum'); + } else { + // 分组存在直接添加 + await ImService.instance.addConversationsToGroup( + groupName: typeEnum, + conversationIDList: ['c2c_${message.sender}'], + ); + logger.i('添加会话分组$typeEnum成功'); + } + if (typeEnum == ConversationType.noFriend.name) { + //陌生人分组特殊处理 满足分组条件且已经有分组, + final ctl = Get.find(); + // 这个方法执行的逻辑:已有则刷新菜单入口数据,没有则创建菜单入口 + ctl.getNoFriendData(csion: cov); + } + } + } else { + logger.w('不需要进行分组'); + } + } + /// 初始化时获取一次未读总数 void _initUnreadCount() async { final res = await TencentImSDKPlugin.v2TIMManager.getConversationManager().getTotalUnreadMessageCount(); diff --git a/lib/IM/im_core.dart b/lib/IM/im_core.dart index b0d121d..3f7ba92 100644 --- a/lib/IM/im_core.dart +++ b/lib/IM/im_core.dart @@ -14,14 +14,16 @@ class ImCore { final res = await TencentImSDKPlugin.v2TIMManager.initSDK( sdkAppID: sdkAppId, - loglevel: LogLevelEnum.V2TIM_LOG_ALL, + loglevel: LogLevelEnum.V2TIM_LOG_ERROR, listener: V2TimSDKListener( - onConnectSuccess: () => logger.i("IM连接成功"), + onConnectSuccess: () { + logger.i("IM连接成功"); + }, onConnectFailed: (code, error) => logger.e("IM连接失败: $code $error"), onKickedOffline: () => logger.w("IM被踢下线"), onUserSigExpired: () => logger.w("UserSig 过期"), onSelfInfoUpdated: (V2TimUserFullInfo info) { - logger.i("用户信息更新: ${info.nickName}"); + logger.i("用户信息更新: ${info.toJson()}"); }, ), ); @@ -29,6 +31,7 @@ class ImCore { if (res.code == 0) { _isInitialized = true; logger.i("IM SDK 初始化成功"); + return true; } else { logger.e("IM SDK 初始化失败: ${res.code} - ${res.desc}"); diff --git a/lib/IM/im_friend_listeners.dart b/lib/IM/im_friend_listeners.dart index 16ac3dd..58234f3 100644 --- a/lib/IM/im_friend_listeners.dart +++ b/lib/IM/im_friend_listeners.dart @@ -1,4 +1,5 @@ import 'package:logger/logger.dart'; +import 'package:loopin/utils/notification_banner.dart'; import 'package:tencent_cloud_chat_sdk/enum/V2TimFriendshipListener.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_application.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart'; @@ -16,7 +17,6 @@ class ImFriendListeners { _listener = V2TimFriendshipListener(onFriendApplicationListAdded: (List list) async { //好友请求数量增加的回调 //applicationList 新增的好友请求信息列表 - logger.i('收到好友申请: ${list.map((e) => e.userID).join(",")}'); }, onFriendApplicationListRead: () async { //好友请求已读的回调 }, onFriendApplicationListDeleted: (List userIDList) async { @@ -25,11 +25,15 @@ class ImFriendListeners { }, onFriendListAdded: (List users) async { //好友列表增加人员的回调 //users 新增的好友信息列表 - logger.i('新增好友: ${users.map((u) => u.userID).join(",")}'); + for (var item in users) { + logger.i('新增好友:${item.toLogString()}'); + } }, onFriendListDeleted: (List userList) async { //好友列表减少人员的回调 //userList 减少的好友id列表 - logger.i('删除好友: ${userList.join(",")}'); + for (var item in userList) { + logger.i('新增好友:$item'); + } }, onFriendInfoChanged: (List list) async { //好友信息改变的回调 //infoList 好友信息改变的好友列表 @@ -43,14 +47,27 @@ class ImFriendListeners { }, onMyFollowingListChanged: (List userInfoList, bool isAdd) async { if (isAdd) { // 关注列表新增用户的通知 + for (var item in userInfoList) { + logger.i('我新关注的人:${item.toJson()}'); + } } else { // 关注列表删除用户的通知 + for (var item in userInfoList) { + logger.i('我取消关注了:${item.toJson()}'); + } } }, onMyFollowersListChanged: (List userInfoList, bool isAdd) async { if (isAdd) { // 粉丝列表新增用户的通知 + for (var item in userInfoList) { + logger.i('新增粉丝:${item.toJson()}'); + } + NotificationBanner.foucs(userInfoList.last); } else { // 粉丝列表删除用户的通知 + for (var item in userInfoList) { + logger.i('掉粉:${item.toJson()}'); + } } }, onMutualFollowersListChanged: (List userInfoList, bool isAdd) async { if (isAdd) { diff --git a/lib/IM/im_message.dart b/lib/IM/im_message.dart index 96a1337..45c8073 100644 --- a/lib/IM/im_message.dart +++ b/lib/IM/im_message.dart @@ -1,20 +1,27 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:get/get.dart'; import 'package:logger/logger.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; import 'package:loopin/IM/im_result.dart'; +import 'package:loopin/utils/parse_message_summary.dart'; import 'package:tencent_cloud_chat_sdk/enum/message_priority_enum.dart'; import 'package:tencent_cloud_chat_sdk/enum/offlinePushInfo.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_msg_create_info_result.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart'; import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; -final logger = Logger(); - class IMMessage { - /// 发送文本消息 - Future sendText({ - required String text, + final logger = Logger(); + + /// 1.发送消息 + Future sendMessage({ + required V2TimMessage msg, String? toUserID, String? groupID, - String? data, + String? cloudCustomData, }) async { // 必须且只能设置一个:toUserID(单聊)或 groupID(群聊) if ((toUserID == null && groupID == null) || (toUserID != null && groupID != null)) { @@ -24,40 +31,44 @@ class IMMessage { desc: "只能指定一个 receiver(toUserID)或 groupID", ); } - - // 创建消息 - final createRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createTextMessage(text: text); - - if (createRes.code != 0 || createRes.data == null) { - return ImResult( - success: false, - code: createRes.code, - desc: "创建消息失败", - ); + if (cloudCustomData != null) { + msg.cloudCustomData = cloudCustomData; } - final V2TimMessage? messageInfo = createRes.data?.messageInfo; - + // 解析消息类型 V2TimValueCallback sendRes; - + // final controller = Get.find(); + final myInfo = Get.find(); + logger.w('启用默认title:${myInfo.nickname.value}'); // 单聊 if (toUserID != null) { sendRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( - message: messageInfo, + message: msg, receiver: toUserID, + // onSyncMsgID: (msgID) async { + // 这里立刻拿到消息ID,可以提前把这条消息展示到列表中(发送中状态)有时间再改吧 + // 根据类型,创建对应的elem; + // logger.w(msg.imageElem!.toLogString()); + // controller.chatList.add(msg.imageElem); + // controller.scrollToBottom(); + // }, groupID: "", priority: MessagePriorityEnum.V2TIM_PRIORITY_DEFAULT, onlineUserOnly: false, isExcludedFromUnreadCount: false, isExcludedFromLastMessage: false, needReadReceipt: false, - offlinePushInfo: OfflinePushInfo(title: "新消息", desc: text), - cloudCustomData: "", + offlinePushInfo: OfflinePushInfo( + title: myInfo.nickname.value, + desc: parseMessageSummary(msg), + ext: jsonEncode({"userID": myInfo.userID.value, "title": myInfo.nickname.value}), + ), + cloudCustomData: cloudCustomData, localCustomData: "", ); } else { // 群聊 sendRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( - message: messageInfo, + message: msg, receiver: "", groupID: groupID!, priority: MessagePriorityEnum.V2TIM_PRIORITY_DEFAULT, @@ -65,7 +76,7 @@ class IMMessage { isExcludedFromUnreadCount: false, isExcludedFromLastMessage: false, needReadReceipt: false, - offlinePushInfo: OfflinePushInfo(title: "新群聊消息", desc: text), + offlinePushInfo: OfflinePushInfo(title: '群聊消息', desc: parseMessageSummary(msg)), cloudCustomData: "", localCustomData: "", ); @@ -79,62 +90,18 @@ class IMMessage { ); } - /// 发送自定义消息 - Future sendCustomMessage({ + /// 2=创建自定义消息 + Future> createCustomMessage({ required String data, - String? toUserID, - String? groupID, - String? description, - String? extension, + String desc = "", + String extension = "", }) async { - // 校验逻辑:单聊或群聊,二选一 - if ((toUserID == null && groupID == null) || (toUserID != null && groupID != null)) { - return ImResult( - success: false, - code: -1, - desc: "只能指定一个 receiver(toUserID)或 groupID", - ); - } - - // 1. 创建自定义消息 - final createRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createCustomMessage( + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createCustomMessage( data: data, - desc: description ?? '', - extension: extension ?? '', + desc: desc, + extension: extension, ); - - if (createRes.code != 0 || createRes.data?.id == null) { - return ImResult( - success: false, - code: createRes.code, - desc: "创建自定义消息失败", - ); - } - final V2TimMessage? messageInfo = createRes.data?.messageInfo; - - // 2. 发送消息 - final sendRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( - message: messageInfo, - receiver: toUserID ?? '', - groupID: groupID ?? '', - priority: MessagePriorityEnum.V2TIM_PRIORITY_DEFAULT, - onlineUserOnly: false, - isExcludedFromUnreadCount: false, - isExcludedFromLastMessage: false, - needReadReceipt: false, - offlinePushInfo: OfflinePushInfo( - title: "自定义消息", - desc: description ?? '您收到一条自定义消息', - ), - cloudCustomData: "", - localCustomData: "", - ); - - return ImResult( - success: sendRes.code == 0, - code: sendRes.code, - desc: sendRes.desc, - ); + return ImResult.wrap(res); } /// 构造单聊伪消息 @@ -162,24 +129,124 @@ class IMMessage { desc: "success", data: timeMsg, ); + } - // final sendRes = await TencentImSDKPlugin.v2TIMManager.getMessageManager().sendMessage( - // message: timeMsg, - // receiver: userId, - // groupID: "", - // onlineUserOnly: false, - // isExcludedFromUnreadCount: true, - // isExcludedFromLastMessage: true, - // needReadReceipt: false, - // cloudCustomData: "", - // localCustomData: "time_label", - // ); + /// 创建文本消息==1 + Future> createTextMessage({ + required String text, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createTextMessage(text: text); + return ImResult.wrap(res); + } - // return ImResult( - // success: sendRes.code == 0, - // code: sendRes.code, - // desc: sendRes.code == 0 ? "时间标签发送成功" : sendRes.desc, - // data: timeMsg, - // ); + /// 创建图片消息==3 + Future> createImageMessage({ + required String imagePath, + String? imageName, + }) async { + final fileExists = await File(imagePath).exists(); + if (fileExists) { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createImageMessage( + imagePath: imagePath, + imageName: imageName, + ); + return ImResult.wrap(res); + } else { + // 构造失败的回调 + final failed = V2TimValueCallback.fromJson({ + "code": -5, + "desc": "imagePath is not found", + "data": V2TimMsgCreateInfoResult.fromJson({}), + }); + + return ImResult.wrap(failed); + } + } + + ///创建视频消息==5 + Future> createVideoMessage({ + //最大100MB + required String videoFilePath, //视频地址 + required String type, // 类型mp4/avi== + required int duration, // 时长 + required String snapshotPath, // 封面图 + }) async { + final videoExists = await File(videoFilePath).exists(); + final snapshotExists = await File(snapshotPath).exists(); + + if (videoExists && snapshotExists) { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createVideoMessage( + videoFilePath: videoFilePath, + type: type, + duration: duration, + snapshotPath: snapshotPath, + ); + + return ImResult.wrap(res); + } + + // 构造失败回调 + final failed = V2TimValueCallback.fromJson({ + "code": -5, + "desc": "视频或首帧图缺失", + "data": V2TimMsgCreateInfoResult.fromJson({}), + }); + + return ImResult.wrap(failed); + } + + /// 语音消息==4 + Future> createSoundMessage({ + required String soundPath, + required int duration, + String? path, + }) async { + final soundExists = await File(soundPath).exists(); + + if (soundExists) { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createSoundMessage( + soundPath: soundPath, + duration: duration, + ); + + return ImResult.wrap(res); + } + + final failed = V2TimValueCallback.fromJson({ + "code": -5, + "desc": "音频文件缺失", + "data": V2TimMsgCreateInfoResult.fromJson({}), + }); + + return ImResult.wrap(failed); + } + + /// 表情 == 8 + Future> createFaceMessage({ + required int index, + required String data, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().createFaceMessage( + index: index, + data: data, + ); + + return ImResult.wrap(res); + } + + ///相当于一个被禁用了网络发送能力的 sendMessage() 接口 + Future> insertC2CMessageToLocalStorageV2({ + required String userID, + required String senderID, + V2TimMessage? message, + String? createdMsgID, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.getMessageManager().insertC2CMessageToLocalStorageV2( + userID: userID, + senderID: senderID, + message: message, + createdMsgID: createdMsgID, + ); + return ImResult.wrap(res); } } diff --git a/lib/IM/im_message_listeners.dart b/lib/IM/im_message_listeners.dart index a3d4241..2d85f31 100644 --- a/lib/IM/im_message_listeners.dart +++ b/lib/IM/im_message_listeners.dart @@ -8,6 +8,7 @@ import 'package:loopin/IM/im_service.dart'; import 'package:loopin/utils/index.dart'; import 'package:loopin/utils/lifecycle_handler.dart'; import 'package:loopin/utils/notification_banner.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; import 'package:tencent_cloud_chat_sdk/enum/V2TimAdvancedMsgListener.dart'; import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; @@ -15,14 +16,18 @@ import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_receipt.dart'; import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; class ImMessageListenerService extends GetxService { + ProgressController? _sendProgressController; // 消息发送提示 + final logger = Logger(); V2TimAdvancedMsgListener? _listener; Timer? _debounceTimer; + /// 插入标签时间间隔 bool needInsertTimeLabel(int lastTimestamp, int newTimestamp, {int interval = 3 * 60}) { return (newTimestamp - lastTimestamp) > interval * 1000; } + ///插入标签 void insertTimeLabel(message) async { // 待插入的消息 List messagesToInsert = []; @@ -54,21 +59,26 @@ class ImMessageListenerService extends GetxService { messagesToInsert.add(resMsg.data); } messagesToInsert.insert(0, message); + // messagesToInsert.add(message); + // 新进入的消息插入列表 chatDetailController.chatList.insertAll(0, messagesToInsert); + // chatDetailController.chatList.addAll(messagesToInsert); + // 滚动 + chatDetailController.scrollToBottom(); } + /// 处理消息 void _handleNewMessage(V2TimMessage message) async { final userID = message.sender ?? ''; if (userID.isEmpty) return; - // 是否正在聊天 优先处理 - if (Get.currentRoute == '/chat' && Get.isRegistered()) { + /// 是否正在聊天 优先处理 + if ((Get.currentRoute == '/chat' || Get.currentRoute == '/chatNoFriend' || Get.currentRoute == '/chatGroup') && Get.isRegistered()) { final chatDetailController = Get.find(); // 单聊的处理 if (chatDetailController.userID == userID) { - // 确认正在聊天 - // 插入消息前检测是否需要打时间标签 + // 确认正在聊天,插入消息前检测是否需要打时间标签 insertTimeLabel(message); // 标注为已读 await ImService.instance.clearConversationUnreadCount(conversationID: 'c2c_$userID'); @@ -142,9 +152,29 @@ class ImMessageListenerService extends GetxService { }, onRecvMessageModified: (V2TimMessage message) { logger.i("消息被修改: ${message.msgID}"); + // 目前就红包领取状态的变更 + if ((Get.currentRoute == '/chat' || Get.currentRoute == '/chatNoFriend' || Get.currentRoute == '/chatGroup') && + Get.isRegistered()) { + final controller = Get.find(); + final index = controller.chatList.indexWhere((m) => m.msgID == message.msgID); + if (index != -1) { + final newJson = message.customElem!.data!; + controller.chatList[index].customElem!.data = newJson; + controller.chatList.refresh(); + } + } }, onSendMessageProgress: (V2TimMessage message, int progress) { logger.i("发送中: ${message.msgID} -> $progress%"); + if (progress < 100) { + _sendProgressController ??= MyDialog.loading( + "发送中...", + duration: null, // 不自动关闭 + ); + } else { + _sendProgressController?.close(); + _sendProgressController = null; + } }, onRecvC2CReadReceipt: (List receiptList) { for (var receipt in receiptList) { @@ -159,7 +189,6 @@ class ImMessageListenerService extends GetxService { ); TencentImSDKPlugin.v2TIMManager.getMessageManager().addAdvancedMsgListener(listener: _listener!); - logger.i("$_listener"); logger.i("高级消息监听器已注册"); return this; diff --git a/lib/IM/im_result.dart b/lib/IM/im_result.dart index 73623a3..f4121d5 100644 --- a/lib/IM/im_result.dart +++ b/lib/IM/im_result.dart @@ -1,3 +1,6 @@ +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart'; + class ImResult { final bool success; final int code; @@ -10,4 +13,21 @@ class ImResult { required this.desc, this.data, }); + static ImResult wrap(V2TimValueCallback res) { + return ImResult( + success: res.code == 0, + code: res.code, + desc: res.desc, + data: res.data, + ); + } + + static ImResult wrapNoData(V2TimCallback res) { + return ImResult( + success: res.code == 0, + code: res.code, + desc: res.desc, + data: null, + ); + } } diff --git a/lib/IM/im_service.dart b/lib/IM/im_service.dart index 317831d..b401f5d 100644 --- a/lib/IM/im_service.dart +++ b/lib/IM/im_service.dart @@ -1,19 +1,40 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:logger/logger.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; import 'package:loopin/IM/controller/tab_bar_controller.dart'; import 'package:loopin/IM/global_badge.dart'; import 'package:loopin/IM/im_core.dart'; import 'package:loopin/IM/im_friend_listeners.dart'; import 'package:loopin/IM/im_message_listeners.dart'; import 'package:loopin/IM/im_result.dart'; +import 'package:loopin/IM/push_service.dart'; import 'package:loopin/models/conversation_view_model.dart'; +import 'package:loopin/utils/wxsdk.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_application_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_response_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_type_enum.dart'; import 'package:tencent_cloud_chat_sdk/enum/history_msg_get_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_callback.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_filter.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_operation_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_type_check_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_info_result.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_friend_operation_result.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message_change_info.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_info_result.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_value_callback.dart'; import 'package:tencent_cloud_chat_sdk/native_im/adapter/tim_conversation_manager.dart'; +import 'package:tencent_cloud_chat_sdk/native_im/adapter/tim_friendship_manager.dart'; +import 'package:tencent_cloud_chat_sdk/native_im/adapter/tim_message_manager.dart'; import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; final logger = Logger(); @@ -40,6 +61,16 @@ class ImService { if (result.success) { logger.i("IM 登录成功:$userID"); + // 初始化push服务 + PushService().initPush( + sdkAppId: 1600080789, + appKey: 'vkFpe55aYqfV7Sk5uGaoxhEstJ3tcI9dquk7JwG1GloDSLD2HeMWeQweWWXgNlhC', + ); + // 初始化微信 SDK + await Wxsdk.init(); + + // 注册用户信息(基本信息+自定义信息) + Get.put(ImUserInfoController(), permanent: true); // 登录成功后注册高级消息监听器 final messageService = ImMessageListenerService(); Get.put(messageService, permanent: true); @@ -48,13 +79,11 @@ class ImService { // 注册关系链监听器 final friendListener = ImFriendListeners(); - logger.i(friendListener); Get.put(friendListener, permanent: true); friendListener.register(); /// 注册消息未读数监听器 Get.put(GlobalBadge(), permanent: true); - // Get.lazyPut(() => GlobalBadge()); } else { logger.i("IM 登录失败:${result.code} - ${result.desc}"); Get.snackbar( @@ -72,6 +101,9 @@ class ImService { Future logout() async { final res = await TencentImSDKPlugin.v2TIMManager.logout(); if (res.code == 0) { + /// 清理用户信息 + Get.delete(force: true); + /// 移出消息监听器 Get.find().onClose(); Get.delete(force: true); @@ -83,25 +115,114 @@ class ImService { /// 清理tabbar Get.find().badgeMap.clear(); + /// 清理会话列表数据 + Get.find().initChatData(); + /// 移出未读消息监听器 Get.find().onClose(); Get.delete(force: true); + /// 移除推送服务 + PushService.unInitPush(); + /// 反初始化 ImCore.unInit(); } - return ImResult( - success: res.code == 0, - code: res.code, - desc: res.desc, + return ImResult.wrapNoData(res); + } + + /// 设置会话自定义属性 + Future>> setConversationCustomData({ + required String customData, + required List conversationIDList, + }) async { + final res = await TIMConversationManager.instance.setConversationCustomData(customData: customData, conversationIDList: conversationIDList); + + return ImResult.wrap(res); + } + + /// 获取符合过滤条件的未读消息总数 + Future> getUnreadMessageCountByFilter({ + required V2TimConversationFilter filter, + }) async { + final res = await TIMConversationManager.instance.getUnreadMessageCountByFilter( + filter: filter, ); + return ImResult.wrap(res); + } + + /// 删除会话 + Future> deleteConversation({ + required String conversationID, + }) async { + final res = await TIMConversationManager.instance.deleteConversation(conversationID: conversationID); + + return ImResult.wrapNoData(res); + } + + /// 创建会话分组 + Future>> createConversationGroup({ + required String groupName, + required List conversationIDList, + }) async { + final res = await TIMConversationManager.instance.createConversationGroup( + groupName: groupName, + conversationIDList: conversationIDList, + ); + + return ImResult.wrap(res); + } + + /// 获取会话分组列表 + Future>> getConversationGroupList() async { + final res = await TIMConversationManager.instance.getConversationGroupList(); + return ImResult.wrap(res); + } + + /// 将会话添加到分组 + Future>> addConversationsToGroup({ + required String groupName, + required List conversationIDList, + }) async { + final res = await TIMConversationManager.instance.addConversationsToGroup( + groupName: groupName, + conversationIDList: conversationIDList, + ); + return ImResult.wrap(res); + } + + ///将会话移除分组 + Future>> deleteConversationsFromGroup({ + required String groupName, + required List conversationIDList, + }) async { + final res = await TIMConversationManager.instance.deleteConversationsFromGroup( + groupName: groupName, + conversationIDList: conversationIDList, + ); + return ImResult.wrap(res); + } + + /// 高级查询会话列表 + Future> getConversationListByFilter({ + required V2TimConversationFilter filter, + required int nextSeq, + int count = 20, + }) async { + final res = await TIMConversationManager.instance.getConversationListByFilter( + filter: filter, + nextSeq: nextSeq, + count: count, + ); + + return ImResult.wrap(res); } /// 查询会话记录 Future getConversationList(String nextSeq, int count) async { - final res = await TencentImSDKPlugin.v2TIMManager.getConversationManager().getConversationList(nextSeq: nextSeq, count: count); - - if (res.code != 0) { + // final res = await TencentImSDKPlugin.v2TIMManager.getConversationManager().getConversationList(nextSeq: nextSeq, count: count); + final res = await getConvData(nextSeq, count); + if (res.success == false) { return ImResult( success: false, code: res.code, @@ -138,7 +259,7 @@ class ImService { // 读取管理员标识 final customInfo = user.customInfo; if (customInfo != null) { - isCustomAdmin = customInfo['Tag_Profile_Custom_admin'] ?? '0'; + isCustomAdmin = customInfo['admin'] ?? '0'; } } } @@ -171,6 +292,40 @@ class ImService { return ConversationViewModel(conversation: conv, faceUrl: faceUrl, isCustomAdmin: isCustomAdmin); }).toList(); + // 筛选数据,过滤掉陌生人消息 + viewList.removeWhere((conv) { + final special = conv.conversation.conversationGroupList ?? []; + return special.contains('noFriend'); + }); + + ChatController chatcontroller = Get.find(); + logger.e('新的分页内容:${res.data!.toLogString()},控制器中的:${chatcontroller.nextSeq.value}'); + String newNextSeq = res.data?.nextSeq ?? '0'; + bool isEnd = res.data?.isFinished ?? true; + + if (isEnd) { + //没数据了,关闭拉取; + chatcontroller.isFinished.value = isEnd; + } else { + // 没拉完,记录游标 + chatcontroller.nextSeq.value = newNextSeq; + } + + // 更新分页 + chatcontroller.nextSeq.value = res.data!.nextSeq!; + if (res.data!.isFinished == false) { + if (viewList.length < 20) { + // 递归补偿拉取 + final nextRes = await getConversationList( + res.data!.nextSeq!, + count, + ); + if (nextRes.success && nextRes.data != null) { + viewList.addAll(nextRes.data as List); + } + } + } + return ImResult( success: res.code == 0, code: res.code, @@ -179,30 +334,13 @@ class ImService { ); } - /// 获取自己的userId - Future selfUserId() async { - V2TimValueCallback self = await TencentImSDKPlugin.v2TIMManager.getLoginUser(); - String? userId = self.data; - return ImResult( - success: self.code == 0, - code: self.code, - desc: self.desc, - data: userId, - ); - } - - /// 查询当前登录用户的个人信息 - Future selfInfo() async { - // 获取当前登录的用户 ID - final idRes = await selfUserId(); - // 获取用户信息 - V2TimValueCallback> res = await TencentImSDKPlugin.v2TIMManager.getUsersInfo(userIDList: [idRes.data]); - return ImResult( - success: res.code == 0, - code: res.code, - desc: res.desc, - data: res.data?.isNotEmpty == true ? res.data![0] : null, - ); + ///获取所有会话数据 + Future> getConvData(String nextSeq, int count) async { + final res = await TencentImSDKPlugin.v2TIMManager.getConversationManager().getConversationList(nextSeq: nextSeq, count: count); + // for (var element in res.data!.conversationList) { + // logger.e('所有的会话数据:${element.toJson()}'); + // } + return ImResult.wrap(res); } ///获取指定会话 @@ -219,13 +357,29 @@ class ImService { ); } + /// 获取消息 + Future>> findMessages({ + required List messageIDList, + }) async { + final res = await TIMMessageManager.instance.findMessages(messageIDList: messageIDList); + return ImResult.wrap(res); + } + + /// 修改消息 + Future> modifyMessage({ + required V2TimMessage message, + }) async { + final res = await TIMMessageManager.instance.modifyMessage(message: message); + return ImResult.wrap(res); + } + /// 获取聊天记录 如果是群聊传 groupID,单聊传 userID,二选一 Future>> getHistoryMessageList({ HistoryMsgGetTypeEnum getType = HistoryMsgGetTypeEnum.V2TIM_GET_LOCAL_OLDER_MSG, String? userID, String? groupID, int? lastMsgSeq, - int count = 20, + int count = 10, V2TimMessage? lastMsg, List? messageTypeList, List? messageSeqList, @@ -298,10 +452,223 @@ class ImService { cleanSequence: cleanSequence, // 群聊生效 ); + return ImResult.wrapNoData(res); + } + + /// 获取自己的userId + Future selfUserId() async { + V2TimValueCallback self = await TencentImSDKPlugin.v2TIMManager.getLoginUser(); + String? userId = self.data; + return ImResult( + success: self.code == 0, + code: self.code, + desc: self.desc, + data: userId, + ); + } + + /// 查询当前登录用户的个人信息 + Future selfInfo() async { + // 获取当前登录的用户 ID + final idRes = await selfUserId(); + // 获取用户信息 + V2TimValueCallback> res = await TencentImSDKPlugin.v2TIMManager.getUsersInfo(userIDList: [idRes.data]); return ImResult( success: res.code == 0, code: res.code, desc: res.desc, + data: res.data?.isNotEmpty == true ? res.data!.first : null, ); } + + /// 查询其他人的信息 + Future otherInfo(id) async { + // 获取用户信息 + V2TimValueCallback> res = await TencentImSDKPlugin.v2TIMManager.getUsersInfo(userIDList: [id]); + return ImResult( + success: res.code == 0, + code: res.code, + desc: res.desc, + data: res.data?.isNotEmpty == true ? res.data!.first : null, + ); + } + + /// 设置个人资料 + Future setSelfInfo({ + required V2TimUserFullInfo userFullInfo, + }) async { + final res = await TencentImSDKPlugin.v2TIMManager.setSelfInfo( + userFullInfo: userFullInfo, + ); + return ImResult.wrapNoData(res); + } + + /// 检查是否是好友(双向或单向) + Future isMyFriend(String userID, FriendTypeEnum checkType) async { + final res = await TIMFriendshipManager.instance.checkFriend( + userIDList: [userID], + checkType: checkType, //V2TIM_FRIEND_TYPE_BOTH V2TIM_FRIEND_TYPE_SINGLE + ); + + if (res.code == 0 && res.data != null && res.data!.isNotEmpty) { + final resultType = res.data!.first.resultType; + final isFriend = resultType == 3; //0=无, 1=单向, 2=我在对方列表,3=双向 + return ImResult( + success: true, + desc: res.desc, + code: res.code, + data: isFriend, + ); + } else { + return ImResult( + success: false, + code: res.code, + desc: res.desc, + data: false, + ); + } + } + + /// 添加好友 + Future> addFriend({ + required String userID, + String? remark, + String? friendGroup, + String? addWording, + String? addSource, + required FriendTypeEnum addType, + }) async { + final res = await TIMFriendshipManager.instance.addFriend( + userID: userID, + remark: remark, + friendGroup: friendGroup, + addWording: addWording, + addSource: addSource, + addType: addType, + ); + return ImResult.wrap(res); + } + + ///接受好友申请 + Future> acceptFriendApplication({ + required FriendResponseTypeEnum responseType, + required FriendApplicationTypeEnum type, // V2TIM_FRIEND_ACCEPT_AGREE,同意添加单向好友;V2TIM_FRIEND_ACCEPT_AGREE_AND_ADD,同意并添加为双向好友 + required String userID, + }) async { + final res = await TIMFriendshipManager.instance.acceptFriendApplication( + responseType: responseType, + type: type, + userID: userID, + ); + return ImResult.wrap(res); + } + + /// 拉黑 + Future>> addToBlackList({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.addToBlackList(userIDList: userIDList); + return ImResult.wrap(res); + } + + /// 取消拉黑 + Future>> deleteFromBlackList({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.deleteFromBlackList(userIDList: userIDList); + return ImResult.wrap(res); + } + + ///获取好友列表 + Future>> getFriendList() async { + final res = await TIMFriendshipManager.instance.getFriendList(); + return ImResult.wrap(res); + } + + /// set好友备注 + Future setFriendInfo({ + required String userID, + String? friendRemark, + Map? friendCustomInfo, + }) async { + late V2TimCallback res; + res = await TIMFriendshipManager.instance.setFriendInfo( + userID: userID, + friendRemark: friendRemark, + friendCustomInfo: friendCustomInfo, + ); + return ImResult.wrapNoData(res); + } + + /// 获取好友信息 + Future>> getFriendInfo({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.getFriendsInfo(userIDList: userIDList); + return ImResult.wrap(res); + } + + ///关注 + Future>> followUser({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.followUser( + userIDList: userIDList, + ); + return ImResult.wrap(res); + } + + ///取关 + Future>> unfollowUser({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.unfollowUser( + userIDList: userIDList, + ); + return ImResult.wrap(res); + } + + /// check关注的类型 + /// 0:不是好友也没有关注 + /// 1:你关注了对方(单向) + /// 2:对方关注了你(单向) + /// 3:互相关注(双向好友) + Future>> checkFollowType({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.checkFollowType( + userIDList: userIDList, + ); + return ImResult.wrap(res); + } + + ///获取指定用户的 关注/粉丝/互关 数量信息 + Future>> getUserFollowInfo({ + required List userIDList, + }) async { + final res = await TIMFriendshipManager.instance.getUserFollowInfo(userIDList: userIDList); + return ImResult.wrap(res); + } + + /// 获取双向关注列表(互关好友) + /// [nextCursor] 分页游标,首次传空字符串 + Future> getMutualFollowersList({ + required String nextCursor, + }) async { + final res = await TIMFriendshipManager.instance.getMutualFollowersList( + nextCursor: nextCursor, + ); + return ImResult.wrap(res); + } + + /// 获取我的粉丝列表 + /// [nextCursor] 分页游标,首次传空字符串 + Future> getMyFollowersList({ + required String nextCursor, + }) async { + final res = await TIMFriendshipManager.instance.getMyFollowersList( + nextCursor: nextCursor, + ); + return ImResult.wrap(res); + } } diff --git a/lib/IM/push_service.dart b/lib/IM/push_service.dart new file mode 100644 index 0000000..02b839a --- /dev/null +++ b/lib/IM/push_service.dart @@ -0,0 +1,208 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:get/get.dart'; +import 'package:logger/logger.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/models/conversation_type.dart'; +import 'package:loopin/utils/storage.dart'; +import 'package:tencent_cloud_chat_push/common/tim_push_listener.dart'; +import 'package:tencent_cloud_chat_push/common/tim_push_message.dart'; +import 'package:tencent_cloud_chat_push/tencent_cloud_chat_push.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/tencent_im_sdk_plugin.dart'; + +final logger = Logger(); + +class PushService { + static late TIMPushListener _timPushListener; + + Future _registerPushInIsolate(Map args) async { + final sdkAppId = args['sdkAppId'] as int; + final appKey = args['appKey'] as String; + final apnsCertificateID = args['apnsCertificateID'] as int; + + try { + await TencentCloudChatPush().registerPush( + sdkAppId: sdkAppId, + appKey: appKey, + apnsCertificateID: apnsCertificateID, + onNotificationClicked: _onNotificationClicked, + ); + } catch (e) { + logger.e('注册推送失败: $e'); + } + } + + /// 初始化推送服务 + Future initPush({ + required int sdkAppId, + required String appKey, // 客户端密钥 + }) async { + int apnsCertificateID; + final devices = await _getDeviceBrand(); + apnsCertificateID = _getApnsCertificateIDForBrand(devices); + if (apnsCertificateID == 0) { + logger.w('手机厂商:$devices, 未配置证书'); + } + + // 注册推送(初始化) + // if (Platform.isAndroid) { + // await compute(_registerPushInIsolate, { + // 'sdkAppId': sdkAppId, + // 'appKey': appKey, + // 'apnsCertificateID': apnsCertificateID, + // }); + // } else { + await TencentCloudChatPush().registerPush( + onNotificationClicked: _onNotificationClicked, + sdkAppId: sdkAppId, + appKey: appKey, + apnsCertificateID: apnsCertificateID, + ); + // } + + // 关闭 App 在前台时弹出通知栏 + await TencentCloudChatPush().disablePostNotificationInForeground(disable: true); + + ///处理安卓端异常问题; + if (Platform.isAndroid) { + await TencentImSDKPlugin.v2TIMManager.login(userID: Storage.read('userId'), userSig: Storage.read('userSig')); + } + + logger.i('推送服务已注册,手机:$devices,证书ID:$apnsCertificateID'); + + // 添加在线时监听器 + _addPushListener(); + } + + /// 注销推送(退出登录时调用) + static Future unInitPush() async { + try { + await TencentCloudChatPush().unRegisterPush(); + _removePushListener(); + } catch (e) { + logger.i("注销推送失败: $e"); + } + } + + /// 添加监听器 + static void _addPushListener() { + _timPushListener = TIMPushListener( + onRecvPushMessage: (TimPushMessage message) { + logger.i("[推送] 收到消息: ${message.toLogString()}"); + }, + onRevokePushMessage: (String messageId) { + logger.i("[推送] 消息被撤回: $messageId"); + }, + onNotificationClicked: (String ext) { + logger.i("[推送] 点击横幅 ext: $ext"); + _handleNotificationClick(ext); + }, + ); + TencentCloudChatPush().addPushListener(listener: _timPushListener); + logger.i('推送服务在线监听器已注册'); + } + + /// 移除监听器 + static void _removePushListener() { + TencentCloudChatPush().removePushListener(listener: _timPushListener); + } + + /// 横幅点击事件处理 + static void _onNotificationClicked({ + required String ext, + String? userID, + String? groupID, + }) { + logger.i("[点击通知回调] ext: $ext, userID: $userID, groupID: $groupID"); + _handleNotificationClick(ext, userID: userID, groupID: groupID); + } + + /// 统一处理跳转逻辑 + static void _handleNotificationClick(String ext, {String? userID, String? groupID}) async { + try { + // ext={id:对应业务ID,type:'newFoucs',userID:发送人的id,groupID:群ID} + // final ext = jsonEncode({ + // "userID": "123456", + // "groupID": "654321", + // }); + final data = jsonDecode(ext); + logger.i(data); + final type = data['type']; + final router = conversationTypeFromString(type); + logger.w(router); + if (router == null || router != '') { + // 聊天 + if (data['userID'] != null) { + logger.w('有userID'); + // 单聊,获取会话 + final covRes = await ImService.instance.getConversation(conversationID: 'c2c_${data['userID']}'); + final V2TimConversation conversation = covRes.data; + logger.w(conversation.toJson()); + if (conversation.conversationGroupList?.contains(ConversationType.noFriend.name) ?? false) { + // nofriend会话,是否第一次聊天 + conversation.showName = conversation.showName ?? data['title']; + Get.toNamed('/chatNoFriend', arguments: conversation); + } else { + // 去正常的会话 + Get.toNamed('/chat', arguments: conversation); + } + } else { + logger.w('没有userID'); + + // 群聊消息 + final groupRes = await ImService.instance.getConversation(conversationID: 'group_${data['groupID']}'); + Get.toNamed('/chatGroup', arguments: groupRes.data); + } + } else { + // 通知类相关 + Get.toNamed('/$router', arguments: data['id'] ?? ''); + } + } catch (e) { + logger.i("[推送点击] ext 解析失败: $e"); + } + } + + /// 获取手机品牌 + static Future _getDeviceBrand() async { + final deviceInfo = DeviceInfoPlugin(); + try { + if (Platform.isAndroid) { + final androidInfo = await deviceInfo.androidInfo; + return androidInfo.brand.toLowerCase(); + } else if (Platform.isIOS) { + return 'apple'; + } else { + return 'unknown'; + } + } catch (e, stack) { + logger.w("获取设备品牌失败: $e\n$stack"); + return 'unknown'; + } + } + + /// 获取对应厂商的证书ID + static int _getApnsCertificateIDForBrand(String brand) { + switch (brand) { + case 'xiaomi': + case 'redmi': + return 41169; + case 'oppo': + return 41170; + case 'vivo': + return 41177; + case 'meizu': + return 41176; + case 'apple': + return 45356; + case 'huawei': + return 41171; + case 'honor': + return 41178; + default: + return 0; + } + } +} diff --git a/lib/api/common_api.dart b/lib/api/common_api.dart index 7fdd2b8..37dce77 100644 --- a/lib/api/common_api.dart +++ b/lib/api/common_api.dart @@ -1,7 +1,15 @@ class CommonApi { - static const String checkVersion = '/check/version'; // 查询版本 - static const String getCode = '/resource/sms/code'; // 发送短信验证码 - static const String login = '/auth/login'; // 登录 - static const String uploadFile = '/upload'; // 上传文件 - static const String accountInfo = '/ums/member/account/'; // 账户信息 + ///----------get + static const String getCode = '/resource/sms/code'; // 发送短信验证码 {'phonenumber'} + static const String accountInfo = '/app/member/info'; // 账户信息 + + ///---------post + static const String login = '/auth/login'; // 登录 {'phonenumber': '', 'smsCode': '', 'clientId': '428a8310cd442757ae699df5d894f051', 'grantType': 'sms'}; + static const String checkVersion = '/system/version/list'; // 查询app版本 {'platformType': Platform.isAndroid ? 'android' : 'ios','status': 1} + static const String uploadFile = '/resource/oss/upload'; + + ///[source]=wechat_open [clientId]=428a8310cd442757ae699df5d894f051 [grantType]=social [socialState]=1 + static const String wxLogin = '/app/member/bind/wechat'; + + ///resource/oss/upload } diff --git a/lib/api/shop_api.dart b/lib/api/shop_api.dart new file mode 100644 index 0000000..f984fd5 --- /dev/null +++ b/lib/api/shop_api.dart @@ -0,0 +1,17 @@ +class ShopApi { + ///---------------------post + /// [size]分页数量 + /// [current] 第几页 + /// [categoryId] 分类id + /// [nameLike] 商品名称 + static const String shopList = '/app/product/page'; // 商品列表 + + /// [showStatus]1=显示 [nameLike]分类名称 + static const String shopCategory = '/app/productCategory/page'; // 商品分类 + /// [] + static const String shopSwiperList = '/app/article/carousel'; // 商品首页轮播图 + + ///---------------------get + /// [url参数/id] + static const String shopDetail = '/app/product'; // 商品详情 +} diff --git a/lib/api/video_api.dart b/lib/api/video_api.dart index b86ce65..7cf0f30 100644 --- a/lib/api/video_api.dart +++ b/lib/api/video_api.dart @@ -1,18 +1,19 @@ class VideoApi { // get - static const String vlogList = '/vlog/indexList'; // 推荐视频列表数据 - static const String myPublicList = '/vlog/myPublicList'; // 我发布的视频 - static const String myPrivateList = '/vlog/myPrivateList'; // 我的私密视频 - static const String myLikedList = '/vlog/myLikedList'; // 我点赞的视频 - static const String friendList = '/vlog/friendList'; //互关好友的视频 - static const String followList = '/vlog/followList'; // 我关注的博主视频 - static const String detail = '/vlog/detail'; // 视频详情 + static const String vlogList = '/app/vlog/indexList'; // 推荐视频列表数据 + static const String myPrivateList = '/app/vlog/myPrivateList'; // 我的私密视频 + static const String friendList = '/app/vlog/friendList'; //互关好友的视频 + static const String followList = '/app/vlog/followList'; // 我关注的博主视频 + static const String detail = '/app/vlog/detail'; // 视频详情 // post - static const String unlike = '/vlog/unlike'; //取消点赞 - static const String totalLikedCounts = '/vlog/totalLikedCounts'; //收到点赞总数 - static const String publish = '/vlog/publish'; //发布视频 - static const String like = '/vlog/like'; //点赞 - static const String changeVlogStatus = '/vlog/changeVlogStatus'; //修改我的视频状态(删除视频) - static const String changeToPublic = '/vlog/changeToPublic'; //将视频改为公开状态 - static const String changeToPrivate = '/vlog/changeToPrivate'; //将视频改为私密状态 + static const String myPublicList = '/app/vlog/myPublicList'; // 我发布的视频 + static const String myLikedList = '/app/vlog/myLikedList'; // 我点赞的视频 + + static const String unlike = '/app/vlog/unlike'; //取消点赞 + static const String totalLikedCounts = '/app/vlog/totalLikedCounts'; //收到点赞总数 + static const String publish = '/app/vlog/publish'; //发布视频 + static const String like = '/app/vlog/like'; //点赞 + static const String changeVlogStatus = '/app/vlog/changeVlogStatus'; //修改我的视频状态(删除视频) + static const String changeToPublic = '/app/vlog/changeToPublic'; //将视频改为公开状态 + static const String changeToPrivate = '/app/vlog/changeToPrivate'; //将视频改为私密状态 } diff --git a/lib/bings/chat_binding.dart b/lib/bings/chat_binding.dart new file mode 100644 index 0000000..dbf8ac0 --- /dev/null +++ b/lib/bings/chat_binding.dart @@ -0,0 +1,11 @@ +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_detail_controller.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; + +class ChatBinding extends Bindings { + @override + void dependencies() { + V2TimConversation conversation = Get.arguments; + Get.put(ChatDetailController(userID: conversation.userID!)); + } +} diff --git a/lib/components/custom_sticky_header.dart b/lib/components/custom_sticky_header.dart index 4d65ae6..8017bea 100644 --- a/lib/components/custom_sticky_header.dart +++ b/lib/components/custom_sticky_header.dart @@ -2,11 +2,20 @@ library; import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +typedef OnPinnedChanged = void Function(bool pinned); class CustomStickyHeader extends SliverPersistentHeaderDelegate { final PreferredSize child; + RxBool? isPinned; + RxDouble? positions; - CustomStickyHeader({required this.child}); + CustomStickyHeader({ + required this.child, + this.isPinned, + this.positions, + }); @override double get minExtent => child.preferredSize.height; @@ -21,6 +30,21 @@ class CustomStickyHeader extends SliverPersistentHeaderDelegate { @override Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) { + // overlapsContent 或 shrinkOffset >= maxExtent - minExtent 都可以判断是否吸顶 + bool pinned = overlapsContent; // true 表示已经吸顶 + if (isPinned != null && isPinned!.value != pinned) { + WidgetsBinding.instance.addPostFrameCallback((_) { + isPinned!.value = pinned; + }); + } + if (positions != null) { + if ((maxExtent - minExtent) >= shrinkOffset) { + WidgetsBinding.instance.addPostFrameCallback((_) { + positions!.value = shrinkOffset; + }); + } + } + return child; } } diff --git a/lib/components/my_toast.dart b/lib/components/my_toast.dart new file mode 100644 index 0000000..806f604 --- /dev/null +++ b/lib/components/my_toast.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; + +class MyToast { + /// + void tip({ + required String title, + String? type, // 默认失败 + String? position, // 默认底部显示 + }) { + final baseStyle = position == 'top' + ? MyDialog.theme.toastStyle?.top() + : position == 'center' + ? MyDialog.theme.toastStyle?.center() + : MyDialog.theme.toastStyle?.bottom(); + MyDialog.toast( + title, + icon: type == 'success' ? const Icon(Icons.check_circle) : Icon(Icons.warning), + duration: Duration(milliseconds: 5000), + style: baseStyle?.copyWith( + backgroundColor: type == 'success' ? Colors.green.withAlpha(200) : Colors.red.withAlpha(200), + ), + ); + } +} diff --git a/lib/components/network_or_asset_image.dart b/lib/components/network_or_asset_image.dart new file mode 100644 index 0000000..5d3d3b9 --- /dev/null +++ b/lib/components/network_or_asset_image.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; + +class NetworkOrAssetImage extends StatelessWidget { + final String? imageUrl; + final double width; + final double? height; + final BoxFit fit; + final String placeholderAsset; + + const NetworkOrAssetImage({ + super.key, + required this.imageUrl, + this.width = 60.0, + this.height, + this.fit = BoxFit.cover, + this.placeholderAsset = 'assets/images/avatar/default.png', + }); + + @override + Widget build(BuildContext context) { + final isNetwork = imageUrl != null && imageUrl!.isNotEmpty && (imageUrl!.startsWith('http://') || imageUrl!.startsWith('https://')); + + if (isNetwork) { + return Image.network( + imageUrl!, + width: width, + height: height, + fit: fit, + errorBuilder: (context, error, stackTrace) { + return Image.asset( + placeholderAsset, + width: width, + height: height, + fit: fit, + ); + }, + ); + } else { + return Image.asset( + (imageUrl != null && imageUrl!.isNotEmpty) ? imageUrl! : placeholderAsset, + width: width, + height: height, + fit: fit, + ); + } + } +} diff --git a/lib/components/preview_video.dart b/lib/components/preview_video.dart new file mode 100644 index 0000000..5a4cfec --- /dev/null +++ b/lib/components/preview_video.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:loopin/components/shark_video.dart'; +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit_video/media_kit_video.dart'; + +class PreviewVideo extends StatefulWidget { + final String videoUrl; + final double? width; + final double? height; + + const PreviewVideo({ + super.key, + required this.videoUrl, + this.width, + this.height, + }); + + @override + State createState() => _PreviewVideoPageState(); +} + +class _PreviewVideoPageState extends State { + late final Player _player = Player(); + late VideoController videoController = VideoController(_player); + + @override + void initState() { + super.initState(); + _player.open(Media(widget.videoUrl)); + } + + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final videoWidth = widget.width ?? 1.0; + final videoHeight = widget.height ?? 1.0; + final isHorizontal = videoWidth > videoHeight; + + return SafeArea( + child: Stack( + children: [ + Scaffold( + backgroundColor: Colors.black, + body: Center( + child: Video( + controller: videoController, + fit: isHorizontal ? BoxFit.contain : BoxFit.cover, + controls: (state) => MyMaterialVideoControls(state), + ), + ), + ), + // 关闭按钮 + Positioned( + top: 20, + left: 20, + child: GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Icon( + Icons.close, + color: Colors.white, + size: 28, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/components/scan_util.dart b/lib/components/scan_util.dart index a74ee88..5de2e22 100644 --- a/lib/components/scan_util.dart +++ b/lib/components/scan_util.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:ai_barcode_scanner/ai_barcode_scanner.dart'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:permission_handler/permission_handler.dart'; class ScanUtil { static Future openScanner({required void Function(String) onResult}) async { @@ -42,6 +43,29 @@ class ScanUtil { width: MediaQuery.of(Get.context!).size.width * 0.8, height: MediaQuery.of(Get.context!).size.height * 0.5, ), + // 异常处理 + errorBuilder: (context, error) { + String message = "无法启动摄像头,请检查权限"; + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.error, color: Colors.red, size: 60), + const SizedBox(height: 16), + Text( + message, + style: const TextStyle(fontSize: 18, color: Colors.red), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: () async => await openAppSettings(), + child: const Text('去开启权限'), + ), + ], + ), + ); + }, ), ); diff --git a/lib/components/shark_video.dart b/lib/components/shark_video.dart new file mode 100644 index 0000000..943b54f --- /dev/null +++ b/lib/components/shark_video.dart @@ -0,0 +1,2062 @@ +/// This file is a part of media_kit (https://github.com/media-kit/media-kit). +/// +/// Copyright © 2021 & onwards, Hitesh Kumar Saini . +/// All rights reserved. +/// Use of this source code is governed by MIT license that can be found in the LICENSE file. +library; + +// ignore_for_file: non_constant_identifier_names +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/methods/video_state.dart'; +import 'package:media_kit_video/media_kit_video_controls/src/controls/widgets/video_controls_theme_data_injector.dart'; +import 'package:screen_brightness_platform_interface/screen_brightness_platform_interface.dart'; +import 'package:volume_controller/volume_controller.dart'; + +/// {@template material_video_controls} +/// +/// [Video] controls which use Material design. +/// +/// {@endtemplate} +Widget MyMaterialVideoControls(VideoState state) { + return const VideoControlsThemeDataInjector( + child: _MaterialVideoControls(), + ); +} + +/// [MaterialVideoControlsThemeData] available in this [context]. +MaterialVideoControlsThemeData _theme(BuildContext context) => FullscreenInheritedWidget.maybeOf(context) == null + ? MaterialVideoControlsTheme.maybeOf(context)?.normal ?? kDefaultMaterialVideoControlsThemeData + : MaterialVideoControlsTheme.maybeOf(context)?.fullscreen ?? kDefaultMaterialVideoControlsThemeDataFullscreen; + +/// Default [MaterialVideoControlsThemeData]. +const kDefaultMaterialVideoControlsThemeData = MaterialVideoControlsThemeData(); + +/// Default [MaterialVideoControlsThemeData] for fullscreen. +const kDefaultMaterialVideoControlsThemeDataFullscreen = MaterialVideoControlsThemeData( + displaySeekBar: true, + automaticallyImplySkipNextButton: true, + automaticallyImplySkipPreviousButton: true, + volumeGesture: true, + brightnessGesture: true, + seekGesture: true, + gesturesEnabledWhileControlsVisible: true, + seekOnDoubleTap: true, + seekOnDoubleTapEnabledWhileControlsVisible: true, + visibleOnMount: false, + speedUpOnLongPress: false, + speedUpFactor: 2.0, + verticalGestureSensitivity: 100, + horizontalGestureSensitivity: 1000, + backdropColor: Color(0x66000000), + padding: null, + controlsHoverDuration: Duration(seconds: 3), + controlsTransitionDuration: Duration(milliseconds: 300), + bufferingIndicatorBuilder: null, + volumeIndicatorBuilder: null, + brightnessIndicatorBuilder: null, + seekIndicatorBuilder: null, + speedUpIndicatorBuilder: null, + primaryButtonBar: [ + Spacer(flex: 2), + MaterialSkipPreviousButton(), + Spacer(), + MaterialPlayOrPauseButton(iconSize: 56.0), + Spacer(), + MaterialSkipNextButton(), + Spacer(flex: 2), + ], + topButtonBar: [], + topButtonBarMargin: EdgeInsets.symmetric( + horizontal: 16.0, + ), + bottomButtonBar: [ + MaterialPositionIndicator(), + Spacer(), + // 全屏后的 + MaterialFullscreenButton(), + ], + bottomButtonBarMargin: EdgeInsets.only( + left: 16.0, + right: 8.0, + bottom: 42.0, + ), + buttonBarHeight: 56.0, + buttonBarButtonSize: 24.0, + buttonBarButtonColor: Color(0xFFFFFFFF), + seekBarMargin: EdgeInsets.only( + left: 16.0, + right: 16.0, + bottom: 42.0, + ), + seekBarHeight: 2.4, + seekBarContainerHeight: 36.0, + seekBarColor: Color(0x3DFFFFFF), + seekBarPositionColor: Color(0xFFFF0000), + seekBarBufferColor: Color(0x3DFFFFFF), + seekBarThumbSize: 12.8, + seekBarThumbColor: Color(0xFFFF0000), + seekBarAlignment: Alignment.bottomCenter, + shiftSubtitlesOnControlsVisibilityChange: false, +); + +/// {@template material_video_controls_theme_data} +/// +/// Theming related data for [MaterialVideoControls]. These values are used to theme the descendant [MaterialVideoControls]. +/// +/// {@endtemplate} +class MaterialVideoControlsThemeData { + // BEHAVIOR + + /// Whether to display seek bar. + final bool displaySeekBar; + + /// Whether a skip next button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipNextButton; + + /// Whether a skip previous button should be displayed if there are more than one videos in the playlist. + final bool automaticallyImplySkipPreviousButton; + + /// Whether to modify volume on vertical drag gesture on the right side of the screen. + final bool volumeGesture; + + /// Whether to modify screen brightness on vertical drag gesture on the left side of the screen. + final bool brightnessGesture; + + /// Whether to seek on horizontal drag gesture. + final bool seekGesture; + + /// Whether to allow gesture controls to work while controls are visible. + /// NOTE: This option is ignored when gestures are false. + final bool gesturesEnabledWhileControlsVisible; + + /// Whether to enable double tap to seek on left or right side of the screen. + final bool seekOnDoubleTap; + + /// Whether to allow double tap to seek on left or right side of the screen to work while controls are visible. + /// NOTE: This option is ignored when [seekOnDoubleTap] is false. + final bool seekOnDoubleTapEnabledWhileControlsVisible; + + /// `seekOnDoubleTapLayoutTapsRatios` defines the width proportions for the interactive areas + /// responsible for seek actions (backward seek, instant tap, forward seek) when a double tap + /// occurs on the video widget. This property divides the video widget into three segments + /// horizontally. Each integer in the list represents the relative width of each segment. + /// By default, the value `[1, 1, 1]` means that the video widget is equally divided into three + /// segments: the left segment for backward seek, the middle segment for instant tap (usually show and hide controls), + /// and the right segment for forward seek. Adjusting these values changes the width of the interactive areas + /// for each double tap action. + final List seekOnDoubleTapLayoutTapsRatios; + + /// `seekOnDoubleTapLayoutWidgetRatios` defines the width proportions for the visual indicators or + /// widgets that appear during the double tap actions (backward seek, instant tap, forward seek). + /// Similar to `seekOnDoubleTapLayoutTapsRatios`, it divides the area where these indicators are + /// displayed into three segments. Each integer in the list represents the relative width of each + /// segment where the corresponding indicators will be shown. The default `[1, 1, 1]` equally divides + /// the space for each indicator. Modifying these values can change the layout of the seek indicators, + /// giving more or less space to each one based on the specified ratios. + final List seekOnDoubleTapLayoutWidgetRatios; + + /// Duration of seek on double tap backward. + final Duration seekOnDoubleTapBackwardDuration; + + /// Duration of seek on double tap forward. + final Duration seekOnDoubleTapForwardDuration; + + /// Whether the controls are initially visible. + final bool visibleOnMount; + + /// Whether to speed up on long press. + final bool speedUpOnLongPress; + + /// Factor to speed up on long press. + final double speedUpFactor; + + /// Gesture sensitivity on vertical drag gestures, the higher the value is the less sensitive the gesture. + final double verticalGestureSensitivity; + + /// Gesture sensitivity on horizontal drag gestures, the higher the value is the less sensitive the gesture. + final double horizontalGestureSensitivity; + + /// Color of backdrop that comes up when controls are visible. + final Color? backdropColor; + + // GENERIC + + /// Padding around the controls. + /// + /// * Default: `EdgeInsets.zero` + /// * FullScreen: `MediaQuery.of(context).padding` + /// + /// NOTE: In fullscreen, this will be safe area (set [padding] to [EdgeInsets.zero] to disable safe area) + final EdgeInsets? padding; + + /// [Duration] after which the controls will be hidden when there is no mouse movement. + final Duration controlsHoverDuration; + + /// [Duration] for which the controls will be animated when shown or hidden. + final Duration controlsTransitionDuration; + + /// Builder for the buffering indicator. + final Widget Function(BuildContext)? bufferingIndicatorBuilder; + + /// Custom builder for volume indicator. + final Widget Function(BuildContext, double)? volumeIndicatorBuilder; + + /// Custom builder for brightness indicator. + final Widget Function(BuildContext, double)? brightnessIndicatorBuilder; + + /// Custom builder for seek indicator. + final Widget Function(BuildContext, Duration)? seekIndicatorBuilder; + + /// Custom builder for seek indicator. + final Widget Function(BuildContext, double)? speedUpIndicatorBuilder; + + // BUTTON BAR + + /// Buttons to be displayed in the primary button bar. + final List primaryButtonBar; + + /// Buttons to be displayed in the top button bar. + final List topButtonBar; + + /// Margin around the top button bar. + final EdgeInsets topButtonBarMargin; + + /// Buttons to be displayed in the bottom button bar. + final List bottomButtonBar; + + /// Margin around the button bar. + final EdgeInsets bottomButtonBarMargin; + + /// Height of the button bar. + final double buttonBarHeight; + + /// Size of the button bar buttons. + final double buttonBarButtonSize; + + /// Color of the button bar buttons. + final Color buttonBarButtonColor; + + // SEEK BAR + + /// Margin around the seek bar. + final EdgeInsets seekBarMargin; + + /// Height of the seek bar. + final double seekBarHeight; + + /// Height of the seek bar [Container]. + final double seekBarContainerHeight; + + /// [Color] of the seek bar. + final Color seekBarColor; + + /// [Color] of the playback position section in the seek bar. + final Color seekBarPositionColor; + + /// [Color] of the playback buffer section in the seek bar. + final Color seekBarBufferColor; + + /// Size of the seek bar thumb. + final double seekBarThumbSize; + + /// [Color] of the seek bar thumb. + final Color seekBarThumbColor; + + /// [Alignment] of seek bar inside the seek bar container. + final Alignment seekBarAlignment; + + // SUBTITLE + + /// Whether to shift the subtitles upwards when the controls are visible. + final bool shiftSubtitlesOnControlsVisibilityChange; + + /// {@macro material_video_controls_theme_data} + const MaterialVideoControlsThemeData({ + this.displaySeekBar = true, + this.automaticallyImplySkipNextButton = true, + this.automaticallyImplySkipPreviousButton = true, + this.volumeGesture = false, + this.brightnessGesture = false, + this.seekGesture = false, + this.gesturesEnabledWhileControlsVisible = true, + this.seekOnDoubleTap = false, + this.seekOnDoubleTapEnabledWhileControlsVisible = true, + this.seekOnDoubleTapLayoutTapsRatios = const [1, 1, 1], + this.seekOnDoubleTapLayoutWidgetRatios = const [1, 1, 1], + this.seekOnDoubleTapBackwardDuration = const Duration(seconds: 10), + this.seekOnDoubleTapForwardDuration = const Duration(seconds: 10), + this.visibleOnMount = false, + this.speedUpOnLongPress = false, + this.speedUpFactor = 2.0, + this.verticalGestureSensitivity = 100, + this.horizontalGestureSensitivity = 1000, + this.backdropColor = const Color(0x66000000), + this.padding, + this.controlsHoverDuration = const Duration(seconds: 3), + this.controlsTransitionDuration = const Duration(milliseconds: 300), + this.bufferingIndicatorBuilder, + this.volumeIndicatorBuilder, + this.brightnessIndicatorBuilder, + this.seekIndicatorBuilder, + this.speedUpIndicatorBuilder, + this.primaryButtonBar = const [ + Spacer(flex: 2), + MaterialSkipPreviousButton(), + Spacer(), + MaterialPlayOrPauseButton(iconSize: 48.0), + Spacer(), + MaterialSkipNextButton(), + Spacer(flex: 2), + ], + this.topButtonBar = const [], + this.topButtonBarMargin = const EdgeInsets.symmetric(horizontal: 16.0), + this.bottomButtonBar = const [ + MaterialPositionIndicator(), + Spacer(), + // 未全屏的 + // MaterialFullscreenButton(), + ], + this.bottomButtonBarMargin = const EdgeInsets.only(left: 16.0, right: 8.0), + this.buttonBarHeight = 56.0, + this.buttonBarButtonSize = 24.0, + this.buttonBarButtonColor = const Color(0xFFFFFFFF), + this.seekBarMargin = EdgeInsets.zero, + this.seekBarHeight = 2.4, + this.seekBarContainerHeight = 36.0, + this.seekBarColor = const Color(0x3DFFFFFF), + this.seekBarPositionColor = const Color(0xFFFF0000), + this.seekBarBufferColor = const Color(0x3DFFFFFF), + this.seekBarThumbSize = 12.8, + this.seekBarThumbColor = const Color(0xFFFF0000), + this.seekBarAlignment = Alignment.bottomCenter, + this.shiftSubtitlesOnControlsVisibilityChange = false, + }); + + /// Creates a copy of this [MaterialVideoControlsThemeData] with the given fields replaced by the non-null parameter values. + MaterialVideoControlsThemeData copyWith({ + bool? displaySeekBar, + bool? automaticallyImplySkipNextButton, + bool? automaticallyImplySkipPreviousButton, + bool? volumeGesture, + bool? brightnessGesture, + bool? seekGesture, + bool? gesturesEnabledWhileControlsVisible, + bool? seekOnDoubleTap, + bool? seekOnDoubleTapEnabledWhileControlsVisible, + List? seekOnDoubleTapLayoutTapsRatios, + List? seekOnDoubleTapLayoutWidgetRatios, + Duration? seekOnDoubleTapBackwardDuration, + Duration? seekOnDoubleTapForwardDuration, + bool? visibleOnMount, + bool? speedUpOnLongPress, + double? speedUpFactor, + double? verticalGestureSensitivity, + double? horizontalGestureSensitivity, + Color? backdropColor, + Duration? controlsHoverDuration, + Duration? controlsTransitionDuration, + Widget Function(BuildContext)? bufferingIndicatorBuilder, + Widget Function(BuildContext, double)? volumeIndicatorBuilder, + Widget Function(BuildContext, double)? brightnessIndicatorBuilder, + Widget Function(BuildContext, Duration)? seekIndicatorBuilder, + Widget Function(BuildContext, double)? speedUpIndicatorBuilder, + List? primaryButtonBar, + List? topButtonBar, + EdgeInsets? topButtonBarMargin, + List? bottomButtonBar, + EdgeInsets? bottomButtonBarMargin, + double? buttonBarHeight, + double? buttonBarButtonSize, + Color? buttonBarButtonColor, + EdgeInsets? seekBarMargin, + double? seekBarHeight, + double? seekBarContainerHeight, + Color? seekBarColor, + Color? seekBarPositionColor, + Color? seekBarBufferColor, + double? seekBarThumbSize, + Color? seekBarThumbColor, + Alignment? seekBarAlignment, + bool? shiftSubtitlesOnControlsVisibilityChange, + }) { + return MaterialVideoControlsThemeData( + displaySeekBar: displaySeekBar ?? this.displaySeekBar, + automaticallyImplySkipNextButton: automaticallyImplySkipNextButton ?? this.automaticallyImplySkipNextButton, + automaticallyImplySkipPreviousButton: automaticallyImplySkipPreviousButton ?? this.automaticallyImplySkipPreviousButton, + volumeGesture: volumeGesture ?? this.volumeGesture, + brightnessGesture: brightnessGesture ?? this.brightnessGesture, + seekGesture: seekGesture ?? this.seekGesture, + gesturesEnabledWhileControlsVisible: gesturesEnabledWhileControlsVisible ?? this.gesturesEnabledWhileControlsVisible, + seekOnDoubleTap: seekOnDoubleTap ?? this.seekOnDoubleTap, + seekOnDoubleTapEnabledWhileControlsVisible: seekOnDoubleTapEnabledWhileControlsVisible ?? this.seekOnDoubleTapEnabledWhileControlsVisible, + seekOnDoubleTapLayoutTapsRatios: seekOnDoubleTapLayoutTapsRatios ?? this.seekOnDoubleTapLayoutTapsRatios, + seekOnDoubleTapLayoutWidgetRatios: seekOnDoubleTapLayoutWidgetRatios ?? this.seekOnDoubleTapLayoutWidgetRatios, + seekOnDoubleTapBackwardDuration: seekOnDoubleTapBackwardDuration ?? this.seekOnDoubleTapBackwardDuration, + seekOnDoubleTapForwardDuration: seekOnDoubleTapForwardDuration ?? this.seekOnDoubleTapForwardDuration, + visibleOnMount: visibleOnMount ?? this.visibleOnMount, + speedUpOnLongPress: speedUpOnLongPress ?? this.speedUpOnLongPress, + speedUpFactor: speedUpFactor ?? this.speedUpFactor, + verticalGestureSensitivity: verticalGestureSensitivity ?? this.verticalGestureSensitivity, + horizontalGestureSensitivity: horizontalGestureSensitivity ?? this.horizontalGestureSensitivity, + backdropColor: backdropColor ?? this.backdropColor, + controlsHoverDuration: controlsHoverDuration ?? this.controlsHoverDuration, + controlsTransitionDuration: controlsTransitionDuration ?? this.controlsTransitionDuration, + bufferingIndicatorBuilder: bufferingIndicatorBuilder ?? this.bufferingIndicatorBuilder, + volumeIndicatorBuilder: volumeIndicatorBuilder ?? this.volumeIndicatorBuilder, + brightnessIndicatorBuilder: brightnessIndicatorBuilder ?? this.brightnessIndicatorBuilder, + seekIndicatorBuilder: seekIndicatorBuilder ?? this.seekIndicatorBuilder, + speedUpIndicatorBuilder: speedUpIndicatorBuilder ?? this.speedUpIndicatorBuilder, + primaryButtonBar: primaryButtonBar ?? this.primaryButtonBar, + topButtonBar: topButtonBar ?? this.topButtonBar, + topButtonBarMargin: topButtonBarMargin ?? this.topButtonBarMargin, + bottomButtonBar: bottomButtonBar ?? this.bottomButtonBar, + bottomButtonBarMargin: bottomButtonBarMargin ?? this.bottomButtonBarMargin, + buttonBarHeight: buttonBarHeight ?? this.buttonBarHeight, + buttonBarButtonSize: buttonBarButtonSize ?? this.buttonBarButtonSize, + buttonBarButtonColor: buttonBarButtonColor ?? this.buttonBarButtonColor, + seekBarMargin: seekBarMargin ?? this.seekBarMargin, + seekBarHeight: seekBarHeight ?? this.seekBarHeight, + seekBarContainerHeight: seekBarContainerHeight ?? this.seekBarContainerHeight, + seekBarColor: seekBarColor ?? this.seekBarColor, + seekBarPositionColor: seekBarPositionColor ?? this.seekBarPositionColor, + seekBarBufferColor: seekBarBufferColor ?? this.seekBarBufferColor, + seekBarThumbSize: seekBarThumbSize ?? this.seekBarThumbSize, + seekBarThumbColor: seekBarThumbColor ?? this.seekBarThumbColor, + seekBarAlignment: seekBarAlignment ?? this.seekBarAlignment, + shiftSubtitlesOnControlsVisibilityChange: shiftSubtitlesOnControlsVisibilityChange ?? this.shiftSubtitlesOnControlsVisibilityChange, + ); + } +} + +/// {@template material_video_controls_theme} +/// +/// Inherited widget which provides [MaterialVideoControlsThemeData] to descendant widgets. +/// +/// {@endtemplate} +class MaterialVideoControlsTheme extends InheritedWidget { + final MaterialVideoControlsThemeData normal; + final MaterialVideoControlsThemeData fullscreen; + const MaterialVideoControlsTheme({ + super.key, + required this.normal, + required this.fullscreen, + required super.child, + }); + + static MaterialVideoControlsTheme? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + static MaterialVideoControlsTheme of(BuildContext context) { + final MaterialVideoControlsTheme? result = maybeOf(context); + assert( + result != null, + 'No [MaterialVideoControlsTheme] found in [context]', + ); + return result!; + } + + @override + bool updateShouldNotify(MaterialVideoControlsTheme oldWidget) => identical(normal, oldWidget.normal) && identical(fullscreen, oldWidget.fullscreen); +} + +/// {@macro material_video_controls} +class _MaterialVideoControls extends StatefulWidget { + const _MaterialVideoControls(); + + @override + State<_MaterialVideoControls> createState() => _MaterialVideoControlsState(); +} + +/// {@macro material_video_controls} +class _MaterialVideoControlsState extends State<_MaterialVideoControls> { + late bool mount = _theme(context).visibleOnMount; + late bool visible = _theme(context).visibleOnMount; + Timer? _timer; + + double _brightnessValue = 0.0; + bool _brightnessIndicator = false; + Timer? _brightnessTimer; + double _currentRate = 1.0; + double _volumeValue = 0.0; + bool _volumeIndicator = false; + Timer? _volumeTimer; + // The default event stream in package:volume_controller is buggy. + bool _volumeInterceptEventStream = false; + + Offset _dragInitialDelta = Offset.zero; // Initial position for horizontal drag + int swipeDuration = 0; // Duration to seek in video + bool showSwipeDuration = false; // Whether to show the seek duration overlay + + bool _speedUpIndicator = false; + late /* private */ var playlist = controller(context).player.state.playlist; + late bool buffering = controller(context).player.state.buffering; + final VolumeController _volumeController = VolumeController.instance; + + bool _mountSeekBackwardButton = false; + bool _mountSeekForwardButton = false; + bool _hideSeekBackwardButton = false; + bool _hideSeekForwardButton = false; + Timer? _timerSeekBackwardButton; + Timer? _timerSeekForwardButton; + + final ValueNotifier _seekBarDeltaValueNotifier = ValueNotifier(Duration.zero); + + final List subscriptions = []; + + double get subtitleVerticalShiftOffset => + (_theme(context).padding?.bottom ?? 0.0) + + (_theme(context).bottomButtonBarMargin.vertical) + + (_theme(context).bottomButtonBar.isNotEmpty ? _theme(context).buttonBarHeight : 0.0); + Offset? _tapPosition; + + void _handleDoubleTapDown(TapDownDetails details) { + setState(() { + _tapPosition = details.localPosition; + }); + } + + void _handleLongPress() { + setState(() { + _speedUpIndicator = true; + }); + _currentRate = controller(context).player.state.rate; + controller(context).player.setRate(_theme(context).speedUpFactor); + } + + void _handleLongPressEnd(LongPressEndDetails details) { + setState(() { + _speedUpIndicator = false; + }); + controller(context).player.setRate(_currentRate); + } + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.playlist.listen( + (event) { + setState(() { + playlist = event; + }); + }, + ), + controller(context).player.stream.buffering.listen( + (event) { + setState(() { + buffering = event; + }); + }, + ), + ], + ); + + if (_theme(context).visibleOnMount) { + _timer = Timer( + _theme(context).controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + } + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + // -------------------------------------------------- + // package:screen_brightness + Future.microtask(() async { + try { + await ScreenBrightnessPlatform.instance.resetApplicationScreenBrightness(); + } catch (_) {} + }); + // -------------------------------------------------- + _timerSeekBackwardButton?.cancel(); + _timerSeekForwardButton?.cancel(); + super.dispose(); + } + + void shiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding + + EdgeInsets.fromLTRB( + 0.0, + 0.0, + 0.0, + subtitleVerticalShiftOffset, + ), + ); + } + } + + void unshiftSubtitle() { + if (_theme(context).shiftSubtitlesOnControlsVisibilityChange) { + state(context).setSubtitleViewPadding( + state(context).widget.subtitleViewConfiguration.padding, + ); + } + } + + void onTap() { + if (!visible) { + setState(() { + mount = true; + visible = true; + }); + shiftSubtitle(); + _timer?.cancel(); + _timer = Timer(_theme(context).controlsHoverDuration, () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }); + } else { + setState(() { + visible = false; + }); + unshiftSubtitle(); + _timer?.cancel(); + } + } + + void onDoubleTapSeekBackward() { + setState(() { + _mountSeekBackwardButton = true; + }); + } + + void onDoubleTapSeekForward() { + setState(() { + _mountSeekForwardButton = true; + }); + } + + void onHorizontalDragUpdate(DragUpdateDetails details) { + if (_dragInitialDelta == Offset.zero) { + _dragInitialDelta = details.localPosition; + return; + } + + final diff = _dragInitialDelta.dx - details.localPosition.dx; + final duration = controller(context).player.state.duration.inSeconds; + final position = controller(context).player.state.position.inSeconds; + + final seconds = -(diff * duration / _theme(context).horizontalGestureSensitivity).round(); + final relativePosition = position + seconds; + + if (relativePosition <= duration && relativePosition >= 0) { + setState(() { + swipeDuration = seconds; + showSwipeDuration = true; + _seekBarDeltaValueNotifier.value = Duration(seconds: seconds); + }); + } + } + + void onHorizontalDragEnd() { + if (swipeDuration != 0) { + Duration newPosition = controller(context).player.state.position + Duration(seconds: swipeDuration); + newPosition = newPosition.clamp( + Duration.zero, + controller(context).player.state.duration, + ); + controller(context).player.seek(newPosition); + } + + setState(() { + _dragInitialDelta = Offset.zero; + showSwipeDuration = false; + }); + } + + bool _isInSegment(double localX, int segmentIndex) { + // Local variable with the list of ratios + List segmentRatios = _theme(context).seekOnDoubleTapLayoutTapsRatios; + + int totalRatios = segmentRatios.reduce((a, b) => a + b); + + double segmentWidthMultiplier = widgetWidth(context) / totalRatios; + double start = 0; + double end; + + for (int i = 0; i < segmentRatios.length; i++) { + end = start + (segmentWidthMultiplier * segmentRatios[i]); + + // Check if the current index matches the segmentIndex and if localX falls within it + if (i == segmentIndex && localX >= start && localX <= end) { + return true; + } + + // Set the start of the next segment + start = end; + } + + // If localX does not fall within the specified segment + return false; + } + + bool _isInRightSegment(double localX) { + return _isInSegment(localX, 2); + } + + bool _isInLeftSegment(double localX) { + return _isInSegment(localX, 0); + } + + void _handlePointerDown(PointerDownEvent event) { + onTap(); + } + + @override + void initState() { + super.initState(); + // -------------------------------------------------- + // package:volume_controller + Future.microtask(() async { + try { + _volumeController.showSystemUI = false; + _volumeValue = await _volumeController.getVolume(); + _volumeController.addListener((value) { + if (mounted && !_volumeInterceptEventStream) { + setState(() { + _volumeValue = value; + }); + } + }); + } catch (_) {} + }); + // -------------------------------------------------- + // -------------------------------------------------- + // package:screen_brightness + Future.microtask(() async { + try { + _brightnessValue = await ScreenBrightnessPlatform.instance.application; + ScreenBrightnessPlatform.instance.onApplicationScreenBrightnessChanged.listen((value) { + if (mounted) { + setState(() { + _brightnessValue = value; + }); + } + }); + } catch (_) {} + }); + // -------------------------------------------------- + } + + Future setVolume(double value) async { + // -------------------------------------------------- + // package:volume_controller + try { + _volumeController.setVolume(value); + } catch (_) {} + setState(() { + _volumeValue = value; + _volumeIndicator = true; + _volumeInterceptEventStream = true; + }); + _volumeTimer?.cancel(); + _volumeTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted) { + setState(() { + _volumeIndicator = false; + _volumeInterceptEventStream = false; + }); + } + }); + // -------------------------------------------------- + } + + Future setBrightness(double value) async { + // -------------------------------------------------- + // package:screen_brightness + try { + await ScreenBrightnessPlatform.instance.setApplicationScreenBrightness(value); + } catch (_) {} + setState(() { + _brightnessIndicator = true; + }); + _brightnessTimer?.cancel(); + _brightnessTimer = Timer(const Duration(milliseconds: 200), () { + if (mounted) { + setState(() { + _brightnessIndicator = false; + }); + } + }); + // -------------------------------------------------- + } + + @override + Widget build(BuildContext context) { + var seekOnDoubleTapEnabledWhileControlsAreVisible = (_theme(context).seekOnDoubleTap && _theme(context).seekOnDoubleTapEnabledWhileControlsVisible); + assert(_theme(context).seekOnDoubleTapLayoutTapsRatios.length == 3, "The number of seekOnDoubleTapLayoutTapsRatios must be 3, i.e. [1, 1, 1]"); + assert(_theme(context).seekOnDoubleTapLayoutWidgetRatios.length == 3, "The number of seekOnDoubleTapLayoutWidgetRatios must be 3, i.e. [1, 1, 1]"); + return Theme( + data: Theme.of(context).copyWith( + focusColor: const Color(0x00000000), + hoverColor: const Color(0x00000000), + splashColor: const Color(0x00000000), + highlightColor: const Color(0x00000000), + ), + child: Focus( + autofocus: true, + child: Material( + elevation: 0.0, + borderOnForeground: false, + animationDuration: Duration.zero, + color: const Color(0x00000000), + shadowColor: const Color(0x00000000), + surfaceTintColor: const Color(0x00000000), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + // Controls: + AnimatedOpacity( + curve: Curves.easeInOut, + opacity: visible ? 1.0 : 0.0, + duration: _theme(context).controlsTransitionDuration, + onEnd: () { + setState(() { + if (!visible) { + mount = false; + } + }); + }, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + Positioned.fill( + child: Container( + color: _theme(context).backdropColor, + ), + ), + // We are adding 16.0 boundary around the actual controls (which contain the vertical drag gesture detectors). + // This will make the hit-test on edges (e.g. swiping to: show status-bar, show navigation-bar, go back in navigation) not activate the swipe gesture annoyingly. + Positioned.fill( + left: 16.0, + top: 16.0, + right: 16.0, + bottom: 16.0 + subtitleVerticalShiftOffset, + child: Listener( + onPointerDown: (event) => _handlePointerDown(event), + child: GestureDetector( + onDoubleTapDown: _handleDoubleTapDown, + onLongPress: _theme(context).speedUpOnLongPress ? _handleLongPress : null, + onLongPressEnd: _theme(context).speedUpOnLongPress ? _handleLongPressEnd : null, + onDoubleTap: () { + if (_tapPosition == null) { + return; + } + if (_isInRightSegment(_tapPosition!.dx)) { + if ((!mount && _theme(context).seekOnDoubleTap) || seekOnDoubleTapEnabledWhileControlsAreVisible) { + onDoubleTapSeekForward(); + } + } else { + if (_isInLeftSegment(_tapPosition!.dx)) { + if ((!mount && _theme(context).seekOnDoubleTap) || seekOnDoubleTapEnabledWhileControlsAreVisible) { + onDoubleTapSeekBackward(); + } + } + } + }, + onHorizontalDragUpdate: (details) { + if ((!mount && _theme(context).seekGesture) || + (_theme(context).seekGesture && _theme(context).gesturesEnabledWhileControlsVisible)) { + onHorizontalDragUpdate(details); + } + }, + onHorizontalDragEnd: (details) { + onHorizontalDragEnd(); + }, + onVerticalDragUpdate: (e) async { + final delta = e.delta.dy; + final Offset position = e.localPosition; + + if (position.dx <= widgetWidth(context) / 2) { + // Left side of screen swiped + if ((!mount && _theme(context).brightnessGesture) || + (_theme(context).brightnessGesture && _theme(context).gesturesEnabledWhileControlsVisible)) { + final brightness = _brightnessValue - delta / _theme(context).verticalGestureSensitivity; + final result = brightness.clamp(0.0, 1.0); + setBrightness(result); + } + } else { + // Right side of screen swiped + + if ((!mount && _theme(context).volumeGesture) || + (_theme(context).volumeGesture && _theme(context).gesturesEnabledWhileControlsVisible)) { + final volume = _volumeValue - delta / _theme(context).verticalGestureSensitivity; + final result = volume.clamp(0.0, 1.0); + setVolume(result); + } + } + }, + child: Container( + color: const Color(0x00000000), + ), + ), + ), + ), + if (mount) + Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) ? MediaQuery.of(context).padding : EdgeInsets.zero), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: _theme(context).topButtonBar, + ), + ), + // Only display [primaryButtonBar] if [buffering] is false. + Expanded( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: buffering ? 0.0 : 1.0, + duration: _theme(context).controlsTransitionDuration, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: _theme(context).primaryButtonBar, + ), + ), + ), + ), + Stack( + alignment: Alignment.bottomCenter, + children: [ + if (_theme(context).displaySeekBar) + MaterialSeekBar( + onSeekStart: () { + _timer?.cancel(); + }, + onSeekEnd: () { + _timer = Timer( + _theme(context).controlsHoverDuration, + () { + if (mounted) { + setState(() { + visible = false; + }); + unshiftSubtitle(); + } + }, + ); + }, + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: _theme(context).bottomButtonBar, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + // Double-Tap Seek Seek-Bar: + if (!mount) + if (_mountSeekBackwardButton || _mountSeekForwardButton || showSwipeDuration) + Column( + children: [ + const Spacer(), + Stack( + alignment: Alignment.bottomCenter, + children: [ + if (_theme(context).displaySeekBar) + MaterialSeekBar( + delta: _seekBarDeltaValueNotifier, + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ], + ), + // Buffering Indicator. + IgnorePointer( + child: Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) ? MediaQuery.of(context).padding : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + ), + Expanded( + child: Center( + child: TweenAnimationBuilder( + tween: Tween( + begin: 0.0, + end: buffering ? 1.0 : 0.0, + ), + duration: _theme(context).controlsTransitionDuration, + builder: (context, value, child) { + // Only mount the buffering indicator if the opacity is greater than 0.0. + // This has been done to prevent redundant resource usage in [CircularProgressIndicator]. + if (value > 0.0) { + return Opacity( + opacity: value, + child: _theme(context).bufferingIndicatorBuilder?.call(context) ?? child!, + ); + } + return const SizedBox.shrink(); + }, + child: const CircularProgressIndicator( + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ), + ), + // Volume Indicator. + IgnorePointer( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: (!mount || _theme(context).gesturesEnabledWhileControlsVisible) && _volumeIndicator ? 1.0 : 0.0, + duration: _theme(context).controlsTransitionDuration, + child: _theme(context).volumeIndicatorBuilder?.call(context, _volumeValue) ?? + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 52.0, + width: 108.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 52.0, + width: 42.0, + alignment: Alignment.centerRight, + child: Icon( + _volumeValue == 0.0 + ? Icons.volume_off + : _volumeValue < 0.5 + ? Icons.volume_down + : Icons.volume_up, + color: const Color(0xFFFFFFFF), + size: 24.0, + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + '${(_volumeValue * 100.0).round()}%', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + const SizedBox(width: 16.0), + ], + ), + ), + ), + ), + // Brightness Indicator. + IgnorePointer( + child: AnimatedOpacity( + curve: Curves.easeInOut, + opacity: (!mount || _theme(context).gesturesEnabledWhileControlsVisible) && _brightnessIndicator ? 1.0 : 0.0, + duration: _theme(context).controlsTransitionDuration, + child: _theme(context).brightnessIndicatorBuilder?.call(context, _brightnessValue) ?? + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 52.0, + width: 108.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 52.0, + width: 42.0, + alignment: Alignment.centerRight, + child: Icon( + _brightnessValue < 1.0 / 3.0 + ? Icons.brightness_low + : _brightnessValue < 2.0 / 3.0 + ? Icons.brightness_medium + : Icons.brightness_high, + color: const Color(0xFFFFFFFF), + size: 24.0, + ), + ), + const SizedBox(width: 8.0), + Expanded( + child: Text( + '${(_brightnessValue * 100.0).round()}%', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + const SizedBox(width: 16.0), + ], + ), + ), + ), + ), + // Speedup Indicator. + IgnorePointer( + child: Padding( + padding: _theme(context).padding ?? + ( + // Add padding in fullscreen! + isFullscreen(context) ? MediaQuery.of(context).padding : EdgeInsets.zero), + child: Column( + children: [ + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).topButtonBarMargin, + ), + Expanded( + child: AnimatedOpacity( + duration: _theme(context).controlsTransitionDuration, + opacity: _speedUpIndicator ? 1 : 0, + child: _theme(context).speedUpIndicatorBuilder?.call(context, _theme(context).speedUpFactor) ?? + Container( + alignment: Alignment.topCenter, + child: Container( + margin: const EdgeInsets.all(16.0), + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 48.0, + width: 108.0, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(width: 16.0), + Expanded( + child: Text( + '${_theme(context).speedUpFactor.toStringAsFixed(1)}x', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + Container( + height: 48.0, + width: 48.0 - 16.0, + alignment: Alignment.centerRight, + child: const Icon( + Icons.fast_forward, + color: Color(0xFFFFFFFF), + size: 24.0, + ), + ), + const SizedBox(width: 16.0), + ], + ), + ), + ), + ), + ), + Container( + height: _theme(context).buttonBarHeight, + margin: _theme(context).bottomButtonBarMargin, + ), + ], + ), + ), + ), + // Seek Indicator. + IgnorePointer( + child: AnimatedOpacity( + duration: _theme(context).controlsTransitionDuration, + opacity: showSwipeDuration ? 1 : 0, + child: _theme(context).seekIndicatorBuilder?.call(context, Duration(seconds: swipeDuration)) ?? + Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: const Color(0x88000000), + borderRadius: BorderRadius.circular(64.0), + ), + height: 52.0, + width: 108.0, + child: Text( + swipeDuration > 0 ? "+ ${Duration(seconds: swipeDuration).label()}" : "- ${Duration(seconds: swipeDuration).label()}", + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + color: Color(0xFFFFFFFF), + ), + ), + ), + ), + ), + + // Double-Tap Seek Button(s): + if (!mount || seekOnDoubleTapEnabledWhileControlsAreVisible) + if (_mountSeekBackwardButton || _mountSeekForwardButton) + Positioned.fill( + child: Row( + children: [ + Expanded( + flex: _theme(context).seekOnDoubleTapLayoutWidgetRatios[0], + child: _mountSeekBackwardButton + ? AnimatedOpacity( + opacity: _hideSeekBackwardButton ? 0 : 1.0, + duration: const Duration(milliseconds: 200), + child: _BackwardSeekIndicator( + duration: _theme(context).seekOnDoubleTapBackwardDuration, + onChanged: (value) { + _seekBarDeltaValueNotifier.value = -value; + }, + onSubmitted: (value) { + _timerSeekBackwardButton?.cancel(); + _timerSeekBackwardButton = Timer( + const Duration(milliseconds: 200), + () { + setState(() { + _hideSeekBackwardButton = false; + _mountSeekBackwardButton = false; + }); + }, + ); + + setState(() { + _hideSeekBackwardButton = true; + }); + var result = controller(context).player.state.position - value; + result = result.clamp( + Duration.zero, + controller(context).player.state.duration, + ); + controller(context).player.seek(result); + }, + ), + ) + : const SizedBox(), + ), + //Area in the middle where the double-tap seek buttons are ignored in + if (_theme(context).seekOnDoubleTapLayoutWidgetRatios[1] > 0) + Spacer( + flex: _theme(context).seekOnDoubleTapLayoutWidgetRatios[1], + ), + Expanded( + flex: _theme(context).seekOnDoubleTapLayoutWidgetRatios[2], + child: _mountSeekForwardButton + ? AnimatedOpacity( + opacity: _hideSeekForwardButton ? 0 : 1.0, + duration: const Duration(milliseconds: 200), + child: _ForwardSeekIndicator( + duration: _theme(context).seekOnDoubleTapForwardDuration, + onChanged: (value) { + _seekBarDeltaValueNotifier.value = value; + }, + onSubmitted: (value) { + _timerSeekForwardButton?.cancel(); + _timerSeekForwardButton = Timer(const Duration(milliseconds: 200), () { + if (_hideSeekForwardButton) { + setState(() { + _hideSeekForwardButton = false; + _mountSeekForwardButton = false; + }); + } + }); + setState(() { + _hideSeekForwardButton = true; + }); + + var result = controller(context).player.state.position + value; + result = result.clamp( + Duration.zero, + controller(context).player.state.duration, + ); + controller(context).player.seek(result); + }, + ), + ) + : const SizedBox(), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + + double widgetWidth(BuildContext context) => (context.findRenderObject() as RenderBox).paintBounds.width; +} + +// SEEK BAR + +/// Material design seek bar. +class MaterialSeekBar extends StatefulWidget { + final ValueNotifier? delta; + final VoidCallback? onSeekStart; + final VoidCallback? onSeekEnd; + + const MaterialSeekBar({ + super.key, + this.delta, + this.onSeekStart, + this.onSeekEnd, + }); + + @override + MaterialSeekBarState createState() => MaterialSeekBarState(); +} + +class MaterialSeekBarState extends State { + bool tapped = false; + double slider = 0.0; + + late bool playing = controller(context).player.state.playing; + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + late Duration buffer = controller(context).player.state.buffer; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + void listener() { + setState(() { + final delta = widget.delta?.value ?? Duration.zero; + position = controller(context).player.state.position + delta; + }); + } + + @override + void initState() { + super.initState(); + widget.delta?.addListener(listener); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty && widget.delta == null) { + subscriptions.addAll( + [ + controller(context).player.stream.playing.listen((event) { + setState(() { + playing = event; + }); + }), + controller(context).player.stream.completed.listen((event) { + setState(() { + position = Duration.zero; + }); + }), + controller(context).player.stream.position.listen((event) { + setState(() { + if (!tapped) { + position = event; + } + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + controller(context).player.stream.buffer.listen((event) { + setState(() { + buffer = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + widget.delta?.removeListener(listener); + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + void onPointerMove(PointerMoveEvent e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + controller(context).player.seek(duration * slider); + } + + void onPointerDown() { + widget.onSeekStart?.call(); + setState(() { + tapped = true; + }); + } + + void onPointerUp() { + widget.onSeekEnd?.call(); + setState(() { + // Explicitly set the position to prevent the slider from jumping. + tapped = false; + position = duration * slider; + }); + controller(context).player.seek(duration * slider); + } + + void onPanStart(DragStartDetails e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onPanDown(DragDownDetails e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + void onPanUpdate(DragUpdateDetails e, BoxConstraints constraints) { + final percent = e.localPosition.dx / constraints.maxWidth; + setState(() { + tapped = true; + slider = percent.clamp(0.0, 1.0); + }); + } + + /// Returns the current playback position in percentage. + double get positionPercent { + if (position == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = position.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + /// Returns the current playback buffer position in percentage. + double get bufferPercent { + if (buffer == Duration.zero || duration == Duration.zero) { + return 0.0; + } else { + final value = buffer.inMilliseconds / duration.inMilliseconds; + return value.clamp(0.0, 1.0); + } + } + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.none, + margin: _theme(context).seekBarMargin, + child: LayoutBuilder( + builder: (context, constraints) => MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onHorizontalDragUpdate: (_) {}, + onPanStart: (e) => onPanStart(e, constraints), + onPanDown: (e) => onPanDown(e, constraints), + onPanUpdate: (e) => onPanUpdate(e, constraints), + child: Listener( + onPointerMove: (e) => onPointerMove(e, constraints), + onPointerDown: (e) => onPointerDown(), + onPointerUp: (e) => onPointerUp(), + child: Container( + color: Colors.transparent, + width: constraints.maxWidth, + alignment: _theme(context).seekBarAlignment, + height: _theme(context).seekBarContainerHeight, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomCenter, + children: [ + Container( + width: constraints.maxWidth, + height: _theme(context).seekBarHeight, + alignment: Alignment.bottomLeft, + color: _theme(context).seekBarColor, + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.bottomLeft, + children: [ + Container( + width: constraints.maxWidth * bufferPercent, + color: _theme(context).seekBarBufferColor, + ), + Container( + width: tapped ? constraints.maxWidth * slider : constraints.maxWidth * positionPercent, + color: _theme(context).seekBarPositionColor, + ), + ], + ), + ), + Positioned( + left: tapped + ? (constraints.maxWidth - _theme(context).seekBarThumbSize / 2) * slider + : (constraints.maxWidth - _theme(context).seekBarThumbSize / 2) * positionPercent, + bottom: -1.0 * _theme(context).seekBarThumbSize / 2 + _theme(context).seekBarHeight / 2, + child: Container( + width: _theme(context).seekBarThumbSize, + height: _theme(context).seekBarThumbSize, + decoration: BoxDecoration( + color: _theme(context).seekBarThumbColor, + borderRadius: BorderRadius.circular( + _theme(context).seekBarThumbSize / 2, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +// BUTTON: PLAY/PAUSE + +/// A material design play/pause button. +class MaterialPlayOrPauseButton extends StatefulWidget { + /// Overriden icon size for [MaterialSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialSkipPreviousButton]. + final Color? iconColor; + + const MaterialPlayOrPauseButton({ + super.key, + this.iconSize, + this.iconColor, + }); + + @override + MaterialPlayOrPauseButtonState createState() => MaterialPlayOrPauseButtonState(); +} + +class MaterialPlayOrPauseButtonState extends State with SingleTickerProviderStateMixin { + late final animation = AnimationController( + vsync: this, + value: controller(context).player.state.playing ? 1 : 0, + duration: const Duration(milliseconds: 200), + ); + + StreamSubscription? subscription; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + subscription ??= controller(context).player.stream.playing.listen((event) { + if (event) { + animation.forward(); + } else { + animation.reverse(); + } + }); + } + + @override + void dispose() { + animation.dispose(); + subscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: controller(context).player.playOrPause, + iconSize: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + icon: IgnorePointer( + child: AnimatedIcon( + progress: animation, + icon: AnimatedIcons.play_pause, + size: widget.iconSize ?? _theme(context).buttonBarButtonSize, + color: widget.iconColor ?? _theme(context).buttonBarButtonColor, + ), + ), + ); + } +} + +// BUTTON: SKIP NEXT + +/// Material design skip next button. +class MaterialSkipNextButton extends StatelessWidget { + /// Icon for [MaterialSkipNextButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialSkipNextButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialSkipNextButton]. + final Color? iconColor; + + const MaterialSkipNextButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipNextButton || + (controller(context).player.state.playlist.medias.length > 1 && _theme(context).automaticallyImplySkipNextButton)) { + return IconButton( + onPressed: controller(context).player.next, + icon: icon ?? const Icon(Icons.skip_next), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: SKIP PREVIOUS + +/// Material design skip previous button. +class MaterialSkipPreviousButton extends StatelessWidget { + /// Icon for [MaterialSkipPreviousButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialSkipPreviousButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialSkipPreviousButton]. + final Color? iconColor; + + const MaterialSkipPreviousButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + if (!_theme(context).automaticallyImplySkipPreviousButton || + (controller(context).player.state.playlist.medias.length > 1 && _theme(context).automaticallyImplySkipPreviousButton)) { + return IconButton( + onPressed: controller(context).player.previous, + icon: icon ?? const Icon(Icons.skip_previous), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } + return const SizedBox.shrink(); + } +} + +// BUTTON: FULL SCREEN + +/// Material design fullscreen button. +class MaterialFullscreenButton extends StatelessWidget { + /// Icon for [MaterialFullscreenButton]. + final Widget? icon; + + /// Overriden icon size for [MaterialFullscreenButton]. + final double? iconSize; + + /// Overriden icon color for [MaterialFullscreenButton]. + final Color? iconColor; + + const MaterialFullscreenButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: () => toggleFullscreen(context), + icon: icon ?? (isFullscreen(context) ? const Icon(Icons.fullscreen_exit) : const Icon(Icons.fullscreen)), + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// BUTTON: CUSTOM + +/// Material design custom button. +class MaterialCustomButton extends StatelessWidget { + /// Icon for [MaterialCustomButton]. + final Widget? icon; + + /// Icon size for [MaterialCustomButton]. + final double? iconSize; + + /// Icon color for [MaterialCustomButton]. + final Color? iconColor; + + /// The callback that is called when the button is tapped or otherwise activated. + final VoidCallback onPressed; + + const MaterialCustomButton({ + super.key, + this.icon, + this.iconSize, + this.iconColor, + required this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: onPressed, + icon: icon ?? const Icon(Icons.settings), + padding: EdgeInsets.zero, + iconSize: iconSize ?? _theme(context).buttonBarButtonSize, + color: iconColor ?? _theme(context).buttonBarButtonColor, + ); + } +} + +// POSITION INDICATOR + +/// Material design position indicator. +class MaterialPositionIndicator extends StatefulWidget { + /// Overriden [TextStyle] for the [MaterialPositionIndicator]. + final TextStyle? style; + const MaterialPositionIndicator({super.key, this.style}); + + @override + MaterialPositionIndicatorState createState() => MaterialPositionIndicatorState(); +} + +class MaterialPositionIndicatorState extends State { + late Duration position = controller(context).player.state.position; + late Duration duration = controller(context).player.state.duration; + + final List subscriptions = []; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (subscriptions.isEmpty) { + subscriptions.addAll( + [ + controller(context).player.stream.position.listen((event) { + setState(() { + position = event; + }); + }), + controller(context).player.stream.duration.listen((event) { + setState(() { + duration = event; + }); + }), + ], + ); + } + } + + @override + void dispose() { + for (final subscription in subscriptions) { + subscription.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Text( + '${position.label(reference: duration)} / ${duration.label(reference: duration)}', + style: widget.style ?? + TextStyle( + height: 1.0, + fontSize: 12.0, + color: _theme(context).buttonBarButtonColor, + ), + ); + } +} + +class _BackwardSeekIndicator extends StatefulWidget { + final Duration duration; + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const _BackwardSeekIndicator({ + super.key, + required this.duration, + required this.onChanged, + required this.onSubmitted, + }); + + @override + State<_BackwardSeekIndicator> createState() => _BackwardSeekIndicatorState(); +} + +class _BackwardSeekIndicatorState extends State<_BackwardSeekIndicator> { + late Duration value = widget.duration; + + Timer? timer; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x88767676), + Color(0x00767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_rewind, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '${value.inSeconds} seconds', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _ForwardSeekIndicator extends StatefulWidget { + final Duration duration; + final void Function(Duration) onChanged; + final void Function(Duration) onSubmitted; + const _ForwardSeekIndicator({ + super.key, + required this.duration, + required this.onChanged, + required this.onSubmitted, + }); + + @override + State<_ForwardSeekIndicator> createState() => _ForwardSeekIndicatorState(); +} + +class _ForwardSeekIndicatorState extends State<_ForwardSeekIndicator> { + late Duration value = widget.duration; + + Timer? timer; + + @override + void setState(VoidCallback fn) { + if (mounted) { + super.setState(fn); + } + } + + @override + void initState() { + super.initState(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + } + + void increment() { + timer?.cancel(); + timer = Timer(const Duration(milliseconds: 400), () { + widget.onSubmitted.call(value); + }); + widget.onChanged.call(value); + setState(() { + value += const Duration(seconds: 10); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ + Color(0x00767676), + Color(0x88767676), + ], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + child: InkWell( + splashColor: const Color(0x44767676), + onTap: increment, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.fast_forward, + size: 24.0, + color: Color(0xFFFFFFFF), + ), + const SizedBox(height: 8.0), + Text( + '${value.inSeconds} seconds', + style: const TextStyle( + fontSize: 12.0, + color: Color(0xFFFFFFFF), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/controller/shop_index_controller.dart b/lib/controller/shop_index_controller.dart new file mode 100644 index 0000000..17667ce --- /dev/null +++ b/lib/controller/shop_index_controller.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/api/shop_api.dart'; +import 'package:loopin/service/http.dart'; + +/// 单个 Tab 的状态 +class TabState { + final ScrollController scrollController; + final RxInt currentPage; + final RxDouble scrollOffset; + final RxList dataList; + final RxBool isLoading; + final RxBool hasLoaded; + + TabState({ + required this.scrollController, + required this.currentPage, + required this.scrollOffset, + required this.dataList, + required this.isLoading, + required this.hasLoaded, + }); +} + +class ShopIndexController extends GetxController with GetSingleTickerProviderStateMixin { + TabController? tabController; + + ///轮播图数据 + RxList swiperData = [].obs; + + /// tab 分类列表 + RxList tabList = [].obs; + + /// 每个 tab 对应的状态 + final Map tabs = {}; + + /// 当前 tab index + RxInt currentTabIndex = 0.obs; + + /// 初始化 Tab 分类 + void initTabs() async { + // 释放旧的 ScrollController + tabs.forEach((_, state) => state.scrollController.dispose()); + tabs.clear(); + tabList.clear(); + // 先释放旧 TabController(并移除监听) + tabController?.removeListener(_tabListener); + tabController?.dispose(); + + // 赋值 tab 数据 + final res = await Http.post(ShopApi.shopCategory, data: { + 'showStatus': 1, + }); + final data = res['data']['records'] as List; + logger.w(data); + tabList.addAll(data); + + // 初始化每个 tab 的状态 + for (int i = 0; i < tabList.length; i++) { + final controller = ScrollController(); + tabs[i] = TabState( + scrollController: controller, + currentPage: 1.obs, + scrollOffset: 0.0.obs, + dataList: [].obs, + isLoading: false.obs, + hasLoaded: false.obs, + ); + } + + // 创建新的 TabController + tabController = TabController(length: tabList.length, vsync: this); + tabController!.addListener(_tabListener); + // 初始化第一个 tab 的数据 + if (tabList.isNotEmpty) { + loadSwiperData(); + loadData(0); + } + } + + /// Tab 切换监听 + void _tabListener() { + if (!tabController!.indexIsChanging) { + currentTabIndex.value = tabController!.index; + + final tab = tabs[currentTabIndex.value]; + if (tab != null && !tab.hasLoaded.value) { + loadData(currentTabIndex.value); + } + } + } + + Future refreshData(int index) async { + await loadSwiperData(); + final tab = tabs[index]; + if (tab == null) return; + + tab.currentPage.value = 1; + tab.dataList.clear(); + tab.isLoading.value = false; + tab.hasLoaded.value = false; + + await loadData(index); + } + + /// 加载pageview数据 + Future loadData(int index) async { + final tab = tabs[index]; + if (tab == null || tab.isLoading.value) return; + + tab.isLoading.value = true; + final res = await Http.post(ShopApi.shopList, data: { + 'size': 10, + 'current': tab.currentPage.value, + 'categoryId': tabList[index]['id'], + }); + + final data = res['data']['records']; + tab.dataList.addAll(data); + logger.w(res); + + tab.currentPage.value += 1; + tab.isLoading.value = false; + tab.hasLoaded.value = true; + } + + /// 加载swiper数据 + Future loadSwiperData() async { + final res = await Http.post(ShopApi.shopSwiperList, data: { + 'type': 1, + }); + final data = res['data']; + logger.w(res); + swiperData.assignAll(data); + } + + @override + void onClose() { + tabController?.removeListener(_tabListener); + tabController?.dispose(); + tabs.forEach((_, state) => state.scrollController.dispose()); + super.onClose(); + } +} diff --git a/lib/layouts/index.dart b/lib/layouts/index.dart index 0d00390..17cff8a 100644 --- a/lib/layouts/index.dart +++ b/lib/layouts/index.dart @@ -5,7 +5,6 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/controller/tab_bar_controller.dart'; -import 'package:loopin/IM/im_service.dart'; import 'package:loopin/models/tab_type.dart'; import 'package:loopin/pages/video/module/recommend.dart'; import 'package:loopin/update/upgrade_service.dart'; @@ -38,7 +37,7 @@ class _LayoutState extends State { // tabs选项 List navItems = [ BottomNavigationBarItem(icon: Icon(Icons.play_circle_outline), label: '视频'), - BottomNavigationBarItem(icon: Icon(Icons.local_mall), label: '团购'), + BottomNavigationBarItem(icon: Icon(Icons.local_mall), label: '易选'), BottomNavigationBarItem( icon: Icon( Icons.camera_alt_rounded, @@ -79,7 +78,7 @@ class _LayoutState extends State { super.initState(); // 页面初始化后检查版本更新 WidgetsBinding.instance.addPostFrameCallback((_) { - UpgradeService.checkUpgrade(context); + UpgradeService.checkUpgrade(this); }); } @@ -209,7 +208,7 @@ class _LayoutState extends State { // 点击底部导航 void onTabTap(int index) { - logger.i(index); + // logger.i(index); if (index == 0) { if (videoModuleController.videoTabIndex.value == 2) { RecommendModule.playVideo(); @@ -223,7 +222,10 @@ class _LayoutState extends State { } if (index == 3) { // 更新会话列表 - Get.find().getConversationList(); + final ctl = Get.find(); + if (ctl.chatList.isEmpty) { + Get.find().getConversationList(); + } } if (index == 4) { myPageKey.currentState?.refreshData(); diff --git a/lib/main.dart b/lib/main.dart index 15593e0..4d8de78 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,6 +28,7 @@ void main() async { Get.put(TabBarController()); // 注入会话列表 Get.put(ChatController()); + // 监听app前后台状态 WidgetsFlutterBinding.ensureInitialized(); WidgetsBinding.instance.addObserver(LifecycleHandler()); @@ -52,13 +53,23 @@ void main() async { // 检测登录状态 if (Common.isLogin()) { // 初始化Im,并进行Im登录 - await im_core.ImCore.init(sdkAppId: 1600080789); - // 缺少userSig先用固定值ios用1587,安卓用188 - await ImService.instance.login(userID: Storage.read('userId'), userSig: Storage.read('userSig')); - // String userId = '1909990634551795712'; //15877777777 - // String userId = '18832510385'; + final res = await im_core.ImCore.init(sdkAppId: 1600080789); + + // 缺少userSig先用固定值ios用9365,安卓用0385 + try { + if (res) { + await ImService.instance.login(userID: Storage.read('userId'), userSig: Storage.read('userSig')); + } else { + logger.w('初始化未完成'); + } + } catch (e) { + logger.w(e.toString()); + Common.logout(); + } + // String userId = '1940667704585248769'; //13212279365 + // String userId = '1943510443312078850'; //18832510385 // String userSig = - // 'eJwtzcsOgjAQBdB-6dqQKThth8QdG*JrIRHjTqGYiagNDzUx-rsVWN5zJ3M-IlvtgqdtRCzCAMRsyFzae8cVDywJiAhUNEeUmlDLcDpry*vJOS5FLBUAGNCGxsa*HTfWOyKGvhq149vftH8DhHLSli9*Jdrk2hqTVgdK2dKxXSfKbYuXpnPSu1zl*0fd18skg2Ihvj*7ADL4'; + // 'eJwtjcEKgkAURf9l1iFPm*e8EdoYYUWFURAtg5nk5VRiEln0703q8p57Ofcj9qtd8LS1SEQUgBh1mY29NXzmDodaQhwrBRIJI0kq1sPsYcpTVbERSRgDAIEi3Tf2VXFtPUfEyFc9bfj6ZwrH4J1Ig4UL-6LX0ihyS7U5bi-Wzd8LzrK8TFs6TJ1sZwWGxlGas71PxPcHwH4y9Q__'; // 'eJwtzLEKwjAUheF3ySwlNzXNbcHFxSIOaqTWUUgsF1FDG2tEfHdj2-F8P5wPO2x00tuWFUwknM2GTcbePV1oYEBMhQSeopxyZ65n58iwAjLOOXKF*VhscNTa6FJKEdOonm5-UxJQpZhN2lET3599Xllbv9ZBH2uHuDfvst5tG6FX0EFYVhpOpZ973z8W7PsDmYwyIw__'; // await ImService.instance.login(userID: userId, userSig: userSig); } diff --git a/lib/models/conversation_type.dart b/lib/models/conversation_type.dart new file mode 100644 index 0000000..e8e286a --- /dev/null +++ b/lib/models/conversation_type.dart @@ -0,0 +1,48 @@ +/// 枚举定义:所有的会话类型分组,用于一级消息分类 +enum ConversationType { + noFriend, // 陌生人消息 + system, //系统消息 + newFoucs, //新的关注 + interaction, //互动 + order, //订单类通知消息 + groupNotify, //群通知 +} + +extension ConversationTypeExtension on ConversationType { + String get name { + switch (this) { + case ConversationType.noFriend: + return 'noFriend'; + case ConversationType.system: + return 'system'; + case ConversationType.newFoucs: + return 'newFoucs'; + case ConversationType.interaction: + return 'interaction'; + case ConversationType.order: + return 'order'; + case ConversationType.groupNotify: + return 'groupNotify'; + } + } +} + +conversationTypeFromString(String? type) { + if (type == null) return null; + + if (type.contains('noFriend')) { + return ConversationType.noFriend.name; + } else if (type.contains('system')) { + return ConversationType.system.name; + } else if (type.contains('newFoucs')) { + return ConversationType.newFoucs.name; + } else if (type.contains('interaction')) { + return ConversationType.interaction.name; + } else if (type.contains('order')) { + return ConversationType.order.name; + } else if (type.contains('groupNotify')) { + return ConversationType.groupNotify.name; + } + + return null; +} diff --git a/lib/models/conversation_view_model.dart b/lib/models/conversation_view_model.dart index e420584..70f26c6 100644 --- a/lib/models/conversation_view_model.dart +++ b/lib/models/conversation_view_model.dart @@ -1,9 +1,9 @@ import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; class ConversationViewModel { - final V2TimConversation conversation; - final String? faceUrl; - final String isCustomAdmin; + late V2TimConversation conversation; + String? faceUrl; + String? isCustomAdmin; ConversationViewModel({ required this.conversation, diff --git a/lib/models/notify_message.type.dart b/lib/models/notify_message.type.dart new file mode 100644 index 0000000..97ebfde --- /dev/null +++ b/lib/models/notify_message.type.dart @@ -0,0 +1,97 @@ +/// 枚举定义:所有通知的二级类型 +enum NotifyMessageType { + newFoucs, //新的关注 + systemNotify, // 系统->通知 + systemReport, // 系统->举报下架(视频,视频评论) + systemCheck, // 系统->审核结果(复审,驳回 ,通过) + systemPush, //系统->推广 + interactionComment, //互动->评论 + interactionAt, //互动->视频评论中的@ + interactionLike, //互动->点赞 + interactionReply, //互动->评论回复 + orderRecharge, //订单->充值 online + orderPay, //订单->订单交易成功通知 online + orderRefund, //订单->退款结果通知 + groupNotifyCheck, //群通知->进群申请 online + groupNotifyAccpet, // 群通知->进群审核审核通过 online + groupNotifyFail, // 群通知->进群审核审核拒绝 online + groupNotifyLeaveUp, // 群通知->群升级为达人群通知 +} + +extension NotifyMessageTypeExtension on NotifyMessageType { + String get name { + switch (this) { + case NotifyMessageType.newFoucs: + return 'newFoucs'; + case NotifyMessageType.systemNotify: + return 'systemNotify'; + case NotifyMessageType.systemReport: + return 'systemReport'; + case NotifyMessageType.systemCheck: + return 'systemCheck'; + case NotifyMessageType.systemPush: + return 'systemPush'; + case NotifyMessageType.interactionComment: + return 'interactionComment'; + case NotifyMessageType.interactionAt: + return 'interactionAt'; + case NotifyMessageType.interactionLike: + return 'interactionLike'; + case NotifyMessageType.interactionReply: + return 'interactionReply'; + case NotifyMessageType.orderRecharge: + return 'orderRecharge'; + case NotifyMessageType.orderPay: + return 'orderPay'; + case NotifyMessageType.orderRefund: + return 'orderRefund'; + case NotifyMessageType.groupNotifyCheck: + return 'groupNotifyCheck'; + case NotifyMessageType.groupNotifyAccpet: + return 'groupNotifyAccpet'; + case NotifyMessageType.groupNotifyFail: + return 'groupNotifyFail'; + case NotifyMessageType.groupNotifyLeaveUp: + return 'groupNotifyLeaveUp'; + } + } +} + +notifyMessageTypeFromString(String? type) { + switch (type) { + case 'newFoucs': + return NotifyMessageType.newFoucs.name; + case 'systemNotify': + return NotifyMessageType.systemNotify.name; + case 'systemReport': + return NotifyMessageType.systemReport.name; + case 'systemCheck': + return NotifyMessageType.systemCheck.name; + case 'systemPush': + return NotifyMessageType.systemPush.name; + case 'interactionComment': + return NotifyMessageType.interactionComment.name; + case 'interactionAt': + return NotifyMessageType.interactionAt.name; + case 'interactionLike': + return NotifyMessageType.interactionLike.name; + case 'interactionReply': + return NotifyMessageType.interactionReply.name; + case 'orderRecharge': + return NotifyMessageType.orderRecharge.name; + case 'orderPay': + return NotifyMessageType.orderPay.name; + case 'orderRefund': + return NotifyMessageType.orderRefund.name; + case 'groupNotifyCheck': + return NotifyMessageType.groupNotifyCheck.name; + case 'groupNotifyAccpet': + return NotifyMessageType.groupNotifyAccpet.name; + case 'groupNotifyFail': + return NotifyMessageType.groupNotifyFail.name; + case 'groupNotifyLeaveUp': + return NotifyMessageType.groupNotifyLeaveUp.name; + default: + return null; + } +} diff --git a/lib/models/summary_type.dart b/lib/models/summary_type.dart new file mode 100644 index 0000000..15db9dc --- /dev/null +++ b/lib/models/summary_type.dart @@ -0,0 +1,6 @@ +/// 枚举定义:自定义消息标签类型 +class SummaryType { + static const hongbao = 'hongbao'; + static const shareVideo = 'shareVideo'; + static const shareTuangou = 'shareTuangou'; +} diff --git a/lib/pages/auth/login.dart b/lib/pages/auth/login.dart index 5fa1c91..19dafc6 100644 --- a/lib/pages/auth/login.dart +++ b/lib/pages/auth/login.dart @@ -74,10 +74,10 @@ class _LoginState extends State { // 初始化im_sdk await im_core.ImCore.init(sdkAppId: 1600080789); - // String userId = '1909990634551795712'; //15877777777 - // String userId = '18832510385'; + // String userId = '1940667704585248769'; //13212279365 + // String userId = '1943510443312078850'; //18832510385 // String userSig = - // 'eJwtzcsOgjAQBdB-6dqQKThth8QdG*JrIRHjTqGYiagNDzUx-rsVWN5zJ3M-IlvtgqdtRCzCAMRsyFzae8cVDywJiAhUNEeUmlDLcDpry*vJOS5FLBUAGNCGxsa*HTfWOyKGvhq149vftH8DhHLSli9*Jdrk2hqTVgdK2dKxXSfKbYuXpnPSu1zl*0fd18skg2Ihvj*7ADL4'; + // 'eJwtjcEKgkAURf9l1iFPm*e8EdoYYUWFURAtg5nk5VRiEln0703q8p57Ofcj9qtd8LS1SEQUgBh1mY29NXzmDodaQhwrBRIJI0kq1sPsYcpTVbERSRgDAIEi3Tf2VXFtPUfEyFc9bfj6ZwrH4J1Ig4UL-6LX0ihyS7U5bi-Wzd8LzrK8TFs6TJ1sZwWGxlGas71PxPcHwH4y9Q__'; // 'eJwtzLEKwjAUheF3ySwlNzXNbcHFxSIOaqTWUUgsF1FDG2tEfHdj2-F8P5wPO2x00tuWFUwknM2GTcbePV1oYEBMhQSeopxyZ65n58iwAjLOOXKF*VhscNTa6FJKEdOonm5-UxJQpZhN2lET3599Xllbv9ZBH2uHuDfvst5tG6FX0EFYVhpOpZ973z8W7PsDmYwyIw__'; try { @@ -87,13 +87,17 @@ class _LoginState extends State { if (loginRes.success) { // 存储登录信息 Storage.write('hasLogged', true); - // Storage.write('userSig', userSig); + Storage.write('userSig', userSig); Storage.write('userId', userId); - // Storage.write('token', obj['access_token']); + Storage.write('token', obj['access_token']); + // 获取用户账户信息 + final accountRes = await Http.get('${CommonApi.accountInfo}/$userId'); + logger.i(accountRes); // 刷新短视频列表 final videoController = Get.find(); videoController.markNeedRefresh(); dialogController.close(); + Get.back(); } } catch (e) { @@ -126,7 +130,7 @@ class _LoginState extends State { vcodeText = '获取验证码(${time--})'; } else { vcodeText = '获取验证码'; - time = 6; + time = 60; disabled = false; timer.cancel(); } diff --git a/lib/pages/chat/chat.dart b/lib/pages/chat/chat.dart index 7a9658d..3f3839a 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -1,18 +1,26 @@ /// 聊天模板 library; +import 'dart:convert'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/controller/chat_detail_controller.dart'; import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_result.dart'; import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; +import 'package:loopin/components/preview_video.dart'; +import 'package:loopin/models/summary_type.dart'; +import 'package:loopin/utils/audio_player_service.dart'; +import 'package:loopin/utils/snapshot.dart'; +import 'package:loopin/utils/voice_service.dart'; import 'package:shirne_dialog/shirne_dialog.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; -import '../../behavior/custom_scroll_behavior.dart'; -import '../../components/image_group.dart'; import '../../styles/index.dart'; import '../../utils/index.dart'; import './components/redpacket.dart'; @@ -30,18 +38,16 @@ class Chat extends StatefulWidget { class _ChatState extends State with SingleTickerProviderStateMixin { late final ChatDetailController controller; // 接收参数 - V2TimConversation arguments = Get.arguments; + late final Rx arguments; late String selfUserId; // 聊天消息模块 final bool isNeedScrollBottom = true; - // final RxList chatList = [].obs; - bool isLoading = false; // 是否在加载中 bool hasMore = true; // 是否还有更多数据 - bool _throttleFlag = false; // 滚动节流锁 + final RxBool _throttleFlag = false.obs; // 滚动节流锁 // 表情json List emoJson = emotionData; @@ -70,7 +76,9 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ]; // controller监听 - ScrollController chatController = ScrollController(); + // ScrollController chatController = ScrollController(); + late ScrollController chatController; + ScrollController emojController = ScrollController(); // 模拟开红包按钮动画 @@ -83,7 +91,10 @@ class _ChatState extends State with SingleTickerProviderStateMixin { @override void initState() { super.initState(); - controller = Get.put(ChatDetailController(userID: arguments.userID ?? '')); + final arg = Get.arguments as V2TimConversation; + arguments = arg.obs; + controller = Get.find(); + chatController = controller.chatController; animController = AnimationController( vsync: this, @@ -103,18 +114,22 @@ class _ChatState extends State with SingleTickerProviderStateMixin { setState(() { toolbarEnable = false; }); - scrollToBottom(); + controller.scrollToBottom(); } }); // 滚动监听 + // Future.delayed(Duration(milliseconds: 1000), () { + + // }); chatController.addListener(() { - if (_throttleFlag) return; + if (_throttleFlag.value) return; if (chatController.position.pixels >= chatController.position.maxScrollExtent - 50) { - _throttleFlag = true; + // if (chatController.position.pixels <= 50) { + _throttleFlag.value = true; getMsgData().then((_) { // 解锁 - Future.delayed(Duration(milliseconds: 300), () { - _throttleFlag = false; + Future.delayed(Duration(milliseconds: 1000), () { + _throttleFlag.value = false; }); }); } @@ -126,16 +141,67 @@ class _ChatState extends State with SingleTickerProviderStateMixin { if (Get.isRegistered()) { Get.delete(); } - chatController.dispose(); emojController.dispose(); editorFocusNode.dispose(); animController.dispose(); super.dispose(); } + // 设置好友备注 + void setRemark() async { + String remark = ''; + await MyDialog.confirm( + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + TextField( + onChanged: (value) => remark = value, + maxLength: 16, + maxLengthEnforcement: MaxLengthEnforcement.enforced, // 强制不能输入超过 + decoration: InputDecoration( + hintText: '请输入备注', + filled: true, + fillColor: const Color(0xFFF5F5F5), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Color(0xFFBE4EFF), width: 1), + ), + ), + ) + ], + ), + title: '设置备注', + buttonText: '确认', + cancelText: '取消', + onConfirm: () async { + // print('备注为:$remark'); + final res = await ImService.instance.setFriendInfo(userID: arguments.value.userID!, friendRemark: remark); + if (res.success) { + // 刷新会话列表数据 + // Get.find().getConversationList(); + arguments.update((val) { + val?.showName = remark; + }); + } else { + print(res.desc); + print(arguments.value.userID); + MyDialog.toast(res.desc, icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + return true; + }, + ); + } + void cleanUnRead() async { - if ((arguments.unreadCount ?? 0) > 0) { - final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.conversationID); + if ((arguments.value.unreadCount ?? 0) > 0) { + final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.value.conversationID); if (!res.success) { MyDialog.toast(res.desc, icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); } @@ -156,18 +222,20 @@ class _ChatState extends State with SingleTickerProviderStateMixin { // 获取最旧一条消息作为游标 V2TimMessage? lastRealMsg; + // for (var msg in controller.chatList.reversed) { for (var msg in controller.chatList.reversed) { if (msg.localCustomData != 'time_label') { lastRealMsg = msg; break; } } - final lastMsg = lastRealMsg ?? arguments.lastMessage; // 如果找不到,就用传入的参数 + final lastMsg = lastRealMsg ?? arguments.value.lastMessage; // 如果找不到,就用传入的参数 + print(lastMsg?.toLogString()); // final lastMsg = controller.chatList.isNotEmpty ? controller.chatList.last : arguments.lastMessage; final res = await ImService.instance.getHistoryMessageList( - userID: arguments.userID, + userID: arguments.value.userID, lastMsg: lastMsg, ); @@ -176,14 +244,26 @@ class _ChatState extends State with SingleTickerProviderStateMixin { if (newMessages.isEmpty) { hasMore = false; - MyDialog.toast('没有更多了~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); - } else { - if (initFlag) { - newMessages.insert(0, lastMsg!); - } - controller.updateChatListWithTimeLabels(newMessages); - print('聊天数据加载成功'); + // MyDialog.toast('没有更多了~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); } + if (initFlag && lastMsg != null) { + newMessages.insert(0, lastMsg); + // controller.scrollToBottom(); + } + controller.updateChatListWithTimeLabels(newMessages); + if (initFlag) { + // 初始化时滚到最底部 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (chatController.hasClients) { + // controller.scrollToBottom(); + // final bottomPadding = MediaQuery.of(context).padding.bottom; // 底部安全区域高度 + // chatController.jumpTo(chatController.position.maxScrollExtent); // 60为底部操作栏高度 + chatController.jumpTo(0); + } + }); + } + + print('聊天数据加载成功'); } else { MyDialog.toast("获取聊天记录失败:${res.desc}", icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); } @@ -210,31 +290,33 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 文本消息模板 + // 文本消息模板=1 else if (item.elemType == 1) { - msgtpl.add(RenderChatItem( - data: item, - child: Ink( - decoration: BoxDecoration( - color: !(item.isSelf ?? false) ? Color(0xFFFFFFFF) : Color(0xFF89E45B), - borderRadius: BorderRadius.circular(10.0), - ), - child: InkWell( - overlayColor: WidgetStateProperty.all(Colors.transparent), - borderRadius: BorderRadius.circular(10.0), - child: Container( - padding: const EdgeInsets.all(10.0), - child: RichTextUtil.getRichText(item.textElem?.text ?? '', color: !(item.isSelf ?? false) ? Colors.black : Colors.white), // 可自定义解析emoj/网址/电话 + msgtpl.add( + RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? Color(0xFFFFFFFF) : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + child: RichTextUtil.getRichText(item.textElem?.text ?? '', color: !(item.isSelf ?? false) ? Colors.black : Colors.white), // 可自定义解析emoj/网址/电话 + ), + onLongPress: () { + contextMenuDialog(); + }, ), - onLongPress: () { - contextMenuDialog(); - }, ), ), - )); + ); } - // gif表情模板 - else if (item.elemType == 4) { + // gif表情模板=8 + else if (item.elemType == 8) { msgtpl.add(RenderChatItem( data: item, child: Ink( @@ -245,7 +327,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { maxHeight: 100.0, maxWidth: 100.0, ), - child: Image.asset('assets/images/emotion/${item.customElem?.data}'), + // child: Image.asset('assets/images/emotion/${item.faceElem?.data}'), + child: Image.asset('${item.faceElem?.data}'), ), onLongPress: () { contextMenuDialog(); @@ -254,9 +337,11 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 图片模板 - else if (item.elemType == 5) { - List imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? []; + // 图片模板=3 + else if (item.elemType == 3) { + // List imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? []; + final originImage = item.imageElem?.imageList?.firstWhere((e) => e?.type == 0 && e?.url != null, orElse: () => null); + List imagePaths = originImage != null ? [originImage.url!] : []; msgtpl.add(RenderChatItem( data: item, child: Ink( @@ -264,9 +349,36 @@ class _ChatState extends State with SingleTickerProviderStateMixin { overlayColor: WidgetStateProperty.all(Colors.transparent), child: ClipRRect( borderRadius: BorderRadius.circular(10.0), - child: ImageGroup( - images: imagePaths, + // child: ImageGroup( + // images: imagePaths, + // width: 120, + // ), + child: Image.network( + imagePaths.first, width: 120, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + // controller.scrollToBottom(); + return child; // 加载完成,显示图片 + } + return Container( + width: 120, + height: 240, + color: Colors.grey[300], + alignment: Alignment.center, + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[300], + alignment: Alignment.center, + child: Icon(Icons.broken_image, color: Colors.grey, size: 40), + ); + }, ), ), onLongPress: () { @@ -276,22 +388,24 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 视频模板 - else if (item.elemType == 6) { + // 视频模板=5 + else if (item.elemType == 5) { + // print(item.videoElem!.toLogString()); msgtpl.add(RenderChatItem( data: item, child: Ink( child: InkWell( overlayColor: WidgetStateProperty.all(Colors.transparent), child: SizedBox( - width: 90.0, + width: 120.0, child: Stack( alignment: Alignment.center, children: [ ClipRRect( borderRadius: BorderRadius.circular(10.0), - child: Image.network( - item.videoElem?.videoUrl ?? '', + child: NetworkOrAssetImage( + imageUrl: item.videoElem?.snapshotUrl ?? '', + width: 120, ), ), const Align( @@ -306,7 +420,24 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), ), onTap: () { - MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + showGeneralDialog( + context: context, + // barrierDismissible: true, + barrierColor: Colors.black.withAlpha((1.0 * 255).round()), + pageBuilder: (_, __, ___) { + return SafeArea( + child: PreviewVideo( + videoUrl: item.videoElem?.videoUrl ?? '', + width: item.videoElem?.snapshotWidth?.toDouble(), + height: item.videoElem?.snapshotHeight?.toDouble(), + ), + ); + }, + transitionBuilder: (_, anim, __, child) { + return FadeTransition(opacity: anim, child: child); + }, + transitionDuration: const Duration(milliseconds: 200), + ); }, onLongPress: () { contextMenuDialog(); @@ -315,8 +446,12 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 语音模板 - else if (item.elemType == 7) { + // 语音模板=4 + else if (item.elemType == 4) { + final durationMs = item.soundElem?.duration ?? 0; + final durationSeconds = (durationMs / 1000).round(); + final maxWidth = (durationSeconds / 60 * 230).clamp(80.0, 230.0); + List audiobody = [ Ink( decoration: BoxDecoration( @@ -329,8 +464,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { child: Container( padding: const EdgeInsets.all(10.0), constraints: BoxConstraints( - // maxWidth: 120.0, - maxWidth: (item.soundElem?.duration)! / 60 * 230, + maxWidth: maxWidth, + // maxWidth: (item.soundElem!.duration! / 1000) / 60 * 230, ), child: Row( mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, @@ -340,10 +475,10 @@ class _ChatState extends State with SingleTickerProviderStateMixin { const SizedBox( width: 5.0, ), - Text('${item.soundElem?.duration}'), + Text('$durationSeconds"'), ] : [ - Text('${item.soundElem?.duration}'), + Text('$durationSeconds"'), const SizedBox( width: 5.0, ), @@ -352,7 +487,15 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), ), onTap: () { - MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + final locUrl = item.soundElem?.path ?? ''; + final netUrl = item.soundElem?.url ?? ''; + if (locUrl.isNotEmpty) { + AudioPlayerService().playNetwork(locUrl); + } else if (netUrl.isNotEmpty) { + AudioPlayerService().playLocal(netUrl); + } else { + MyDialog.toast('音频文件已过期', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } }, onLongPress: () { contextMenuDialog(); @@ -362,7 +505,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { const SizedBox( width: 5.0, ), - FStyle.badge(0, isdot: true), + + // FStyle.badge(0, isdot: true), ]; if (item.isSelf ?? false) { @@ -379,8 +523,157 @@ class _ChatState extends State with SingleTickerProviderStateMixin { children: audiobody, ))); } - // 红包模板 - else if (item.elemType == 0 && item.customElem?.desc == 'hongbao') { + // 分享团购商品 + else if (item.elemType == 2 && item.cloudCustomData == SummaryType.shareTuangou) { + //price,title,url,sell + final obj = jsonDecode(item.customElem!.data!); + final url = obj['url']; + final title = obj['title']; + final price = obj['price']; + final sell = Utils().graceNumber(int.tryParse(obj['sell'])!); + msgtpl.add(RenderChatItem( + data: item, + child: GestureDetector( + child: Container( + width: 160, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15.0), boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(5), + offset: Offset(0.0, 1.0), + blurRadius: 1.0, + spreadRadius: 0.0, + ), + ]), + child: Column( + children: [ + NetworkOrAssetImage( + imageUrl: url, + width: 160.0, + ), + Container( + padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5.0, + children: [ + Text( + '$title', + style: TextStyle(fontSize: 14.0, height: 1.2), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Text.rich( + overflow: TextOverflow.ellipsis, + maxLines: 1, + TextSpan(style: TextStyle(color: Colors.red, fontSize: 12.0, fontWeight: FontWeight.w700, fontFamily: 'Arial'), children: [ + TextSpan(text: '¥'), + TextSpan( + text: '$price', + style: TextStyle( + fontSize: 14.0, + )), + ]), + ), + ), + SizedBox( + width: 5, + ), + Text( + '已售$sell件', + style: TextStyle(color: Colors.grey, fontSize: 10.0), + ), + ], + ), + ], + ), + ) + ], + ), + ), + onTap: () { + // 这里带上分享人的ID + Get.toNamed('/goods'); + }, + ), + )); + } + // 分享短视频 + else if (item.elemType == 2 && item.cloudCustomData == SummaryType.shareVideo) { + /// {imgUrl,videoUrl,width,height} + final obj = jsonDecode(item.customElem!.data!); + final videoUrl = obj['videoUrl']; + final imgUrl = obj['imgUrl']; + final width = obj['width'] as num; + final height = obj['height'] as num; + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: SizedBox( + width: 120.0, + child: Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: NetworkOrAssetImage( + imageUrl: imgUrl, + width: 120, + ), + ), + const Align( + alignment: Alignment.center, + child: Icon( + Icons.play_circle, + color: Colors.white, + size: 30.0, + ), + ), + ], + ), + ), + onTap: () { + showGeneralDialog( + context: context, + barrierColor: Colors.black.withAlpha((1.0 * 255).round()), + pageBuilder: (_, __, ___) { + return SafeArea( + bottom: true, + child: Padding( + padding: const EdgeInsets.only(bottom: 4), + child: PreviewVideo( + videoUrl: videoUrl, + width: width.toDouble(), + height: height.toDouble(), + ), + ), + ); + }, + transitionBuilder: (_, anim, __, child) { + return FadeTransition(opacity: anim, child: child); + }, + transitionDuration: const Duration(milliseconds: 200), + ); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 红包模板=自定义=2; + else if (item.elemType == 2 && item.cloudCustomData == SummaryType.hongbao) { + final obj = jsonDecode(item.customElem!.data!); + final open = obj['open'] ?? false; + final remark = obj['remark']; + // final maxNum = obj['maxNum']; msgtpl.add(RenderChatItem( data: item, child: Ink( @@ -399,16 +692,26 @@ class _ChatState extends State with SingleTickerProviderStateMixin { crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( + width: 210.0, padding: const EdgeInsets.all(10.0), child: Row( - spacing: 10.0, children: [ - Image.asset( - 'assets/images/hbico.png', - width: 32.0, - fit: BoxFit.contain, + open + ? Icon(Icons.check_circle, size: 32.0, color: Colors.white70) + : Image.asset( + 'assets/images/hbico.png', + width: 32.0, + fit: BoxFit.contain, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + '$remark', + style: const TextStyle(color: Colors.white, fontSize: 14.0), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - Text(item.customElem?.data ?? '', style: const TextStyle(color: Colors.white, fontSize: 14.0)), ], ), ), @@ -418,7 +721,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { width: double.infinity, decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.white30, width: .5))), child: const Text( - '拼手气红包', + '红包', style: TextStyle(color: Colors.white70, fontSize: 11.0), ), ), @@ -435,8 +738,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), )); } - // 位置模板 - else if (item.elemType == 9) { + // 位置模板=7 + else if (item.elemType == 7) { msgtpl.add(RenderChatItem( data: item, child: Ink( @@ -583,7 +886,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { child: Image.asset(emoj), ), onTap: () { - handleGIFClick(emoj); + handleGIFClick(emoj, item['index']); }, ), ); @@ -663,17 +966,19 @@ class _ChatState extends State with SingleTickerProviderStateMixin { // chatController.animateTo(isNeedScrollBottom ? 0 : chatController.position.maxScrollExtent, // duration: const Duration(milliseconds: 200), curve: Curves.easeIn); // } - void scrollToBottom() { - Future.delayed(Duration(milliseconds: 100), () { - if (chatController.hasClients) { - chatController.animateTo( - 0, // reverse: true 时滚动到底部是 offset: 0 - duration: Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } - }); - } + + // void scrollToBottom() { + // Future.delayed(Duration(milliseconds: 300), () { + // if (chatController.hasClients) { + // chatController.animateTo( + // // 0, // reverse: true 时滚动到底部是 offset: 0 + // chatController.position.maxScrollExtent, + // duration: Duration(milliseconds: 300), + // curve: Curves.easeOut, + // ); + // } + // }); + // } // 点击消息区域 void handleClickChatArea() { @@ -745,20 +1050,22 @@ class _ChatState extends State with SingleTickerProviderStateMixin { } // 不需要时间标签 - final res = await IMMessage().sendText( - text: message['content'], - toUserID: arguments.userID, + // 消息类型 + late final ImResult res; + res = await IMMessage().sendMessage( + msg: message, + toUserID: arguments.value.userID, ); if (res.success && res.data != null) { messagesToInsert.insert(0, res.data); // 加入消息本体 + // messagesToInsert.add(res.data); // 加入消息本体 controller.chatList.insertAll(0, messagesToInsert); + // controller.chatList.addAll(messagesToInsert); - scrollToBottom(); + controller.scrollToBottom(); print('发送成功'); - // 更新会话列表数据 - Get.find().getConversationList(); } else { print('消息发送失败: ${res.code} - ${res.desc}'); } @@ -783,7 +1090,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { toolbarIndex = index; voiceBtnEnable = false; }); - scrollToBottom(); + controller.scrollToBottom(); } // 表情Tab切换 @@ -799,51 +1106,107 @@ class _ChatState extends State with SingleTickerProviderStateMixin { emojController.jumpTo(0); } - // 点击表情 + // 点击表情插入到输入框 void handleEmojClick(emoj) { insertTextAtCursor(emoj); } - // 点击Gif大图 - void handleGIFClick(gifpath) { + // 点击Gif大图发送=8 + void handleGIFClick(gifpath, index) async { // 消息队列 - Map message = { - 'id': Utils.uuid(), - 'contentType': 4, - 'isme': true, - 'avatar': 'assets/images/avatar/img11.jpg', - 'author': 'Andy', - 'content': '', - 'image': gifpath, - 'video': '', - }; - sendMessage(message); + // Map message = { + // 'contentType': 8, + // 'content': gifpath, + // }; + final res = await IMMessage().createFaceMessage(data: gifpath, index: index); + if (res.success) { + sendMessage(res.data?.messageInfo); + } } - // 提交消息 - void handleSubmit() { + // 发送文本消息=1 + void handleSubmit() async { if (editorController.text.isEmpty) return; // 消息队列 - Map message = { - 'id': Utils.uuid(), - 'contentType': 3, - 'isme': true, - 'avatar': 'assets/images/avatar/img11.jpg', - 'author': 'Andy', - 'content': editorController.text, - 'image': '', - 'video': '', - }; - sendMessage(message); - editorController.clear(); + // Map message = { + // 'contentType': 1, + // 'content': editorController.text, + // }; + final res = await IMMessage().createTextMessage(text: editorController.text); + if (res.success) { + sendMessage(res.data?.messageInfo); + editorController.clear(); + } } - // 选择区操作 + // 发红包消息 + void sendHongbao(date) async { + final amount = date['amount']; //用户输入的金额 + final remark = date['remark']; //用户输入的留言 + final maxNum = date['maxNum']; //红包数量 + + // 先检测可用余额 + final makeJson = jsonEncode({ + "amount": amount, + "remark": remark, + "maxNum": maxNum, + "open": false, + }); + final res = await IMMessage().createCustomMessage(data: makeJson); + if (res.success && (res.data != null)) { + final custMsg = res.data!.messageInfo; + custMsg!.cloudCustomData = SummaryType.hongbao; + sendMessage(res.data!.messageInfo); + Get.back(); + } + } + + // 发送图片消息=3 + void sendImage(imgPath) async { + final resImg = await IMMessage().createImageMessage(imagePath: imgPath); + if (resImg.success) { + sendMessage(resImg.data?.messageInfo); + } + } + + // 发送语音消息=4 + void sendVoiceMsg() async { + final fileMap = await VoiceService().stopRecording(); + if (fileMap != null) { + final res = await IMMessage().createSoundMessage( + soundPath: fileMap['path'], + duration: fileMap['duration'], + ); + if (res.success && res.data != null) { + sendMessage(res.data!.messageInfo); + } else { + MyDialog.toast('创建语音消息失败'); + } + } else { + MyDialog.toast('语音限制1-60秒'); + } + } + + // 发送视频消息=5 + void sendVideo(videoFilePath, type, duration, snapshotPath) async { + final resImg = await IMMessage().createVideoMessage( + videoFilePath: videoFilePath, + type: type, + duration: duration, + snapshotPath: snapshotPath, + ); + if (resImg.success) { + sendMessage(resImg.data?.messageInfo); + } + } + + // 底部操作蓝选择区操作 void handleChooseAction(key) { MyDialog.toast('$key'); switch (key) { case 'photo': // .... + pickFile(context); break; case 'camera': // .... @@ -854,12 +1217,97 @@ class _ChatState extends State with SingleTickerProviderStateMixin { } } + ///从相册选取图片/视频 + void pickFile(BuildContext context) async { + final pickedAssets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + textDelegate: const AssetPickerTextDelegate(), + pathNameBuilder: (AssetPathEntity album) { + return Utils.translateAlbumName(album); + }, + maxAssets: 5, + requestType: RequestType.common, + filterOptions: FilterOptionGroup( + imageOption: const FilterOption(), + videoOption: const FilterOption( + durationConstraint: DurationConstraint( + max: Duration(seconds: 120), + ), + ), + ), + ), + ); + + if (pickedAssets != null && pickedAssets.isNotEmpty) { + for (final asset in pickedAssets) { + switch (asset.type) { + case AssetType.image: + print("选中了图片:${asset.title}"); + var file = await asset.file; + if (file != null) { + var fileSizeInBytes = await file.length(); + var sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 28) { + MyDialog.toast('图片大小不能超过28MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + // 执行发送逻辑 + sendImage(file.path); + } + } + + break; + case AssetType.video: + print("选中了视频:${asset.title}"); + var file = await asset.file; + if (file != null) { + var fileSizeInBytes = await file.length(); + var sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 28) { + MyDialog.toast('图片大小不能超过28MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + // 执行发送逻辑 + var snapshot = await generateVideoThumbnail(file.path); + String? mimeType = await asset.mimeTypeAsync; + String vdType = mimeType?.split('/').last ?? 'mp4'; + print(vdType); + sendVideo(file.path, vdType, asset.duration, snapshot); + } + } + break; + default: + print("不支持的类型:${asset.type}"); + } + } + // final asset = pickedAssets.first; + // final file = await asset.file; // 获取实际文件 + // if (file != null) { + // final fileSizeInBytes = await file.length(); + // final sizeInMB = fileSizeInBytes / (1024 * 1024); + // if (sizeInMB > 100) { + // MyDialog.toast('图片大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + // } else { + // print("图片合法,大小:$sizeInMB MB"); + // //走upload(file)上传图片拿到url地址 + // // file; + // } + // } + } + } + /* ---------- { 弹窗功能模块 } ---------- */ // 红包弹窗 - void receiveRedPacketDialog(data) { + void receiveRedPacketDialog(V2TimMessage data) { showDialog( context: context, builder: (context) { + final obj = jsonDecode(data.customElem!.data!); + final amount = obj['amount']; + final remark = obj['remark']; + final open = obj['open'] ?? false; + return Material( type: MaterialType.transparency, child: Column( @@ -868,7 +1316,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { Container( width: double.infinity, margin: const EdgeInsets.symmetric(horizontal: 50.0), - padding: const EdgeInsets.symmetric(vertical: 50.0), + padding: const EdgeInsets.symmetric(vertical: 50.0, horizontal: 20.0), decoration: const BoxDecoration( color: Color(0xFFFF7F43), borderRadius: BorderRadius.all(Radius.circular(12.0)), @@ -877,54 +1325,70 @@ class _ChatState extends State with SingleTickerProviderStateMixin { children: [ ClipRRect( borderRadius: BorderRadius.circular(5.0), - child: Image.asset(data['avatar'], height: 40.0, width: 40.0, fit: BoxFit.cover), + child: NetworkOrAssetImage( + imageUrl: data.senderProfile?.faceUrl, + ), ), const SizedBox( height: 5.0, ), Text( - data['author'], + '${data.senderProfile?.nickName}的红包', style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w600), ), + SizedBox(height: 10), Text( - data['content'], + amount, + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), + ), + SizedBox(height: 20.0), + Text( + remark, style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), ), SizedBox( height: 100.0, ), - AnimatedBuilder( - animation: animTurns, - builder: (context, child) { - return Transform( - transform: Matrix4.rotationY(animTurns.value), - alignment: Alignment.center, - child: FilledButton( - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all(const Color(0xFFFFF9C7)), - padding: WidgetStateProperty.all(EdgeInsets.zero), - minimumSize: WidgetStateProperty.all(const Size(80.0, 80.0)), - shape: WidgetStateProperty.all(const CircleBorder()), - elevation: WidgetStateProperty.all(3.0), + if (open == false && data.isSelf == false) + AnimatedBuilder( + animation: animTurns, + builder: (context, child) { + return Transform( + transform: Matrix4.rotationY(animTurns.value), + alignment: Alignment.center, + child: FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(const Color(0xFFFFF9C7)), + padding: WidgetStateProperty.all(EdgeInsets.zero), + minimumSize: WidgetStateProperty.all(const Size(80.0, 80.0)), + shape: WidgetStateProperty.all(const CircleBorder()), + elevation: WidgetStateProperty.all(3.0), + ), + child: Text( + '开', + style: TextStyle(color: Color(0xFF3B3B3B), fontSize: 28.0), + ), + onPressed: () async { + // 点击开红包,开始动画 + animController.repeat(); + // 执行抢红包结果查询,(群)展示抢红包人员信息,单不用管 + // 执行消费红包动作 + //-------- + // 成功后修改消息体 + obj['open'] = true; //成功标记为true + data.customElem!.data = jsonEncode(obj); + ImService.instance.modifyMessage(message: data); + // 模拟开红包逻辑,1 秒后停止动画 + Future.delayed(Duration(seconds: 1), () { + animController.stop(); + animController.reset(); + Get.back(); + }); + }, ), - child: Text( - '開', - style: TextStyle(color: Color(0xFF3B3B3B), fontSize: 28.0), - ), - onPressed: () { - // 开始动画 - animController.repeat(); - // 模拟开红包逻辑,1 秒后停止动画 - Future.delayed(Duration(seconds: 1), () { - animController.stop(); - animController.reset(); - Get.back(); - }); - }, - ), - ); - }, - ), + ); + }, + ), ], ), ), @@ -998,7 +1462,11 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), context: context, builder: (context) { - return const RedPacket(); + return RedPacket( + flag: false, + onSend: (date) { + sendHongbao(date); + }); }, ); } @@ -1013,6 +1481,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { // 页面主体(聊天消息区/底部操作区) Scaffold( backgroundColor: Colors.grey[200], + resizeToAvoidBottomInset: true, // 启用键盘自动避让 appBar: AppBar( centerTitle: true, backgroundColor: Colors.transparent, @@ -1027,11 +1496,13 @@ class _ChatState extends State with SingleTickerProviderStateMixin { }, ), titleSpacing: 1.0, - title: Text( - // '${arguments['title']}', - '${arguments.showName}', - style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'), - ), + title: Obx(() { + return Text( + // '${arguments['title']}', + '${arguments.value.showName}', + style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'), + ); + }), flexibleSpace: Container( decoration: const BoxDecoration( gradient: LinearGradient( @@ -1046,7 +1517,109 @@ class _ChatState extends State with SingleTickerProviderStateMixin { Icons.more_horiz, color: Colors.white, ), - onPressed: () {}, + onPressed: () async { + final paddingTop = MediaQuery.of(Get.context!).padding.top; + + final selected = await showMenu( + context: Get.context!, + position: RelativeRect.fromLTRB( + double.infinity, + kToolbarHeight + paddingTop - 12, + 8, + double.infinity, + ), + color: FStyle.primaryColor, + elevation: 8, + items: [ + PopupMenuItem( + value: 'remark', + child: Row( + children: [ + Icon(Icons.edit, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设置备注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'not', + child: Row( + children: [ + Icon(Icons.do_not_disturb_on, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设为免打扰', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'report', + child: Row( + children: [ + Icon(Icons.report, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '举报', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'block', + child: Row( + children: [ + Icon(Icons.block, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '拉黑', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'foucs', + child: Row( + children: [ + Icon(Icons.person_remove_alt_1, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '取消关注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ); + + if (selected != null) { + switch (selected) { + case 'remark': + print('点击了备注'); + setRemark(); + break; + case 'not': + print('点击了免打扰'); + break; + case 'report': + print('点击了举报'); + break; + case 'block': + print('点击了拉黑'); + break; + case 'foucs': + print('点击了取关'); + break; + } + } + }, ), ], ), @@ -1056,19 +1629,31 @@ class _ChatState extends State with SingleTickerProviderStateMixin { children: [ // 渲染聊天消息 Expanded( - child: ScrollConfiguration( - behavior: CustomScrollBehavior(), - child: GestureDetector( - child: Obx(() { - return ListView( - controller: chatController, - reverse: true, - padding: const EdgeInsets.all(10.0), - children: renderChatList(), - ); - }), - onTap: () { - handleClickChatArea(); + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: handleClickChatArea, + child: LayoutBuilder( + builder: (context, constraints) { + return Obx(() { + final msgWidgets = renderChatList().reversed.toList(); + + return ListView( + controller: chatController, + reverse: true, + padding: const EdgeInsets.all(10.0), + children: [ + ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 40, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: msgWidgets, + ), + ), + ], + ); + }); }, ), ), @@ -1076,201 +1661,210 @@ class _ChatState extends State with SingleTickerProviderStateMixin { // 底部操作栏 Container( - color: Colors.grey[100], - child: SafeArea( - bottom: true, - child: Container( - decoration: BoxDecoration( - color: Colors.grey[100], - border: const Border(top: BorderSide(color: Colors.black38, width: .1)), - ), - child: Column( - children: [ - // 输入框编辑器模块 - Container( - padding: const EdgeInsets.all(10.0), - child: Row( - children: [ - InkWell( - child: Icon( - voiceBtnEnable ? Icons.keyboard_outlined : Icons.contactless_outlined, - color: const Color(0xFF3B3B3B), - size: 30.0, + color: Colors.grey[100], + child: SafeArea( + bottom: true, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[100], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: Column( + children: [ + // 输入框编辑器模块 + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + children: [ + InkWell( + child: Icon( + voiceBtnEnable ? Icons.keyboard_outlined : Icons.contactless_outlined, + color: const Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + setState(() { + toolbarEnable = false; + if (voiceBtnEnable) { + voiceBtnEnable = false; + editorFocusNode.requestFocus(); + } else { + voiceBtnEnable = true; + editorFocusNode.unfocus(); + } + }); + }, + ), + const SizedBox( + width: 10.0, + ), + Expanded( + child: Container( + constraints: const BoxConstraints(minHeight: 40.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), ), - onTap: () { - setState(() { - toolbarEnable = false; - if (voiceBtnEnable) { - voiceBtnEnable = false; - editorFocusNode.requestFocus(); - } else { - voiceBtnEnable = true; - editorFocusNode.unfocus(); - } - }); - }, - ), - const SizedBox( - width: 10.0, - ), - Expanded( - child: Container( - constraints: const BoxConstraints(minHeight: 40.0), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - child: Stack( - children: [ - // 输入框 - Offstage( - offstage: voiceBtnEnable, - child: TextField( - decoration: const InputDecoration( - isDense: true, - hoverColor: Colors.transparent, - contentPadding: EdgeInsets.all(8.0), - border: OutlineInputBorder(borderSide: BorderSide.none), - ), - style: const TextStyle( - fontSize: 16.0, - ), - maxLines: null, - controller: editorController, - focusNode: editorFocusNode, - cursorColor: const Color(0xFF07C160), - onChanged: (value) {}, + child: Stack( + children: [ + // 输入框 + Offstage( + offstage: voiceBtnEnable, + child: TextField( + decoration: const InputDecoration( + isDense: true, + hoverColor: Colors.transparent, + contentPadding: EdgeInsets.all(8.0), + border: OutlineInputBorder(borderSide: BorderSide.none), ), + style: const TextStyle( + fontSize: 16.0, + ), + maxLines: null, + controller: editorController, + focusNode: editorFocusNode, + cursorColor: const Color(0xFF07C160), + onChanged: (value) {}, ), - // 语音 - Offstage( - offstage: !voiceBtnEnable, - child: GestureDetector( - child: Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(5), - ), - alignment: Alignment.center, - height: 40.0, - width: double.infinity, - child: Text( - voiceTypeMap[voiceType], - style: const TextStyle(fontSize: 15.0), - ), + ), + // 语音 + Offstage( + offstage: !voiceBtnEnable, + child: GestureDetector( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), ), - onPanStart: (details) { + alignment: Alignment.center, + height: 40.0, + width: double.infinity, + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(fontSize: 15.0), + ), + ), + onPanStart: (details) async { + // 开始录音 + final res = await VoiceService().startRecording(); + if (res) { setState(() { voiceType = 1; voicePanelEnable = true; }); - }, - onPanUpdate: (details) { - Offset pos = details.globalPosition; - double swipeY = MediaQuery.of(context).size.height - 120; - double swipeX = MediaQuery.of(context).size.width / 2 + 50; - setState(() { - if (pos.dy >= swipeY) { - voiceType = 1; // 松开发送 - } else if (pos.dy < swipeY && pos.dx < swipeX) { - voiceType = 2; // 左滑松开取消 - } else if (pos.dy < swipeY && pos.dx >= swipeX) { - voiceType = 3; // 右滑语音转文字 - } - }); - }, - onPanEnd: (details) { - // print('停止录音'); - setState(() { - switch (voiceType) { - case 1: - MyDialog.toast('发送录音文件'); - voicePanelEnable = false; - break; - case 2: - MyDialog.toast('取消发送'); - voicePanelEnable = false; - break; - case 3: - MyDialog.toast('语音转文字'); - voicePanelEnable = true; - voiceToTransfer = true; - break; - } - voiceType = 0; - }); - }, - ), + } else { + MyDialog.toast('未获得麦克风权限'); + } + }, + onPanUpdate: (details) { + Offset pos = details.globalPosition; + double swipeY = MediaQuery.of(context).size.height - 120; + double swipeX = MediaQuery.of(context).size.width / 2 + 50; + setState(() { + if (pos.dy >= swipeY) { + voiceType = 1; // 松开发送 + } else if (pos.dy < swipeY && pos.dx < swipeX) { + voiceType = 2; // 左滑松开取消 + } else if (pos.dy < swipeY && pos.dx >= swipeX) { + voiceType = 3; // 右滑语音转文字 + } + }); + }, + onPanEnd: (details) { + // print('停止录音'); + setState(() { + switch (voiceType) { + case 1: + // MyDialog.toast('发送录音文件'); + sendVoiceMsg(); + voicePanelEnable = false; + break; + case 2: + // MyDialog.toast('取消发送'); + VoiceService().cancelRecording; + voicePanelEnable = false; + break; + case 3: + MyDialog.toast('语音转文字'); + voicePanelEnable = true; + voiceToTransfer = true; + break; + } + voiceType = 0; + }); + }, ), - ], - ), + ), + ], ), ), - const SizedBox( - width: 10.0, + ), + const SizedBox( + width: 10.0, + ), + InkWell( + child: const Icon( + Icons.add_reaction_rounded, + color: Color(0xFF3B3B3B), + size: 30.0, ), - InkWell( + onTap: () { + handleEmojChooseState(0); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: const Icon( + Icons.add, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(1); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: Container( + height: 25.0, + width: 25.0, + decoration: BoxDecoration( + color: const Color(0xFF07C160), + borderRadius: BorderRadius.circular(20.0), + ), child: const Icon( - Icons.add_reaction_rounded, - color: Color(0xFF3B3B3B), - size: 30.0, + Icons.arrow_upward, + color: Colors.white, + size: 20.0, ), - onTap: () { - handleEmojChooseState(0); - }, ), - const SizedBox( - width: 8.0, - ), - InkWell( - child: const Icon( - Icons.add, - color: Color(0xFF3B3B3B), - size: 30.0, - ), - onTap: () { - handleEmojChooseState(1); - }, - ), - const SizedBox( - width: 8.0, - ), - InkWell( - child: Container( - height: 25.0, - width: 25.0, - decoration: BoxDecoration( - color: const Color(0xFF07C160), - borderRadius: BorderRadius.circular(20.0), - ), - child: const Icon( - Icons.arrow_upward, - color: Colors.white, - size: 20.0, - ), - ), - onTap: () { - handleSubmit(); - }, - ), - ], + onTap: () { + handleSubmit(); + }, + ), + ], + ), + ), + + // 表情+选择模块 + Visibility( + visible: toolbarEnable, + child: SizedBox( + height: keyboardHeight, + child: Column( + children: toolbarIndex == 0 ? renderEmojWidget() : renderChooseWidget(), ), ), - - // 表情+选择模块 - Visibility( - visible: toolbarEnable, - child: SizedBox( - height: keyboardHeight, - child: Column( - children: toolbarIndex == 0 ? renderEmojWidget() : renderChooseWidget(), - ), - ), - ) - ], - ), + ) + ], ), - )) + ), + ), + ) ], ), ), @@ -1285,13 +1879,14 @@ class _ChatState extends State with SingleTickerProviderStateMixin { children: [ // 取消发送+语音转文字 Positioned( - bottom: 120, + bottom: 160, left: 30, right: 30, child: Visibility( visible: !voiceToTransfer, child: Column( - crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center, + // crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, children: [ // 语音动画层 Stack( @@ -1300,7 +1895,8 @@ class _ChatState extends State with SingleTickerProviderStateMixin { AnimatedContainer( duration: Duration(milliseconds: 200), height: 70.0, - width: voiceType == 2 ? 70.0 : 200.0, + // width: voiceType == 2 ? 70.0 : 200.0, + width: 200.0, decoration: BoxDecoration( color: voiceType == 2 ? Colors.red : Color(0xFF89E45B), borderRadius: BorderRadius.circular(15.0), @@ -1324,7 +1920,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), // 操作项 Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.center, children: [ // 取消发送 Container( @@ -1340,18 +1936,18 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), ), // 语音转文字 - Container( - height: 60.0, - width: 60.0, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(50.0), - color: voiceType == 3 ? Color(0xFF89E45B) : Colors.black38, - ), - child: Icon( - Icons.translate, - color: Colors.white54, - ), - ), + // Container( + // height: 60.0, + // width: 60.0, + // decoration: BoxDecoration( + // borderRadius: BorderRadius.circular(50.0), + // color: voiceType == 3 ? Color(0xFF89E45B) : Colors.black38, + // ), + // child: Icon( + // Icons.translate, + // color: Colors.white54, + // ), + // ), ], ), ], @@ -1404,7 +2000,7 @@ class _ChatState extends State with SingleTickerProviderStateMixin { ), // 操作项 Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.center, children: [ GestureDetector( child: Container( @@ -1498,7 +2094,12 @@ class _ChatState extends State with SingleTickerProviderStateMixin { alignment: Alignment.bottomCenter, child: Visibility( visible: !voiceToTransfer, - child: Image.asset('assets/images/voice_bg.webp', width: double.infinity, height: 100.0, fit: BoxFit.fill), + child: Image.asset( + 'assets/images/voice_bg.webp', + width: double.infinity, + height: 100.0, + fit: BoxFit.fill, + ), ), ), // 背景图标 @@ -1564,7 +2165,7 @@ class RenderChatItem extends StatelessWidget { width: 35.0, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(20.0)), - child: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), + child: NetworkOrAssetImage(imageUrl: data.faceUrl), ), ) : const SizedBox.shrink(), @@ -1612,7 +2213,7 @@ class RenderChatItem extends StatelessWidget { width: 35.0, child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(20.0)), - child: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), + child: NetworkOrAssetImage(imageUrl: data.faceUrl), ), ) : const SizedBox.shrink(), diff --git a/lib/pages/chat/chat_group.dart b/lib/pages/chat/chat_group.dart new file mode 100644 index 0000000..988dac8 --- /dev/null +++ b/lib/pages/chat/chat_group.dart @@ -0,0 +1,1813 @@ +/// 聊天模板 +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; +import 'package:loopin/IM/controller/chat_detail_controller.dart'; +import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; + +import '../../behavior/custom_scroll_behavior.dart'; +import '../../components/image_group.dart'; +import '../../styles/index.dart'; +import '../../utils/index.dart'; +import './components/redpacket.dart'; +import './components/richtext.dart'; +// import 'mock/chat_json.dart'; +import 'mock/emoj_json.dart'; + +class ChatGroup extends StatefulWidget { + const ChatGroup({super.key}); + + @override + State createState() => _ChatState(); +} + +class _ChatState extends State with SingleTickerProviderStateMixin { + late final ChatDetailController controller; + // 接收参数 + // Rx arguments = Get.arguments.obs; + late final Rx arguments; + + late String selfUserId; + + // 聊天消息模块 + final bool isNeedScrollBottom = true; + + // final RxList chatList = [].obs; + + bool isLoading = false; // 是否在加载中 + bool hasMore = true; // 是否还有更多数据 + bool _throttleFlag = false; // 滚动节流锁 + + // 表情json + List emoJson = emotionData; + + // 底部操作栏模块 + TextEditingController editorController = TextEditingController(); + FocusNode editorFocusNode = FocusNode(); + bool voiceBtnEnable = false; // 语音按钮 + bool voicePanelEnable = false; // 语音操作面板 + bool voiceToTransfer = false; // 语音转文字中 + int voiceType = 0; // 语音操作类型 + Map voiceTypeMap = { + 0: '按住 说话', // 按住说话 + 1: '松开 发送', // 松开发送 + 2: '松开 取消', // 松开取消(左滑) + 3: '语音转文字', // 语音转文字(右滑) + }; + bool toolbarEnable = false; // 显示表情/选择区域 + int toolbarIndex = 0; // 0 表情 1 选择 + double keyboardHeight = 157.6; // 键盘高度 + List chooseOptions = [ + {'key': 'photo', 'name': '相册', 'icon': 'assets/images/icon_photo.webp'}, + {'key': 'camera', 'name': '拍摄', 'icon': 'assets/images/icon_camera.webp'}, + {'key': 'location', 'name': '位置', 'icon': 'assets/images/icon_location.webp'}, + {'key': 'redpacket', 'name': '红包', 'icon': 'assets/images/icon_hb.webp'}, + ]; + + // controller监听 + ScrollController chatController = ScrollController(); + ScrollController emojController = ScrollController(); + + // 模拟开红包按钮动画 + late AnimationController animController; + + // 创建一个从 0 到 π 的旋转动画 + late Animation animTurns; + + // 初始化状态 + @override + void initState() { + super.initState(); + final arg = Get.arguments as V2TimConversation; + arguments = arg.obs; + + controller = Get.put(ChatDetailController(userID: arguments.value.userID ?? '')); + + animController = AnimationController( + vsync: this, + duration: Duration(milliseconds: 500), + ); + animTurns = Tween(begin: 0, end: 3.1415926).animate(animController); + + cleanUnRead(); + + getUserId(); + + getMsgData(initFlag: true); + + // 编辑框获取焦点 + editorFocusNode.addListener(() { + if (editorFocusNode.hasFocus) { + setState(() { + toolbarEnable = false; + }); + scrollToBottom(); + } + }); + // 滚动监听 + chatController.addListener(() { + if (_throttleFlag) return; + if (chatController.position.pixels >= chatController.position.maxScrollExtent - 50) { + _throttleFlag = true; + getMsgData().then((_) { + // 解锁 + Future.delayed(Duration(milliseconds: 300), () { + _throttleFlag = false; + }); + }); + } + }); + } + + @override + void dispose() { + if (Get.isRegistered()) { + Get.delete(); + } + // 更新会话列表数据 + Get.find().getConversationList(); + chatController.dispose(); + emojController.dispose(); + editorFocusNode.dispose(); + animController.dispose(); + super.dispose(); + } + + // 设置好友备注 + void setRemark() async { + String remark = ''; + await MyDialog.confirm( + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + TextField( + onChanged: (value) => remark = value, + maxLength: 16, + maxLengthEnforcement: MaxLengthEnforcement.enforced, // 强制不能输入超过 + decoration: InputDecoration( + hintText: '请输入备注', + filled: true, + fillColor: const Color(0xFFF5F5F5), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Color(0xFFBE4EFF), width: 1), + ), + ), + ) + ], + ), + title: '设置备注', + buttonText: '确认', + cancelText: '取消', + onConfirm: () async { + // print('备注为:$remark'); + final res = await ImService.instance.setFriendInfo(userID: arguments.value.userID!, friendRemark: remark); + if (res.success) { + // 刷新会话列表数据 + // Get.find().getConversationList(); + arguments.update((val) { + val?.showName = remark; + }); + } else { + print(res.desc); + print(arguments.value.userID); + MyDialog.toast(res.desc, icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + return true; + }, + ); + } + + void cleanUnRead() async { + if ((arguments.value.unreadCount ?? 0) > 0) { + final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.value.conversationID); + if (!res.success) { + MyDialog.toast(res.desc, icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + } + } + + void getUserId() async { + final idRes = await ImService.instance.selfUserId(); + if (idRes.success) { + selfUserId = idRes.data; + } + } + + Future getMsgData({bool initFlag = false}) async { + if (isLoading || !hasMore) return; // 正在加载 or 没有更多了 + + isLoading = true; + + // 获取最旧一条消息作为游标 + V2TimMessage? lastRealMsg; + for (var msg in controller.chatList.reversed) { + if (msg.localCustomData != 'time_label') { + lastRealMsg = msg; + break; + } + } + final lastMsg = lastRealMsg ?? arguments.value.lastMessage; // 如果找不到,就用传入的参数 + + // final lastMsg = controller.chatList.isNotEmpty ? controller.chatList.last : arguments.lastMessage; + + final res = await ImService.instance.getHistoryMessageList( + userID: arguments.value.userID, + lastMsg: lastMsg, + ); + + if (res.success) { + final newMessages = res.data ?? []; + + if (newMessages.isEmpty) { + hasMore = false; + MyDialog.toast('没有更多了~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + if (initFlag) { + newMessages.insert(0, lastMsg!); + } + controller.updateChatListWithTimeLabels(newMessages); + print('聊天数据加载成功'); + } + } else { + MyDialog.toast("获取聊天记录失败:${res.desc}", icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + + isLoading = false; + } + + // 渲染聊天消息 + List renderChatList() { + List msgtpl = []; + for (var item in controller.chatList) { + // 时间提示,公告提示 + if (item.localCustomData == 'time_label') { + msgtpl.add(Container( + margin: const EdgeInsets.only(bottom: 15.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.customElem!.data ?? '', + style: TextStyle(color: Colors.grey[600], fontSize: 12.0), + ), + ], + ), + )); + } + // 文本消息模板 + else if (item.elemType == 1) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? Color(0xFFFFFFFF) : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + child: RichTextUtil.getRichText(item.textElem?.text ?? '', color: !(item.isSelf ?? false) ? Colors.black : Colors.white), // 可自定义解析emoj/网址/电话 + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // gif表情模板 + else if (item.elemType == 4) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: Container( + constraints: const BoxConstraints( + maxHeight: 100.0, + maxWidth: 100.0, + ), + child: Image.asset('assets/images/emotion/${item.customElem?.data}'), + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 图片模板 + else if (item.elemType == 5) { + List imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? []; + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: ImageGroup( + images: imagePaths, + width: 120, + ), + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 视频模板 + else if (item.elemType == 6) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: SizedBox( + width: 90.0, + child: Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: Image.network( + item.videoElem?.videoUrl ?? '', + ), + ), + const Align( + alignment: Alignment.center, + child: Icon( + Icons.play_circle, + color: Colors.white, + size: 30.0, + ), + ), + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 语音模板 + else if (item.elemType == 7) { + List audiobody = [ + Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? const Color(0xFFFFFFFF) : const Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + constraints: BoxConstraints( + // maxWidth: 120.0, + maxWidth: (item.soundElem?.duration)! / 60 * 230, + ), + child: Row( + mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, + children: !(item.isSelf ?? false) + ? [ + const Icon(Icons.multitrack_audio), + const SizedBox( + width: 5.0, + ), + Text('${item.soundElem?.duration}'), + ] + : [ + Text('${item.soundElem?.duration}'), + const SizedBox( + width: 5.0, + ), + const Icon(Icons.multitrack_audio), + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + const SizedBox( + width: 5.0, + ), + FStyle.badge(0, isdot: true), + ]; + + if (item.isSelf ?? false) { + // 内容反转 + audiobody = audiobody.reversed.toList(); + } else { + audiobody = audiobody; + } + + msgtpl.add(RenderChatItem( + data: item, + child: Row( + mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, + children: audiobody, + ))); + } + // 红包模板 + else if (item.elemType == 0 && item.customElem?.desc == 'hongbao') { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: const Color(0xFFFF7F43), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + constraints: const BoxConstraints( + maxWidth: 210.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + spacing: 10.0, + children: [ + Image.asset( + 'assets/images/hbico.png', + width: 32.0, + fit: BoxFit.contain, + ), + Text(item.customElem?.data ?? '', style: const TextStyle(color: Colors.white, fontSize: 14.0)), + ], + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 10.0), + padding: const EdgeInsets.symmetric(vertical: 5.0), + width: double.infinity, + decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.white30, width: .5))), + child: const Text( + '拼手气红包', + style: TextStyle(color: Colors.white70, fontSize: 11.0), + ), + ), + ], + ), + ), + onTap: () { + receiveRedPacketDialog(item); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 位置模板 + else if (item.elemType == 9) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + // splashColor: Colors.transparent, + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + constraints: const BoxConstraints( + maxWidth: 210.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, + vertical: 5.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.locationElem?.desc ?? '位置信息异常', + overflow: TextOverflow.ellipsis, + ), + Text( + "${item.locationElem?.latitude},${item.locationElem?.longitude}", + style: const TextStyle(color: Colors.grey, fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ClipRRect( + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(10.0)), + child: Image.asset('assets/images/map.jpg', width: 210.0, height: 70.0, fit: BoxFit.cover), + ) + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + } + return msgtpl; + } + + // 表情列表集合 + List renderEmojWidget() { + return [ + // Tab切换 + Container( + padding: const EdgeInsets.symmetric(horizontal: 5.0), + child: Row( + children: emoJson.map((item) { + return InkWell( + child: Container( + margin: const EdgeInsets.all(5.0), + alignment: Alignment.center, + height: 40.0, + width: 40.0, + decoration: BoxDecoration(color: item['selected'] ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(5.0)), + child: item['index'] == 0 + ? Text( + item['pathLabel'], + style: const TextStyle(fontSize: 22.0), + ) + : Image.asset(item['pathLabel'], height: 24.0, width: 24.0, fit: BoxFit.cover), + ), + onTap: () { + handleEmojTab(item['index']); + }, + ); + }).toList(), + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.grey[200], + border: const Border(top: BorderSide(color: Colors.black54, width: .1)), + ), + child: ListView( + controller: emojController, + padding: const EdgeInsets.all(10.0), + children: emoJson.map((item) { + return Visibility( + visible: item['selected'], + child: GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + // 横轴元素个数 + crossAxisCount: item['type'] == 'emoj' ? 8 : 5, + // 纵轴间距 + mainAxisSpacing: 5.0, + // 横轴间距 + crossAxisSpacing: 5.0, + // 子组件宽高比例 + childAspectRatio: 1, + ), + children: item['nodes'].map((emoj) { + if (item['type'] == 'emoj') { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(5.0), + child: Container( + alignment: Alignment.center, + height: 40.0, + width: 40.0, + child: Text( + emoj, + style: const TextStyle(fontSize: 24.0), + ), + ), + onTap: () { + handleEmojClick(emoj); + }, + ), + ); + } else { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(5.0), + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(5.0), + height: 68.0, + width: 68.0, + child: Image.asset(emoj), + ), + onTap: () { + handleGIFClick(emoj); + }, + ), + ); + } + }).toList(), + ), + ); + }).toList(), + ), + ), + ), + ]; + } + + // 选择功能列表 + List renderChooseWidget() { + return [ + Expanded( + child: Container( + padding: const EdgeInsets.fromLTRB(30.0, 35.0, 30.0, 15.0), + decoration: BoxDecoration( + color: Colors.grey[200], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + // 横轴元素个数 + crossAxisCount: 4, + // 纵轴间距 + mainAxisSpacing: 30.0, + // 横轴间距 + crossAxisSpacing: 25.0, + // 子组件宽高比例 + childAspectRatio: .8, + ), + children: chooseOptions.map((item) { + return Column( + children: [ + Expanded( + child: Material( + type: MaterialType.transparency, + child: Ink( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + ), + child: InkWell( + borderRadius: BorderRadius.circular(15.0), + child: Image.asset(item['icon'], height: 40.0, fit: BoxFit.cover), + onTap: () { + handleChooseAction(item['key']); + }, + ), + ), + ), + ), + const SizedBox(height: 5.0), + Text( + item['name'], + style: const TextStyle(color: Colors.black87, fontSize: 12.0), + ) + ], + ); + }).toList(), + ), + ), + ), + ]; + } + + /* ---------- { 聊天消息模块 } ---------- */ + // 聊天消息滚动到底部 + // void scrollToBottom() async { + // chatList = await fetchChatList(); + // chatController.animateTo(isNeedScrollBottom ? 0 : chatController.position.maxScrollExtent, + // duration: const Duration(milliseconds: 200), curve: Curves.easeIn); + // } + void scrollToBottom() { + Future.delayed(Duration(milliseconds: 100), () { + if (chatController.hasClients) { + chatController.animateTo( + 0, // reverse: true 时滚动到底部是 offset: 0 + duration: Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); + } + + // 点击消息区域 + void handleClickChatArea() { + hideKeyboard(); + setState(() { + toolbarEnable = false; + }); + } + + /* ---------- { 底部Toolbar模块 } ---------- */ + // 光标处插入内容 + void insertTextAtCursor(String html) { + var editorNotifier = editorController.value; // The current value stored in this notifier. + var start = editorNotifier.selection.baseOffset; + var end = editorNotifier.selection.extentOffset; + if (editorNotifier.selection.isValid) { + String newText = ''; + if (editorNotifier.selection.isCollapsed) { + if (end > 0) { + newText += editorNotifier.text.substring(0, end); + } + newText += html; + if (editorNotifier.text.length > end) { + newText += editorNotifier.text.substring(end, editorNotifier.text.length); + } + } else { + newText = editorNotifier.text.replaceRange(start, end, html); + end = start; + } + editorController.value = + editorNotifier.copyWith(text: newText, selection: editorNotifier.selection.copyWith(baseOffset: end + html.length, extentOffset: end + html.length)); + } else { + editorController.value = TextEditingValue( + text: html, + selection: TextSelection.fromPosition(TextPosition(offset: html.length)), + ); + } + } + + // 发送消息队列 + void sendMessage(message) async { + // 待插入的消息 + List messagesToInsert = []; + V2TimMessage? lastRealMsg; + + for (var msg in controller.chatList) { + if (msg.localCustomData != 'time_label') { + lastRealMsg = msg; + break; + } + } + + // 如果有数据,检测时间,是否需要插入伪消息 + if (lastRealMsg != null && + needInsertTimeLabel( + (lastRealMsg.timestamp ?? 0) * 1000, // 转为毫秒级 + DateTime.now().millisecondsSinceEpoch, + )) { + // 消息时间间隔超过3分钟插入伪消息 + final showLabel = Utils().formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000); + final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId); + messagesToInsert.add(resMsg.data); + } else { + // 没数据的时候直接插入伪消息 + final showLabel = Utils().formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000); + + final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId); + messagesToInsert.add(resMsg.data); + } + + // 不需要时间标签 + // final res = await IMMessage().sendText( + // text: message['content'], + // toUserID: arguments.value.userID, + // ); + + // if (res.success && res.data != null) { + // messagesToInsert.insert(0, res.data); // 加入消息本体 + + // controller.chatList.insertAll(0, messagesToInsert); + + // scrollToBottom(); + // print('发送成功'); + // } else { + // print('消息发送失败: ${res.code} - ${res.desc}'); + // } + } + + bool needInsertTimeLabel(int lastTimestamp, int newTimestamp, {int interval = 3 * 60}) { + return (newTimestamp - lastTimestamp) > interval * 1000; + } + + // 隐藏键盘 + void hideKeyboard() { + if (editorFocusNode.hasFocus) { + editorFocusNode.unfocus(); + } + } + + // 表情/选择切换 + void handleEmojChooseState(index) { + hideKeyboard(); + setState(() { + toolbarEnable = true; + toolbarIndex = index; + voiceBtnEnable = false; + }); + scrollToBottom(); + } + + // 表情Tab切换 + void handleEmojTab(index) { + var emols = emoJson; + for (var i = 0, len = emols.length; i < len; i++) { + emols[i]['selected'] = false; + } + emols[index]['selected'] = true; + setState(() { + emoJson = emols; + }); + emojController.jumpTo(0); + } + + // 点击表情 + void handleEmojClick(emoj) { + insertTextAtCursor(emoj); + } + + // 点击Gif大图 + void handleGIFClick(gifpath) { + // 消息队列 + Map message = { + 'id': Utils.uuid(), + 'contentType': 4, + 'isme': true, + 'avatar': 'assets/images/avatar/img11.jpg', + 'author': 'Andy', + 'content': '', + 'image': gifpath, + 'video': '', + }; + sendMessage(message); + } + + // 提交消息 + void handleSubmit() { + if (editorController.text.isEmpty) return; + // 消息队列 + Map message = { + 'id': Utils.uuid(), + 'contentType': 3, + 'isme': true, + 'avatar': 'assets/images/avatar/img11.jpg', + 'author': 'Andy', + 'content': editorController.text, + 'image': '', + 'video': '', + }; + sendMessage(message); + editorController.clear(); + } + + // 选择区操作 + void handleChooseAction(key) { + MyDialog.toast('$key'); + switch (key) { + case 'photo': + // .... + break; + case 'camera': + // .... + break; + case 'redpacket': + sendRedPacketDialog(); + break; + } + } + + /* ---------- { 弹窗功能模块 } ---------- */ + // 红包弹窗 + void receiveRedPacketDialog(data) { + showDialog( + context: context, + builder: (context) { + return Material( + type: MaterialType.transparency, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 50.0), + padding: const EdgeInsets.symmetric(vertical: 50.0), + decoration: const BoxDecoration( + color: Color(0xFFFF7F43), + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5.0), + child: Image.asset(data['avatar'], height: 40.0, width: 40.0, fit: BoxFit.cover), + ), + const SizedBox( + height: 5.0, + ), + Text( + data['author'], + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w600), + ), + Text( + data['content'], + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), + ), + SizedBox( + height: 100.0, + ), + AnimatedBuilder( + animation: animTurns, + builder: (context, child) { + return Transform( + transform: Matrix4.rotationY(animTurns.value), + alignment: Alignment.center, + child: FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(const Color(0xFFFFF9C7)), + padding: WidgetStateProperty.all(EdgeInsets.zero), + minimumSize: WidgetStateProperty.all(const Size(80.0, 80.0)), + shape: WidgetStateProperty.all(const CircleBorder()), + elevation: WidgetStateProperty.all(3.0), + ), + child: Text( + '開', + style: TextStyle(color: Color(0xFF3B3B3B), fontSize: 28.0), + ), + onPressed: () { + // 开始动画 + animController.repeat(); + // 模拟开红包逻辑,1 秒后停止动画 + Future.delayed(Duration(seconds: 1), () { + animController.stop(); + animController.reset(); + Get.back(); + }); + }, + ), + ); + }, + ), + ], + ), + ), + GestureDetector( + child: Container( + margin: const EdgeInsets.only(top: 20.0), + height: 30.0, + width: 30.0, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 1.5), + borderRadius: BorderRadius.circular(50.0), + ), + child: const Icon( + Icons.close_outlined, + color: Colors.white, + size: 18.0, + ), + ), + onTap: () { + Navigator.of(context).pop(); + }, + ) + ], + )); + }); + } + + // 长按消息菜单 + void contextMenuDialog() { + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + backgroundColor: Colors.white, + surfaceTintColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 5.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), + children: [ + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('复制')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('发送给朋友')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('收藏')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('删除')), + onPressed: () {}, + ), + ], + ); + }, + ); + } + + // 发群红包弹窗 + void sendRedPacketDialog() { + showModalBottomSheet( + backgroundColor: Colors.grey[50], + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))), + showDragHandle: true, + clipBehavior: Clip.hardEdge, + isScrollControlled: true, // 屏幕最大高度 + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height - 180, // 自定义最大高度 + ), + context: context, + builder: (context) { + return RedPacket( + flag: true, + ); + }, + ); + } + + /* ---------- { 其它功能模块 } ---------- */ + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + // 页面主体(聊天消息区/底部操作区) + Scaffold( + backgroundColor: Colors.grey[200], + appBar: AppBar( + centerTitle: true, + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios_rounded, + size: 20.0, + ), + onPressed: () { + Get.back(); + }, + ), + titleSpacing: 1.0, + title: Obx(() { + return Text( + // '${arguments['title']}', + '${arguments.value.showName}', + style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'), + ); + }), + flexibleSpace: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFBE4EFF), Color(0xFF1DFFC7)], + )), + ), + actions: [ + IconButton( + icon: const Icon( + Icons.more_horiz, + color: Colors.white, + ), + onPressed: () async { + final paddingTop = MediaQuery.of(Get.context!).padding.top; + + final selected = await showMenu( + context: Get.context!, + position: RelativeRect.fromLTRB( + double.infinity, + kToolbarHeight + paddingTop - 12, + 8, + double.infinity, + ), + color: FStyle.primaryColor, + elevation: 8, + items: [ + PopupMenuItem( + value: 'remark', + child: Row( + children: [ + Icon(Icons.edit, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设置备注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'not', + child: Row( + children: [ + Icon(Icons.do_not_disturb_on, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设为免打扰', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'report', + child: Row( + children: [ + Icon(Icons.report, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '举报', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'block', + child: Row( + children: [ + Icon(Icons.block, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '拉黑', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'foucs', + child: Row( + children: [ + Icon(Icons.person_remove_alt_1, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '取消关注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ); + + if (selected != null) { + switch (selected) { + case 'remark': + print('点击了备注'); + setRemark(); + break; + case 'not': + print('点击了免打扰'); + break; + case 'report': + print('点击了举报'); + break; + case 'block': + print('点击了拉黑'); + break; + case 'foucs': + print('点击了取关'); + break; + } + } + }, + ), + ], + ), + body: Flex( + direction: Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 渲染聊天消息 + Expanded( + child: ScrollConfiguration( + behavior: CustomScrollBehavior(), + child: GestureDetector( + child: Obx(() { + return ListView( + controller: chatController, + reverse: true, + padding: const EdgeInsets.all(10.0), + children: renderChatList(), + ); + }), + onTap: () { + handleClickChatArea(); + }, + ), + ), + ), + + // 底部操作栏 + Container( + color: Colors.grey[100], + child: SafeArea( + bottom: true, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[100], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: Column( + children: [ + // 输入框编辑器模块 + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + children: [ + InkWell( + child: Icon( + voiceBtnEnable ? Icons.keyboard_outlined : Icons.contactless_outlined, + color: const Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + setState(() { + toolbarEnable = false; + if (voiceBtnEnable) { + voiceBtnEnable = false; + editorFocusNode.requestFocus(); + } else { + voiceBtnEnable = true; + editorFocusNode.unfocus(); + } + }); + }, + ), + const SizedBox( + width: 10.0, + ), + Expanded( + child: Container( + constraints: const BoxConstraints(minHeight: 40.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + child: Stack( + children: [ + // 输入框 + Offstage( + offstage: voiceBtnEnable, + child: TextField( + decoration: const InputDecoration( + isDense: true, + hoverColor: Colors.transparent, + contentPadding: EdgeInsets.all(8.0), + border: OutlineInputBorder(borderSide: BorderSide.none), + ), + style: const TextStyle( + fontSize: 16.0, + ), + maxLines: null, + controller: editorController, + focusNode: editorFocusNode, + cursorColor: const Color(0xFF07C160), + onChanged: (value) {}, + ), + ), + // 语音 + Offstage( + offstage: !voiceBtnEnable, + child: GestureDetector( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + alignment: Alignment.center, + height: 40.0, + width: double.infinity, + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(fontSize: 15.0), + ), + ), + onPanStart: (details) { + setState(() { + voiceType = 1; + voicePanelEnable = true; + }); + }, + onPanUpdate: (details) { + Offset pos = details.globalPosition; + double swipeY = MediaQuery.of(context).size.height - 120; + double swipeX = MediaQuery.of(context).size.width / 2 + 50; + setState(() { + if (pos.dy >= swipeY) { + voiceType = 1; // 松开发送 + } else if (pos.dy < swipeY && pos.dx < swipeX) { + voiceType = 2; // 左滑松开取消 + } else if (pos.dy < swipeY && pos.dx >= swipeX) { + voiceType = 3; // 右滑语音转文字 + } + }); + }, + onPanEnd: (details) { + // print('停止录音'); + setState(() { + switch (voiceType) { + case 1: + MyDialog.toast('发送录音文件'); + voicePanelEnable = false; + break; + case 2: + MyDialog.toast('取消发送'); + voicePanelEnable = false; + break; + case 3: + MyDialog.toast('语音转文字'); + voicePanelEnable = true; + voiceToTransfer = true; + break; + } + voiceType = 0; + }); + }, + ), + ), + ], + ), + ), + ), + const SizedBox( + width: 10.0, + ), + InkWell( + child: const Icon( + Icons.add_reaction_rounded, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(0); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: const Icon( + Icons.add, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(1); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: Container( + height: 25.0, + width: 25.0, + decoration: BoxDecoration( + color: const Color(0xFF07C160), + borderRadius: BorderRadius.circular(20.0), + ), + child: const Icon( + Icons.arrow_upward, + color: Colors.white, + size: 20.0, + ), + ), + onTap: () { + handleSubmit(); + }, + ), + ], + ), + ), + + // 表情+选择模块 + Visibility( + visible: toolbarEnable, + child: SizedBox( + height: keyboardHeight, + child: Column( + children: toolbarIndex == 0 ? renderEmojWidget() : renderChooseWidget(), + ), + ), + ) + ], + ), + ), + )) + ], + ), + ), + // 录音主体(按住说话/松开取消/语音转文本) + IgnorePointer( + ignoring: false, + child: Visibility( + visible: voicePanelEnable, + child: Material( + color: const Color(0xDD1B1B1B), + child: Stack( + children: [ + // 取消发送+语音转文字 + Positioned( + bottom: 120, + left: 30, + right: 30, + child: Visibility( + visible: !voiceToTransfer, + child: Column( + crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center, + children: [ + // 语音动画层 + Stack( + alignment: Alignment.bottomCenter, + children: [ + AnimatedContainer( + duration: Duration(milliseconds: 200), + height: 70.0, + width: voiceType == 2 ? 70.0 : 200.0, + decoration: BoxDecoration( + color: voiceType == 2 ? Colors.red : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(15.0), + ), + clipBehavior: Clip.antiAlias, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset('assets/images/voice_waves.gif', height: 23.0, width: voiceType == 2 ? 30.0 : 70.0, fit: BoxFit.cover) + ], + ), + ), + RotatedBox( + quarterTurns: 0, + child: CustomPaint(painter: ArrowShape(arrowColor: voiceType == 2 ? Colors.red : Color(0xFF89E45B), arrowSize: 10.0)), + ) + ], + ), + const SizedBox( + height: 50.0, + ), + // 操作项 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 取消发送 + Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: voiceType == 2 ? Colors.red : Colors.black38, + ), + child: Icon( + Icons.close, + color: Colors.white54, + ), + ), + // 语音转文字 + Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: voiceType == 3 ? Color(0xFF89E45B) : Colors.black38, + ), + child: Icon( + Icons.translate, + color: Colors.white54, + ), + ), + ], + ), + ], + ), + ), + ), + // 语音转文字(识别结果状态) + Positioned( + bottom: 120, + left: 30, + right: 30, + child: Visibility( + visible: voiceToTransfer, + child: Column( + children: [ + // 提示结果 + Stack( + children: [ + Container( + height: 100.0, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(15.0), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outlined, + color: Colors.white, + ), + Text( + '未识别到文字。', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + Positioned( + right: 35.0, + bottom: 1, + child: RotatedBox( + quarterTurns: 0, + child: CustomPaint(painter: ArrowShape(arrowColor: Colors.red, arrowSize: 10.0)), + )), + ], + ), + const SizedBox( + height: 50.0, + ), + // 操作项 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + child: Container( + height: 60.0, + width: 60.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.undo, + color: Colors.white54, + ), + Text( + '取消', + style: TextStyle(color: Colors.white70), + ) + ], + ), + ), + onTap: () { + setState(() { + voicePanelEnable = false; + voiceToTransfer = false; + }); + }, + ), + GestureDetector( + child: Container( + height: 60.0, + width: 100.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.graphic_eq_rounded, + color: Colors.white54, + ), + Text( + '发送原语音', + style: TextStyle(color: Colors.white70), + ) + ], + ), + ), + onTap: () {}, + ), + GestureDetector( + child: Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: Colors.white12, + ), + child: const Icon( + Icons.check, + color: Colors.white12, + ), + ), + onTap: () {}, + ), + ], + ), + ], + ), + ), + ), + // 提示文字(操作状态) + Positioned( + bottom: 120, + left: 0, + width: MediaQuery.of(context).size.width, + child: Visibility( + visible: !voiceToTransfer, + child: Align( + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(color: Colors.white70), + ), + ), + ), + ), + // 背景 + Align( + alignment: Alignment.bottomCenter, + child: Visibility( + visible: !voiceToTransfer, + child: Image.asset('assets/images/voice_bg.webp', width: double.infinity, height: 100.0, fit: BoxFit.fill), + ), + ), + // 背景图标 + Positioned( + bottom: 25, + left: 0, + width: MediaQuery.of(context).size.width, + child: Visibility( + visible: !voiceToTransfer, + child: const Align( + child: Icon( + Icons.graphic_eq_rounded, + color: Colors.black54, + ), + ), + ), + ), + ], + ), + ), + ), + ) + ], + ); + } +} + +// 渲染聊天消息公共部分 +class RenderChatItem extends StatelessWidget { + const RenderChatItem({ + super.key, + required this.data, + required this.child, + }); + final V2TimMessage data; // 消息数据 + final Widget? child; // 消息体 + + // 设置箭头颜色 + // Color arrowColor(data) { + // Color color = Colors.transparent; + // if ([8].contains(data.elemType)) { + // // 红包箭头颜色 + // color = const Color(0xFFFFA52F); + // } else if ([9].contains(data.elemType)) { + // // 位置箭头颜色 + // color = const Color(0xFFFFFFFF); + // } else { + // color = !data['isme'] ? const Color(0xFFFFFFFF) : const Color(0xFF9543FF); + // } + // return color; + // } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + !(data.isSelf ?? false) + ? SizedBox( + height: 35.0, + width: 35.0, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + child: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), + ), + ) + : const SizedBox.shrink(), + Expanded( + child: Padding( + padding: !(data.isSelf ?? false) ? const EdgeInsets.only(left: 10.0, right: 40.0) : const EdgeInsets.only(left: 40.0, right: 10.0), + child: Column( + crossAxisAlignment: !(data.isSelf ?? false) ? CrossAxisAlignment.start : CrossAxisAlignment.end, + children: [ + Text( + data.friendRemark ?? data.nameCard ?? data.nickName ?? '未知昵称', + style: const TextStyle(color: Colors.grey, fontSize: 12.0), + ), + const SizedBox( + height: 3.0, + ), + Stack( + children: [ + // 气泡箭头 + /* Visibility( + // 显示箭头(消息+语音+红包+位置) + visible: [3, 7, 8, 9].contains(data['contentType']), + child: Positioned( + left: !data['isme'] ? 1 : null, + right: data['isme'] ? 1 : null, + top: 20.0, + child: RotatedBox( + quarterTurns: !data['isme'] ? 1 : -1, + child: CustomPaint(painter: ArrowShape(arrowColor: arrowColor(data))), + ) + ), + ), */ + Container( + child: child, + ), + ], + ), + ], + ), + ), + ), + data.isSelf ?? false + ? SizedBox( + height: 35.0, + width: 35.0, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + child: Image.network(data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png'), + ), + ) + : const SizedBox.shrink(), + ], + ), + ); + } +} + +// 绘制气泡箭头 +class ArrowShape extends CustomPainter { + ArrowShape({ + required this.arrowColor, + this.arrowSize = 7, + }); + + final Color arrowColor; // 箭头颜色 + final double arrowSize; // 箭头大小 + + @override + void paint(Canvas canvas, Size size) { + var paint = Paint()..color = arrowColor; + + var path = Path(); + path.lineTo(-arrowSize, 0); + path.lineTo(0, arrowSize); + path.lineTo(arrowSize, 0); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return false; + } +} diff --git a/lib/pages/chat/chat_no_friend.dart b/lib/pages/chat/chat_no_friend.dart new file mode 100644 index 0000000..d57b3cb --- /dev/null +++ b/lib/pages/chat/chat_no_friend.dart @@ -0,0 +1,2052 @@ +/// 聊天模板 +library; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_detail_controller.dart'; +import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_result.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/components/preview_video.dart'; +import 'package:loopin/models/conversation_type.dart'; +import 'package:loopin/utils/snapshot.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/enum/friend_type_enum.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; + +import '../../styles/index.dart'; +import '../../utils/index.dart'; +import './components/redpacket.dart'; +import './components/richtext.dart'; +// import 'mock/chat_json.dart'; +import 'mock/emoj_json.dart'; + +class ChatNoFriend extends StatefulWidget { + const ChatNoFriend({super.key}); + + @override + State createState() => _ChatNoFriendState(); +} + +class _ChatNoFriendState extends State with SingleTickerProviderStateMixin { + late final ChatDetailController controller; + // 接收参数 + late final Rx arguments; + + late String selfUserId; + + // 聊天消息模块 + final bool isNeedScrollBottom = true; + + bool isLoading = false; // 是否在加载中 + bool hasMore = true; // 是否还有更多数据 + final RxBool _throttleFlag = false.obs; // 滚动节流锁 + + // 表情json + List emoJson = emotionData; + + // 底部操作栏模块 + TextEditingController editorController = TextEditingController(); + FocusNode editorFocusNode = FocusNode(); + bool voiceBtnEnable = false; // 语音按钮 + bool voicePanelEnable = false; // 语音操作面板 + bool voiceToTransfer = false; // 语音转文字中 + int voiceType = 0; // 语音操作类型 + Map voiceTypeMap = { + 0: '按住 说话', // 按住说话 + 1: '松开 发送', // 松开发送 + 2: '松开 取消', // 松开取消(左滑) + 3: '语音转文字', // 语音转文字(右滑) + }; + bool toolbarEnable = false; // 显示表情/选择区域 + int toolbarIndex = 0; // 0 表情 1 选择 + double keyboardHeight = 157.6; // 键盘高度 + List chooseOptions = [ + {'key': 'photo', 'name': '相册', 'icon': 'assets/images/icon_photo.webp'}, + {'key': 'camera', 'name': '拍摄', 'icon': 'assets/images/icon_camera.webp'}, + {'key': 'location', 'name': '位置', 'icon': 'assets/images/icon_location.webp'}, + {'key': 'redpacket', 'name': '红包', 'icon': 'assets/images/icon_hb.webp'}, + ]; + + // controller监听 + // ScrollController chatController = ScrollController(); + late ScrollController chatController; + + ScrollController emojController = ScrollController(); + + // 模拟开红包按钮动画 + late AnimationController animController; + + // 创建一个从 0 到 π 的旋转动画 + late Animation animTurns; + + // 初始化状态 + @override + void initState() { + super.initState(); + final arg = Get.arguments as V2TimConversation; + arguments = arg.obs; + controller = Get.find(); + chatController = controller.chatController; + + animController = AnimationController( + vsync: this, + duration: Duration(milliseconds: 500), + ); + animTurns = Tween(begin: 0, end: 3.1415926).animate(animController); + + cleanUnRead(); + + getUserId(); + + isMyFriend(); + + getMsgData(initFlag: true); + + // 编辑框获取焦点 + editorFocusNode.addListener(() { + if (editorFocusNode.hasFocus) { + setState(() { + toolbarEnable = false; + }); + controller.scrollToBottom(); + } + }); + // 滚动监听 + // Future.delayed(Duration(milliseconds: 1000), () { + + // }); + chatController.addListener(() { + if (_throttleFlag.value) return; + if (chatController.position.pixels >= chatController.position.maxScrollExtent - 50) { + // if (chatController.position.pixels <= 50) { + _throttleFlag.value = true; + getMsgData().then((_) { + // 解锁 + Future.delayed(Duration(milliseconds: 1000), () { + _throttleFlag.value = false; + }); + }); + } + }); + } + + @override + void dispose() { + if (Get.isRegistered()) { + Get.delete(); + } + emojController.dispose(); + editorFocusNode.dispose(); + animController.dispose(); + super.dispose(); + } + + // 设置好友备注 + void setRemark() async { + print('不支持'); + } + + void isMyFriend() async { + final isFriendId = arguments.value.userID; + final isfd = await ImService.instance.isMyFriend(isFriendId!, FriendTypeEnum.V2TIM_FRIEND_TYPE_BOTH); + if (isfd.success) { + controller.isFriend.value = isfd.data; + print(isfd.data); + } else { + controller.isFriend.value = false; + print(isfd.desc); + } + } + + bool checkSend() { + if (controller.isFriend.value) { + // 是好友直接发 + return true; + } else { + // 不是好友 + if (arguments.value.lastMessage != null) { + // 最后一条消息是否我发的 + // final isSelf = arguments.value.lastMessage!.isSelf; + final isSelf = controller.chatList.first.isSelf; + return isSelf == true ? false : true; + } else { + return true; + } + } + } + + void cleanUnRead() async { + if ((arguments.value.unreadCount ?? 0) > 0) { + final res = await ImService.instance.clearConversationUnreadCount(conversationID: arguments.value.conversationID); + if (!res.success) { + MyDialog.toast('清理未读异常:${res.desc}', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + print('清理未读异常:${res.desc}'); + } + } + } + + void getUserId() async { + final idRes = await ImService.instance.selfUserId(); + if (idRes.success) { + selfUserId = idRes.data; + } + } + + Future getMsgData({bool initFlag = false}) async { + if (isLoading || !hasMore) return; // 正在加载 or 没有更多了 + + isLoading = true; + + // 获取最旧一条消息作为游标 + V2TimMessage? lastRealMsg; + // for (var msg in controller.chatList.reversed) { + for (var msg in controller.chatList.reversed) { + if (msg.localCustomData != 'time_label') { + lastRealMsg = msg; + break; + } + } + final lastMsg = lastRealMsg ?? arguments.value.lastMessage; // 如果找不到,就用传入的参数 + // print(lastMsg?.toJson()); + + // final lastMsg = controller.chatList.isNotEmpty ? controller.chatList.last : arguments.lastMessage; + + final res = await ImService.instance.getHistoryMessageList( + userID: arguments.value.userID, + lastMsg: lastMsg, + ); + + if (res.success) { + final newMessages = res.data ?? []; + + if (newMessages.isEmpty) { + hasMore = false; + // MyDialog.toast('没有更多了~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + if (initFlag && lastMsg != null) { + newMessages.insert(0, lastMsg); + // controller.scrollToBottom(); + } + controller.updateChatListWithTimeLabels(newMessages); + if (initFlag) { + // 初始化时滚到最底部 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (chatController.hasClients) { + // controller.scrollToBottom(); + // final bottomPadding = MediaQuery.of(context).padding.bottom; // 底部安全区域高度 + // chatController.jumpTo(chatController.position.maxScrollExtent); // 60为底部操作栏高度 + chatController.jumpTo(0); + } + }); + } + + print('聊天数据加载成功'); + } else { + MyDialog.toast("获取聊天记录失败:${res.desc}", icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + + isLoading = false; + } + + // 渲染聊天消息 + List renderChatList() { + List msgtpl = []; + for (var item in controller.chatList) { + // 时间提示,公告提示 + if (item.localCustomData == 'time_label') { + msgtpl.add(Container( + margin: const EdgeInsets.only(bottom: 15.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.customElem!.data ?? '', + style: TextStyle(color: Colors.grey[600], fontSize: 12.0), + ), + ], + ), + )); + } + // 文本消息模板=1 + else if (item.elemType == 1) { + msgtpl.add( + RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? Color(0xFFFFFFFF) : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + child: RichTextUtil.getRichText(item.textElem?.text ?? '', color: !(item.isSelf ?? false) ? Colors.black : Colors.white), // 可自定义解析emoj/网址/电话 + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + ), + ); + } + // gif表情模板=8 + else if (item.elemType == 8) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: Container( + constraints: const BoxConstraints( + maxHeight: 100.0, + maxWidth: 100.0, + ), + // child: Image.asset('assets/images/emotion/${item.faceElem?.data}'), + child: Image.asset('${item.faceElem?.data}'), + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 图片模板=3 + else if (item.elemType == 3) { + // List imagePaths = item.imageElem?.imageList?.where((e) => e != null && e.url != null).map((e) => e!.url!).toList() ?? []; + final originImage = item.imageElem?.imageList?.firstWhere((e) => e?.type == 0 && e?.url != null, orElse: () => null); + List imagePaths = originImage != null ? [originImage.url!] : []; + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: ClipRRect( + borderRadius: BorderRadius.circular(10.0), + // child: ImageGroup( + // images: imagePaths, + // width: 120, + // ), + child: Image.network( + imagePaths.first, + width: 120, + fit: BoxFit.cover, + loadingBuilder: (context, child, loadingProgress) { + if (loadingProgress == null) { + // controller.scrollToBottom(); + return child; // 加载完成,显示图片 + } + return Container( + width: 120, + height: 240, + color: Colors.grey[300], + alignment: Alignment.center, + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, + ), + ); + }, + errorBuilder: (context, error, stackTrace) { + return Container( + color: Colors.grey[300], + alignment: Alignment.center, + child: Icon(Icons.broken_image, color: Colors.grey, size: 40), + ); + }, + ), + ), + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 视频模板=5 + else if (item.elemType == 5) { + // print(item.videoElem!.toLogString()); + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + child: SizedBox( + width: 120.0, + child: Stack( + alignment: Alignment.center, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(10.0), + child: Image.network( + fit: BoxFit.cover, + item.videoElem?.snapshotUrl ?? '', + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/pic1.jpg', + height: 60.0, + width: 60.0, + fit: BoxFit.cover, + ); + }, + ), + ), + const Align( + alignment: Alignment.center, + child: Icon( + Icons.play_circle, + color: Colors.white, + size: 30.0, + ), + ), + ], + ), + ), + // onTap: () { + // MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + // }, + onTap: () { + showGeneralDialog( + context: context, + // barrierDismissible: true, + barrierColor: Colors.black.withAlpha((1.0 * 255).round()), + pageBuilder: (_, __, ___) { + return SafeArea( + child: PreviewVideo( + videoUrl: item.videoElem?.videoUrl ?? '', + width: item.videoElem?.snapshotWidth?.toDouble(), + height: item.videoElem?.snapshotHeight?.toDouble(), + ), + ); + }, + transitionBuilder: (_, anim, __, child) { + return FadeTransition(opacity: anim, child: child); + }, + transitionDuration: const Duration(milliseconds: 200), + ); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 语音模板=4 + else if (item.elemType == 4) { + List audiobody = [ + Ink( + decoration: BoxDecoration( + color: !(item.isSelf ?? false) ? const Color(0xFFFFFFFF) : const Color(0xFF89E45B), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + padding: const EdgeInsets.all(10.0), + constraints: BoxConstraints( + // maxWidth: 120.0, + maxWidth: (item.soundElem?.duration)! / 60 * 230, + ), + child: Row( + mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, + children: !(item.isSelf ?? false) + ? [ + const Icon(Icons.multitrack_audio), + const SizedBox( + width: 5.0, + ), + Text('${item.soundElem?.duration}'), + ] + : [ + Text('${item.soundElem?.duration}'), + const SizedBox( + width: 5.0, + ), + const Icon(Icons.multitrack_audio), + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + const SizedBox( + width: 5.0, + ), + FStyle.badge(0, isdot: true), + ]; + + if (item.isSelf ?? false) { + // 内容反转 + audiobody = audiobody.reversed.toList(); + } else { + audiobody = audiobody; + } + + msgtpl.add(RenderChatItem( + data: item, + child: Row( + mainAxisAlignment: !(item.isSelf ?? false) ? MainAxisAlignment.start : MainAxisAlignment.end, + children: audiobody, + ))); + } + // 红包模板=自定义=2; + else if (item.elemType == 2 && item.cloudCustomData == 'hongbao') { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: const Color(0xFFFF7F43), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + constraints: const BoxConstraints( + maxWidth: 210.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + spacing: 10.0, + children: [ + Image.asset( + 'assets/images/hbico.png', + width: 32.0, + fit: BoxFit.contain, + ), + Text(item.customElem?.data ?? '', style: const TextStyle(color: Colors.white, fontSize: 14.0)), + ], + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 10.0), + padding: const EdgeInsets.symmetric(vertical: 5.0), + width: double.infinity, + decoration: const BoxDecoration(border: Border(top: BorderSide(color: Colors.white30, width: .5))), + child: const Text( + '拼手气红包', + style: TextStyle(color: Colors.white70, fontSize: 11.0), + ), + ), + ], + ), + ), + onTap: () { + receiveRedPacketDialog(item); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + // 位置模板=7 + else if (item.elemType == 7) { + msgtpl.add(RenderChatItem( + data: item, + child: Ink( + decoration: BoxDecoration( + color: const Color(0xFFFFFFFF), + borderRadius: BorderRadius.circular(10.0), + ), + child: InkWell( + // splashColor: Colors.transparent, + overlayColor: WidgetStateProperty.all(Colors.transparent), + borderRadius: BorderRadius.circular(10.0), + child: Container( + constraints: const BoxConstraints( + maxWidth: 210.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10.0, + vertical: 5.0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.locationElem?.desc ?? '位置信息异常', + overflow: TextOverflow.ellipsis, + ), + Text( + "${item.locationElem?.latitude},${item.locationElem?.longitude}", + style: const TextStyle(color: Colors.grey, fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ClipRRect( + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(10.0)), + child: Image.asset('assets/images/map.jpg', width: 210.0, height: 70.0, fit: BoxFit.cover), + ) + ], + ), + ), + onTap: () { + MyDialog.toast('该功能暂未支持~', icon: Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + }, + onLongPress: () { + contextMenuDialog(); + }, + ), + ), + )); + } + } + // msgtpl.insert( + // 0, + // SizedBox.shrink(), + // ); + return msgtpl; + } + + // 表情列表集合 + List renderEmojWidget() { + return [ + // Tab切换 + Container( + padding: const EdgeInsets.symmetric(horizontal: 5.0), + child: Row( + children: emoJson.map((item) { + return InkWell( + child: Container( + margin: const EdgeInsets.all(5.0), + alignment: Alignment.center, + height: 40.0, + width: 40.0, + decoration: BoxDecoration(color: item['selected'] ? Colors.white : Colors.transparent, borderRadius: BorderRadius.circular(5.0)), + child: item['index'] == 0 + ? Text( + item['pathLabel'], + style: const TextStyle(fontSize: 22.0), + ) + : Image.asset(item['pathLabel'], height: 24.0, width: 24.0, fit: BoxFit.cover), + ), + onTap: () { + handleEmojTab(item['index']); + }, + ); + }).toList(), + ), + ), + Expanded( + child: Container( + decoration: BoxDecoration( + color: Colors.grey[200], + border: const Border(top: BorderSide(color: Colors.black54, width: .1)), + ), + child: ListView( + controller: emojController, + padding: const EdgeInsets.all(10.0), + children: emoJson.map((item) { + return Visibility( + visible: item['selected'], + child: GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + // 横轴元素个数 + crossAxisCount: item['type'] == 'emoj' ? 8 : 5, + // 纵轴间距 + mainAxisSpacing: 5.0, + // 横轴间距 + crossAxisSpacing: 5.0, + // 子组件宽高比例 + childAspectRatio: 1, + ), + children: item['nodes'].map((emoj) { + if (item['type'] == 'emoj') { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(5.0), + child: Container( + alignment: Alignment.center, + height: 40.0, + width: 40.0, + child: Text( + emoj, + style: const TextStyle(fontSize: 24.0), + ), + ), + onTap: () { + handleEmojClick(emoj); + }, + ), + ); + } else { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(5.0), + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(5.0), + height: 68.0, + width: 68.0, + child: Image.asset(emoj), + ), + onTap: () { + handleGIFClick(emoj, item['index']); + }, + ), + ); + } + }).toList(), + ), + ); + }).toList(), + ), + ), + ), + ]; + } + + // 选择功能列表 + List renderChooseWidget() { + return [ + Expanded( + child: Container( + padding: const EdgeInsets.fromLTRB(30.0, 35.0, 30.0, 15.0), + decoration: BoxDecoration( + color: Colors.grey[200], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: GridView( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + // 横轴元素个数 + crossAxisCount: 4, + // 纵轴间距 + mainAxisSpacing: 30.0, + // 横轴间距 + crossAxisSpacing: 25.0, + // 子组件宽高比例 + childAspectRatio: .8, + ), + children: chooseOptions.map((item) { + return Column( + children: [ + Expanded( + child: Material( + type: MaterialType.transparency, + child: Ink( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + ), + child: InkWell( + borderRadius: BorderRadius.circular(15.0), + child: Image.asset(item['icon'], height: 40.0, fit: BoxFit.cover), + onTap: () { + handleChooseAction(item['key']); + }, + ), + ), + ), + ), + const SizedBox(height: 5.0), + Text( + item['name'], + style: const TextStyle(color: Colors.black87, fontSize: 12.0), + ) + ], + ); + }).toList(), + ), + ), + ), + ]; + } + + /* ---------- { 聊天消息模块 } ---------- */ + // 聊天消息滚动到底部 + // void scrollToBottom() async { + // chatList = await fetchChatList(); + // chatController.animateTo(isNeedScrollBottom ? 0 : chatController.position.maxScrollExtent, + // duration: const Duration(milliseconds: 200), curve: Curves.easeIn); + // } + + // void scrollToBottom() { + // Future.delayed(Duration(milliseconds: 300), () { + // if (chatController.hasClients) { + // chatController.animateTo( + // // 0, // reverse: true 时滚动到底部是 offset: 0 + // chatController.position.maxScrollExtent, + // duration: Duration(milliseconds: 300), + // curve: Curves.easeOut, + // ); + // } + // }); + // } + + // 点击消息区域 + void handleClickChatArea() { + hideKeyboard(); + setState(() { + toolbarEnable = false; + }); + } + + /* ---------- { 底部Toolbar模块 } ---------- */ + // 光标处插入内容 + void insertTextAtCursor(String html) { + var editorNotifier = editorController.value; // The current value stored in this notifier. + var start = editorNotifier.selection.baseOffset; + var end = editorNotifier.selection.extentOffset; + if (editorNotifier.selection.isValid) { + String newText = ''; + if (editorNotifier.selection.isCollapsed) { + if (end > 0) { + newText += editorNotifier.text.substring(0, end); + } + newText += html; + if (editorNotifier.text.length > end) { + newText += editorNotifier.text.substring(end, editorNotifier.text.length); + } + } else { + newText = editorNotifier.text.replaceRange(start, end, html); + end = start; + } + editorController.value = + editorNotifier.copyWith(text: newText, selection: editorNotifier.selection.copyWith(baseOffset: end + html.length, extentOffset: end + html.length)); + } else { + editorController.value = TextEditingValue( + text: html, + selection: TextSelection.fromPosition(TextPosition(offset: html.length)), + ); + } + } + + // 发送消息队列 + void sendMessage(message) async { + final canSend = checkSend(); + if (!canSend) { + final baseStyle = MyDialog.theme.toastStyle?.top(); + MyDialog.toast( + '对方未回关或回复,只能发送一条消息', + icon: const Icon(Icons.check_circle), + duration: Duration(milliseconds: 5000), + style: baseStyle?.copyWith( + backgroundColor: Colors.red.withAlpha(200), + ), + ); + + print('禁止发送$canSend'); + return; + } + // 待插入的消息 + List messagesToInsert = []; + V2TimMessage? lastRealMsg; + + for (var msg in controller.chatList) { + if (msg.localCustomData != 'time_label') { + lastRealMsg = msg; + break; + } + } + + // 如果有数据,检测时间,是否需要插入伪消息 + if (lastRealMsg != null && + needInsertTimeLabel( + (lastRealMsg.timestamp ?? 0) * 1000, // 转为毫秒级 + DateTime.now().millisecondsSinceEpoch, + )) { + // 消息时间间隔超过3分钟插入伪消息 + final showLabel = Utils().formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000); + final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId); + messagesToInsert.add(resMsg.data); + } else { + // 没数据的时候直接插入伪消息 + final showLabel = Utils().formatChatTime(DateTime.now().millisecondsSinceEpoch ~/ 1000); + + final resMsg = await IMMessage().insertTimeLabel(showLabel, selfUserId); + messagesToInsert.add(resMsg.data); + } + + // 不需要时间标签 + // 消息类型,message['type'] + late final ImResult res; + res = await IMMessage().sendMessage( + msg: message, + toUserID: arguments.value.userID, + cloudCustomData: canSend == true ? ConversationType.noFriend.name : '', + ); + + if (res.success && res.data != null) { + messagesToInsert.insert(0, res.data); // 加入消息本体 + // messagesToInsert.add(res.data); // 加入消息本体 + + controller.chatList.insertAll(0, messagesToInsert); + // controller.chatList.addAll(messagesToInsert); + + controller.scrollToBottom(); + print('发送成功'); + } else { + print('消息发送失败: ${res.code} - ${res.desc}'); + } + } + + bool needInsertTimeLabel(int lastTimestamp, int newTimestamp, {int interval = 3 * 60}) { + return (newTimestamp - lastTimestamp) > interval * 1000; + } + + // 隐藏键盘 + void hideKeyboard() { + if (editorFocusNode.hasFocus) { + editorFocusNode.unfocus(); + } + } + + // 表情/选择切换 + void handleEmojChooseState(index) { + hideKeyboard(); + setState(() { + toolbarEnable = true; + toolbarIndex = index; + voiceBtnEnable = false; + }); + controller.scrollToBottom(); + } + + // 表情Tab切换 + void handleEmojTab(index) { + var emols = emoJson; + for (var i = 0, len = emols.length; i < len; i++) { + emols[i]['selected'] = false; + } + emols[index]['selected'] = true; + setState(() { + emoJson = emols; + }); + emojController.jumpTo(0); + } + + // 点击表情插入到输入框 + void handleEmojClick(emoj) { + insertTextAtCursor(emoj); + } + + // 点击Gif大图发送=8 + void handleGIFClick(gifpath, index) async { + // 消息队列 + // Map message = { + // 'contentType': 8, + // 'content': gifpath, + // }; + final res = await IMMessage().createFaceMessage(data: gifpath, index: index); + if (res.success) { + sendMessage(res.data?.messageInfo); + } + } + + // 发送文本消息=1 + void handleSubmit() async { + if (editorController.text.isEmpty) return; + // 消息队列 + // Map message = { + // 'contentType': 1, + // 'content': editorController.text, + // }; + final res = await IMMessage().createTextMessage(text: editorController.text); + if (res.success) { + sendMessage(res.data?.messageInfo); + editorController.clear(); + } + } + + // 发送图片消息=3 + void sendImage(imgPath) async { + final resImg = await IMMessage().createImageMessage(imagePath: imgPath); + if (resImg.success) { + sendMessage(resImg.data?.messageInfo); + } + } + + // 发送视频消息=5 + void sendVideo(videoFilePath, type, duration, snapshotPath) async { + final resImg = await IMMessage().createVideoMessage( + videoFilePath: videoFilePath, + type: type, + duration: duration, + snapshotPath: snapshotPath, + ); + if (resImg.success) { + sendMessage(resImg.data?.messageInfo); + } + } + + // 底部操作蓝选择区操作 + void handleChooseAction(key) { + MyDialog.toast('$key'); + switch (key) { + case 'photo': + // .... + pickFile(context); + break; + case 'camera': + // .... + break; + case 'redpacket': + sendRedPacketDialog(); + break; + } + } + + ///从相册选取图片/视频 + void pickFile(BuildContext context) async { + final pickedAssets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + textDelegate: const AssetPickerTextDelegate(), + pathNameBuilder: (AssetPathEntity album) { + return Utils.translateAlbumName(album); + }, + maxAssets: 5, + requestType: RequestType.common, + filterOptions: FilterOptionGroup( + imageOption: const FilterOption(), + videoOption: const FilterOption( + durationConstraint: DurationConstraint( + max: Duration(seconds: 120), + ), + ), + ), + ), + ); + + if (pickedAssets != null && pickedAssets.isNotEmpty) { + for (final asset in pickedAssets) { + switch (asset.type) { + case AssetType.image: + print("选中了图片:${asset.title}"); + var file = await asset.file; + if (file != null) { + var fileSizeInBytes = await file.length(); + var sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 28) { + MyDialog.toast('图片大小不能超过28MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + // 执行发送逻辑 + sendImage(file.path); + } + } + + break; + case AssetType.video: + print("选中了视频:${asset.title}"); + var file = await asset.file; + if (file != null) { + var fileSizeInBytes = await file.length(); + var sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 100) { + MyDialog.toast('视频大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("视频合法,大小:$sizeInMB MB"); + // 执行发送逻辑 + var snapshot = await generateVideoThumbnail(file.path); + String? mimeType = await asset.mimeTypeAsync; + String vdType = mimeType?.split('/').last ?? 'mp4'; + print(vdType); + sendVideo(file.path, vdType, asset.duration, snapshot); + } + } + break; + default: + print("不支持的类型:${asset.type}"); + } + } + // final asset = pickedAssets.first; + // final file = await asset.file; // 获取实际文件 + // if (file != null) { + // final fileSizeInBytes = await file.length(); + // final sizeInMB = fileSizeInBytes / (1024 * 1024); + // if (sizeInMB > 100) { + // MyDialog.toast('图片大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + // } else { + // print("图片合法,大小:$sizeInMB MB"); + // //走upload(file)上传图片拿到url地址 + // // file; + // } + // } + } + } + + /* ---------- { 弹窗功能模块 } ---------- */ + // 红包弹窗 + void receiveRedPacketDialog(data) { + showDialog( + context: context, + builder: (context) { + return Material( + type: MaterialType.transparency, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(horizontal: 50.0), + padding: const EdgeInsets.symmetric(vertical: 50.0), + decoration: const BoxDecoration( + color: Color(0xFFFF7F43), + borderRadius: BorderRadius.all(Radius.circular(12.0)), + ), + child: Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(5.0), + child: Image.asset(data['avatar'], height: 40.0, width: 40.0, fit: BoxFit.cover), + ), + const SizedBox( + height: 5.0, + ), + Text( + data['author'], + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w600), + ), + Text( + data['content'], + style: const TextStyle(color: Color(0xFFFFF9C7), fontWeight: FontWeight.w500, fontSize: 20.0), + ), + SizedBox( + height: 100.0, + ), + AnimatedBuilder( + animation: animTurns, + builder: (context, child) { + return Transform( + transform: Matrix4.rotationY(animTurns.value), + alignment: Alignment.center, + child: FilledButton( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.all(const Color(0xFFFFF9C7)), + padding: WidgetStateProperty.all(EdgeInsets.zero), + minimumSize: WidgetStateProperty.all(const Size(80.0, 80.0)), + shape: WidgetStateProperty.all(const CircleBorder()), + elevation: WidgetStateProperty.all(3.0), + ), + child: Text( + '開', + style: TextStyle(color: Color(0xFF3B3B3B), fontSize: 28.0), + ), + onPressed: () { + // 开始动画 + animController.repeat(); + // 模拟开红包逻辑,1 秒后停止动画 + Future.delayed(Duration(seconds: 1), () { + animController.stop(); + animController.reset(); + Get.back(); + }); + }, + ), + ); + }, + ), + ], + ), + ), + GestureDetector( + child: Container( + margin: const EdgeInsets.only(top: 20.0), + height: 30.0, + width: 30.0, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 1.5), + borderRadius: BorderRadius.circular(50.0), + ), + child: const Icon( + Icons.close_outlined, + color: Colors.white, + size: 18.0, + ), + ), + onTap: () { + Navigator.of(context).pop(); + }, + ) + ], + )); + }); + } + + // 长按消息菜单 + void contextMenuDialog() { + showDialog( + context: context, + builder: (context) { + return SimpleDialog( + backgroundColor: Colors.white, + surfaceTintColor: Colors.white, + contentPadding: const EdgeInsets.symmetric(vertical: 5.0), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15.0)), + children: [ + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('复制')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('发送给朋友')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('收藏')), + onPressed: () {}, + ), + SimpleDialogOption( + child: const Padding(padding: EdgeInsets.symmetric(vertical: 5.0), child: Text('删除')), + onPressed: () {}, + ), + ], + ); + }, + ); + } + + // 发群红包弹窗 + void sendRedPacketDialog() { + showModalBottomSheet( + backgroundColor: Colors.grey[50], + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))), + showDragHandle: true, + clipBehavior: Clip.hardEdge, + isScrollControlled: true, // 屏幕最大高度 + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height - 180, // 自定义最大高度 + ), + context: context, + builder: (context) { + return RedPacket( + flag: false, + ); + }, + ); + } + + /* ---------- { 其它功能模块 } ---------- */ + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + // 页面主体(聊天消息区/底部操作区) + Scaffold( + backgroundColor: Colors.grey[200], + resizeToAvoidBottomInset: true, // 启用键盘自动避让 + appBar: AppBar( + centerTitle: true, + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + leading: IconButton( + icon: Icon( + Icons.arrow_back_ios_rounded, + size: 20.0, + ), + onPressed: () { + Get.back(); + }, + ), + titleSpacing: 1.0, + title: Obx(() { + return Text( + // '${arguments['title']}', + '${arguments.value.showName}', + style: const TextStyle(fontSize: 18.0, fontFamily: 'Arial'), + ); + }), + flexibleSpace: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFBE4EFF), Color(0xFF1DFFC7)], + )), + ), + actions: [ + IconButton( + icon: const Icon( + Icons.more_horiz, + color: Colors.white, + ), + onPressed: () async { + final paddingTop = MediaQuery.of(Get.context!).padding.top; + + final selected = await showMenu( + context: Get.context!, + position: RelativeRect.fromLTRB( + double.infinity, + kToolbarHeight + paddingTop - 12, + 8, + double.infinity, + ), + color: FStyle.primaryColor, + elevation: 8, + items: [ + PopupMenuItem( + value: 'remark', + child: Row( + children: [ + Icon(Icons.edit, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设置备注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'not', + child: Row( + children: [ + Icon(Icons.do_not_disturb_on, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '设为免打扰', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'report', + child: Row( + children: [ + Icon(Icons.report, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '举报', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'block', + child: Row( + children: [ + Icon(Icons.block, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '拉黑', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + PopupMenuItem( + value: 'foucs', + child: Row( + children: [ + Icon(Icons.person_remove_alt_1, color: Colors.white, size: 18), + SizedBox(width: 8), + Text( + '取消关注', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ], + ); + + if (selected != null) { + switch (selected) { + case 'remark': + print('点击了备注'); + setRemark(); + break; + case 'not': + print('点击了免打扰'); + break; + case 'report': + print('点击了举报'); + break; + case 'block': + print('点击了拉黑'); + break; + case 'foucs': + print('点击了取关'); + break; + } + } + }, + ), + ], + ), + body: Flex( + direction: Axis.vertical, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 渲染聊天消息 + // Expanded( + // child: ScrollConfiguration( + // behavior: CustomScrollBehavior(), + // child: GestureDetector( + // child: Obx(() { + // return ListView( + // reverse: true, + // controller: chatController, + // padding: const EdgeInsets.all(10.0), + // children: renderChatList(), + // ); + // }), + // onTap: () { + // handleClickChatArea(); + // }, + // ), + // ), + // ), + + // 聊天内容 + Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: handleClickChatArea, + child: LayoutBuilder( + builder: (context, constraints) { + return Obx(() { + final msgWidgets = renderChatList().reversed.toList(); + + return ListView( + controller: chatController, + reverse: true, + padding: const EdgeInsets.all(10.0), + children: [ + ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight - 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: msgWidgets, + ), + ), + ], + ); + }); + }, + ), + ), + ), + + // 底部操作栏 + Container( + color: Colors.grey[100], + child: SafeArea( + bottom: true, + child: Container( + decoration: BoxDecoration( + color: Colors.grey[100], + border: const Border(top: BorderSide(color: Colors.black38, width: .1)), + ), + child: Column( + children: [ + // 输入框编辑器模块 + Container( + padding: const EdgeInsets.all(10.0), + child: Row( + children: [ + InkWell( + child: Icon( + voiceBtnEnable ? Icons.keyboard_outlined : Icons.contactless_outlined, + color: const Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + setState(() { + toolbarEnable = false; + if (voiceBtnEnable) { + voiceBtnEnable = false; + editorFocusNode.requestFocus(); + } else { + voiceBtnEnable = true; + editorFocusNode.unfocus(); + } + }); + }, + ), + const SizedBox( + width: 10.0, + ), + Expanded( + child: Container( + constraints: const BoxConstraints(minHeight: 40.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + child: Stack( + children: [ + // 输入框 + Offstage( + offstage: voiceBtnEnable, + child: TextField( + decoration: const InputDecoration( + isDense: true, + hoverColor: Colors.transparent, + contentPadding: EdgeInsets.all(8.0), + border: OutlineInputBorder(borderSide: BorderSide.none), + ), + style: const TextStyle( + fontSize: 16.0, + ), + maxLines: null, + controller: editorController, + focusNode: editorFocusNode, + cursorColor: const Color(0xFF07C160), + onChanged: (value) {}, + ), + ), + // 语音 + Offstage( + offstage: !voiceBtnEnable, + child: GestureDetector( + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(5), + ), + alignment: Alignment.center, + height: 40.0, + width: double.infinity, + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(fontSize: 15.0), + ), + ), + onPanStart: (details) { + setState(() { + voiceType = 1; + voicePanelEnable = true; + }); + }, + onPanUpdate: (details) { + Offset pos = details.globalPosition; + double swipeY = MediaQuery.of(context).size.height - 120; + double swipeX = MediaQuery.of(context).size.width / 2 + 50; + setState(() { + if (pos.dy >= swipeY) { + voiceType = 1; // 松开发送 + } else if (pos.dy < swipeY && pos.dx < swipeX) { + voiceType = 2; // 左滑松开取消 + } else if (pos.dy < swipeY && pos.dx >= swipeX) { + voiceType = 3; // 右滑语音转文字 + } + }); + }, + onPanEnd: (details) { + // print('停止录音'); + setState(() { + switch (voiceType) { + case 1: + MyDialog.toast('发送录音文件'); + voicePanelEnable = false; + break; + case 2: + MyDialog.toast('取消发送'); + voicePanelEnable = false; + break; + case 3: + MyDialog.toast('语音转文字'); + voicePanelEnable = true; + voiceToTransfer = true; + break; + } + voiceType = 0; + }); + }, + ), + ), + ], + ), + ), + ), + const SizedBox( + width: 10.0, + ), + InkWell( + child: const Icon( + Icons.add_reaction_rounded, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(0); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: const Icon( + Icons.add, + color: Color(0xFF3B3B3B), + size: 30.0, + ), + onTap: () { + handleEmojChooseState(1); + }, + ), + const SizedBox( + width: 8.0, + ), + InkWell( + child: Container( + height: 25.0, + width: 25.0, + decoration: BoxDecoration( + color: const Color(0xFF07C160), + borderRadius: BorderRadius.circular(20.0), + ), + child: const Icon( + Icons.arrow_upward, + color: Colors.white, + size: 20.0, + ), + ), + onTap: () { + handleSubmit(); + }, + ), + ], + ), + ), + + // 表情+选择模块 + Visibility( + visible: toolbarEnable, + child: SizedBox( + height: keyboardHeight, + child: Column( + children: toolbarIndex == 0 ? renderEmojWidget() : renderChooseWidget(), + ), + ), + ) + ], + ), + ), + ), + ) + ], + ), + ), + // 录音主体(按住说话/松开取消/语音转文本) + IgnorePointer( + ignoring: false, + child: Visibility( + visible: voicePanelEnable, + child: Material( + color: const Color(0xDD1B1B1B), + child: Stack( + children: [ + // 取消发送+语音转文字 + Positioned( + bottom: 120, + left: 30, + right: 30, + child: Visibility( + visible: !voiceToTransfer, + child: Column( + crossAxisAlignment: voiceType == 2 ? CrossAxisAlignment.start : CrossAxisAlignment.center, + children: [ + // 语音动画层 + Stack( + alignment: Alignment.bottomCenter, + children: [ + AnimatedContainer( + duration: Duration(milliseconds: 200), + height: 70.0, + width: voiceType == 2 ? 70.0 : 200.0, + decoration: BoxDecoration( + color: voiceType == 2 ? Colors.red : Color(0xFF89E45B), + borderRadius: BorderRadius.circular(15.0), + ), + clipBehavior: Clip.antiAlias, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset('assets/images/voice_waves.gif', height: 23.0, width: voiceType == 2 ? 30.0 : 70.0, fit: BoxFit.cover) + ], + ), + ), + RotatedBox( + quarterTurns: 0, + child: CustomPaint(painter: ArrowShape(arrowColor: voiceType == 2 ? Colors.red : Color(0xFF89E45B), arrowSize: 10.0)), + ) + ], + ), + const SizedBox( + height: 50.0, + ), + // 操作项 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // 取消发送 + Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: voiceType == 2 ? Colors.red : Colors.black38, + ), + child: Icon( + Icons.close, + color: Colors.white54, + ), + ), + // 语音转文字 + Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: voiceType == 3 ? Color(0xFF89E45B) : Colors.black38, + ), + child: Icon( + Icons.translate, + color: Colors.white54, + ), + ), + ], + ), + ], + ), + ), + ), + // 语音转文字(识别结果状态) + Positioned( + bottom: 120, + left: 30, + right: 30, + child: Visibility( + visible: voiceToTransfer, + child: Column( + children: [ + // 提示结果 + Stack( + children: [ + Container( + height: 100.0, + decoration: BoxDecoration( + color: Colors.red, + borderRadius: BorderRadius.circular(15.0), + ), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.info_outlined, + color: Colors.white, + ), + Text( + '未识别到文字。', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + Positioned( + right: 35.0, + bottom: 1, + child: RotatedBox( + quarterTurns: 0, + child: CustomPaint(painter: ArrowShape(arrowColor: Colors.red, arrowSize: 10.0)), + )), + ], + ), + const SizedBox( + height: 50.0, + ), + // 操作项 + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + GestureDetector( + child: Container( + height: 60.0, + width: 60.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.undo, + color: Colors.white54, + ), + Text( + '取消', + style: TextStyle(color: Colors.white70), + ) + ], + ), + ), + onTap: () { + setState(() { + voicePanelEnable = false; + voiceToTransfer = false; + }); + }, + ), + GestureDetector( + child: Container( + height: 60.0, + width: 100.0, + decoration: const BoxDecoration( + color: Colors.transparent, + ), + child: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.graphic_eq_rounded, + color: Colors.white54, + ), + Text( + '发送原语音', + style: TextStyle(color: Colors.white70), + ) + ], + ), + ), + onTap: () {}, + ), + GestureDetector( + child: Container( + height: 60.0, + width: 60.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(50.0), + color: Colors.white12, + ), + child: const Icon( + Icons.check, + color: Colors.white12, + ), + ), + onTap: () {}, + ), + ], + ), + ], + ), + ), + ), + // 提示文字(操作状态) + Positioned( + bottom: 120, + left: 0, + width: MediaQuery.of(context).size.width, + child: Visibility( + visible: !voiceToTransfer, + child: Align( + child: Text( + voiceTypeMap[voiceType], + style: const TextStyle(color: Colors.white70), + ), + ), + ), + ), + // 背景 + Align( + alignment: Alignment.bottomCenter, + child: Visibility( + visible: !voiceToTransfer, + child: Image.asset('assets/images/voice_bg.webp', width: double.infinity, height: 100.0, fit: BoxFit.fill), + ), + ), + // 背景图标 + Positioned( + bottom: 25, + left: 0, + width: MediaQuery.of(context).size.width, + child: Visibility( + visible: !voiceToTransfer, + child: const Align( + child: Icon( + Icons.graphic_eq_rounded, + color: Colors.black54, + ), + ), + ), + ), + ], + ), + ), + ), + ) + ], + ); + } +} + +// 渲染聊天消息公共部分 +class RenderChatItem extends StatelessWidget { + const RenderChatItem({ + super.key, + required this.data, + required this.child, + }); + final V2TimMessage data; // 消息数据 + final Widget? child; // 消息体 + + // 设置箭头颜色 + // Color arrowColor(data) { + // Color color = Colors.transparent; + // if ([8].contains(data.elemType)) { + // // 红包箭头颜色 + // color = const Color(0xFFFFA52F); + // } else if ([9].contains(data.elemType)) { + // // 位置箭头颜色 + // color = const Color(0xFFFFFFFF); + // } else { + // color = !data['isme'] ? const Color(0xFFFFFFFF) : const Color(0xFF9543FF); + // } + // return color; + // } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + !(data.isSelf ?? false) + ? SizedBox( + height: 35.0, + width: 35.0, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + child: Image.network( + data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png', + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/pic1.jpg', + height: 60.0, + width: 60.0, + fit: BoxFit.cover, + ); + }, + ), + ), + ) + : const SizedBox.shrink(), + Expanded( + child: Padding( + padding: !(data.isSelf ?? false) ? const EdgeInsets.only(left: 10.0, right: 40.0) : const EdgeInsets.only(left: 40.0, right: 10.0), + child: Column( + crossAxisAlignment: !(data.isSelf ?? false) ? CrossAxisAlignment.start : CrossAxisAlignment.end, + children: [ + Text( + data.friendRemark ?? data.nameCard ?? data.nickName ?? '未知昵称', + style: const TextStyle(color: Colors.grey, fontSize: 12.0), + ), + const SizedBox( + height: 3.0, + ), + Stack( + children: [ + // 气泡箭头 + /* Visibility( + // 显示箭头(消息+语音+红包+位置) + visible: [3, 7, 8, 9].contains(data['contentType']), + child: Positioned( + left: !data['isme'] ? 1 : null, + right: data['isme'] ? 1 : null, + top: 20.0, + child: RotatedBox( + quarterTurns: !data['isme'] ? 1 : -1, + child: CustomPaint(painter: ArrowShape(arrowColor: arrowColor(data))), + ) + ), + ), */ + Container( + child: child, + ), + ], + ), + ], + ), + ), + ), + data.isSelf ?? false + ? SizedBox( + height: 35.0, + width: 35.0, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(20.0)), + child: Image.network( + data.faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png', + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/pic1.jpg', + height: 60.0, + width: 60.0, + fit: BoxFit.cover, + ); + }, + ), + ), + ) + : const SizedBox.shrink(), + ], + ), + ); + } +} + +// 绘制气泡箭头 +class ArrowShape extends CustomPainter { + ArrowShape({ + required this.arrowColor, + this.arrowSize = 7, + }); + + final Color arrowColor; // 箭头颜色 + final double arrowSize; // 箭头大小 + + @override + void paint(Canvas canvas, Size size) { + var paint = Paint()..color = arrowColor; + + var path = Path(); + path.lineTo(-arrowSize, 0); + path.lineTo(0, arrowSize); + path.lineTo(arrowSize, 0); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return false; + } +} diff --git a/lib/pages/chat/components/redpacket.dart b/lib/pages/chat/components/redpacket.dart index 4764edf..26e46a6 100644 --- a/lib/pages/chat/components/redpacket.dart +++ b/lib/pages/chat/components/redpacket.dart @@ -2,20 +2,41 @@ library; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; class RedPacket extends StatefulWidget { - const RedPacket({super.key}); + final bool flag; // true=群,false=单 + final void Function(Map)? onSend; + final int? maxNum; // 红包最大数量 + const RedPacket({super.key, required this.flag, this.onSend, this.maxNum}); - @override - State createState() => _RedPacketState(); + @override + State createState() => _RedPacketState(); } class _RedPacketState extends State { - String amount = '0.00'; + final TextEditingController _amountController = TextEditingController(); + final TextEditingController _maxNumController = TextEditingController(); + final TextEditingController _remarkController = TextEditingController(); - @override - Widget build(BuildContext context) { - return Material( + String amount = '0.00'; + String remark = '恭喜发财,大吉大利'; + // 限制只能输入数字和小数点,且最多两位小数 + final List _decimalInputFormatters = [ + FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d{0,2}')), + ]; + @override + void dispose() { + _amountController.dispose(); + _maxNumController.dispose(); + _remarkController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( type: MaterialType.transparency, child: Column( children: [ @@ -23,55 +44,67 @@ class _RedPacketState extends State { shrinkWrap: true, padding: const EdgeInsets.only(bottom: 50.0), children: [ - const SizedBox(height: 10.0), - Container( - margin: const EdgeInsets.symmetric(horizontal: 15.0), - padding: const EdgeInsets.symmetric(horizontal: 10.0), - decoration: BoxDecoration( - color: Colors.white, borderRadius: BorderRadius.circular(10.0), - ), - child: Row( - children: [ - const Text('红包个数'), - Expanded( - child: TextField( - textAlign: TextAlign.right, - decoration: const InputDecoration( - hintText: "填写个数", - isDense: true, - hintStyle: TextStyle(fontSize: 14.0), - border: OutlineInputBorder(borderSide: BorderSide.none) + if (widget.flag) const SizedBox(height: 10.0), + if (widget.flag) + Container( + margin: const EdgeInsets.symmetric(horizontal: 15.0), + padding: const EdgeInsets.symmetric(horizontal: 10.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10.0), + ), + child: Row( + children: [ + const Text('红包个数'), + Expanded( + child: TextField( + maxLength: widget.maxNum, + controller: _maxNumController, + textAlign: TextAlign.right, + buildCounter: (_, {required currentLength, maxLength, required isFocused}) => null, // 隐藏计数器 + decoration: const InputDecoration( + hintText: "填写个数", isDense: true, hintStyle: TextStyle(fontSize: 14.0), border: OutlineInputBorder(borderSide: BorderSide.none)), + onChanged: (value) { + // 输入的红包个数 + setState(() { + remark = value; + }); + }, ), - onChanged: (value) {}, ), - ), - const Text('个'), - ], + const Text('个'), + ], + ), ), - ), const SizedBox(height: 10.0), Container( margin: const EdgeInsets.symmetric(horizontal: 15.0), padding: const EdgeInsets.symmetric(horizontal: 10.0), decoration: BoxDecoration( - color: Colors.white, borderRadius: BorderRadius.circular(10.0), + color: Colors.white, + borderRadius: BorderRadius.circular(10.0), ), child: Row( children: [ const Text('总金额'), Expanded( child: TextField( + controller: _amountController, keyboardType: const TextInputType.numberWithOptions(decimal: true), textAlign: TextAlign.right, + inputFormatters: _decimalInputFormatters, + maxLength: 6, + buildCounter: (_, {required currentLength, maxLength, required isFocused}) => null, // 隐藏计数器 decoration: const InputDecoration( - hintText: "¥0.00", - isDense: true, - hintStyle: TextStyle(fontSize: 14.0), - border: OutlineInputBorder(borderSide: BorderSide.none) - ), + hintText: "¥0.00", isDense: true, hintStyle: TextStyle(fontSize: 14.0), border: OutlineInputBorder(borderSide: BorderSide.none)), onChanged: (value) { + double val = double.tryParse(value) ?? 0.0; + if (val > 200) { + _amountController.text = '200'; + val = 200; + } setState(() { - amount = value != '' ? value : '0.00'; + amount = val.toStringAsFixed(2); }); }, ), @@ -85,7 +118,8 @@ class _RedPacketState extends State { margin: const EdgeInsets.symmetric(horizontal: 15.0), padding: const EdgeInsets.symmetric(horizontal: 10.0), decoration: BoxDecoration( - color: Colors.white, borderRadius: BorderRadius.circular(10.0), + color: Colors.white, + borderRadius: BorderRadius.circular(10.0), ), child: Row( children: [ @@ -93,15 +127,26 @@ class _RedPacketState extends State { Expanded( child: TextField( maxLines: null, + maxLength: 16, + controller: _remarkController, keyboardType: TextInputType.multiline, textAlign: TextAlign.right, + buildCounter: (_, {required currentLength, maxLength, required isFocused}) => null, // 隐藏计数器 decoration: const InputDecoration( - hintText: "恭喜发财,大吉大利", - isDense: true, - hintStyle: TextStyle(fontSize: 14.0), - border: OutlineInputBorder(borderSide: BorderSide.none) - ), - onChanged: (value) {}, + hintText: "恭喜发财,大吉大利", + isDense: true, + hintStyle: TextStyle(fontSize: 14.0), + border: OutlineInputBorder(borderSide: BorderSide.none)), + onChanged: (value) { + // 留言内容 + setState(() { + if (value.isEmpty) { + remark = '恭喜发财,大吉大利'; + } else { + remark = value; + } + }); + }, ), ), ], @@ -109,12 +154,11 @@ class _RedPacketState extends State { ), const SizedBox(height: 30.0), Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('¥', style: TextStyle(fontSize: 24.0)), Text(amount, style: const TextStyle(fontSize: 36.0)) - ] + mainAxisAlignment: MainAxisAlignment.center, + children: [const Text('¥', style: TextStyle(fontSize: 24.0)), Text(amount, style: const TextStyle(fontSize: 36.0))]), + const SizedBox( + height: 20.0, ), - const SizedBox(height: 20.0,), UnconstrainedBox( constrainedAxis: Axis.vertical, child: FilledButton( @@ -122,23 +166,51 @@ class _RedPacketState extends State { backgroundColor: WidgetStateProperty.all(Color(0xFFFF7F43)), padding: WidgetStateProperty.all(EdgeInsets.zero), minimumSize: WidgetStateProperty.all(const Size(180.0, 45.0)), - shape: WidgetStatePropertyAll( - RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0)) - ), + shape: WidgetStatePropertyAll(RoundedRectangleBorder(borderRadius: BorderRadius.circular(8.0))), + ), + onPressed: () { + double amountDouble = double.tryParse(amount) ?? 0.0; + if (amountDouble > 0) { + //发送红包 + widget.onSend!( + { + 'maxNum': widget.maxNum ?? 1, + 'amount': amount, + 'remark': remark, + }, + ); + } else { + final baseStyle = MyDialog.theme.toastStyle?.top(); + MyDialog.toast( + '未输入金额', + icon: const Icon(Icons.check_circle), + duration: Duration(milliseconds: 5000), + style: baseStyle?.copyWith( + backgroundColor: Colors.red.withAlpha(200), + ), + ); + } + }, + child: const Text( + '塞钱进红包', + style: TextStyle(fontSize: 16.0), ), - onPressed: () {}, - child: const Text('塞钱进红包', style: TextStyle(fontSize: 16.0),), ), ), - const SizedBox(height: 10.0,), + const SizedBox( + height: 10.0, + ), const Align( alignment: Alignment.center, - child: Text('未领取的红包,将于24小时后发起退款', style: TextStyle(color: Colors.grey, fontSize: 12.0),), + child: Text( + '未领取的红包,将于24小时后发起退款', + style: TextStyle(color: Colors.grey, fontSize: 12.0), + ), ), ], ), ], ), ); - } + } } diff --git a/lib/pages/chat/index.dart b/lib/pages/chat/index.dart index ab47c61..9bb40e8 100644 --- a/lib/pages/chat/index.dart +++ b/lib/pages/chat/index.dart @@ -8,6 +8,7 @@ import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/global_badge.dart'; import 'package:loopin/IM/im_service.dart'; import 'package:loopin/components/scan_util.dart'; +import 'package:loopin/models/conversation_view_model.dart'; import 'package:loopin/utils/parse_message_summary.dart'; import 'package:shirne_dialog/shirne_dialog.dart'; @@ -30,6 +31,14 @@ class ChatPageState extends State { controller = Get.find(); } + void deletConv(context, ConversationViewModel item) async { + final res = await ImService.instance.deleteConversation(conversationID: item.conversation.conversationID); + if (res.success) { + Navigator.of(context).pop(); + controller.chatList.remove(item); + } + } + // 长按坐标点 double posDX = 0.0; double posDY = 0.0; @@ -41,7 +50,7 @@ class ChatPageState extends State { } // 长按菜单 - void showContextMenu(BuildContext context) { + void showContextMenu(BuildContext context, ConversationViewModel item) { bool isLeft = posDX > MediaQuery.of(context).size.width / 2 ? false : true; bool isTop = posDY > MediaQuery.of(context).size.height / 2 ? false : true; @@ -92,7 +101,9 @@ class ChatPageState extends State { style: TextStyle(color: Colors.black87, fontSize: 14.0), ), dense: true, - onTap: () {}, + onTap: () { + deletConv(context, item); + }, ) ], ), @@ -306,6 +317,7 @@ class ChatPageState extends State { onRefresh: handleRefresh, child: Obx(() { final chatList = controller.chatList; + return ListView.builder( shrinkWrap: true, physics: BouncingScrollPhysics(), @@ -322,11 +334,35 @@ class ChatPageState extends State { children: [ // 头图 ClipOval( - child: Image.network( - chatList[index].faceUrl ?? 'https://wuzhongjie.com.cn/download/logo.png', - height: 50.0, - width: 50.0, - fit: BoxFit.cover, + child: Builder( + builder: (context) { + final faceUrl = chatList[index].faceUrl; + final isNetwork = + faceUrl != null && faceUrl.isNotEmpty && (faceUrl.startsWith('http://') || faceUrl.startsWith('https://')); + if (isNetwork) { + return Image.network( + faceUrl, + height: 50.0, + width: 50.0, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Image.asset( + 'assets/images/pic1.jpg', + height: 50.0, + width: 50.0, + fit: BoxFit.cover, + ); + }, + ); + } else { + return Image.asset( + (faceUrl != null && faceUrl.isNotEmpty) ? faceUrl : 'assets/images/pic1.jpg', + height: 50.0, + width: 50.0, + fit: BoxFit.cover, + ); + } + }, ), ), @@ -335,9 +371,17 @@ class ChatPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Text( + // chatList[index].conversation.showName ?? '', + // style: const TextStyle(fontSize: 16.0), + // ), Text( chatList[index].conversation.showName ?? '', - style: const TextStyle(fontSize: 16.0), + style: TextStyle( + fontSize: (chatList[index].conversation.conversationGroupList?.isNotEmpty ?? false) ? 20 : 16, + fontWeight: + (chatList[index].conversation.conversationGroupList?.isNotEmpty ?? false) ? FontWeight.bold : FontWeight.normal, + ), ), const SizedBox(height: 2.0), Text( @@ -351,6 +395,7 @@ class ChatPageState extends State { ), ), // 右侧 + Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ @@ -396,7 +441,7 @@ class ChatPageState extends State { posDY = details.globalPosition.dy; }, onLongPress: () { - showContextMenu(context); + showContextMenu(context, chatList[index]); }, ), ); diff --git a/lib/pages/goods/detail.dart b/lib/pages/goods/detail.dart index baf8857..5c00772 100644 --- a/lib/pages/goods/detail.dart +++ b/lib/pages/goods/detail.dart @@ -1,9 +1,24 @@ /// 商品详情页 library; +import 'dart:convert'; + import 'package:card_swiper/card_swiper.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_html/flutter_html.dart'; import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; +import 'package:loopin/IM/im_message.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/api/shop_api.dart'; +import 'package:loopin/components/my_toast.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; +import 'package:loopin/models/summary_type.dart'; +import 'package:loopin/service/http.dart'; +import 'package:loopin/utils/index.dart'; +import 'package:loopin/utils/wxsdk.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; import '../../behavior/custom_scroll_behavior.dart'; import '../../components/backtop.dart'; @@ -16,18 +31,29 @@ class Goods extends StatefulWidget { } class _GoodsState extends State { + // late int shopId; //商品id + dynamic shopObj; late ScrollController scrollController = ScrollController(); + final ChatController chatController = Get.find(); // 滚动位置 double scrollOffset = 0; + // 分享列表 + List shareList = [ + {'icon': 'assets/images/share-wx.png', 'label': '好友'}, + {'icon': 'assets/images/share-wx.png', 'label': '微信'}, + {'icon': 'assets/images/share-pyq.png', 'label': '朋友圈'}, + ]; @override void initState() { super.initState(); + final shopId = Get.arguments; scrollController.addListener(() { setState(() { scrollOffset = scrollController.offset; }); }); + shopDetail(shopId); } @override @@ -36,8 +62,199 @@ class _GoodsState extends State { super.dispose(); } + ///商品详情 + void shopDetail(shopId) async { + try { + final res = await Http.get('${ShopApi.shopDetail}/$shopId'); + logger.e(res['data']); + setState(() { + shopObj = res['data']; // 注意取 data 部分 + }); + } catch (e) { + logger.e(e); + Get.back(); + } + } + + void handleShareClick(int index) { + final description = shopObj['describe']; // 商品描述 + if (index == 1) { + // 好友 + Wxsdk.shareToFriend(title: '快看看我分享的商品', description: description, webpageUrl: 'https://baidu.com'); + } else if (index == 2) { + // 朋友圈 + Wxsdk.shareToTimeline(title: '快看看我分享的商品', webpageUrl: 'https://baidu.com'); + } + } + + void handlCoverClick(V2TimConversation conv) async { + // 发送VideoMsg,获取当前视频信息 + final userId = conv.userID; + //price,title,url,sell + final makeJson = jsonEncode({ + "price": shopObj['price'], + "title": shopObj['describe'], + "url": shopObj['pic'], + "sell": Utils().graceNumber(int.parse(shopObj['sales'] ?? '0')), + }); + final res = await IMMessage().createCustomMessage( + data: makeJson, + ); + if (res.success) { + final sendRes = await IMMessage().sendMessage(msg: res.data!.messageInfo!, toUserID: userId, cloudCustomData: SummaryType.shareTuangou); + if (sendRes.success) { + MyToast().tip( + title: '分享成功', + position: 'center', + type: 'success', + ); + Get.back(); + } else { + logger.e(res.desc); + } + } else { + logger.e(res.desc); + } + } + + // 分享弹框 + void handleShare() { + if (chatController.chatList.isNotEmpty) { + chatController.getConversationList(); + } + showModalBottomSheet( + backgroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(15.0)), + ), + clipBehavior: Clip.antiAlias, + context: context, + isScrollControlled: true, + builder: (context) { + return Material( + color: Colors.white, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 分享列表 + SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: shareList.length, + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + return GestureDetector( + onTap: () => handleShareClick(index), + child: Container( + width: 64, + margin: EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('${shareList[index]['icon']}', width: 48.0), + SizedBox(height: 5), + Text( + '${shareList[index]['label']}', + style: TextStyle(fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + ), + + // 会话列表 + Obx(() { + // 这里过滤掉有分组的会话 + final filteredList = chatController.chatList.where((item) => item.conversation.conversationGroupList?.isEmpty == true).toList(); + if (filteredList.isEmpty) return SizedBox.shrink(); + return SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: filteredList.length, + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + return GestureDetector( + // 点击分享 + onTap: () => handlCoverClick(filteredList[index].conversation), + child: Container( + width: 64, + margin: EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Image.asset('${chatController.chatList[index].faceUrl}', width: 48.0), + NetworkOrAssetImage( + imageUrl: filteredList[index].faceUrl, + width: 48.0, + height: 48.0, + ), + SizedBox(height: 5), + Text( + '${filteredList[index].conversation.showName}', + style: TextStyle(fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ); + }, + ), + ); + }), + + // 取消按钮 + SafeArea( + top: false, + child: InkWell( + onTap: () => Get.back(), + child: Container( + alignment: Alignment.center, + width: double.infinity, + height: 50.0, + color: Colors.grey[50], + child: Text( + '取消', + style: TextStyle(color: Colors.black87), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + @override Widget build(BuildContext context) { + if (shopObj == null) { + return Center(child: CircularProgressIndicator()); + } + String swiperInfo = shopObj['albumPics'] ?? ""; + List swiperList; + if (swiperInfo.isNotEmpty) { + swiperList = swiperInfo.split(','); // 商品详情轮播图 + } else { + swiperList = []; + } + dynamic attr = shopObj['productAttr']; //json数据 + List attrList = []; + if (!Utils.isEmpty(attr)) { + attrList = jsonDecode(attr); + } + logger.e(attrList); + return Scaffold( backgroundColor: Colors.grey[50], body: CustomScrollView( @@ -63,32 +280,26 @@ class _GoodsState extends State { }, ), actions: [ - IconButton( - icon: Icon( - Icons.search, - size: 20.0, - ), - onPressed: () {}, - ), - IconButton( - icon: Icon( - Icons.favorite_border, - size: 20.0, - ), - onPressed: () {}, - ), IconButton( icon: Icon( Icons.share, size: 20.0, ), - onPressed: () {}, + onPressed: () { + // 分享 + handleShare(); + }, ), ], // 自定义伸缩区域(轮播图) flexibleSpace: Container( - decoration: - BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF5000), Color(0xFFFFAA00)])), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF5000), Color(0xFFFFAA00)], + ), + ), child: FlexibleSpaceBar( background: ScrollConfiguration( behavior: CustomScrollBehavior(), @@ -99,20 +310,7 @@ class _GoodsState extends State { activeColor: Colors.white, )), indicatorLayout: PageIndicatorLayout.SCALE, - children: [ - Image.network( - 'https://img13.360buyimg.com/n1/jfs/t1/263909/5/4187/123220/676eb220F3e481086/0cee829b1894fc4c.jpg', - fit: BoxFit.cover, - ), - Image.network( - 'https://img13.360buyimg.com/n1/jfs/t1/245928/34/24374/150795/673b0a0cFdb8831f9/9235d1ed7654aa44.jpg', - fit: BoxFit.cover, - ), - Image.network( - 'https://img30.360buyimg.com/n1/jfs/t1/240005/25/26374/136411/6756e1f9Fb685b2ec/3be83b3e1a08169d.jpg', - fit: BoxFit.cover, - ), - ], + children: swiperList.map((sw) => NetworkOrAssetImage(imageUrl: sw)).toList(), ), ), ), @@ -126,7 +324,12 @@ class _GoodsState extends State { Container( padding: EdgeInsets.fromLTRB(15.0, 10.0, 15.0, 25.0), decoration: BoxDecoration( - gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF5000), Color(0xFFFFAA00)])), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF5000), Color(0xFFFFAA00)], + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, spacing: 5.0, @@ -134,16 +337,17 @@ class _GoodsState extends State { Row( spacing: 5.0, children: [ - Text( - '¥3838', - style: TextStyle( - color: Colors.white, - fontSize: 16.0, - decoration: TextDecoration.lineThrough, - decorationColor: Colors.black, - decorationThickness: 1.5, - ), - ), + // 原价 + // Text( + // '¥${shopObj['price']}', + // style: TextStyle( + // color: Colors.white, + // fontSize: 16.0, + // decoration: TextDecoration.lineThrough, + // decorationColor: Colors.black, + // decorationThickness: 1.5, + // ), + // ), Container( padding: EdgeInsets.symmetric(horizontal: 8.0, vertical: 3.0), decoration: BoxDecoration( @@ -151,21 +355,23 @@ class _GoodsState extends State { borderRadius: BorderRadius.circular(50.0), ), child: Text( - '现价¥3800', + '¥${shopObj['price']}', style: TextStyle(color: Colors.red, fontSize: 12.0), ), ), + Text( + // '已售${Utils().graceNumber(shopObj['sales'] ?? 0)}', + '已售${Utils().graceNumber(int.tryParse(shopObj['sales']?.toString() ?? '0') ?? 0)}', + style: TextStyle(color: Colors.white, fontSize: 12.0), + ), ], ), - Text( - '已售1.1w', - style: TextStyle(color: Colors.white, fontSize: 12.0), - ), ], ), ), Container( padding: EdgeInsets.fromLTRB(10.0, 10.0, 10.0, 0), + width: double.infinity, decoration: BoxDecoration( color: Color(0xFFFAFAFA), borderRadius: BorderRadius.vertical(top: Radius.circular(15.0)), @@ -176,18 +382,45 @@ class _GoodsState extends State { // 标题 Container( padding: EdgeInsets.all(5.0), - child: Text.rich( - TextSpan(children: [ - TextSpan(text: ' 年货节 ', style: TextStyle(fontSize: 11.0, backgroundColor: const Color(0xFFFF5000), color: Colors.white)), + child: Align( + alignment: Alignment.centerLeft, + child: Text.rich( TextSpan( - text: ' 茅台(MOUTAI)飞天 53度 酱香型白酒 500ml*2 海外版送礼袋年货送礼', - style: TextStyle(fontSize: 14.0, fontWeight: FontWeight.w700), + children: [ + WidgetSpan( + alignment: PlaceholderAlignment.middle, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFFF5000), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + shopObj['productCategoryName'] ?? '未知分类名称', + style: const TextStyle( + fontSize: 12.0, + color: Colors.white, + ), + ), + ), + ), + const WidgetSpan(child: SizedBox(width: 4)), + TextSpan( + text: '${shopObj['describe'] ?? ''}', + style: TextStyle( + fontSize: 14.0, + fontWeight: FontWeight.w700, + ), + ), + ], ), - ]), - maxLines: 2, - overflow: TextOverflow.ellipsis, + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + ), ), ), + // 规格 Container( margin: EdgeInsets.only(top: 10.0), @@ -196,84 +429,90 @@ class _GoodsState extends State { color: Colors.white, borderRadius: BorderRadius.circular(15.0), ), + // child: Column( + // spacing: 10.0, + // children: [ + // Row( + // spacing: 5.0, + // children: [ + // Icon( + // Icons.timer, + // size: 16.0, + // ), + // Expanded( + // child: Text( + // '本商品请于2025.01.25前进行核销', + // style: TextStyle(fontSize: 12.0), + // ), + // ), + // ], + // ), + // Row( + // spacing: 5.0, + // children: [ + // Icon( + // Icons.house_outlined, + // size: 16.0, + // ), + // Expanded( + // child: Text( + // '营业时间:7x24', + // style: TextStyle(fontSize: 12.0), + // ), + // ), + // ], + // ), + // Row( + // spacing: 5.0, + // children: [ + // Icon( + // Icons.location_on, + // size: 16.0, + // ), + // Expanded( + // child: Text( + // '河北省唐山市玉田县', + // style: TextStyle(fontSize: 12.0), + // ), + // ), + // ], + // ), + // ], + // ), child: Column( - spacing: 10.0, - children: [ - Row( - spacing: 5.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: attrList.map((attr) { + final attrName = attr['name'] ?? ''; + final options = attr['options'] as List? ?? []; + final optionNames = options.map((o) => o['name']).join(' / '); + return Row( children: [ - Icon( - Icons.timer, - size: 16.0, + Text( + '$attrName: ', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.bold), ), Expanded( child: Text( - '本商品请于2025.01.25前进行核销', - style: TextStyle(fontSize: 12.0), + optionNames, + style: TextStyle(fontSize: 12), ), ), ], - ), - Row( - spacing: 5.0, - children: [ - Icon( - Icons.house_outlined, - size: 16.0, - ), - Expanded( - child: Text( - '营业时间:7x24', - style: TextStyle(fontSize: 12.0), - ), - ), - ], - ), - Row( - spacing: 5.0, - children: [ - Icon( - Icons.location_on, - size: 16.0, - ), - Expanded( - child: Text( - '河北省唐山市玉田县', - style: TextStyle(fontSize: 12.0), - ), - ), - ], - ), - ], + ); + }).toList(), ), ), // 详情 Container( - margin: EdgeInsets.only(top: 10.0), - padding: EdgeInsets.all(10.0), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(15.0), - ), - child: Column( - spacing: 10.0, - children: [ - Text('【飞天茅台】传承悠久,酱香型白酒典范,四大名酒之一。 【爆款直降】纯粮酿造,固态发酵。 【精髓制作】工艺精湛,入口绵、落口甜、饮后余香。'), - Image.network( - 'https://img30.360buyimg.com/n1/jfs/t1/187328/18/54595/115429/6756e1c7F126ab0d4/fe96f6fd5dfe125d.jpg', - fit: BoxFit.contain, - ), - Image.network( - 'https://img30.360buyimg.com/n1/jfs/t1/240005/25/26374/136411/6756e1f9Fb685b2ec/3be83b3e1a08169d.jpg', - fit: BoxFit.contain, - ), - Image.network( - 'https://img30.360buyimg.com/n1/jfs/t1/247398/2/28177/97778/6756da95F518f621c/746dc23032c171ca.jpg', - fit: BoxFit.contain, - ), - ], - ), - ), + margin: EdgeInsets.only(top: 10.0), + padding: EdgeInsets.all(10.0), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + ), + child: Html( + data: shopObj['detailMobileHtml'] ?? '暂无', + )), ], ), ), @@ -296,50 +535,66 @@ class _GoodsState extends State { child: Row( spacing: 15.0, children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.store, - color: Color(0xFFFF5000), - size: 18.0, - ), - Text( - '店铺', - style: TextStyle(fontSize: 12.0), - ) - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.child_care_outlined, - size: 18.0, - ), - Text( - '客服', - style: TextStyle(fontSize: 12.0), - ) - ], - ), - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Badge.count( - backgroundColor: Color(0xFFFF5000), - count: 6, - child: Icon( - Icons.shopping_cart_outlined, + // Column( + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // Icon( + // Icons.store, + // color: Color(0xFFFF5000), + // size: 18.0, + // ), + // Text( + // '店铺', + // style: TextStyle(fontSize: 12.0), + // ) + // ], + // ), + GestureDetector( + onTap: () async { + // 可以在这里打开聊天、拨打电话等 + logger.i('联系客服'); + final res = await ImService.instance.getConversation(conversationID: 'c2c_${shopObj['shoperId']}'); + V2TimConversation conversation = res.data; + logger.i(conversation.toLogString()); + if (res.success) { + // 客服聊天不用检测关注关系 + conversation.showName = conversation.showName ?? shopObj['storeName']; + Get.toNamed('/chat', arguments: conversation); + } else { + MyDialog.toast(res.desc, icon: const Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.child_care_outlined, size: 18.0, ), - ), - Text( - '购物车', - style: TextStyle(fontSize: 12.0), - ) - ], - ), + Text( + '联系商家', + style: TextStyle(fontSize: 12.0), + ) + ], + ), + ) + // Column( + // mainAxisAlignment: MainAxisAlignment.center, + // children: [ + // Badge.count( + // backgroundColor: Color(0xFFFF5000), + // count: 6, + // child: Icon( + // Icons.shopping_cart_outlined, + // size: 18.0, + // ), + // ), + // Text( + // '购物车', + // style: TextStyle(fontSize: 12.0), + // ) + // ], + // ), ], ), ), @@ -353,19 +608,19 @@ class _GoodsState extends State { ), child: Row( children: [ - Padding( - padding: EdgeInsets.symmetric(horizontal: 10.0), - child: Text( - '加入购物车', - style: TextStyle(color: Color(0xFFFF5000), fontSize: 14.0), - ), - ), + // Padding( + // padding: EdgeInsets.symmetric(horizontal: 10.0), + // child: Text( + // '加入购物车', + // style: TextStyle(color: Color(0xFFFF5000), fontSize: 14.0), + // ), + // ), Container( alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 20.0), color: Color(0xFFFF5000), child: Text( - '领券购买', + '立即购买', style: TextStyle(color: Colors.white, fontSize: 14.0), ), ), diff --git a/lib/pages/index/index.dart b/lib/pages/index/index.dart index da2cd54..f04c58d 100644 --- a/lib/pages/index/index.dart +++ b/lib/pages/index/index.dart @@ -1,18 +1,15 @@ /// 首页模板 library; -import 'dart:ui'; - import 'package:card_swiper/card_swiper.dart'; +import 'package:easy_refresh/easy_refresh.dart'; import 'package:flutter/material.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:get/get.dart'; -import 'package:loopin/IM/im_message.dart'; -import 'package:loopin/components/custom_sticky_header.dart'; - -import '../../behavior/custom_scroll_behavior.dart'; -import '../../components/backtop.dart'; -import '../../components/loading.dart'; +import 'package:loopin/components/backtop.dart'; +import 'package:loopin/components/loading.dart'; +import 'package:loopin/controller/shop_index_controller.dart'; +import 'package:loopin/utils/index.dart'; class IndexPage extends StatefulWidget { const IndexPage({super.key}); @@ -23,133 +20,25 @@ class IndexPage extends StatefulWidget { class _IndexPageState extends State with SingleTickerProviderStateMixin { // 分类列表 - List cateList = [ - { - 'id': 1, - 'list': [ - { - 'icon': 'order.svg', - 'label': '我的订单', - }, - { - 'icon': 'chongzhi.svg', - 'label': '充值中心', - }, - {'icon': 'qianbao.svg', 'label': '余额'}, - {'icon': 'comment.svg', 'label': '评价中心'} - ] - } - ]; - - List tabList = ['推荐', '美食', '娱乐', '文旅', '医疗', '房产']; - - // 瀑布流列表 - List waterfallData = [ - { - 'price': 199.00, - 'title': '韩料界的萨莉亚!', - 'shop': '萨莉亚专卖店', - 'image': 'https://qcloud.dpfile.com/pc/1c3egbzM_ICz90dhi6MAiTsazjxWYQcHCd-sbpD1Wqtph2eIJA04NCRvoGqL4_opG45IiB1YIyNuDTtqzVRwesm_qA1Pf8rFcayTY-n-rG8.jpg', - 'saleNum': '2.1万' - }, - { - 'price': 1499.90, - 'title': '茅台(MOUTAI)飞天 53%vol 500ml 贵州茅台酒(带杯)', - 'shop': '茅台京东自营旗舰店', - 'image': 'https://img13.360buyimg.com/n1/jfs/t1/97097/12/15694/245806/5e7373e6Ec4d1b0ac/9d8c13728cc2544d.jpg', - 'saleNum': '1254' - }, - { - 'price': 18.90, - 'title': '上海街头苹果糖!一口一个不吱声', - 'shop': '芝洛洛自营旗舰店', - 'image': 'https://p0.meituan.net/coverpic/f0eefdfa02619fb09ca53eacd4d97231123115.jpg', - 'saleNum': '1.2万' - }, - { - 'price': 59.00, - 'title': '谁懂,就是这个菜,尝了第一口,立马决定加单了,真正的咸甜永动机啊🍬 去过云南的朋友都知道,当地的乳扇真的很好吃。', - 'shop': '薄荷牛舌卷旗舰店', - 'image': 'https://qcloud.dpfile.com/pc/UcW-v6AN1TxVTt9--5Kaw2-t4W55jUhEG_pM5S-w_AQ4IP3z9WxHzwJ9fOthIjEYY0q73sB2DyQcgmKUxZFQtw.jpg', - 'saleNum': '1639' - }, - { - 'price': 2499.00, - 'title': '小米 REDMI K80 国家补贴 第三代骁龙 8 6550mAh大电池 澎湃OS 玄夜黑 12GB+256GB 红米5G至尊手机', - 'shop': '小米京东自营旗舰店', - 'image': 'https://img10.360buyimg.com/n1/s450x450_jfs/t1/264409/38/13856/102861/678dcfdaFb723c58f/5b97cf154bbba96c.jpg', - 'saleNum': '9726' - }, - { - 'price': 1.00, - 'title': '圣菲尔伯爵法国红酒Saintfilcount干红葡萄酒珍藏13.5度单瓶送礼红酒 一元试饮', - 'shop': '小森葡萄酒专营店', - 'image': 'https://img10.360buyimg.com/n7/jfs/t1/226168/23/3411/118733/65537e5fF2db2d109/7d1d11a8013d6e8f.jpg', - 'saleNum': '9.9万' - }, - { - 'price': 42.00, - 'title': '美的(Midea)LED便携充电小台灯书桌学习阅读灯学生宿舍卧室床头灯学习台灯', - 'shop': '美的(Midea)旗舰店', - 'image': 'https://img14.360buyimg.com/mobilecms/s360x360_jfs/t1/226233/4/10194/156936/658e8f88Fcfc9cb40/cea4a48783f11a7a.jpg', - 'saleNum': '5106' - }, - { - 'price': 22.90, - 'title': '蒙都 羊杂500g 加热即食 京东超市肉干肉脯及礼包11.11真便宜', - 'shop': '蒙都旗舰店', - 'image': 'https://img10.360buyimg.com/n7/jfs/t1/155306/32/25324/231912/62d22fb8E4ffab855/c6001ee702fb240a.jpg', - 'saleNum': '1.6万' - }, - { - 'price': 19.90, - 'title': '『 江西炒米粉 』本次最佳😋香就一个字话。锅气的香🔥干辣椒的焦香🌶️油的润香🐷蔬菜混合的清香🥬', - 'shop': '去月球野餐嗎', - 'image': 'https://qcloud.dpfile.com/pc/pOAOL-DQRBWfkVZIWYVoy0mMQf6_UutNlOpEpGkT_nz3b1n7ZbpikPgtXMhMsjXNY0q73sB2DyQcgmKUxZFQtw.jpg', - 'saleNum': '3.2万' - }, - { - 'price': 109.00, - 'title': '附近新开业的,作为江西人当然要去试试。点了几个家常菜。', - 'shop': '辣评新开江西菜', - 'image': 'https://qcloud.dpfile.com/pc/HePD48CFNnS0kMZyf3Q391wxaW_zVgHimctthH__J6UI54HLPUkNt5e3qtP4Nl2G_aW_B6sGElzX-tSmYRvRnQxxxek7cKy7_R0W-KdxWUk.jpg', - 'saleNum': '8764' - }, - ]; - // 列表 - List dataList = []; - // 是否加载中 - bool isLoading = false; - - late ScrollController scrollController = ScrollController(); - late TabController tabController = TabController(initialIndex: 0, length: tabList.length, vsync: this); - final PageController pageController = PageController(); - // 滚动位置 - double scrollOffset = 0; - - // 加载更多 - loadMoreData() async { - setState(() { - isLoading = true; - }); - await Future.delayed(Duration(seconds: 1)); - setState(() { - dataList.addAll(waterfallData); - isLoading = false; - }); - } - - // 下拉刷新 - Future handleRefresh() async { - await Future.delayed(Duration(seconds: 1)); - dataList.clear(); - for (int i = 0; i < waterfallData.length; i++) { - dataList.add(waterfallData[i]); - } - if (mounted) { - setState(() {}); - } - } + // List cateList = [ + // { + // 'id': 1, + // 'list': [ + // { + // 'icon': 'order.svg', + // 'label': '我的订单', + // }, + // { + // 'icon': 'chongzhi.svg', + // 'label': '充值中心', + // }, + // {'icon': 'qianbao.svg', 'label': '余额'}, + // {'icon': 'comment.svg', 'label': '评价中心'} + // ] + // } + // ]; + final ScrollController pageScrollController = ScrollController(); + final ShopIndexController controller = Get.put(ShopIndexController()); // 瀑布流卡片 Widget cardList(item) { @@ -166,7 +55,7 @@ class _IndexPageState extends State with SingleTickerProviderStateMix ]), child: Column( children: [ - Image.network('${item['image']}'), + Image.network('${item['pic']}'), Container( padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), child: Column( @@ -174,7 +63,7 @@ class _IndexPageState extends State with SingleTickerProviderStateMix spacing: 5.0, children: [ Text( - '${item['title']}', + '${item['name']}', style: TextStyle(fontSize: 14.0, height: 1.2), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -193,13 +82,13 @@ class _IndexPageState extends State with SingleTickerProviderStateMix ]), ), Text( - '已售${item['saleNum']}件', + '已售${Utils().graceNumber(int.parse(item['sales'] ?? '0'))}件', style: TextStyle(color: Colors.grey, fontSize: 10.0), ), ], ), Text( - '${item['shop']}', + '${item['storeName']}', style: TextStyle(color: Colors.grey, fontSize: 12.0), ), ], @@ -209,7 +98,7 @@ class _IndexPageState extends State with SingleTickerProviderStateMix ), ), onTap: () { - Get.toNamed('/goods'); + Get.toNamed('/goods', arguments: item['id']); }, ); } @@ -217,282 +106,194 @@ class _IndexPageState extends State with SingleTickerProviderStateMix @override void initState() { super.initState(); - scrollController.addListener(() { - setState(() { - scrollOffset = scrollController.offset; - }); - if (scrollController.position.pixels == scrollController.position.maxScrollExtent) { - debugPrint('[index]滚动到底部'); - if (!isLoading) { - loadMoreData(); - } - } - }); - // 初始化加载 - handleRefresh(); - } - - @override - void dispose() { - scrollController.dispose(); - tabController.dispose(); - pageController.dispose(); - super.dispose(); + controller.initTabs(); } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: Colors.grey[50], - body: ScrollConfiguration( - behavior: CustomScrollBehavior().copyWith(scrollbars: false), - child: CustomScrollView( - scrollBehavior: CustomScrollBehavior().copyWith(scrollbars: false), - controller: scrollController, - slivers: [ - SliverAppBar( - backgroundColor: Colors.transparent, - foregroundColor: Colors.white, - pinned: true, - expandedHeight: 200.0, - titleSpacing: 10.0, - // 搜索框(高斯模糊背景) - title: ClipRRect( - borderRadius: BorderRadius.circular(30.0), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), - child: Container( - height: 45.0, - decoration: BoxDecoration( - color: Colors.white.withAlpha(200), - ), - child: TextField( - decoration: InputDecoration( - isDense: true, - hintText: "2025百亿补贴", - hintStyle: TextStyle(fontSize: 15.0), - prefixIcon: Icon( - Icons.search, - color: Colors.black38, - size: 21.0, - ), - suffixIcon: Container( - padding: EdgeInsets.only(right: 15.0), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 10.0, - children: [ - Icon( - Icons.keyboard_voice, - color: Colors.black45, - size: 21.0, - ), - Icon( - Icons.camera_alt_outlined, - color: Colors.black45, - size: 21.0, - ), - ], - ), - ), - contentPadding: EdgeInsets.symmetric(vertical: 0, horizontal: 10.0), - border: OutlineInputBorder(borderSide: BorderSide.none, borderRadius: BorderRadius.circular(30.0))), - cursorColor: Colors.black, - onChanged: (val) { - debugPrint(val); - }, - ), - ), - ), + return Obx(() { + final tabIndex = controller.currentTabIndex.value; + final currentTab = controller.tabs[tabIndex]; + + return Scaffold( + backgroundColor: Colors.grey[50], + body: Column( + children: [ + // 顶部固定区域(轮播图 + TabBar) + _buildTopSection(), + + // 内容区域 + Expanded( + child: TabBarView( + controller: controller.tabController, + children: controller.tabList.asMap().entries.map((entry) { + final index = entry.key; + return _buildTabContent(index); + }).toList(), ), - actions: [ - IconButton( - icon: Icon(Icons.shopping_cart_outlined), - onPressed: () {}, - ), - ], - // 自定义伸缩区域(轮播图) - flexibleSpace: Container( - decoration: BoxDecoration( - gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF5000), Color(0xFFfcaec4)])), - child: FlexibleSpaceBar( - background: Swiper.children( - pagination: SwiperPagination( - builder: DotSwiperPaginationBuilder( + ), + ], + ), + floatingActionButton: currentTab != null + ? Backtop( + controller: currentTab.scrollController, + offset: currentTab.scrollOffset.value, + ) + : null, + ); + }); + } + + // 构建顶部固定区域 + Widget _buildTopSection() { + double screenWidth = MediaQuery.of(context).size.width; + int tabCount = controller.tabList.length; + // 每个 Tab 的最小宽度 + double minTabWidth = 80; + // 是否可滚动 + bool isScrollable = tabCount * minTabWidth > screenWidth; + return Column( + children: [ + // 轮播图 + Container( + width: double.infinity, + height: 240, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF5000), Color(0xFFfcaec4)], + ), + ), + child: controller.swiperData.length <= 1 + ? (controller.swiperData.isNotEmpty + ? Image.network( + controller.swiperData.first['images'] ?? '', + fit: BoxFit.fill, + ) + : const SizedBox.shrink()) + : Swiper( + itemCount: controller.swiperData.length, + autoplay: true, + loop: true, + pagination: SwiperPagination( + builder: DotSwiperPaginationBuilder( color: Colors.white70, activeColor: Colors.white, - )), - indicatorLayout: PageIndicatorLayout.SCALE, - children: [ - Image.network( - 'https://m.360buyimg.com/babel/jfs/t20271217/224114/35/38178/150060/6760d559Fd654f946/968c156726b6e822.png', - fit: BoxFit.fill, - ), - Image.network( - 'https://m.360buyimg.com/babel/jfs/t20280117/88832/5/48468/139826/6789cbcfF4e0b2a3d/9dc54355b6f65c40.jpg', - fit: BoxFit.fill, - ), - Image.network( - 'https://m.360buyimg.com/babel/jfs/t20280108/255505/29/10540/137372/677ddbc1F6cdbbed0/bc477fadedef22a8.jpg', - fit: BoxFit.fill, - ), - ], - ), - ), - ), - ), - - // 分类 - // SliverToBoxAdapter( - // child: Container( - // margin: EdgeInsets.all(10.0), - // padding: EdgeInsets.symmetric(vertical: 10.0), - // height: 90.0, - // clipBehavior: Clip.antiAlias, - // decoration: BoxDecoration( - // color: Colors.white, - // borderRadius: BorderRadius.circular(15.0), - // ), - // child: Column( - // children: [ - // Expanded( - // child: PageView.builder( - // controller: pageController, - // itemCount: cateList.length, - // itemBuilder: (context, index) { - // final item = cateList[index]; - // return GridView.builder( - // shrinkWrap: true, - // padding: EdgeInsets.zero, - // physics: NeverScrollableScrollPhysics(), - // gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - // crossAxisCount: 4, - // ), - // itemCount: item['list'].length, - // itemBuilder: (BuildContext context, int index) { - // final citem = item['list'][index]; - // // return Column( - // // spacing: 3.0, - // // children: [ - // // if (citem['icon'] != null) - // // SvgPicture.asset( - // // 'assets/images/svg/${citem['icon']}', - // // height: 30.0, - // // width: 30.0, - // // ), - // // Text(citem['label']), - // // ], - // // ); - // return GestureDetector( - // onTap: () { - // logger.i('点击了$index'); - // // 跳转逻辑,用你自己的目标路由替换 - // Get.toNamed('/order'); - // }, - // child: Column( - // mainAxisSize: MainAxisSize.min, - // children: [ - // if (citem['icon'] != null) - // SvgPicture.asset( - // 'assets/images/svg/${citem['icon']}', - // height: 30.0, - // width: 30.0, - // ), - // Text(citem['label']), - // ], - // ), - // ); - // }, - // ); - // }, - // ), - // ), - // CustomPageViewIndicator( - // controller: pageController, - // count: cateList.length, - // color: Color(0xFFCECECE), - // activeColor: Color(0xFFFF5000), - // ), - // ], - // )), - // ), - - // tabbar列表 - SliverPersistentHeader( - pinned: true, - delegate: CustomStickyHeader( - child: PreferredSize( - preferredSize: Size.fromHeight(45.0), - child: Container( - color: Colors.white, - height: 45.0, - child: TabBar( - controller: tabController, - onTap: (index) { - logger.i('点击了第 $index 个 tab'); - }, - tabs: tabList.map((v) => Tab(text: v)).toList(), - isScrollable: false, - overlayColor: WidgetStateProperty.all(Colors.transparent), - unselectedLabelColor: Colors.black87, - labelColor: Color(0xFFFF5000), - indicatorColor: Color(0xFFFF5000), - indicatorSize: TabBarIndicatorSize.tab, - unselectedLabelStyle: TextStyle(fontSize: 15.0, fontFamily: 'Microsoft YaHei'), - labelStyle: TextStyle(fontSize: 15.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.w700), - dividerHeight: 0, - padding: EdgeInsets.symmetric(horizontal: 10.0), - labelPadding: EdgeInsets.symmetric(horizontal: 7.5), - indicatorPadding: EdgeInsets.symmetric(horizontal: 15.0, vertical: 5.0), ), ), + itemBuilder: (context, index) { + final imageUrl = controller.swiperData[index]['images'] ?? ''; + return imageUrl.isNotEmpty ? Image.network(imageUrl, fit: BoxFit.fill) : const SizedBox.shrink(); + }, ), - ), - ), + ), - // 瀑布流列表 + // TabBar + Container( + color: Colors.white, + child: TabBar( + controller: controller.tabController, + tabs: controller.tabList.map((item) { + return Tab( + child: Text(item['name'], style: const TextStyle(fontWeight: FontWeight.bold)), + ); + }).toList(), + isScrollable: isScrollable, + overlayColor: WidgetStateProperty.all(Colors.transparent), + unselectedLabelColor: Colors.black87, + labelColor: Color.fromARGB(255, 236, 108, 49), + indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Color.fromARGB(255, 236, 108, 49), width: 2.0)), + unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), + labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), + dividerHeight: 0, + ), + ), + ], + ); + } + + // 构建标签页内容 + Widget _buildTabContent(int index) { + final tabState = controller.tabs[index]!; + + return Obx(() { + if (tabState.dataList.isEmpty && tabState.isLoading.value) { + return Center( + child: RefreshProgressIndicator( + backgroundColor: Colors.white, + color: Color(0xFFFF5000), + ), + ); + } + + // 添加 下拉刷新 + return EasyRefresh( + onRefresh: () async { + await controller.refreshData(index); + }, + header: ClassicHeader( + dragText: '下拉刷新', + armedText: '释放刷新', + readyText: '刷新中...', + processingText: '刷新完成', + messageText: '最后更新于 %T', + ), + child: CustomScrollView( + controller: tabState.scrollController, + key: PageStorageKey('tab_$index'), + physics: const AlwaysScrollableScrollPhysics(), // 确保可下拉 + slivers: [ + SliverPadding( + padding: EdgeInsets.fromLTRB(10, 10, 10, 10), + sliver: tabState.dataList.isEmpty + ? SliverToBoxAdapter( + child: SizedBox( + height: MediaQuery.of(context).size.height - 500, // 给个足够高度让下拉触发 + child: Center(child: _emptyTip('暂无数据')), + ), + ) + : SliverMasonryGrid.count( + crossAxisCount: 2, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + childCount: tabState.dataList.length, + itemBuilder: (context, idx) => cardList(tabState.dataList[idx]), + ), + ), SliverToBoxAdapter( - child: Container( - padding: EdgeInsets.all(10.0), - child: Column( - children: [ - dataList.isEmpty - ? - // 初始loading提示 - Column( - children: [ - RefreshProgressIndicator( - backgroundColor: Colors.white, - color: Color(0xFFFF5000), - ), - ], - ) - : MasonryGridView.count( - shrinkWrap: true, - padding: EdgeInsets.zero, - physics: NeverScrollableScrollPhysics(), - crossAxisCount: 2, - mainAxisSpacing: 10.0, - crossAxisSpacing: 10.0, - itemCount: dataList.length + (isLoading ? 1 : 0), - itemBuilder: (BuildContext context, int index) { - if (index < dataList.length) { - return cardList(dataList[index]); - } else { - return SizedBox.shrink(); - } - }, - ), - Opacity(opacity: dataList.isNotEmpty && isLoading ? 1 : 0, child: Loading(title: 'loading...')), - ], + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: tabState.isLoading.value + ? const Loading(title: 'loading...') + : (tabState.dataList.isNotEmpty ? const Text('没有更多数据了') : const SizedBox.shrink()), ), ), ), ], ), + ); + }); + } + + // 空状态提示 + Widget _emptyTip(String text) { + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 50), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/images/empty.png', width: 100), + const SizedBox(height: 8), + Text( + text, + style: const TextStyle(color: Colors.grey, fontSize: 13), + ), + ], + ), ), - // 返回顶部 - floatingActionButton: Backtop(controller: scrollController, offset: scrollOffset), ); } } diff --git a/lib/pages/index/indexcopy.dart b/lib/pages/index/indexcopy.dart new file mode 100644 index 0000000..5d0eb6a --- /dev/null +++ b/lib/pages/index/indexcopy.dart @@ -0,0 +1,497 @@ +/// 首页模板 +library; + +import 'dart:ui'; + +import 'package:card_swiper/card_swiper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:get/get.dart'; +import 'package:loopin/behavior/custom_scroll_behavior.dart'; +import 'package:loopin/components/backtop.dart'; +import 'package:loopin/components/custom_sticky_header.dart'; +import 'package:loopin/components/loading.dart'; +import 'package:loopin/components/only_down_scroll_physics.dart'; +import 'package:loopin/controller/shop_index_controller.dart'; + +class IndexPage extends StatefulWidget { + const IndexPage({super.key}); + + @override + State createState() => _IndexPageState(); +} + +class _IndexPageState extends State with SingleTickerProviderStateMixin { + // 分类列表 + // List cateList = [ + // { + // 'id': 1, + // 'list': [ + // { + // 'icon': 'order.svg', + // 'label': '我的订单', + // }, + // { + // 'icon': 'chongzhi.svg', + // 'label': '充值中心', + // }, + // {'icon': 'qianbao.svg', 'label': '余额'}, + // {'icon': 'comment.svg', 'label': '评价中心'} + // ] + // } + // ]; + final ScrollController pageScrollController = ScrollController(); + final ShopIndexController controller = Get.put(ShopIndexController()); + + // 下拉刷新初始化 + Future handleRefresh() async {} + + ///商品详情 + void shopDetail() async { + // final res = await Http.get('${ShopApi.shopDetail}/1938137499482869762'); + // logger.e(res['data']); + } + + // 瀑布流卡片 + Widget cardList(item) { + return GestureDetector( + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(15.0), boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha(5), + offset: Offset(0.0, 1.0), + blurRadius: 1.0, + spreadRadius: 0.0, + ), + ]), + child: Column( + children: [ + Image.network('${item['pic']}'), + Container( + padding: EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 5.0, + children: [ + Text( + '${item['name']}', + style: TextStyle(fontSize: 14.0, height: 1.2), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + Row( + spacing: 5.0, + children: [ + Text.rich( + TextSpan(style: TextStyle(color: Colors.red, fontSize: 12.0, fontWeight: FontWeight.w700, fontFamily: 'Arial'), children: [ + TextSpan(text: '¥'), + TextSpan( + text: '${item['price']}', + style: TextStyle( + fontSize: 16.0, + )), + ]), + ), + Text( + '已售${item['sales']}件', + style: TextStyle(color: Colors.grey, fontSize: 10.0), + ), + ], + ), + Text( + '${item['shop']}', + style: TextStyle(color: Colors.grey, fontSize: 12.0), + ), + ], + ), + ) + ], + ), + ), + onTap: () { + Get.toNamed('/goods'); + }, + ); + } + + @override + void initState() { + super.initState(); + controller.initTabs(); + } + + @override + Widget build(BuildContext context) { + return Obx(() { + final tabIndex = controller.currentTabIndex.value; + final scrollController = controller.tabs[tabIndex]?.scrollController; + final pagesView = controller.tabs[tabIndex]; + return Scaffold( + backgroundColor: Colors.grey[50], + + body: ScrollConfiguration( + behavior: CustomScrollBehavior().copyWith(scrollbars: false), + child: CustomScrollView( + scrollBehavior: CustomScrollBehavior().copyWith(scrollbars: false), + controller: scrollController, + slivers: [ + SliverAppBar( + backgroundColor: Colors.transparent, + foregroundColor: Colors.white, + pinned: true, + expandedHeight: 200.0, + titleSpacing: 10.0, + // 搜索框(高斯模糊背景) + // title: ClipRRect( + // borderRadius: BorderRadius.circular(30.0), + // child: BackdropFilter( + // filter: ImageFilter.blur(sigmaX: 10.0, sigmaY: 10.0), + // child: Container( + // height: 45.0, + // decoration: BoxDecoration( + // color: Colors.white.withAlpha(200), + // ), + // child: TextField( + // decoration: InputDecoration( + // isDense: true, + // hintText: "2025百亿补贴", + // hintStyle: TextStyle(fontSize: 15.0), + // prefixIcon: Icon( + // Icons.search, + // color: Colors.black38, + // size: 21.0, + // ), + // suffixIcon: Container( + // padding: EdgeInsets.only(right: 15.0), + // child: Row( + // mainAxisSize: MainAxisSize.min, + // spacing: 10.0, + // children: [ + // Icon( + // Icons.keyboard_voice, + // color: Colors.black45, + // size: 21.0, + // ), + // Icon( + // Icons.camera_alt_outlined, + // color: Colors.black45, + // size: 21.0, + // ), + // ], + // ), + // ), + // contentPadding: EdgeInsets.symmetric(vertical: 0, horizontal: 10.0), + // border: OutlineInputBorder(borderSide: BorderSide.none, borderRadius: BorderRadius.circular(30.0))), + // cursorColor: Colors.black, + // onChanged: (val) { + // debugPrint(val); + // }, + // ), + // ), + // ), + // ), + // actions: [ + // IconButton( + // icon: Icon(Icons.shopping_cart_outlined), + // onPressed: () {}, + // ), + // ], + // 自定义伸缩区域(轮播图) + flexibleSpace: Container( + decoration: BoxDecoration( + gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFF5000), Color(0xFFfcaec4)])), + child: FlexibleSpaceBar( + background: Swiper.children( + pagination: SwiperPagination( + builder: DotSwiperPaginationBuilder( + color: Colors.white70, + activeColor: Colors.white, + ), + ), + indicatorLayout: PageIndicatorLayout.SCALE, + children: [ + ...controller.swiperData.map((item) { + final imageUrl = item['images'] ?? ''; + return imageUrl.isNotEmpty + ? Image.network( + imageUrl, + fit: BoxFit.fill, + ) + : SizedBox.shrink(); + }), + ], + ), + ), + ), + ), + + // tabbar列表 + SliverPersistentHeader( + pinned: true, + delegate: CustomStickyHeader( + child: PreferredSize( + preferredSize: Size.fromHeight(45.0), + child: Container( + color: Colors.white, + height: 45.0, + child: TabBar( + controller: controller.tabController, + onTap: (index) { + print('点击了第 $index 个 tab'); + }, + tabs: controller.tabList.map((v) => Tab(text: v['name'])).toList(), + isScrollable: true, + overlayColor: WidgetStateProperty.all(Colors.transparent), + unselectedLabelColor: Colors.black87, + labelColor: Color(0xFFFF5000), + indicatorColor: Color(0xFFFF5000), + indicatorSize: TabBarIndicatorSize.tab, + unselectedLabelStyle: TextStyle(fontSize: 15.0, fontFamily: 'Microsoft YaHei'), + labelStyle: TextStyle(fontSize: 15.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.w700), + dividerHeight: 0, + padding: EdgeInsets.symmetric(horizontal: 10.0), + labelPadding: EdgeInsets.symmetric(horizontal: 7.5), + indicatorPadding: EdgeInsets.symmetric(horizontal: 15.0, vertical: 5.0), + ), + ), + ), + ), + ), + + // 瀑布流列表 + SliverToBoxAdapter( + child: Container( + padding: EdgeInsets.all(10.0), + child: Column( + children: [ + pagesView?.dataList.isEmpty ?? true + ? + // 初始loading提示 + Column( + children: [ + RefreshProgressIndicator( + backgroundColor: Colors.white, + color: Color(0xFFFF5000), + ), + ], + ) + : MasonryGridView.count( + shrinkWrap: true, + padding: EdgeInsets.zero, + physics: NeverScrollableScrollPhysics(), + crossAxisCount: 2, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + itemCount: ((pagesView?.dataList.length ?? 0) + (pagesView?.isLoading.value == true ? 1 : 0)), + itemBuilder: (BuildContext context, int index) { + final dataList = pagesView?.dataList ?? []; + if (index < dataList.length) { + return cardList(dataList[index]); + } else { + return SizedBox.shrink(); + } + }, + ), + Opacity( + opacity: ((pagesView?.dataList.isNotEmpty ?? false) && (pagesView?.isLoading.value ?? false)) ? 1 : 0, + child: Loading(title: 'loading...'), + ), + ], + ), + ), + ), + ], + ), + ), + // 返回顶部 + floatingActionButton: pagesView != null + ? Backtop( + controller: pagesView.scrollController, + offset: pagesView.scrollOffset.value, + ) + : null, + ); + }); + } + + // @override + // Widget build(BuildContext context) { + // return Obx(() { + // final tabIndex = controller.currentTabIndex.value; + // final pagesView = controller.tabs[tabIndex]; + // return Scaffold( + // backgroundColor: Colors.grey[50], + // body: NestedScrollViewPlus( + // // controller: pageScrollController, + // overscrollBehavior: OverscrollBehavior.outer, + // physics: (pagesView!.dataList.length > 4 && pagesView.currentPage > 1) + // ? const OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) + // : const AlwaysScrollableScrollPhysics(), + // headerSliverBuilder: (context, innerBoxIsScrolled) { + // return [ + // SliverAppBar( + // backgroundColor: Colors.transparent, + // foregroundColor: Colors.white, + // pinned: true, + // stretch: false, + // onStretchTrigger: () async { + // print('触发 stretch 拉伸'); + // // 加载刷新逻辑 + // }, + // expandedHeight: 180.0, + // // collapsedHeight: kToolbarHeight, + // collapsedHeight: 180.0, + // // 自定义伸缩区域(轮播图) + // flexibleSpace: Container( + // decoration: BoxDecoration( + // gradient: LinearGradient( + // begin: Alignment.topLeft, + // end: Alignment.bottomRight, + // colors: [ + // Color(0xFFFF5000), + // Color(0xFFfcaec4), + // ], + // ), + // ), + // child: FlexibleSpaceBar( + // background: Swiper.children( + // pagination: SwiperPagination( + // builder: DotSwiperPaginationBuilder( + // color: Colors.white70, + // activeColor: Colors.white, + // ), + // ), + // indicatorLayout: PageIndicatorLayout.SCALE, + // children: [ + // ...controller.swiperData.map((item) { + // final imageUrl = item['images'] ?? ''; + // return imageUrl.isNotEmpty + // ? Image.network( + // imageUrl, + // fit: BoxFit.fill, + // ) + // : SizedBox.shrink(); + // }), + // ], + // ), + // ), + // ), + // ), + // // tab吸顶 + // SliverPersistentHeader( + // pinned: true, + // delegate: CustomStickyHeader( + // child: PreferredSize( + // preferredSize: const Size.fromHeight(48.0), + // child: Container( + // color: Colors.white, + // child: TabBar( + // controller: controller.tabController, + // tabs: controller.tabList.map((item) { + // return Tab( + // child: Text(item['name'], style: TextStyle(fontWeight: FontWeight.bold)), + // ); + // }).toList(), + // isScrollable: false, + // overlayColor: WidgetStateProperty.all(Colors.transparent), + // unselectedLabelColor: Colors.black87, + // labelColor: const Color(0xFFFF5000), + // indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Color(0xFFFF5000), width: 2.0)), + // indicatorSize: TabBarIndicatorSize.tab, + // unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), + // labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), + // dividerHeight: 0, + // padding: const EdgeInsets.symmetric(horizontal: 10.0), + // labelPadding: const EdgeInsets.symmetric(horizontal: 15.0), + // ), + // ), + // ), + // ), + // ), + // ]; + // }, + // body: TabBarView( + // controller: controller.tabController, + // children: controller.tabList.map((tabItem) { + // final index = controller.tabList.indexOf(tabItem); + // return buildTabContent(index); + // }).toList(), + // ), + // ), + // ); + // }); + // } + + //子view + Widget buildTabContent(int index) { + final tabState = controller.tabs[index]!; + + return Obx(() { + if (tabState.dataList.isEmpty && tabState.isLoading.value) { + return const Center( + child: RefreshProgressIndicator( + backgroundColor: Colors.white, + color: Color(0xFFFF5000), + ), + ); + } + + return CustomScrollView( + primary: false, + controller: tabState.scrollController, + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(10.0), + sliver: SliverMasonryGrid.count( + crossAxisCount: 2, + mainAxisSpacing: 10.0, + crossAxisSpacing: 10.0, + childCount: tabState.dataList.length, + itemBuilder: (context, idx) => cardList(tabState.dataList[idx]), + ), + ), + SliverFillRemaining( + hasScrollBody: false, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: tabState.isLoading.value ? const Loading(title: 'loading...') : const Text('没有更多数据了'), + ), + ), + ), + ], + ); + }); + } + + // + // 空状态提示 + Widget emptyTip(String text) { + return CustomScrollView( + physics: const OnlyDownScrollPhysics(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 50.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/images/empty.png', width: 100.0), + const SizedBox(height: 8.0), + Text( + text, + style: const TextStyle(color: Colors.grey, fontSize: 13.0), + ), + ], + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/my/des.dart b/lib/pages/my/des.dart new file mode 100644 index 0000000..13a9273 --- /dev/null +++ b/lib/pages/my/des.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; + +class Des extends StatefulWidget { + const Des({super.key}); + + @override + State createState() => _DesState(); +} + +class _DesState extends State { + final _formKey = GlobalKey(); + final userInfoController = Get.find(); + + void _save() async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final signature = _formKey.currentState?.fields['signature']?.value; + final result = await userInfoController.updateSignature(signature); + if (result) { + Get.back(); + } + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + title: const Text('修改简介'), + actions: [ + TextButton( + onPressed: _save, + child: const Text( + '保存', + style: TextStyle(color: Colors.red, fontSize: 16), + ), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: FormBuilder( + key: _formKey, + child: Obx(() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '简介', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + FormBuilderTextField( + name: 'signature', + initialValue: userInfoController.signature.value, + maxLines: 6, // 最多显示6行 + minLines: 3, // 最少显示3行 + decoration: const InputDecoration( + hintText: '请输入内容', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + maxLength: 100, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: '内容不能为空'), + FormBuilderValidators.maxLength(100, errorText: '内容不能超过100个字符'), + ]), + ), + const SizedBox(height: 8), + const Text( + '最长支持100个字符,请文明用语', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ); + })), + ), + ), + ); + } +} diff --git a/lib/pages/my/index.dart b/lib/pages/my/index.dart index a118cf1..eed230a 100644 --- a/lib/pages/my/index.dart +++ b/lib/pages/my/index.dart @@ -3,12 +3,17 @@ import 'package:flutter/services.dart'; import 'package:flutter_svg/svg.dart'; import 'package:get/get.dart'; import 'package:get/get_rx/src/rx_typedefs/rx_typedefs.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/api/video_api.dart'; import 'package:loopin/components/custom_sticky_header.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; import 'package:loopin/components/only_down_scroll_physics.dart'; import 'package:loopin/controller/video_module_controller.dart'; +import 'package:loopin/service/http.dart'; import 'package:nested_scroll_view_plus/nested_scroll_view_plus.dart'; import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_info.dart'; import '../../utils/common.dart'; @@ -17,13 +22,26 @@ class PageParams { int pageSize; bool isLoading; bool hasMore; + int total; + bool isInitLoading; PageParams({ this.page = 1, this.pageSize = 10, this.isLoading = false, this.hasMore = true, + this.total = 0, + this.isInitLoading = true, }); + + void init() { + page = 1; + pageSize = 10; + isLoading = false; + hasMore = true; + total = 0; + isInitLoading = true; + } } class MyPage extends StatefulWidget { @@ -37,28 +55,38 @@ class MyPageState extends State with SingleTickerProviderStateMixin { late RxInt currentTabIndex = 0.obs; late RxList items = [].obs; late RxList favoriteItems = [].obs; - late RxMap userInfo = {}.obs; + RxBool isLogin = Common.isLogin().obs; + //用户基本信息 + // late Rx userInfo = Rx(null); + + ImUserInfoController? imUserInfoController; + + // 关注,互关,粉丝数量 + late Rx followInfo = Rx(null); RxBool get shouldFixHeader => (currentTabIndex.value == 0 && items.isEmpty) || (currentTabIndex.value == 1 && favoriteItems.isEmpty) ? true.obs : false.obs; List tabList = [ - {'name': "作品", 'badge': 99}, + {'name': "作品"}, {'name': "喜欢"}, ]; - late PageParams itemsParams; - late PageParams favoriteParams; + PageParams itemsParams = PageParams(); + PageParams favoriteParams = PageParams(); late TabController tabController; late ScrollController scrollController; + RxDouble positions = 0.0.obs; + late Callback tabListener; late Callback scrollListener; + RxBool isPinned = false.obs; // 是否吸顶 + @override void initState() { super.initState(); - itemsParams = PageParams(); - favoriteParams = PageParams(); + initControllers(); scrollListener = () { @@ -77,16 +105,17 @@ class MyPageState extends State with SingleTickerProviderStateMixin { tabListener = () { currentTabIndex.value = tabController.index; - scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeIn); if (tabController.index == 0 && items.isEmpty) { loadData(0); + scrollInnerList(); } else if (tabController.index == 1 && favoriteItems.isEmpty) { loadData(1); + scrollInnerList(); } }; tabController.addListener(tabListener); - loadData(0); + // loadData(0); } @override @@ -99,87 +128,121 @@ class MyPageState extends State with SingleTickerProviderStateMixin { super.dispose(); } - void loadData([int? tabIndex]) async { + // 添加控制子列表滚动的方法 + void scrollInnerList([double? offset]) async { + if (isPinned.value) { + // 如果已经吸顶,先给父滚动权限 + WidgetsBinding.instance.addPostFrameCallback((_) { + isPinned.value = false; + // 直接滚动到指定位置 + scrollController.jumpTo(positions.value); + // 重置滚动位置 + positions.value = 0.0; + }); + } + } + + Future loadData([int? tabIndex]) async { final index = tabIndex ?? currentTabIndex.value; if (index == 0) { if (itemsParams.isLoading || !itemsParams.hasMore) return; itemsParams.isLoading = true; + // itemsParams.isInitLoading = true; - await Future.delayed(const Duration(seconds: 1)); + try { + final res = await Http.post(VideoApi.myPublicList, data: { + "userId": imUserInfoController?.userID.value, + "yesOrNo": 0, + "current": itemsParams.page, + "size": itemsParams.pageSize, + }); + final obj = res['data']; + final total = obj['total']; + final row = obj['rows']; + logger.i(res['data']); + // 判断是否还有更多数据 + if (items.length >= total) { + itemsParams.hasMore = false; + } + // 添加新数据,触发响应式更新 + items.addAll(row); - // 模拟生成新数据 - List newItems = List.generate( - itemsParams.pageSize, - (i) => '作品 ${(itemsParams.page - 1) * itemsParams.pageSize + i + 1}', - ); - - // 模拟判断是否还有更多数据 - if (itemsParams.page >= 2) { - itemsParams.hasMore = false; + // 页码加一 + itemsParams.page++; + } finally { + itemsParams.isLoading = false; + itemsParams.isInitLoading = false; } - - // 添加新数据,触发响应式更新 - items.addAll(newItems); - - // 页码加一 - itemsParams.page++; - - itemsParams.isLoading = false; } else if (index == 1) { - // 喜欢列表同理 if (favoriteParams.isLoading || !favoriteParams.hasMore) return; favoriteParams.isLoading = true; + // favoriteParams.isInitLoading = true; - await Future.delayed(const Duration(seconds: 1)); + try { + final res = await Http.post(VideoApi.myPublicList, data: { + "userId": imUserInfoController?.userID.value, + "yesOrNo": 0, + "current": itemsParams.page, + "size": itemsParams.pageSize, + }); + final obj = res['data']; + final total = obj['total']; + final row = obj['rows']; - List newFavorites = List.generate( - favoriteParams.pageSize, - (i) => '喜欢 ${(favoriteParams.page - 1) * favoriteParams.pageSize + i + 1}', - ); + if (favoriteItems.length >= total) { + itemsParams.hasMore = false; + } - if (favoriteParams.page >= 2) { - favoriteParams.hasMore = false; + favoriteItems.addAll(row); + favoriteParams.page++; + } finally { + favoriteParams.isLoading = false; + favoriteParams.isInitLoading = false; } - - favoriteItems.addAll(newFavorites); - - favoriteParams.page++; - - favoriteParams.isLoading = false; } } void initControllers() { tabController = TabController(initialIndex: 0, length: tabList.length, vsync: this); scrollController = ScrollController(); + if (Common.isLogin()) { + imUserInfoController = Get.find(); + } } // 初始化页面数据 - void refreshData() { + void refreshData([int? tabIndex]) async { if (!mounted) { logger.i('未挂载'); return; } + isLogin.value = Common.isLogin(); if (!Common.isLogin()) return; - itemsParams = PageParams(); - favoriteParams = PageParams(); - currentTabIndex.value = 0; + final idx = tabIndex ?? currentTabIndex.value; + // 恢复位置 + WidgetsBinding.instance.addPostFrameCallback((_) { + scrollInnerList(); + }); items.clear(); favoriteItems.clear(); - scrollController.animateTo(0, duration: const Duration(milliseconds: 100), curve: Curves.easeIn); + itemsParams.init(); + favoriteParams.init(); + // currentTabIndex.value = 0; selfInfo(); - loadData(); + loadData(idx); } // 获取当前登录用户基本信息 void selfInfo() async { - final resIm = await ImService.instance.selfInfo(); - if (resIm.success) { - for (var user in resIm.data ?? []) { - logger.i(user.toLogString()); - } + // imUserInfoController = Get.find(); + final res = await ImService.instance.getUserFollowInfo(userIDList: [imUserInfoController!.userID.value]); + if (res.success) { + //这里少个点赞,从服务端获取 + // followersCount粉丝,多少人关注了我,mutualFollowersCount互关,followingCount我关注了多少人 + followInfo.value = res.data!.first; + logger.i(followInfo.value!.toJson()); } } @@ -260,103 +323,118 @@ class MyPageState extends State with SingleTickerProviderStateMixin { @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFFAF6F9), - body: Obx(() { - return NestedScrollViewPlus( - controller: scrollController, - physics: shouldFixHeader.value ? const OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) : const AlwaysScrollableScrollPhysics(), - overscrollBehavior: OverscrollBehavior.outer, - headerSliverBuilder: (context, innerBoxIsScrolled) { - return [ - SliverAppBar( - backgroundColor: Colors.transparent, - surfaceTintColor: Colors.transparent, - expandedHeight: 180.0, - collapsedHeight: 120.0, - pinned: true, - stretch: true, - onStretchTrigger: () async { - logger.i('触发 stretch 拉伸'); - // 加载刷新逻辑 - }, - actions: [ - _buildIcon('assets/images/svg/service.svg', () { - logger.i('点击客服按钮'); - }), - const SizedBox(width: 8.0), - _buildIcon('assets/images/svg/setting.svg', () { - logger.i('点击设置按钮'); - }), - const SizedBox(width: 10.0), - ], - flexibleSpace: _buildFlexibleSpace(), - ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(10.0), - child: Column( - children: [ - _buildStatsCard(), - const SizedBox(height: 10.0), - _buildOrderCard(context), - const SizedBox(height: 10.0), + // 如果没登录,直接返回一个登录提示页面 + return Obx(() { + if (!isLogin.value) { + return SizedBox(); + } else { + return Scaffold( + backgroundColor: const Color(0xFFFAF6F9), + body: Obx(() { + return NestedScrollViewPlus( + controller: scrollController, + physics: shouldFixHeader.value + ? OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) + : isPinned.value + ? NeverScrollableScrollPhysics() + : AlwaysScrollableScrollPhysics(), + // physics: shouldFixHeader.value ? OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) : AlwaysScrollableScrollPhysics(), + overscrollBehavior: OverscrollBehavior.outer, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverAppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + expandedHeight: 180.0, + collapsedHeight: 120.0, + pinned: true, + stretch: true, + onStretchTrigger: () async { + logger.i('触发 stretch 拉伸'); + // 加载刷新逻辑 + }, + actions: [ + // _buildIcon('assets/images/svg/service.svg', () { + // logger.i('点击客服按钮'); + // }), + const SizedBox(width: 8.0), + _buildIcon('assets/images/svg/setting.svg', () { + logger.i('点击设置按钮'); + Get.toNamed('/setting'); + }), + const SizedBox(width: 10.0), ], + flexibleSpace: _buildFlexibleSpace(), ), - ), - ), - SliverPersistentHeader( - pinned: true, - delegate: CustomStickyHeader( - child: PreferredSize( - preferredSize: const Size.fromHeight(48.0), - child: Container( - color: Colors.white, - child: TabBar( - controller: tabController, - tabs: tabList.map((item) { - return Tab( - child: Badge.count( - backgroundColor: Colors.red, - count: item['badge'] ?? 0, - isLabelVisible: item['badge'] != null, - alignment: Alignment.topRight, - offset: const Offset(14, -6), - child: Text(item['name'], style: const TextStyle(fontWeight: FontWeight.bold)), - ), - ); - }).toList(), - isScrollable: false, - overlayColor: WidgetStateProperty.all(Colors.transparent), - unselectedLabelColor: Colors.black87, - labelColor: const Color(0xFFFF5000), - indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Color(0xFFFF5000), width: 2.0)), - indicatorSize: TabBarIndicatorSize.tab, - unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), - labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), - dividerHeight: 0, - padding: const EdgeInsets.symmetric(horizontal: 10.0), - labelPadding: const EdgeInsets.symmetric(horizontal: 15.0), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + Obx(() => _buildStatsCard()), + const SizedBox(height: 10.0), + _buildOrderCard(context), + const SizedBox(height: 10.0), + ], ), ), ), - ), - ), - ]; - }, - body: TabBarView( - controller: tabController, - children: [ - // Tab 1: - Obx(() => _buildGridTab(0)), + SliverPersistentHeader( + pinned: true, + delegate: CustomStickyHeader( + isPinned: isPinned, + positions: positions, + child: PreferredSize( + preferredSize: const Size.fromHeight(48.0), + child: Container( + color: Colors.white, + child: TabBar( + controller: tabController, + tabs: tabList.map((item) { + return Tab( + child: Badge.count( + backgroundColor: Colors.red, + count: item['badge'] ?? 0, + isLabelVisible: item['badge'] != null, + alignment: Alignment.topRight, + offset: const Offset(14, -6), + child: Text(item['name'], style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ); + }).toList(), + isScrollable: false, + overlayColor: WidgetStateProperty.all(Colors.transparent), + unselectedLabelColor: Colors.black87, + labelColor: const Color(0xFFFF5000), + indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Color(0xFFFF5000), width: 2.0)), + indicatorSize: TabBarIndicatorSize.tab, + unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), + labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), + dividerHeight: 0, + padding: const EdgeInsets.symmetric(horizontal: 10.0), + labelPadding: const EdgeInsets.symmetric(horizontal: 15.0), + ), + ), + ), + ), + ), + ]; + }, + body: TabBarView( + controller: tabController, + children: [ + // Tab 1: + _buildGridTab(0), - // Tab 2: - Obx(() => _buildGridTab(1)) - ], - ), + // Tab 2: + _buildGridTab(1) + ], + ), + ); + }), ); - }), - ); + } + }); } // 空状态提示 @@ -390,47 +468,134 @@ class MyPageState extends State with SingleTickerProviderStateMixin { Widget _buildGridTab(int tabIndex) { final listToShow = tabIndex == 0 ? items : favoriteItems; - final params = tabIndex == 0 ? itemsParams : favoriteParams; - + PageParams params = tabIndex == 0 ? itemsParams : favoriteParams; + if (params.isInitLoading) { + return Center(child: CircularProgressIndicator()); + } if (listToShow.isEmpty) { return emptyTip('暂无相关数据'); } - return CustomScrollView( - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(10.0), - sliver: SliverGrid( - delegate: SliverChildBuilderDelegate( - (context, index) { - return Container( - decoration: BoxDecoration( - color: Colors.blue[100 * ((index % 8) + 1)], - borderRadius: BorderRadius.circular(10.0), + return Obx(() { + return CustomScrollView( + // physics: !isPinned.value ? NeverScrollableScrollPhysics() : AlwaysScrollableScrollPhysics(), + // physics: AlwaysScrollableScrollPhysics(), + key: PageStorageKey('myindex_$tabIndex'), + slivers: [ + SliverPadding( + padding: EdgeInsets.all(10.0), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Container( + decoration: BoxDecoration( + color: Colors.blue[100 * ((index % 8) + 1)], + borderRadius: BorderRadius.circular(10.0), + ), + alignment: Alignment.center, + child: _buildVdCard(listToShow[index]), + ); + }, + childCount: listToShow.length, + ), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10.0, + mainAxisSpacing: 10.0, + childAspectRatio: 0.6, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: params.hasMore ? const CircularProgressIndicator() : const Text('没有更多数据了'), + ), + ), + ), + ], + ); + }); + } + + Widget _buildVdCard(item) { + return InkWell( + onTap: () { + //去视频详情 + }, + onLongPress: () { + showModalBottomSheet( + context: Get.context!, + backgroundColor: Colors.black.withOpacity(0.8), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.lock, color: Colors.white), + title: const Text('设为私密', style: TextStyle(color: Colors.white)), + onTap: () { + Navigator.pop(context); + // TODO: 修改为私密逻辑 + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.redAccent), + title: const Text('删除视频', style: TextStyle(color: Colors.redAccent)), + onTap: () { + Navigator.pop(context); + // TODO: 删除逻辑 + }, + ), + ], + ); + }, + ); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12.0), + color: Colors.grey[900], + ), + child: Stack( + children: [ + /// 视频缩略图 + ClipRRect( + borderRadius: BorderRadius.circular(12.0), + child: Image.network( + item['cover'] ?? item['firstFrameImg'], + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + ), + ), + + /// 右下角的点赞数 + Positioned( + right: 8, + bottom: 8, + child: Row( + children: [ + const Icon(Icons.favorite, color: Colors.white, size: 16), + const SizedBox(width: 4), + Text( + '${item['likeCounts'] ?? 0}', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.bold, + ), ), - alignment: Alignment.center, - child: Text(listToShow[index], style: const TextStyle(fontWeight: FontWeight.bold)), - ); - }, - childCount: listToShow.length, + ], + ), ), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - crossAxisSpacing: 10.0, - mainAxisSpacing: 10.0, - childAspectRatio: 1.0, - ), - ), + ], ), - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 20.0), - child: Center( - child: params.hasMore ? const CircularProgressIndicator() : const Text('没有更多数据了'), - ), - ), - ), - ], + ), ); } @@ -449,7 +614,7 @@ class MyPageState extends State with SingleTickerProviderStateMixin { return LayoutBuilder( builder: (BuildContext context, BoxConstraints constraints) { final double maxHeight = 180.0; - final double minHeight = 100.0; + final double minHeight = 120.0; final double currentHeight = constraints.maxHeight; double ratio = (currentHeight - minHeight) / (maxHeight - minHeight); ratio = ratio.clamp(0.0, 1.0); @@ -457,7 +622,12 @@ class MyPageState extends State with SingleTickerProviderStateMixin { return Stack( fit: StackFit.expand, children: [ - Positioned.fill(child: Opacity(opacity: 1.0, child: Image.asset('assets/images/pic2.jpg', fit: BoxFit.cover))), + Positioned.fill( + child: Opacity( + opacity: 1.0, + child: NetworkOrAssetImage(imageUrl: imUserInfoController?.customInfo['coverBg'], placeholderAsset: 'assets/images/bk.jpg'), + ), + ), Positioned( left: 15.0, bottom: 0, @@ -467,7 +637,19 @@ class MyPageState extends State with SingleTickerProviderStateMixin { child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ - ClipOval(child: Image.asset('assets/images/avatar/img11.jpg', height: 60.0, width: 60.0, fit: BoxFit.cover)), + ClipOval( + child: Obx(() { + final faceUrl = imUserInfoController?.faceUrl.value; + return ClipRRect( + borderRadius: BorderRadius.circular(30), + child: NetworkOrAssetImage( + imageUrl: faceUrl, + width: 80, + height: 80, + ), + ); + }), + ), const SizedBox(width: 15.0), Expanded( child: Column( @@ -476,13 +658,14 @@ class MyPageState extends State with SingleTickerProviderStateMixin { Row( children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), - decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), - child: const Text( - '新用户2025', - style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold, fontFamily: 'Arial', color: Colors.white), - ), - ), + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), + child: Obx( + () => Text( + imUserInfoController!.nickname.value.isNotEmpty == true ? imUserInfoController!.nickname.value : '昵称', + style: TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold, fontFamily: 'Arial', color: Colors.white), + ), + )), const SizedBox(width: 8.0), InkWell( onTap: () { @@ -502,11 +685,11 @@ class MyPageState extends State with SingleTickerProviderStateMixin { decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), child: InkWell( onTap: () { - logger.i('点击个人简介'); - Clipboard.setData(const ClipboardData(text: '1234')); + logger.i('点击id'); + Clipboard.setData(ClipboardData(text: imUserInfoController!.userID.value)); MyDialog.toast('ID已复制', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.green.withAlpha(200))); }, - child: const Text('ID:32938293892839232', style: TextStyle(fontSize: 12.0, color: Colors.white)), + child: Text('ID:${imUserInfoController!.userID.value}', style: TextStyle(fontSize: 12.0, color: Colors.white)), ), ), ], @@ -531,17 +714,28 @@ class MyPageState extends State with SingleTickerProviderStateMixin { ), clipBehavior: Clip.antiAlias, child: Padding( - padding: const EdgeInsets.all(10.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceAround, - children: [ - Column(children: const [Text('9999', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('获赞')]), - Column(children: const [Text('25', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('互关')]), - Column(children: const [Text('11', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('关注')]), - Column(children: const [Text('10', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('粉丝')]), - ], - ), - ), + padding: const EdgeInsets.all(10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column(children: [Text('9999', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('获赞')]), + Column(children: [ + Text('${followInfo.value?.mutualFollowersCount ?? 0}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('互关') + ]), + Column(children: [ + Text('${followInfo.value?.followingCount ?? 0}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('关注') + ]), + Column(children: [ + Text('${followInfo.value?.followersCount ?? 0}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('粉丝') + ]), + ], + )), ); } @@ -583,10 +777,12 @@ class MyPageState extends State with SingleTickerProviderStateMixin { _buildOrderIcon('assets/images/ico_order.png', '订单', () { Get.toNamed('/order'); }), - _buildOrderIcon('assets/images/ico_dhx.png', '余额', () { + _buildOrderIcon('assets/images/ico_dhx.png', '余额logout', () { showLogoutDialog(context); }), - _buildOrderIcon('assets/images/ico_sh.png', '提现', () {}), + _buildOrderIcon('assets/images/ico_sh.png', '提现vloger', () { + Get.toNamed('/vloger'); + }), _buildOrderIcon('assets/images/ico_tgm.png', '推广码', () {}), ], ), diff --git a/lib/pages/my/nick_name.dart b/lib/pages/my/nick_name.dart new file mode 100644 index 0000000..d2e2338 --- /dev/null +++ b/lib/pages/my/nick_name.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_form_builder/flutter_form_builder.dart'; +import 'package:form_builder_validators/form_builder_validators.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; + +class NickName extends StatefulWidget { + const NickName({super.key}); + + @override + State createState() => _NickNameState(); +} + +class _NickNameState extends State { + final _formKey = GlobalKey(); + final userInfoController = Get.find(); + + void _save() async { + if (_formKey.currentState?.saveAndValidate() ?? false) { + final nickname = _formKey.currentState?.fields['nickname']?.value; + final result = await userInfoController.updateNickname(nickname); + if (result) { + Get.back(); + } + } + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( + backgroundColor: Colors.white, + appBar: AppBar( + backgroundColor: Colors.white, + title: const Text('修改昵称'), + actions: [ + TextButton( + onPressed: _save, + child: const Text( + '保存', + style: TextStyle(color: Colors.red, fontSize: 16), + ), + ), + ], + ), + body: Padding( + padding: const EdgeInsets.all(16), + child: FormBuilder( + key: _formKey, + child: Obx(() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '我的昵称', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + FormBuilderTextField( + name: 'nickname', + initialValue: userInfoController.nickname.value, + decoration: const InputDecoration( + hintText: '请输入昵称', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 10), + ), + maxLength: 20, + validator: FormBuilderValidators.compose([ + FormBuilderValidators.required(errorText: '昵称不能为空'), + FormBuilderValidators.maxLength(20, errorText: '昵称不能超过20个字符'), + ]), + ), + const SizedBox(height: 8), + const Text( + '昵称最长支持20个字符,请文明用语', + style: TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ); + })), + ), + ), + ); + } +} diff --git a/lib/pages/my/setting.dart b/lib/pages/my/setting.dart new file mode 100644 index 0000000..a110511 --- /dev/null +++ b/lib/pages/my/setting.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/styles/index.dart'; + +class Setting extends StatelessWidget { + const Setting({super.key}); + + @override + Widget build(BuildContext context) { + final List> settings = [ + { + 'icon': Icons.person, + 'title': '账号设置', + 'onTap': () => Get.toNamed('/userInfo'), + }, + { + 'icon': Icons.notifications, + 'title': '通知设置', + 'onTap': () => Get.toNamed('/notifications'), + }, + { + 'icon': Icons.lock, + 'title': '隐私', + 'onTap': () => Get.toNamed('/privacy'), + }, + { + 'icon': Icons.info, + 'title': '关于我们', + 'onTap': () => Get.toNamed('/about'), + }, + ]; + + return Scaffold( + appBar: AppBar(title: const Text('设置')), + backgroundColor: Colors.grey[200], + body: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + children: [ + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + color: Colors.white, + child: Column( + children: settings.map((item) { + return Column( + children: [ + ListTile( + leading: Icon(item['icon'] as IconData, color: FStyle.c999), + title: Text(item['title']), + trailing: const Icon( + Icons.chevron_right, + color: FStyle.c999, + ), + onTap: item['onTap'], + ), + if (item != settings.last) const Divider(height: 1, indent: 16, endIndent: 16), + ], + ); + }).toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/my/user_info.dart b/lib/pages/my/user_info.dart new file mode 100644 index 0000000..20e2de5 --- /dev/null +++ b/lib/pages/my/user_info.dart @@ -0,0 +1,490 @@ +import 'package:bottom_picker/bottom_picker.dart'; +import 'package:city_pickers/city_pickers.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; +import 'package:loopin/api/common_api.dart'; +import 'package:loopin/service/http.dart'; +import 'package:loopin/styles/index.dart'; +import 'package:loopin/utils/index.dart'; +import 'package:loopin/utils/wxsdk.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:wechat_assets_picker/wechat_assets_picker.dart'; + +class UserInfo extends StatefulWidget { + const UserInfo({super.key}); + + @override + State createState() => _UserInfoState(); +} + +class _UserInfoState extends State { + final userInfoController = Get.find(); + late List> items; + + @override + void initState() { + super.initState(); + items = [ + { + 'title': '昵称', + 'value': userInfoController.nickname, + 'onTap': () => Get.toNamed('/nickName'), + }, + { + 'title': '简介', + 'value': userInfoController.signature, + 'onTap': () => Get.toNamed('/des'), + }, + { + 'title': '性别', + 'value': userInfoController.gender, + 'onTap': () => setGender(context), + }, + { + 'title': '生日', + 'value': userInfoController.birthday, + 'onTap': () => selectDate(context), + }, + { + 'title': '区域', + 'value': userInfoController.customInfo, + 'onTap': () => pickCity(), + }, + { + 'title': '绑定微信', + 'value': userInfoController.customInfo, + 'onTap': () => wechatLogin(), + }, + ]; + } + + /// 微信授权 + Future wechatLogin() async { + await Wxsdk.login(); + } + + /// 选性别 + void setGender(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (BuildContext context) { + return SafeArea( + child: Container( + height: 300, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + children: [ + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ListTile( + title: Center(child: Text('男')), + onTap: () { + userInfoController.gender.value = 1; + userInfoController.updateGender(); + Get.back(); + }, + ), + Divider(), + ListTile( + title: Center(child: Text('女')), + onTap: () { + userInfoController.gender.value = 2; + userInfoController.updateGender(); + Get.back(); + }), + Divider(), + ListTile( + title: Center(child: Text('保密')), + onTap: () { + userInfoController.gender.value = 0; + userInfoController.updateGender(); + Get.back(); + }, + ), + Container( + height: 10, + color: Colors.grey[200], + ), + ], + ), + ), + ListTile( + title: Center(child: Text('取消')), + onTap: () => Get.back(), + ), + ], + ), + ), + ); + }, + ); + } + + /// 选生日 + void selectDate(BuildContext context) { + DateTime selectedDate = DateTime.now(); // 初始值设为当前时间 + BottomPicker.date( + pickerTitle: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + TextButton( + onPressed: () { + Get.back(); + }, + child: Text('取消'), + ), + TextButton( + onPressed: () { + // 获取选择结果逻辑 + final dateStr = "${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}"; + print(dateStr); + DateTime birthdayDate = DateTime(selectedDate.year, selectedDate.month, selectedDate.day); + userInfoController.birthday.value = birthdayDate.millisecondsSinceEpoch ~/ 1000; //秒级时间戳 + userInfoController.updateBirthday(); + Get.back(); + }, + child: Text('确认'), + ), + ], + ), + displaySubmitButton: false, + displayCloseIcon: false, + dismissable: true, // 允许点击空白关闭 + initialDateTime: DateTime.now(), + minDateTime: DateTime(1900), + maxDateTime: DateTime( + DateTime.now().year, + DateTime.now().month, + DateTime.now().day, + 23, + 59, + 59, + ), + dateOrder: DatePickerDateOrder.ymd, + pickerTextStyle: const TextStyle(fontSize: 16, color: Colors.black87), + height: 300, + onChange: (date) { + selectedDate = date as DateTime; + }, + ).show(context); + } + + ///选所在地 + void pickCity() async { + final result = await CityPickers.showCityPicker( + context: context, + showType: ShowType.pca, // 显示省市区 + height: 300.0, + borderRadius: 16.0, + barrierDismissible: true, + theme: Theme.of(context).copyWith( + scaffoldBackgroundColor: Colors.white, + ), + ); + if (result != null) { + final areaName = '${result.provinceName}-${result.cityName}-${result.areaName}'; + print(result.toString()); + print('${result.provinceName}-${result.cityName}-${result.areaName}-${result.areaId}'); + //修改 + userInfoController.customInfo['area'] = areaName; + userInfoController.customInfo['areaCode'] = '${result.areaId}'; + userInfoController.updateArea(); + userInfoController.customInfo.refresh(); + } + } + + ///选背景 + void pickCover(BuildContext context) async { + final pickedAssets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + textDelegate: const AssetPickerTextDelegate(), + pathNameBuilder: (AssetPathEntity album) { + return Utils.translateAlbumName(album); + }, + maxAssets: 1, + requestType: RequestType.image, + filterOptions: FilterOptionGroup( + imageOption: const FilterOption(), + ), + ), + ); + + if (pickedAssets != null && pickedAssets.isNotEmpty) { + final asset = pickedAssets.first; + final file = await asset.file; // 获取实际文件 + if (file != null) { + final fileSizeInBytes = await file.length(); + final sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 100) { + MyDialog.toast('图片大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + //走upload(file)上传图片拿到url地址 + final istance = MyDialog.loading('上传中'); + final res = await Http.upload(CommonApi.uploadFile, filePath: file.path); + userInfoController.customInfo['coverBg'] = res['data']['url']; + userInfoController.updateCover(); + userInfoController.customInfo.refresh(); + istance.close(); + } + } + } + } + + ///选头像 + void pickFaceUrl(BuildContext context) async { + final pickedAssets = await AssetPicker.pickAssets( + context, + pickerConfig: AssetPickerConfig( + textDelegate: const AssetPickerTextDelegate(), + pathNameBuilder: (AssetPathEntity album) { + return Utils.translateAlbumName(album); + }, + maxAssets: 1, + requestType: RequestType.image, + filterOptions: FilterOptionGroup( + imageOption: const FilterOption(), + ), + ), + ); + + if (pickedAssets != null && pickedAssets.isNotEmpty) { + final asset = pickedAssets.first; + final file = await asset.file; // 获取实际文件 + if (file != null) { + final fileSizeInBytes = await file.length(); + final sizeInMB = fileSizeInBytes / (1024 * 1024); + if (sizeInMB > 100) { + MyDialog.toast('图片大小不能超过100MB', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } else { + print("图片合法,大小:$sizeInMB MB"); + //走upload(file)上传图片拿到url地址 + final istance = MyDialog.loading('上传中'); + final res = await Http.upload(CommonApi.uploadFile, filePath: file.path); + userInfoController.faceUrl.value = res['data']['url']; + userInfoController.updateFaceUrl(); + userInfoController.customInfo.refresh(); + istance.close(); + } + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + extendBodyBehindAppBar: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + actions: [ + Padding( + padding: const EdgeInsets.only(right: 12.0), + child: GestureDetector( + onTap: () => pickCover(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0), + decoration: BoxDecoration( + color: Colors.black.withAlpha(100), + borderRadius: BorderRadius.circular(20.0), + ), + child: Row( + children: const [ + Icon(Icons.camera_alt, color: Colors.white, size: 16), + SizedBox(width: 4), + Text('更换封面', style: TextStyle(color: Colors.white, fontSize: 14)), + ], + ), + ), + ), + ), + ], + ), + body: SizedBox.expand( + child: Stack( + children: [ + // 封面图 + SizedBox( + height: 240, + width: double.infinity, + child: Obx(() { + final imageUrl = userInfoController.customInfo['coverBg']; + return GestureDetector( + onTap: () => pickCover(context), + child: Image( + image: (imageUrl != null && imageUrl.isNotEmpty) ? NetworkImage(imageUrl) : const AssetImage('assets/images/pic2.jpg') as ImageProvider, + fit: BoxFit.cover, + ), + ); + }), + ), + + // 白色内容容器 + Positioned( + top: 220, + left: 0, + right: 0, + bottom: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(30), + topRight: Radius.circular(30), + ), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(30), + topRight: Radius.circular(30), + ), + ), + child: SingleChildScrollView( + padding: const EdgeInsets.only(top: 60, bottom: 40), + child: Column( + children: [ + const SizedBox(height: 0), + const SizedBox(height: 20), + Card( + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + color: Colors.white, + child: Column( + children: items.map((item) { + return Column( + children: [ + ListTile( + title: Row( + children: [ + Text( + item['title'], + style: const TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(width: 50), + Expanded(child: Obx(() { + final val = item['value']; + if (val is RxString) { + return Text( + val.value, + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } else if (val is RxInt) { + String displayText; + if (item['title'] == '性别') { + displayText = val.value == 0 + ? '保密' + : val.value == 1 + ? '男' + : val.value == 2 + ? '女' + : ''; + } else { + // 生日 + // displayText = val.value == 0 ? '' : val.value.toString(); + if (val.value == 0) { + displayText = ''; + } else { + final date = DateTime.fromMillisecondsSinceEpoch(val.value * 1000); + displayText = "${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}"; + } + } + return Text( + displayText, + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } else if (val is String) { + return Text( + val, + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } else if (val is Map) { + if (item['title'] == '区域') { + return Text( + val['area'] ?? '', + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ); + } else { + String wxText = val['unionId'] == null || val['unionId'] == '' ? '未授权' : '已授权'; + return Row( + children: [ + Spacer(), + Text( + wxText, + style: const TextStyle(color: Colors.black), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ], + ); + } + } else { + return const SizedBox.shrink(); + } + })) + ], + ), + trailing: const Icon( + Icons.chevron_right, + color: FStyle.c999, + ), + onTap: item['onTap'], + ), + ], + ); + }).toList(), + ), + ) + ], + ), + ), + ), + ), + ), + + // 头像层级最高,放在Stack最后面,覆盖白色容器和封面图 + Positioned( + top: 220 - 60, // 封面图高度减去头像半径,使头像中线对齐封面底线 + left: 0, + right: 0, + child: Obx(() { + final avatar = userInfoController.faceUrl.value; + return Center( + child: GestureDetector( + onTap: () => pickFaceUrl(context), + child: CircleAvatar( + radius: 60, + backgroundColor: Colors.white, + child: CircleAvatar( + radius: 57, + backgroundImage: avatar.isNotEmpty ? NetworkImage(avatar) : const AssetImage('assets/images/avatar/img11.jpg') as ImageProvider, + ), + ), + ), + ); + }), + ), + ], + ), + ), + ); + } +} diff --git a/lib/pages/my/vloger.dart b/lib/pages/my/vloger.dart new file mode 100644 index 0000000..873db9e --- /dev/null +++ b/lib/pages/my/vloger.dart @@ -0,0 +1,648 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:get/get_rx/src/rx_typedefs/rx_typedefs.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/components/custom_sticky_header.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; +import 'package:loopin/components/only_down_scroll_physics.dart'; +import 'package:loopin/styles/index.dart'; +import 'package:nested_scroll_view_plus/nested_scroll_view_plus.dart'; +import 'package:shirne_dialog/shirne_dialog.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_follow_info.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart'; + +class PageParams { + int page; + int pageSize; + bool isLoading; + bool hasMore; + + PageParams({ + this.page = 1, + this.pageSize = 10, + this.isLoading = false, + this.hasMore = true, + }); +} + +class Vloger extends StatefulWidget { + const Vloger({super.key}); + + @override + State createState() => MyPageState(); +} + +class MyPageState extends State with SingleTickerProviderStateMixin { + late dynamic args; + late RxInt currentTabIndex = 0.obs; + late RxList items = [].obs; + late RxList favoriteItems = [].obs; + late final Rx userInfo = Rx(V2TimUserFullInfo( + userID: '', + nickName: '', + faceUrl: '', + selfSignature: '', + gender: 0, + customInfo: { + 'coverBg': '', + }, + role: 0, + )); + + late RxInt followed = 0.obs; // 是否关注 + // followersCount粉丝,多少人关注了我,mutualFollowersCount互关,followingCount我关注了多少人 + late final Rx followInfo = Rx(V2TimFollowInfo( + followersCount: 0, + followingCount: 0, + )); + + RxBool get shouldFixHeader => (currentTabIndex.value == 0 && items.isEmpty) || (currentTabIndex.value == 1 && favoriteItems.isEmpty) ? true.obs : false.obs; + + List tabList = [ + {'name': "作品"}, + ]; + + late PageParams itemsParams; + late PageParams favoriteParams; + + late TabController tabController; + late ScrollController scrollController; + + late Callback tabListener; + late Callback scrollListener; + + @override + void initState() { + super.initState(); + args = Get.arguments ?? {}; + itemsParams = PageParams(); + favoriteParams = PageParams(); + selfInfo(); + flowInfo(); + checkFollowType(); + initControllers(); + + scrollListener = () { + final pos = scrollController.position; + final isNearBottom = pos.pixels >= pos.maxScrollExtent - 100; + + if (!isNearBottom) return; + + if (currentTabIndex.value == 0 && !itemsParams.isLoading && itemsParams.hasMore) { + loadData(0); + } else if (currentTabIndex.value == 1 && !favoriteParams.isLoading && favoriteParams.hasMore) { + loadData(1); + } + }; + scrollController.addListener(scrollListener); + + tabListener = () { + currentTabIndex.value = tabController.index; + scrollController.animateTo(0, duration: const Duration(milliseconds: 300), curve: Curves.easeIn); + loadData(0); + }; + tabController.addListener(tabListener); + + loadData(0); + } + + @override + void dispose() { + tabController.removeListener(tabListener); + scrollController.removeListener(scrollListener); + + tabController.dispose(); + scrollController.dispose(); + super.dispose(); + } + + void loadData([int? tabIndex]) async { + final index = tabIndex ?? currentTabIndex.value; + if (index == 0) { + if (itemsParams.isLoading || !itemsParams.hasMore) return; + + itemsParams.isLoading = true; + + await Future.delayed(const Duration(seconds: 1)); + + // 模拟生成新数据 + List newItems = List.generate( + itemsParams.pageSize, + (i) => '作品 ${(itemsParams.page - 1) * itemsParams.pageSize + i + 1}', + ); + + // 模拟判断是否还有更多数据 + if (itemsParams.page >= 2) { + itemsParams.hasMore = false; + } + + // 添加新数据,触发响应式更新 + items.addAll(newItems); + + // 页码加一 + itemsParams.page++; + + itemsParams.isLoading = false; + } + } + + void initControllers() { + tabController = TabController(initialIndex: 0, length: tabList.length, vsync: this); + scrollController = ScrollController(); + } + + // 获取当前博主基本信息 + void selfInfo() async { + final resIm = await ImService.instance.otherInfo(args['memberId']); + if (resIm.success && resIm.data != null) { + userInfo.value = resIm.data!; + logger.i(userInfo.value.toLogString()); + } else { + logger.e(resIm.desc); + } + } + + // 博主的关注与粉丝 + void flowInfo() async { + logger.w(args.toString()); + final res = await ImService.instance.getUserFollowInfo(userIDList: [args['memberId']]); + if (res.success && res.data?.first != null) { + //这里少个点赞,从服务端获取 + // followersCount粉丝,多少人关注了我,mutualFollowersCount互关,followingCount我关注了多少人 + followInfo.value = res.data!.first; + logger.i(followInfo.value.toJson()); + } else { + logger.e(res.desc); + } + } + + // 检测当前用户是否关注博主 + void checkFollowType() async { + /// 0:不是好友也没有关注 + /// 1:你关注了对方(单向) + /// 2:对方关注了你(单向) + /// 3:互相关注(双向好友) + final res = await ImService.instance.checkFollowType(userIDList: [args['memberId']]); + if (res.success) { + final followType = res.data?.first.followType ?? 0; + logger.i(res.data?.first.toJson()); + followed.value = followType; + logger.i(followed.value); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFFAF6F9), + body: Obx(() { + return NestedScrollViewPlus( + controller: scrollController, + physics: shouldFixHeader.value ? const OnlyDownScrollPhysics(parent: AlwaysScrollableScrollPhysics()) : const AlwaysScrollableScrollPhysics(), + overscrollBehavior: OverscrollBehavior.outer, + headerSliverBuilder: (context, innerBoxIsScrolled) { + return [ + SliverAppBar( + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + expandedHeight: 180.0, + collapsedHeight: 120.0, + pinned: true, + stretch: true, + onStretchTrigger: () async { + logger.i('触发 stretch 拉伸'); + // 加载刷新逻辑 + }, + flexibleSpace: Obx(() { + userInfo.value; + return _buildFlexibleSpace(); + }), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + Obx(() => _buildStatsCard()), + const SizedBox(height: 10.0), + Obx(() => _buildInfoDesc(context)), + const SizedBox(height: 10.0), + Obx(() => _buildFoucsButton(context)), + ], + ), + ), + ), + SliverPersistentHeader( + pinned: true, + delegate: CustomStickyHeader( + child: PreferredSize( + preferredSize: const Size.fromHeight(48.0), + child: Container( + color: Colors.white, + child: TabBar( + controller: tabController, + tabs: tabList.map((item) { + return Tab( + child: Badge.count( + backgroundColor: Colors.red, + count: item['badge'] ?? 0, + isLabelVisible: item['badge'] != null, + alignment: Alignment.topRight, + offset: const Offset(14, -6), + child: Text(item['name'], style: const TextStyle(fontWeight: FontWeight.bold)), + ), + ); + }).toList(), + isScrollable: true, //禁止左右滑动 + tabAlignment: TabAlignment.start, + overlayColor: WidgetStateProperty.all(Colors.transparent), + unselectedLabelColor: Colors.black87, + labelColor: Colors.black, + indicator: const UnderlineTabIndicator(borderSide: BorderSide(color: Colors.transparent, width: 2.0)), + indicatorSize: TabBarIndicatorSize.label, + unselectedLabelStyle: const TextStyle(fontSize: 16.0, fontFamily: 'Microsoft YaHei'), + labelStyle: const TextStyle(fontSize: 18.0, fontFamily: 'Microsoft YaHei', fontWeight: FontWeight.bold), + dividerHeight: 0, + padding: const EdgeInsets.symmetric(horizontal: 10.0), + labelPadding: const EdgeInsets.symmetric(horizontal: 15.0), + ), + ), + ), + ), + ), + ]; + }, + body: TabBarView( + controller: tabController, + children: [ + // Tab 1: + Obx(() => _buildGridTab(0)), + ], + ), + ); + }), + ); + } + + // 空状态提示 + Widget emptyTip(String text) { + return CustomScrollView( + physics: const OnlyDownScrollPhysics(), + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Align( + alignment: Alignment.topCenter, + child: Padding( + padding: const EdgeInsets.only(top: 50.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset('assets/images/empty.png', width: 100.0), + const SizedBox(height: 8.0), + Text( + text, + style: const TextStyle(color: Colors.grey, fontSize: 13.0), + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildGridTab(int tabIndex) { + final listToShow = tabIndex == 0 ? items : favoriteItems; + final params = tabIndex == 0 ? itemsParams : favoriteParams; + + if (listToShow.isEmpty) { + return emptyTip('暂无相关数据'); + } + + return CustomScrollView( + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(10.0), + sliver: SliverGrid( + delegate: SliverChildBuilderDelegate( + (context, index) { + return Container( + decoration: BoxDecoration( + color: Colors.blue[100 * ((index % 8) + 1)], + borderRadius: BorderRadius.circular(10.0), + ), + alignment: Alignment.center, + child: Text(listToShow[index], style: const TextStyle(fontWeight: FontWeight.bold)), + ); + }, + childCount: listToShow.length, + ), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + crossAxisSpacing: 10.0, + mainAxisSpacing: 10.0, + childAspectRatio: 1.0, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20.0), + child: Center( + child: params.hasMore ? const CircularProgressIndicator() : const Text('没有更多数据了'), + ), + ), + ), + ], + ); + } + + Widget _buildFlexibleSpace() { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final double maxHeight = 180.0; + final double minHeight = 100.0; + final double currentHeight = constraints.maxHeight; + double ratio = (currentHeight - minHeight) / (maxHeight - minHeight); + ratio = ratio.clamp(0.0, 1.0); + String coverBg = userInfo.value.customInfo?['coverBg'] ?? ''; + coverBg = coverBg.isEmpty ? 'assets/images/pic2.jpg' : coverBg; + logger.w(coverBg); + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Opacity( + opacity: 1.0, + child: Image.asset( + coverBg, + fit: BoxFit.cover, + ))), + Positioned( + left: 15.0, + bottom: 0, + right: 15.0, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + ClipOval( + child: NetworkOrAssetImage(imageUrl: userInfo.value.faceUrl), + // child: Image.asset( + // userInfo.value.faceUrl ?? 'assets/images/pic1.jpg', + // height: 60.0, + // width: 60.0, + // fit: BoxFit.cover, + // errorBuilder: (context, error, stackTrace) { + // return Image.asset( + // 'assets/images/pic1.jpg', + // height: 60.0, + // width: 60.0, + // fit: BoxFit.cover, + // ); + // }, + // ), + ), + const SizedBox(width: 15.0), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), + child: Text( + userInfo.value.nickName ?? '未知', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold, + fontFamily: 'Arial', + color: Colors.white, + ), + ), + ), + ], + ), + const SizedBox(height: 8.0), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 5.0), + decoration: BoxDecoration(color: Colors.black.withAlpha((0.3 * 255).round()), borderRadius: BorderRadius.circular(20.0)), + child: InkWell( + onTap: () { + logger.i('点击个ID'); + Clipboard.setData(ClipboardData(text: '${userInfo.value.userID}')); + MyDialog.toast('ID已复制', icon: const Icon(Icons.check_circle), style: ToastStyle(backgroundColor: Colors.green.withAlpha(200))); + }, + child: Text('ID:${userInfo.value.userID}', style: TextStyle(fontSize: 12.0, color: Colors.white)), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildStatsCard() { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + boxShadow: [BoxShadow(color: Colors.black.withAlpha(10), offset: const Offset(0.0, 1.0), blurRadius: 2.0, spreadRadius: 0.0)], + ), + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Column(children: [Text('9999', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), SizedBox(height: 3.0), Text('获赞')]), + Column(children: [ + Text('${followInfo.value.followingCount}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('关注') + ]), + Column(children: [ + Text('${followInfo.value.followersCount}', style: TextStyle(fontSize: 16.0, fontWeight: FontWeight.bold)), + SizedBox(height: 3.0), + Text('粉丝') + ]), + ], + ), + ), + ); + } + + Widget _buildInfoDesc(BuildContext context) { + final tx = userInfo.value.selfSignature; + if (tx == null || tx.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15.0), + boxShadow: [BoxShadow(color: Colors.black.withAlpha(10), offset: const Offset(0.0, 1.0), blurRadius: 2.0, spreadRadius: 0.0)], + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(12), + width: double.infinity, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${userInfo.value.selfSignature}', + style: const TextStyle(fontSize: 16), + ), + ), + ], + )); + } + + /// 关注按钮 + Widget _buildFoucsButton(BuildContext context) { + // final vlogerId = '1943510443312078850'; // 18832510385,后面改回博主的id + final vlogerId = args['vlogerId']; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + transitionBuilder: (child, animation) { + final offsetAnimation = Tween( + begin: Offset((followed.value == 0) ? -1 : 1, 0), + end: Offset.zero, + ).animate(animation); + + return SlideTransition( + position: offsetAnimation, + child: child, + ); + }, + child: [1, 3].contains(followed.value) + ? Row( + key: const ValueKey('followed'), + children: [ + Expanded( + child: ElevatedButton( + onPressed: () async { + print('点击已关注'); + final res = await ImService.instance.unfollowUser(userIDList: [vlogerId]); + if (res.success) { + // 如果为1那么状态置为0,为3则置为2 + followed.value = followed.value == 1 ? 0 : 2; + // 取关后不需重置陌生人消息group + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[300], + foregroundColor: Colors.black87, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text(followed.value == 1 + ? '已关注' + : followed.value == 3 + ? '互关' + : '未知状态'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + onPressed: () async { + print('私信'); + // 获取指定会话 + final res = await ImService.instance.getConversation(conversationID: 'c2c_$vlogerId'); + V2TimConversation conversation = res.data; + logger.i(conversation.toLogString()); + if (res.success) { + // final isFriend = await ImService.instance.isMyFriend(vlogerId, FriendTypeEnum.V2TIM_FRIEND_TYPE_BOTH); + // 这里需要注意处理取关后重新关注逻辑 + // 是否互相关注 + if (followed.value == 3) { + Get.toNamed('/chat', arguments: conversation); + } else { + logger.i('对方没关注我'); + logger.i(conversation.toLogString()); + conversation.showName = conversation.showName ?? userInfo.value.nickName; + Get.toNamed('/chatNoFriend', arguments: conversation); + } + } else { + MyDialog.toast(res.desc, icon: const Icon(Icons.warning), style: ToastStyle(backgroundColor: Colors.red.withAlpha(200))); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.grey[300], + foregroundColor: Colors.black87, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text('私信'), + ), + ), + ], + ) + : SizedBox( + key: const ValueKey('not_followed'), + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () async { + // 0没关系,2对方关注了我 + print('点击关注'); + final res = await ImService.instance.followUser(userIDList: [vlogerId]); + if (res.success) { + followed.value = followed.value == 0 ? 1 : 3; + if (followed.value == 3) { + // 修改后若为3,我将此会话移除noFriend会话组 + final res = await ImService.instance.getConversation(conversationID: 'c2c_$vlogerId'); + if (res.success) { + V2TimConversation conversation = res.data; + if (conversation.conversationGroupList?.isNotEmpty == true) { + //移除陌生人会话 + await ImService.instance.deleteConversationsFromGroup( + groupName: conversation.conversationGroupList!.first!, + conversationIDList: [conversation.conversationID], + ); + //重新构建会话 + } + } + } + } + }, + icon: const Icon(Icons.add), + label: const Text('关注'), + style: ElevatedButton.styleFrom( + backgroundColor: FStyle.primaryColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + ); + } +} diff --git a/lib/pages/video/index.dart b/lib/pages/video/index.dart index d3859d3..0ca9d6e 100644 --- a/lib/pages/video/index.dart +++ b/lib/pages/video/index.dart @@ -80,20 +80,20 @@ class _VideoPageState extends State with SingleTickerProviderStateMix backgroundColor: ![0, 1, 2].contains(videoModuleController.videoTabIndex.value) ? null : Colors.transparent, foregroundColor: ![0, 1, 2].contains(videoModuleController.videoTabIndex.value) ? Colors.black : Colors.white, titleSpacing: 1.0, - leading: Obx(() => IconButton( - icon: Badge.count( - backgroundColor: Colors.red, - count: 6, - child: Icon( - Icons.sort_rounded, - color: tabColor(), - ), - ), - onPressed: () { - // 自定义打开右侧drawer - scaffoldKey.currentState?.openDrawer(); - }, - )), + // leading: Obx(() => IconButton( + // icon: Badge.count( + // backgroundColor: Colors.red, + // count: 6, + // child: Icon( + // Icons.sort_rounded, + // color: tabColor(), + // ), + // ), + // onPressed: () { + // // 自定义打开右侧drawer + // scaffoldKey.currentState?.openDrawer(); + // }, + // )), title: Obx(() { return ScrollConfiguration( behavior: CustomScrollBehavior().copyWith(scrollbars: false), @@ -160,30 +160,30 @@ class _VideoPageState extends State with SingleTickerProviderStateMix ), ), // 侧边栏 - drawer: Drawer( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(right: Radius.circular(15.0))), - clipBehavior: Clip.antiAlias, - width: 300, - child: Container( - color: Colors.grey[50], - child: Column( - children: [ - SizedBox( - height: 80.0, - ), - Icon( - Icons.tips_and_updates_outlined, - color: Colors.grey, - size: 50.0, - ), - Text( - '自定义侧边栏~', - style: TextStyle(color: Colors.grey, fontSize: 12.0), - ) - ], - ), - ), - ), + // drawer: Drawer( + // shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(right: Radius.circular(15.0))), + // clipBehavior: Clip.antiAlias, + // width: 300, + // child: Container( + // color: Colors.grey[50], + // child: Column( + // children: [ + // SizedBox( + // height: 80.0, + // ), + // Icon( + // Icons.tips_and_updates_outlined, + // color: Colors.grey, + // size: 50.0, + // ), + // Text( + // '自定义侧边栏~', + // style: TextStyle(color: Colors.grey, fontSize: 12.0), + // ) + // ], + // ), + // ), + // ), ); } } diff --git a/lib/pages/video/module/recommend.dart b/lib/pages/video/module/recommend.dart index 82f3647..2b7fbfd 100644 --- a/lib/pages/video/module/recommend.dart +++ b/lib/pages/video/module/recommend.dart @@ -2,16 +2,24 @@ library; import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:get/get.dart'; +import 'package:loopin/IM/controller/chat_controller.dart'; import 'package:loopin/IM/im_core.dart'; +import 'package:loopin/IM/im_message.dart'; import 'package:loopin/api/video_api.dart'; +import 'package:loopin/components/my_toast.dart'; +import 'package:loopin/components/network_or_asset_image.dart'; +import 'package:loopin/models/summary_type.dart'; import 'package:loopin/service/http.dart'; +import 'package:loopin/utils/wxsdk.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video_controls/src/controls/extensions/duration.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_conversation.dart'; import '../../../behavior/custom_scroll_behavior.dart'; import '../../../controller/video_module_controller.dart'; @@ -40,6 +48,7 @@ class RecommendModule extends StatefulWidget { class _RecommendModuleState extends State { VideoModuleController videoModuleController = Get.put(VideoModuleController()); + final ChatController chatController = Get.find(); // class _RecommendModuleState extends State with AutomaticKeepAliveClientMixin { // @override @@ -80,10 +89,16 @@ class _RecommendModuleState extends State { ]; // 分享列表 List shareList = [ + {'icon': 'assets/images/share-wx.png', 'label': '好友'}, {'icon': 'assets/images/share-wx.png', 'label': '微信'}, {'icon': 'assets/images/share-pyq.png', 'label': '朋友圈'}, {'icon': 'assets/images/share-link.png', 'label': '复制链接'}, {'icon': 'assets/images/share-download.png', 'label': '下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载下载下载下载下载下载下载下载下载下载下载下载'}, + {'icon': 'assets/images/share-download.png', 'label': '下载下载下载下载下载下载下载下载下载下载下载下载'}, ]; @override @@ -178,10 +193,12 @@ class _RecommendModuleState extends State { if (data['rows'] is List) { List videos = data['rows']; - // for (var item in videos) { - // print("喜欢:${item['likeCounts']}"); - // print("评论:${item['commentsCounts']}"); - // } + for (var item in videos) { + // print("喜欢:${item['likeCounts']}"); + // print("评论:${item['commentsCounts']}"); + // logger.i(item); + item['expanded'] = false; + } setState(() { if (page == 1) { @@ -221,6 +238,8 @@ class _RecommendModuleState extends State { // 评论弹框 void handleComment(index) { + //获取评论数据 + showModalBottomSheet( backgroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))), @@ -423,59 +442,114 @@ class _RecommendModuleState extends State { // 分享弹框 void handleShare(index) { + if (chatController.chatList.isNotEmpty) { + chatController.getConversationList(); + } showModalBottomSheet( backgroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(15.0))), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(15.0)), + ), clipBehavior: Clip.antiAlias, context: context, + isScrollControlled: true, builder: (context) { return Material( color: Colors.white, - child: SizedBox( - height: 170, - width: double.infinity, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Expanded( - child: ScrollConfiguration( - behavior: CustomScrollBehavior().copyWith(scrollbars: false), - child: ListView.builder( - shrinkWrap: true, - scrollDirection: Axis.horizontal, - padding: EdgeInsets.symmetric(vertical: 20.0, horizontal: 10.0), - itemCount: shareList.length, - itemBuilder: (context, index) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 12.0), + // 分享列表 + SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: shareList.length, + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + return GestureDetector( + onTap: () => handleShareClick(index), + child: Container( + width: 64, + margin: EdgeInsets.symmetric(horizontal: 8.0), child: Column( - spacing: 5.0, + mainAxisSize: MainAxisSize.min, children: [ Image.asset('${shareList[index]['icon']}', width: 48.0), + SizedBox(height: 5), Text( '${shareList[index]['label']}', style: TextStyle(fontSize: 12.0), - ) + overflow: TextOverflow.ellipsis, + ), ], ), + ), + ); + }, + ), + ), + + // 会话列表 + Obx(() { + // 这里过滤掉有分组的会话 + final filteredList = chatController.chatList.where((item) => item.conversation.conversationGroupList?.isEmpty == true).toList(); + if (filteredList.isEmpty) return SizedBox.shrink(); + return SizedBox( + height: 110, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: filteredList.length, + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 20.0), + itemBuilder: (context, index) { + return GestureDetector( + // 点击分享 + onTap: () => handlCoverClick(filteredList[index].conversation), + child: Container( + width: 64, + margin: EdgeInsets.symmetric(horizontal: 8.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Image.asset('${chatController.chatList[index].faceUrl}', width: 48.0), + NetworkOrAssetImage( + imageUrl: filteredList[index].faceUrl, + width: 48.0, + height: 48.0, + ), + SizedBox(height: 5), + Text( + '${filteredList[index].conversation.showName}', + style: TextStyle(fontSize: 12.0), + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), ); }, ), - ), - ), - InkWell( - child: Container( - alignment: Alignment.center, - width: double.infinity, - height: 50.0, - color: Colors.grey[50], - child: Text( - '取消', - style: TextStyle(color: Colors.black87), + ); + }), + + // 取消按钮 + SafeArea( + top: false, + child: InkWell( + onTap: () => Get.back(), + child: Container( + alignment: Alignment.center, + width: double.infinity, + height: 50.0, + color: Colors.grey[50], + child: Text( + '取消', + style: TextStyle(color: Colors.black87), + ), ), ), - onTap: () { - Get.back(); - }, ), ], ), @@ -485,6 +559,52 @@ class _RecommendModuleState extends State { ); } + void handleShareClick(int index) { + print("分享项 $index 被点击"); + final description = videoList[videoModuleController.videoPlayIndex.value]['title'] ?? '获取title失败'; + + if (index == 1) { + // 好友 + Wxsdk.shareToFriend(title: '快来看看这个视频', description: description, webpageUrl: 'https://baidu.com'); + } else if (index == 2) { + // 朋友圈 + Wxsdk.shareToTimeline(title: '快来看看这个视频', webpageUrl: 'https://baidu.com'); + } + } + + void handlCoverClick(V2TimConversation conv) async { + // 发送VideoMsg,获取当前视频信息 + final userId = conv.userID; + final String url = videoList[videoModuleController.videoPlayIndex.value]['url']; + final img = videoList[videoModuleController.videoPlayIndex.value]['firstFrameImg']; + final width = videoList[videoModuleController.videoPlayIndex.value]['width']; + final height = videoList[videoModuleController.videoPlayIndex.value]['height']; + final makeJson = jsonEncode({ + "width": width, + "height": height, + "imgUrl": img, + "videoUrl": url, + }); + final res = await IMMessage().createCustomMessage( + data: makeJson, + ); + if (res.success) { + final sendRes = await IMMessage().sendMessage(msg: res.data!.messageInfo!, toUserID: userId, cloudCustomData: SummaryType.shareVideo); + if (sendRes.success) { + MyToast().tip( + title: '分享成功', + position: 'center', + type: 'success', + ); + Get.back(); + } else { + logger.e(res.desc); + } + } else { + logger.e(res.desc); + } + } + @override Widget build(BuildContext context) { // super.build(context); @@ -601,18 +721,25 @@ class _RecommendModuleState extends State { SizedBox( height: 55.0, width: 48.0, - child: UnconstrainedBox( - alignment: Alignment.topCenter, - child: Container( - height: 48.0, - width: 48.0, - decoration: BoxDecoration( - border: Border.all(color: Colors.white, width: 2.0), - borderRadius: BorderRadius.circular(100.0), - ), - child: ClipOval( - child: - Image.network(videoList[index]['vlogerFace'] ?? 'https://wuzhongjie.com.cn/download/logo.png', fit: BoxFit.cover), + child: GestureDetector( + onTap: () { + player.pause(); + Get.toNamed('/vloger', arguments: videoList[videoModuleController.videoPlayIndex.value]); + }, + child: UnconstrainedBox( + alignment: Alignment.topCenter, + child: Container( + height: 48.0, + width: 48.0, + decoration: BoxDecoration( + border: Border.all(color: Colors.white, width: 2.0), + borderRadius: BorderRadius.circular(100.0), + ), + child: ClipOval( + child: NetworkOrAssetImage( + imageUrl: videoList[index]['avatar'], + ), + ), ), ), ), @@ -643,6 +770,7 @@ class _RecommendModuleState extends State { ), ], ), + // 点赞 GestureDetector( child: Column( children: [ @@ -664,6 +792,7 @@ class _RecommendModuleState extends State { }); }, ), + // 评论 GestureDetector( child: Column( children: [ @@ -697,6 +826,7 @@ class _RecommendModuleState extends State { // ), // ], // ), + // 转发 GestureDetector( child: Column( children: [ @@ -706,39 +836,96 @@ class _RecommendModuleState extends State { height: 40.0, width: 40.0, ), - // Text( - // '${videoList[index]['shareNum']}', - // style: TextStyle(color: Colors.white, fontSize: 12.0), - // ), ], ), onTap: () { handleShare(index); }, ), + //举报 + GestureDetector( + child: Column( + children: [ + SvgPicture.asset( + 'assets/images/svg/report.svg', + colorFilter: ColorFilter.mode(Colors.white, BlendMode.srcIn), + height: 40.0, + width: 40.0, + ), + ], + ), + onTap: () { + // 举报 + }, + ), ], ), ), // 底部信息区域 Positioned( - bottom: 15.0, - left: 10.0, - right: 80.0, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 5.0, - children: [ - Text( - '@${videoList[index]['vlogerName'] ?? '未知'}', - style: TextStyle(color: Colors.white, fontSize: 16.0), - ), - Text( - '${videoList[index]['content'] ?? '未知'}', - style: TextStyle(color: Colors.white, fontSize: 14.0), - ), - ], - ), - ), + bottom: 15.0, + left: 10.0, + right: 80.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '@${videoList[videoModuleController.videoPlayIndex.value]['nickname'] ?? '未知'}', + style: const TextStyle(color: Colors.white, fontSize: 16.0), + ), + LayoutBuilder( + builder: (context, constraints) { + final text = videoList[videoModuleController.videoPlayIndex.value]['title'] ?? '未知'; + // 先用 TextPainter 判断是否超过 3 行 + final span = TextSpan( + text: text, + style: const TextStyle(color: Colors.white, fontSize: 14.0), + ); + final tp = TextPainter( + text: span, + maxLines: 3, + textDirection: TextDirection.ltr, + ); + tp.layout(maxWidth: constraints.maxWidth); + final isOverflow = tp.didExceedMaxLines; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + maxLines: videoList[videoModuleController.videoPlayIndex.value]['expanded'] ? null : 3, + overflow: + videoList[videoModuleController.videoPlayIndex.value]['expanded'] ? TextOverflow.visible : TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 14.0), + ), + if (isOverflow) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: GestureDetector( + onTap: () { + setState(() { + videoList[videoModuleController.videoPlayIndex.value]['expanded'] = + !videoList[videoModuleController.videoPlayIndex.value]['expanded']; + }); + }, + child: Text( + videoList[videoModuleController.videoPlayIndex.value]['expanded'] ? '收起' : '展开更多', + textAlign: TextAlign.right, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ); + }, + ), + ], + )), // mini播放进度条 Positioned( bottom: 0.0, diff --git a/lib/router/index.dart b/lib/router/index.dart index 5e438e0..f1b894b 100644 --- a/lib/router/index.dart +++ b/lib/router/index.dart @@ -3,7 +3,15 @@ library; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:loopin/bings/chat_binding.dart'; import 'package:loopin/pages/chat/chat.dart'; +import 'package:loopin/pages/chat/chat_group.dart'; +import 'package:loopin/pages/chat/chat_no_friend.dart'; +import 'package:loopin/pages/my/des.dart'; +import 'package:loopin/pages/my/nick_name.dart'; +import 'package:loopin/pages/my/setting.dart'; +import 'package:loopin/pages/my/user_info.dart'; +import 'package:loopin/pages/my/vloger.dart'; import '../layouts/index.dart'; /* 引入路由页面 */ @@ -19,25 +27,61 @@ import '../utils/common.dart'; // 路由地址集合 final Map routes = { '/': const Layout(), - // '/upload': const UploadVideoPage(), '/goods': const Goods(), - '/chat': const Chat(), + // '/chat': const Chat(), + // '/chatNoFriend': const ChatNoFriend(), + // '/chatGroup': const ChatGroup(), '/order': const Order(), '/order/detail': const OrderDetail(), + '/vloger': const Vloger(), + //settins + '/setting': const Setting(), + '/userInfo': const UserInfo(), + '/notifications': const Setting(), + '/privacy': const Setting(), + '/about': const Setting(), + '/des': const Des(), + '/nickName': const NickName(), }; final List routeList = routes.entries .map((e) => GetPage( name: e.key, // 路由名称 page: () => e.value, // 路由页面 - transition: Transition.rightToLeftWithFade, // 跳转路由动画 + // transition: Transition.rightToLeftWithFade, // 跳转路由动画 + transition: Transition.rightToLeft, // 跳转路由动画 middlewares: [RouteMiddleware()], // 路由中间件 )) .toList(); +final List bingsRoutes = [ + GetPage( + name: '/chat', + page: () => const Chat(), + binding: ChatBinding(), + transition: Transition.rightToLeft, + middlewares: [RouteMiddleware()], + ), + GetPage( + name: '/chatNoFriend', + page: () => const ChatNoFriend(), + binding: ChatBinding(), + transition: Transition.rightToLeft, + middlewares: [RouteMiddleware()], + ), + GetPage( + name: '/chatGroup', + page: () => const ChatGroup(), + binding: ChatBinding(), + transition: Transition.rightToLeft, + middlewares: [RouteMiddleware()], + ), +]; + final List routePages = [ GetPage(name: '/login', page: () => const Login()), ...routeList, + ...bingsRoutes, ]; // 路由中间件拦截验证 diff --git a/lib/service/http.dart b/lib/service/http.dart index 89a8777..fc709aa 100644 --- a/lib/service/http.dart +++ b/lib/service/http.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:loopin/IM/push_service.dart'; import 'http_config.dart'; @@ -15,12 +16,16 @@ class Http { } static Future post(String url, {dynamic data, Map? headers}) async { - final res = await _dio.post( - url, - data: data, - options: Options(extra: headers ?? {}), - ); - return res.data; + try { + final res = await _dio.post( + url, + data: data, + options: Options(extra: headers ?? {}), + ); + return res.data; + } catch (e) { + logger.e('$e--------$url'); + } } static Future put(String url, {dynamic data, Map? headers}) async { diff --git a/lib/service/http_config.dart b/lib/service/http_config.dart index 56b1b8e..140ca7c 100644 --- a/lib/service/http_config.dart +++ b/lib/service/http_config.dart @@ -6,10 +6,11 @@ import 'package:get_storage/get_storage.dart'; class HttpConfig { static final Dio dio = Dio(BaseOptions( // baseUrl: 'http://43.143.227.203:8099', - baseUrl: 'http://111.62.22.190:8080', + // baseUrl: 'http://111.62.22.190:8080', // baseUrl: 'http://cjh.wuzhongjie.com.cn', - connectTimeout: Duration(seconds: 30), - receiveTimeout: Duration(seconds: 30), + baseUrl: 'http://82.156.121.2:8880', + // connectTimeout: Duration(seconds: 30), + // receiveTimeout: Duration(seconds: 30), )); static final box = GetStorage(); @@ -33,13 +34,14 @@ class HttpConfig { handler.next(options); }, onResponse: (response, handler) { + // logger.e(response.requestOptions.data); final data = response.data; - // data['code'] = 200; // 旧接口测试用 if (data is Map) { if (data['code'] != 200) { Get.snackbar( - '错误', - data['msg'] ?? '请求失败', + '错误码${data['code']}', + '${response.requestOptions.uri}\n${response.requestOptions.data}\n${data['msg']}' ?? '请求失败', + duration: Duration(minutes: 1), backgroundColor: Colors.red.withAlpha(230), colorText: Colors.white, icon: const Icon(Icons.error_outline, color: Colors.white), diff --git a/lib/update/upgrade_dialog.dart b/lib/update/upgrade_dialog.dart index a77d8e9..c9c8caf 100644 --- a/lib/update/upgrade_dialog.dart +++ b/lib/update/upgrade_dialog.dart @@ -27,7 +27,7 @@ class UpgradeDialog extends StatelessWidget { children: [ Image.asset('assets/images/update/rocket.png', width: 80, height: 80), const SizedBox(height: 12), - Text("发现新版本 v$version", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + Text("发现新版本 $version", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 10), // Text(content, style: TextStyle(fontSize: 14)), Column( diff --git a/lib/update/upgrade_service.dart b/lib/update/upgrade_service.dart index 09a8299..2afa19b 100644 --- a/lib/update/upgrade_service.dart +++ b/lib/update/upgrade_service.dart @@ -1,52 +1,64 @@ import 'dart:io'; import 'package:flutter/material.dart'; +import 'package:loopin/IM/im_core.dart'; +import 'package:loopin/api/common_api.dart'; +import 'package:loopin/service/http.dart'; import 'package:package_info_plus/package_info_plus.dart'; -// import 'package:loopin/api/common_api.dart'; -// import 'package:loopin/service/http.dart'; - import 'upgrade_dialog.dart'; import 'upgrade_util.dart'; class UpgradeService { - static Future checkUpgrade(BuildContext context) async { + static Future checkUpgrade(State state) async { final info = await PackageInfo.fromPlatform(); - print('App version: ${info.version}'); - print('version_code: ${info.buildNumber}'); - final currentVersion = info.version; - // final res = await Http.get(CommonApi.checkVersion); - // final data = res['data']; - final data = { - "version": "4.1.0", - "content": [ - "新增火箭弹窗", - "修复若干 Bug", - "优化界面动画", - ], - "force": 0, - "apkUrl": "https://wuzhongjie.com.cn/download/wzj.apk", - "iosUrl": "https://apps.apple.com/cn/app/无终街/id6479185362", - }; - // 0 表示 false非强制,非 0 表示 true强制 - final bool force = (data['force'] ?? 0) != 0; - // 弹窗 - showDialog( - context: context, - barrierDismissible: !force, - builder: (_) => UpgradeDialog( - version: data['version']?.toString() ?? '', - content: (data['content'] as List).map((e) => e.toString()).toList(), - force: force, - onConfirm: () { - if (Platform.isAndroid) { - Navigator.pop(context); - UpgradeUtil.downloadAndInstallAPK(context, data['apkUrl']?.toString() ?? ''); - } else if (Platform.isIOS) { - UpgradeUtil.launchAppStore(data['iosUrl']?.toString() ?? ''); - } - }, - ), - ); + if (!state.mounted) return; + + logger.i('App version: ${info.version}'); + logger.i('version_code: ${info.buildNumber}'); + final res = await Http.post(CommonApi.checkVersion, data: { + 'platformType': Platform.isAndroid ? 'android' : 'ios', + 'status': 1, + }); + if (!state.mounted) return; + + // logger.i(res); + final result = res['data']['records'] as List; + final data = result.first; + final currentVersion = info.buildNumber; + if (currentVersion != data['versionCode']) { + // 版本号不一致 + // 0 表示 false非强制,非 0 表示 true强制 + final bool force = (data['isForceUpdate'] ?? 0) != 0; + // 弹窗 + showDialog( + context: state.context, + barrierDismissible: !force, + builder: (_) => UpgradeDialog( + version: data['versionName']?.toString() ?? '', + content: (data['releaseNotes'] as String).split(',').map((e) => e.trim()).toList(), + force: force, + onConfirm: () { + if (Platform.isAndroid) { + Navigator.pop(state.context); + UpgradeUtil.downloadAndInstallAPK(state.context, data['downloadUrl']?.toString() ?? ''); + } else if (Platform.isIOS) { + UpgradeUtil.launchAppStore(data['downloadUrl']?.toString() ?? ''); + } + }, + ), + ); + } + // final data = { + // "version": "4.1.0", + // "content": [ + // "新增火箭弹窗", + // "修复若干 Bug", + // "优化界面动画", + // ], + // "force": 0, + // "apkUrl": "https://wuzhongjie.com.cn/download/wzj.apk", + // "iosUrl": "https://apps.apple.com/cn/app/无终街/id6479185362", + // }; } } diff --git a/lib/utils/audio_player_service.dart b/lib/utils/audio_player_service.dart new file mode 100644 index 0000000..1392b95 --- /dev/null +++ b/lib/utils/audio_player_service.dart @@ -0,0 +1,57 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:loopin/IM/im_core.dart'; + +class AudioPlayerService { + static final AudioPlayerService _instance = AudioPlayerService._internal(); + factory AudioPlayerService() => _instance; + AudioPlayerService._internal(); + + final AudioPlayer _audioPlayer = AudioPlayer(); + + /// 播放本地文件 + Future playLocal(String filePath) async { + try { + await _audioPlayer.setSourceDeviceFile(filePath); + await _audioPlayer.resume(); + } catch (e) { + logger.e('播放本地音频失败: $e'); + } + } + + /// 播放网络音频 + Future playNetwork(String url) async { + try { + await _audioPlayer.setSourceUrl(url); + await _audioPlayer.resume(); + } catch (e) { + logger.e('播放网络音频失败: $e'); + } + } + + /// 暂停播放 + Future pause() async { + try { + await _audioPlayer.pause(); + } catch (e) { + logger.e('暂停播放失败: $e'); + } + } + + /// 停止播放 + Future stop() async { + try { + await _audioPlayer.stop(); + } catch (e) { + logger.e('停止播放失败: $e'); + } + } + + /// 释放资源 + Future dispose() async { + try { + await _audioPlayer.dispose(); + } catch (e) { + logger.e('释放播放器失败: $e'); + } + } +} diff --git a/lib/utils/common.dart b/lib/utils/common.dart index 44316a5..3bbb784 100644 --- a/lib/utils/common.dart +++ b/lib/utils/common.dart @@ -5,7 +5,7 @@ import './storage.dart'; class Common { /// 判断是否登录 - static isLogin() { + static bool isLogin() { return Storage.hasData('hasLogged'); } diff --git a/lib/utils/index.dart b/lib/utils/index.dart index 662a7a9..50d613c 100644 --- a/lib/utils/index.dart +++ b/lib/utils/index.dart @@ -55,7 +55,7 @@ class Utils { } /// 是否为空 - static isEmpty(val) { + static bool isEmpty(val) { if (val == null) return true; if (val is bool && val == false) return true; if (val is String) return val.isEmpty; @@ -87,6 +87,7 @@ class Utils { //翻译媒体库title static String translateAlbumName(AssetPathEntity album) { + // logger.i(album.name); final Map albumNameMap = { 'recents': '最近项目', 'favorites': '收藏', @@ -105,6 +106,8 @@ class Utils { 'animated': '动图', 'raw': 'RAW格式', 'hidden': '已隐藏', + 'recent': '最近项目', // 安卓 + 'camera': '相机', // 安卓 }; return albumNameMap[album.name.toLowerCase()] ?? album.name; } diff --git a/lib/utils/notification_banner.dart b/lib/utils/notification_banner.dart index e737646..c453e0c 100644 --- a/lib/utils/notification_banner.dart +++ b/lib/utils/notification_banner.dart @@ -4,6 +4,7 @@ import 'package:loopin/IM/im_service.dart'; import 'package:loopin/utils/parse_message_summary.dart'; import 'package:shirne_dialog/shirne_dialog.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; +import 'package:tencent_cloud_chat_sdk/models/v2_tim_user_full_info.dart'; class NotificationBanner { static void show(V2TimMessage msg) { @@ -49,4 +50,32 @@ class NotificationBanner { }, ); } + + /// 被关注通知 + static void foucs(V2TimUserFullInfo msg) { + final nickname = msg.nickName ?? '未知用户'; + final avatar = msg.faceUrl ?? ''; + final text = '$nickname:关注了你'; + + Get.snackbar( + '新的关注', + text, + duration: const Duration(seconds: 5), + snackPosition: SnackPosition.TOP, + margin: const EdgeInsets.all(12), + backgroundColor: Get.theme.cardColor, + colorText: Get.theme.textTheme.bodyLarge?.color, + icon: avatar.isNotEmpty + ? CircleAvatar( + backgroundImage: NetworkImage(avatar), + radius: 16, + ) + : null, + onTap: (_) async { + // 点击后立刻关闭 + Get.closeCurrentSnackbar(); + // 跳转到新关注我的页面 + }, + ); + } } diff --git a/lib/utils/parse_message_summary.dart b/lib/utils/parse_message_summary.dart index 2568e16..a395485 100644 --- a/lib/utils/parse_message_summary.dart +++ b/lib/utils/parse_message_summary.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:loopin/models/summary_type.dart'; import 'package:tencent_cloud_chat_sdk/enum/message_elem_type.dart'; import 'package:tencent_cloud_chat_sdk/models/v2_tim_message.dart'; @@ -14,37 +15,38 @@ String parseMessageSummary(V2TimMessage msg) { case MessageElemType.V2TIM_ELEM_TYPE_VIDEO: return '[视频]'; case MessageElemType.V2TIM_ELEM_TYPE_FILE: - return '[文件]'; + return '[文件]'; // 先不做 case MessageElemType.V2TIM_ELEM_TYPE_LOCATION: - return '[位置]'; + return '[位置]'; // 先不做 case MessageElemType.V2TIM_ELEM_TYPE_FACE: return '[表情]'; case MessageElemType.V2TIM_ELEM_TYPE_CUSTOM: - return _parseCustomMessage(msg.customElem?.data); + return _parseCustomMessage(msg); case MessageElemType.V2TIM_ELEM_TYPE_MERGER: return '[合并转发消息]'; case MessageElemType.V2TIM_ELEM_TYPE_GROUP_TIPS: return '[群提示]'; default: - return '[未知消息类型]'; + return '[未知消息类型1]'; } } -String _parseCustomMessage(String? data) { - if (data == null || data.isEmpty) return '[自定义消息]'; - +String _parseCustomMessage(V2TimMessage? msg) { + if (msg == null) return '[null]'; + final sum = msg.cloudCustomData; try { - final jsonData = json.decode(data); - final type = jsonData['type']; - switch (type) { - case 'interaction': - return '[互动] ${jsonData['action'] ?? ''}'; - case 'forward': - return '[转发] ${jsonData['title'] ?? ''}'; + switch (sum) { + case SummaryType.hongbao: + final hbData = jsonDecode(msg.customElem!.data!); + return '[红包]${hbData['remark']}'; + case SummaryType.shareTuangou: + return '[分享商品]'; + case SummaryType.shareVideo: + return '[分享视频]'; default: - return '[自定义消息]'; + return '[未知消息类型2]'; } } catch (_) { - return '[自定义消息]'; + return '[未知消息类型3]'; } } diff --git a/lib/utils/snapshot.dart b/lib/utils/snapshot.dart new file mode 100644 index 0000000..1be4149 --- /dev/null +++ b/lib/utils/snapshot.dart @@ -0,0 +1,15 @@ +import 'package:path_provider/path_provider.dart'; +import 'package:video_thumbnail/video_thumbnail.dart'; + +/// 视频首帧截取 +Future generateVideoThumbnail(String videoPath) async { + final tempDir = await getTemporaryDirectory(); + final thumbnailPath = await VideoThumbnail.thumbnailFile( + video: videoPath, + thumbnailPath: tempDir.path, + imageFormat: ImageFormat.JPEG, + maxWidth: 120, + quality: 75, + ); + return thumbnailPath; +} diff --git a/lib/utils/voice_service.dart b/lib/utils/voice_service.dart new file mode 100644 index 0000000..bee4098 --- /dev/null +++ b/lib/utils/voice_service.dart @@ -0,0 +1,80 @@ +import 'dart:io'; + +import 'package:loopin/IM/im_core.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +class VoiceService { + static final VoiceService _instance = VoiceService._internal(); + factory VoiceService() => _instance; + VoiceService._internal(); + + final AudioRecorder _recorder = AudioRecorder(); + String? _voiceFilePath; + DateTime? _startTime; + + /// 开始录音 + Future startRecording() async { + if (await _recorder.hasPermission()) { + final dir = await getTemporaryDirectory(); // 临时目录 + final filePath = '${dir.path}/${DateTime.now().millisecondsSinceEpoch}.m4a'; + _voiceFilePath = filePath; + _startTime = DateTime.now(); + + await _recorder.start( + const RecordConfig( + encoder: AudioEncoder.aacLc, + bitRate: 128000, + sampleRate: 44100, + ), + path: filePath, + ); + logger.i('开始录音,文件路径: $filePath'); + + return true; + } else { + logger.e("没有录音权限"); + + return false; + } + } + + /// 停止录音并返回文件路径 + Future?> stopRecording() async { + await _recorder.stop(); + + if (_voiceFilePath != null && File(_voiceFilePath!).existsSync() && _startTime != null) { + final duration = DateTime.now().difference(_startTime!); + final durationSeconds = duration.inSeconds; + if (durationSeconds < 1 || durationSeconds > 60) { + logger.w('录音时长不在允许范围(1-60秒),删除文件: $_voiceFilePath,时长: $durationSeconds 秒'); + try { + await File(_voiceFilePath!).delete(); + } catch (e) { + logger.e('删除录音文件失败: $e'); + } + return null; + } + logger.i('录音完成: $_voiceFilePath,时长: ${duration.inSeconds}秒'); + return { + 'path': _voiceFilePath, + 'duration': duration.inMilliseconds, + }; + } + logger.e("没有录到音频文件"); + return null; + } + + /// 取消录音(删除文件) + Future cancelRecording() async { + await _recorder.stop(); + if (_voiceFilePath != null) { + final file = File(_voiceFilePath!); + if (await file.exists()) { + await file.delete(); + logger.i('录音已取消并删除'); + } + } + _voiceFilePath = null; + } +} diff --git a/lib/utils/wxsdk.dart b/lib/utils/wxsdk.dart new file mode 100644 index 0000000..cdb8ea6 --- /dev/null +++ b/lib/utils/wxsdk.dart @@ -0,0 +1,144 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_image_compress/flutter_image_compress.dart'; +import 'package:fluwx/fluwx.dart'; +import 'package:get/get.dart'; +import 'package:loopin/IM/controller/im_user_info_controller.dart'; +import 'package:loopin/IM/im_service.dart'; +import 'package:loopin/api/common_api.dart'; +import 'package:loopin/service/http.dart'; + +class Wxsdk { + static bool _inited = false; + + static final Fluwx fluwx = Fluwx(); + + static Future init() async { + if (_inited) { + return true; + } + _inited = true; + final initRes = await fluwx.registerApi( + appId: 'wxebcdaea31881caab', + doOnAndroid: true, + doOnIOS: true, + universalLink: 'https://wuzhongjie.com.cn/', + ); + if (initRes) { + logger.i('微信sdk初始化成功'); + // 全局监听授权回调 + fluwx.addSubscriber( + (res) async { + //授权 + if (res is WeChatAuthResponse) { + if (res.isSuccessful) { + final code = res.code; + logger.i('微信回调,code: $code,类型:${res.state}'); + if (res.state == 'getOpenId') { + // TODO: 使用 code 向后台换取 access_token、unionid + final serverRes = await Http.post(CommonApi.wxLogin, data: { + "source": "wechat_open", + "socialCode": "${res.code}", + "socialState": "1", + "clientId": "428a8310cd442757ae699df5d894f051", + "grantType": "social" + }); + final info = Get.find(); + info.customInfo['openId'] = serverRes['data']['openId']; + info.updateOpenId(); + info.customInfo.refresh(); + logger.w(serverRes['data']['openId']); + } + } else { + logger.w('微信授权失败: ${res.errStr}-类型:${res.state}'); + } + } + // 分享 + if (res is WeChatShareResponse) { + logger.w(res.isSuccessful); + // 这里只能确保打开了微信,是取消了还是确认了没办法知道 + if (res.isSuccessful) {} + } + }, + ); + } else { + logger.i('微信SDK初始化失败:$initRes'); + } + return initRes; + } + + /// 调用微信登录 + static Future login() async { + final result = await fluwx.authBy( + which: NormalAuth( + scope: 'snsapi_userinfo', + state: 'getOpenId', + ), + ); + if (!result) { + logger.e('微信授权请求发送失败'); + } + } + + ///分享好友 + static Future shareToFriend({ + required String title, + required String description, + required String webpageUrl, + String thumbnailAssetPath = 'assets/images/logo/logo.png', + }) async { + Uint8List? thumbData; + thumbData = await _loadLocalThumbnail(thumbnailAssetPath); + final model = WeChatShareWebPageModel( + webpageUrl, + title: title, + description: description, + thumbData: thumbData, + scene: WeChatScene.session, + ); + return Fluwx().share(model); + } + + ///分享到朋友圈 + static Future shareToTimeline({ + required String title, + required String webpageUrl, + String thumbnailAssetPath = 'assets/images/logo/logo.png', + }) async { + Uint8List? thumbData; + thumbData = await _loadLocalThumbnail(thumbnailAssetPath); + final model = WeChatShareWebPageModel( + webpageUrl, + title: title, + thumbData: thumbData, + scene: WeChatScene.timeline, + ); + return Fluwx().share(model); + } + + static Future _loadLocalThumbnail(String assetPath) async { + final byteData = await rootBundle.load(assetPath); + final originBytes = byteData.buffer.asUint8List(); + + final compressedBytes = await FlutterImageCompress.compressWithList( + originBytes, + minWidth: 120, // 微信要求120X120且小于32kb + minHeight: 120, + quality: 80, // 控制质量 + format: CompressFormat.jpeg, // 转为 JPEG + ); + + logger.i("thumbData size: ${compressedBytes.length} bytes"); + + return compressedBytes; + } + + /// 跳转小程序 + static Future openMiniApp({required orderId}) async { + var miniProgram = MiniProgram( + username: "gh_2ffaecc5508e", // 小程序原始ID + path: "/pages/index/index?id=$orderId", // 打开时带的路径参数 + miniProgramType: WXMiniProgramType.test, + ); + Fluwx().open(target: miniProgram); + } +} diff --git a/pubspec.lock b/pubspec.lock index ef230e6..8315bef 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.12.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: e653f162ddfcec1da2040ba2d8553fff1662b5c2a5c636f4c21a3b11bee497de + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.5.0" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.2.1" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.3.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.1" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.1.1" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.1" boolean_selector: dependency: transitive description: @@ -49,6 +105,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" + bottom_picker: + dependency: "direct main" + description: + name: bottom_picker + sha256: b83c35861314aafdef6857be1a8d900d82fa90c979a12af9b653d5d9e7d35beb + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.1" card_swiper: dependency: "direct main" description: @@ -73,6 +137,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.3" + city_pickers: + dependency: "direct main" + description: + name: city_pickers + sha256: "583102c8d9eecb1f7abc5ff52a22d7cb019b9808cdb24b80c7692c769f8da153" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" cli_util: dependency: transitive description: @@ -177,6 +249,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.1" + easy_refresh: + dependency: "direct main" + description: + name: easy_refresh + sha256: "486e30abfcaae66c0f2c2798a10de2298eb9dc5e0bb7e1dba9328308968cae0c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.4.0" extended_image: dependency: transitive description: @@ -270,6 +350,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "10.0.1" + flutter_html: + dependency: "direct main" + description: + name: flutter_html + sha256: "38a2fd702ffdf3243fb7441ab58aa1bc7e6922d95a50db76534de8260638558d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.0" + flutter_image_compress: + dependency: "direct main" + description: + name: flutter_image_compress + sha256: "51d23be39efc2185e72e290042a0da41aed70b14ef97db362a6b5368d0523b27" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.0" + flutter_image_compress_common: + dependency: transitive + description: + name: flutter_image_compress_common + sha256: c5c5d50c15e97dd7dc72ff96bd7077b9f791932f2076c5c5b6c43f2c88607bfb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.6" + flutter_image_compress_macos: + dependency: transitive + description: + name: flutter_image_compress_macos + sha256: "20019719b71b743aba0ef874ed29c50747461e5e8438980dfa5c2031898f7337" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.3" + flutter_image_compress_ohos: + dependency: transitive + description: + name: flutter_image_compress_ohos + sha256: e76b92bbc830ee08f5b05962fc78a532011fcd2041f620b5400a593e96da3f51 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.0.3" + flutter_image_compress_platform_interface: + dependency: transitive + description: + name: flutter_image_compress_platform_interface + sha256: "579cb3947fd4309103afe6442a01ca01e1e6f93dc53bb4cbd090e8ce34a41889" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" + flutter_image_compress_web: + dependency: transitive + description: + name: flutter_image_compress_web + sha256: b9b141ac7c686a2ce7bb9a98176321e1182c9074650e47bb140741a44b6f5a96 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.1.5" flutter_launcher_icons: dependency: "direct dev" description: @@ -341,6 +477,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluwx: + dependency: "direct main" + description: + name: fluwx + sha256: "9db31d54043363c9c8283b5f0bc4df982edb45ba19d800df9d7de96a205371ae" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.7.0" form_builder_validators: dependency: "direct main" description: @@ -581,6 +725,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "5.1.1" + list_counter: + dependency: transitive + description: + name: list_counter + sha256: c447ae3dfcd1c55f0152867090e67e219d42fe6d4f2807db4bbe8b8d69912237 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" logger: dependency: "direct main" description: @@ -597,6 +749,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.3.0" + lpinyin: + dependency: transitive + description: + name: lpinyin + sha256: "0bb843363f1f65170efd09fbdfc760c7ec34fc6354f9fcb2f89e74866a0d814a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.3" matcher: dependency: transitive description: @@ -749,6 +909,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_drawing: + dependency: transitive + description: + name: path_drawing + sha256: bbb1934c0cbb03091af082a6389ca2080345291ef07a5fa6d6e078ba8682f977 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" path_parsing: dependency: transitive description: @@ -917,6 +1085,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.1.5" + record: + dependency: "direct main" + description: + name: record + sha256: daeb3f9b3fea9797094433fe6e49a879d8e4ca4207740bc6dc7e4a58764f0817 + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "97d7122455f30de89a01c6c244c839085be6b12abca251fc0e78f67fed73628b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "73706ebbece6150654c9d6f57897cf9b622c581148304132ba85dba15df0fdfb" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "0626678a092c75ce6af1e32fe7fd1dea709b92d308bc8e3b6d6348e2430beb95" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "02240833fde16c33fcf2c589f3e08d4394b704761b4a3bb609d872ff3043fbbd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: c1ad38f51e4af88a085b3e792a22c685cb3e7c23fc37aa7ce44c4cf18f25fe89 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: a12856d0b3dd03d336b4b10d7520a8b3e21649a06a8f95815318feaa8f07adbb + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.9" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "85a22fc97f6d73ecd67c8ba5f2f472b74ef1d906f795b7970f771a0914167e99" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.6" safe_local_storage: dependency: transitive description: @@ -941,6 +1173,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.0" + scrollable_positioned_list: + dependency: transitive + description: + name: scrollable_positioned_list + sha256: "1b54d5f1329a1e263269abc9e2543d90806131aa14fe7c6062a8054d57249287" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.8" shirne_dialog: dependency: "direct main" description: @@ -1002,6 +1242,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.3.1" + tencent_cloud_chat_push: + dependency: "direct main" + description: + name: tencent_cloud_chat_push + sha256: "7a76d107715e99fd4ed11489b9aa662e2f22d3f4614d9cee72e3f9d97c6f8b0f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.6.7019+1" tencent_cloud_chat_sdk: dependency: "direct main" description: @@ -1202,6 +1450,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.3.5" + video_thumbnail: + dependency: "direct main" + description: + name: video_thumbnail + sha256: "181a0c205b353918954a881f53a3441476b9e301641688a581e0c13f00dc588b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.6" visibility_detector: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b31d016..2ebbf70 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -41,8 +41,11 @@ dependencies: cupertino_icons: ^1.0.8 card_swiper: ^3.0.1 flutter_svg: ^2.0.16 + easy_refresh: ^3.4.0 # 腾讯IM tencent_cloud_chat_sdk: ^8.6.7019+2 + # 离线推送 + tencent_cloud_chat_push: ^8.6.7019+1 # 瀑布流组件 flutter_staggered_grid_view: ^0.7.0 # 状态管理 @@ -67,14 +70,23 @@ dependencies: wechat_assets_picker: ^9.5.1 device_info_plus: ^11.5.0 - photo_manager: ^3.7.1 + photo_manager: ^3.7.1 #翻译媒体库 flutter_form_builder: ^10.0.1 form_builder_validators: ^11.1.2 geolocator: ^14.0.1 - nested_scroll_view_plus: ^3.0.0 + nested_scroll_view_plus: ^3.0.0 #滚动 ai_barcode_scanner: ^7.0.0 + city_pickers: ^1.3.0 + bottom_picker: ^3.2.1 + + fluwx: ^5.7.0 #微信sdk + flutter_image_compress: ^2.4.0 #处理图片 + video_thumbnail: ^0.5.6 #视频首帧截取 + record: ^6.0.0 #音频 + audioplayers: ^6.5.0 #音频播放 + flutter_html: ^3.0.0 dev_dependencies: flutter_launcher_icons: ^0.13.1 # 使用最新版本 @@ -101,6 +113,8 @@ flutter: assets: - assets/images/ + #notify + - assets/images/notify/ #logo - assets/images/logo/ #avatar