From dad8b31cf217c6c757e6d37138bb4b41fb399d0f Mon Sep 17 00:00:00 2001 From: needle10 Date: Sat, 15 Nov 2025 23:39:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B8=D0=B7=D0=BC=D0=B5=D0=BD=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D0=B0=20=D0=BF=D0=B0=D0=BF=D0=BE=D0=BA,=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=BA?= =?UTF-8?q?=D0=BD=D0=BE=D0=BF=D0=BA=D0=B0=20=D1=81=D0=BE=D0=B7=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BF=D0=B0=D0=BF=D0=BE=D0=BA,=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BE=D0=B1=D1=80=D0=B0=D0=B6=D0=B0=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=BE=D1=82=20=D0=BA=D0=BE=D0=B3=D0=BE=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D0=BB=D0=B0=D0=BD=D0=BE=20=D1=81=D0=BE=D0=BE=D0=B1?= =?UTF-8?q?=D1=89=D0=B5=D0=BD=D0=B8=D0=B5,=20=D0=BF=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80=20=D1=81=D0=BE?= =?UTF-8?q?=D0=BE=D0=B1=D1=89=D0=B5=D0=BD=D0=B8=D0=B9=20=D0=BF=D0=BE=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B6=D0=B0=D1=82=D0=B8=D0=B8=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- .../kometteam/komet}/MainActivity.kt | 0 assets/images/spermum.png | Bin 0 -> 29657 bytes assets/images/spermum_but_dark.webp | Bin 0 -> 41750 bytes devtools_options.yaml | 3 + lib/api_service.dart | 285 ++-------- lib/chat_screen.dart | 110 ++-- lib/chats_screen.dart | 512 +++++++++++++----- lib/widgets/chat_message_bubble.dart | 394 ++++++++------ lib/widgets/message_preview_dialog.dart | 412 ++++++++++++++ linux/CMakeLists.txt | 2 +- pubspec.lock | 8 +- 12 files changed, 1122 insertions(+), 612 deletions(-) rename android/app/src/main/kotlin/com/{gwid/app/gwid => github/kometteam/komet}/MainActivity.kt (100%) create mode 100644 assets/images/spermum.png create mode 100644 assets/images/spermum_but_dark.webp create mode 100644 devtools_options.yaml create mode 100644 lib/widgets/message_preview_dialog.dart diff --git a/README.md b/README.md index d3b7d5b..2e103a2 100644 --- a/README.md +++ b/README.md @@ -5,5 +5,11 @@ ## How to build? ### This is app built on flutter, use flutter guide ## How to countibute? -### Join the dev team +### Create a fork, do everything +### And create pull requeste +### Make sure your commits looks like: +fix: something went worng when user... +add: search by id +edit: refactored something +Other actions should marked as "other:" and discribes what you did diff --git a/android/app/src/main/kotlin/com/gwid/app/gwid/MainActivity.kt b/android/app/src/main/kotlin/com/github/kometteam/komet/MainActivity.kt similarity index 100% rename from android/app/src/main/kotlin/com/gwid/app/gwid/MainActivity.kt rename to android/app/src/main/kotlin/com/github/kometteam/komet/MainActivity.kt diff --git a/assets/images/spermum.png b/assets/images/spermum.png new file mode 100644 index 0000000000000000000000000000000000000000..d42ecc179917581f8343b048d8a4ca8f5481db38 GIT binary patch literal 29657 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 literal 0 HcmV?d00001 diff --git a/assets/images/spermum_but_dark.webp b/assets/images/spermum_but_dark.webp new file mode 100644 index 0000000000000000000000000000000000000000..c643a24325ebd43acf843044b11bd0c1dccd08d1 GIT binary patch literal 41750 zcmV)BK*PUMNk&EvqW}O`MM6+kP&il$0000G0000|1OOic06|PpNV1Cn00I9eBuMc8 zcxyzY?l}tDHgXdFu?aE^_UB{UwrktA73UclSbYr$r0J-2x=Ig}BSoInOmlSo50KoNFa_u4~<65Yc~H z02l{}r3{=3v`l$PEC}e{C-eU(@yUOD@*ki4$0z^s$$xzEAD{fkC;#!ue|+*EpZv!s z|MAIxeDWWk{KqFqz$(-(9^Srz!R0E1hE=W_5mBv1Eks0CkBF#RIjmAh`QRXbA5T}U zlKp=L^HM5RYOSlMUtrk^mBOpl`n+lTZaoJM8~xMRiBo3Hows1gDnzVUG;iMQ$rH!_ z`u(tP`*x3M(Wp-KDwQgh3-t4H*J;!$CCmOFKu4&kK8=fKKyXNv$mmXkzMryW)4tRH zUcLP={`s4v)Qs%>0u#saU_}LRoVg%BFDo_a-OGfyyVozBKCpGgjGqQ|Z&|NuXt@9{ z*N;UNPWG#%E}!lZajJUHYp`Dw?J<+n#^X4rO3i~9ssO3QIrkObDYIu zHk(XFePLc!TGIR1@i$NJSvCE~uRApPEG)pq*$#zL;}#s%vge2i^EVtkd;9V8x5*i~ zdhatY{$`m?=Y5FUqR4?tx*SwdnKPl3l0nCFXO0<3*%*LM&t;0A8OGcXs#Ev0t~T6CUKLQz@Lm z^W6MGYPTLT|L~3HX(pNWo`_iFdc&mm+Loq>tj42iyHWFFFC~1)Dzxw**@~Fn z6QnPV+aY{n-lES=esz7{qA{IodTE`ki@FBYXggx|_A~e2q!)7HCIkXI!a#a|)A)`4 zyuT8oA@g0_Ke3B{h>0ra;UtB|uX>X{zpp%Y=UtAG7w@=8fx>(KH{O(&a{sRlQwOz- zEaT+-WSdpH1=nmjXzs2{FY^F%UkE^S9$t)BrM=#0;eg@8%|i=Dboi{iyGG%xJmJM7 zuzHJ;YtBE<FCYC_nn`_&CZWC3`OXqx?z_MO7y8d_H zvpi1JzN~;+0^wE2a^Au@aT!?^m4=zJiPt+?{{rwdJD+&fb43sgRT4H6lrf7B`m~ z&0T~5V~kf&a+5iI%I$*-zH3#*L*cxG(zR@zF28TRoM^<{m2zmLYZIq0P7?sPU8dxl z2WR$cSixQ8q`RUyy!ogN*OQEZG!BrzbApM1L~`LA9=u%zm`#wfUHJ3|JAP_gBfv%J zY`d|f#=B;}rPtCe2xkyZ2N1UlKv;}Ol2~S9aHA_+8>_S=Jrvo3%Z=MS8{GVt9XH78;oxk>>-N^%8`~Nm!EWR@A$TrT%2I_ct-Y`e&k`g3B=7r_K+i6 z?|vzL)9`y>hiwAptoYLlzik+(bZSj;DcgAPl5;N&IJN-N?L*$YE}W+Yw>s&Ga__Hh z{JvGFo5D#2m1kJX?{_`S;xX(CFAu7{Z>uuty- z%U|rV_tm4Oj1Mw2MIdOJ+b^} zC+763=Bae?X>*JxxiM=6qSxN9Dv> zqFjp!r!qJ^ePjCq$mLhqN!QlPk-YN_0a5_6HXaZeLE(-0ox|J}&YBZ+8%@5LjS{mQ zP9y(ENB~xt_6|{3FTdwo!Tsg!y`3?aGO7Zb&$*bS=Y>8YbpnPb;pYJovyyi**V|1v z(|d}Vp_%}0<7C^OnBa{W_ttd}(K=h6m5=%3?CS!I6Lh5j#L^Q>Sb|^RL2&P^vk%x! ztr`{*;B5{Wx2J^f2}!=Tymz=W<)dt6nN|~zzR>e>D4yPC2*Bfeo$3-|Sb@64!V49j z|KET9FaLvoTUlqxnY@~y2BTx2>d^uwdQ`$wBXC$rf=bI1xzGRiKlx{WX;#t23Gzhc zQ+v>+r+O?^(;0b8R#u)oj$pyvhRmDM*pdFu>;z2K|M%U29~uTYJE~D=%5~fLio=yP zg97lB3yvjsrx*)A|u>PYkGHLQgeT$^8qf^`>lRhfYvhL*g)lG!%ntSOjNc4HBv~RC}sYp zPuM=RjyvmQ`1M^u&8A(*#M3s`q&7 zI6kcOh!}c2nM3=5jo9t869h$6pUv7{mG@^qZF7OlR5hPy{`4E4edU+>zAC4|^D^DG zrF5~#1icW&3E{&#L&Ejml!NHEt zHlgX2nga$Ra}JRw67A+E@``UR@6NJLKJ>pzR$F6aYeftVZ|B&7<2bA_+|L4Q)3$kD5?F_pjLAqn6VWO zc*-Uje2AZN@fT0$-zb3_9)}%8;rAcO9W*3Fgi(RwUv29fs-^RVlodyx%aMDxCS@A$ zi8RfzKx}?9H$Aj^_wJ&e;*$vgv!#ZIvunDrG=8x!R>v3ecKb{Se+e+MpwVm&oxEF6 z1S+d;&8NXYMrKSr(6bD6{~X)3=BU3CZGVfG9iN&HT>_4%BH6~sy`bNYi^ODvU7r+v zS~9OpZsbANFHH>Yxad|sFYTxY$lx3MpWJDX=}>r60qkM&8+;0~H#}M2vjR)euOzBA zbnjbE@_|%`N2hHq4p@}e?}-(XPy8I^PRR=?edfDBWT;@087(=Q2YSc*iAMJ-l(G}s>=@1 z$tUASr~x$BG@u{zv*Nk;%ZF=xW11;@_cg*T1dNJJrj!DR+(;q-?)R}gmk*yHF0d`krNC{rt zMOWjH^U*;r^=fCI0IK~|5%S&sw`S?i1-2d}t8S@{_wWkMB?FZ*_{8SFG{UcWcY>yK zDQAAGp`+K&JnD|SkipAh7WUFS#k{)dApPdZ()5D+^P2im>Wht3L9LfPGh!GBp1Q#{ z9h@8&-W?+bOG_|hV#3p?g#CWmV=pdw)0ZI{Dt&Qcg+BXpL4qBKX9SYOZ?6iE6iyU+ z4^bupuZkd>+ebcs_cvkF4d+LP)94|5>`jxX`~mO;4L?q$Lbodir0gf4G07efMFJR*%WS88uu&ZB{;58bN{`aAe*%Q(g;s^}-WgjwC+c z(042k=Qi`9!*#efnIA8dwM_b^Lb$p_Of3BNA^-dR2Jg1Ucq?e|9TZyZNCt^k0IQ!p z!dNS~w>+STB?FNGX8Oub`i@f_kg{mm;d3BY?+|1^zQv4&>0~gJJMCCD*!&sx{B=ZIES$NT8x0VYy*nxPPMKM;_rq97$#ihHYzTekM+Sl7(NoQ}N z5VJ28@N^DXeGG3eB`bn(S79!tfvUMr`KhkNFcx|1-}0~Y4y3RH>(}d24ll`s0Hl9& z=}1H2t#ru#*JdKRs|p>w<#+#JWGHnV6cqrZMXnkfwt6dNcA&)k z8U8ZdoBWxYvVLeC`Rl(0Qhh*TS0%xOf<{gd|0@}c&d;o-bS8mfAIV%@1X^`hbj zd95YefN5YQeasK>&HQ9hBP~l$&;746>!GDc@{+s>+vk{8g+U@)5U#-(YIWK1IbyXu zU(wKul2*aGM9hC?!8RKJQG4JF?&iVSCGn~{Yj{X{2bQNj$XZ}t+_sx z$Le5UB!X{oaP1@(9-Cpnn4iwNxq6$8&-Lz&Cp-Dj(NlHTr-(cU+vPw;L%P2b)* z&`$Vu-H`-X4gz-AsfliYt`-|BChi|K{a}H#!~OiI=6V!f-FGKpItZYh2k<%}R`3wF z6QM$w5B4qnXwN_u&HUf|(Eex!W(P;~ps!dmaHaNL`JT+De=xDh?wcU_iO&|uIom6k zS`Msto3nWm3#?hfn17 z1d@$}|I%1m7n!>}KjvML_~td8|2l|H!vCv7*&w2S5U~!CBml#FG?;{84Ce!?{<Sp>!L+!8aZdfvB~B*nvkRiX^LjTmai1MxZt5G0Jbe&o z-kt!3K(d7hw-?C{gwnu}`DeJA_Dw50?3vNV0I;2f0R>|%~2IsD-Z#q_93i*aMUE-Wt)UHBS_r>;9M5n|7 zKUdD*0oa+a4D5mF+xhOzkv{b9E8I6G;@XK+@4+S&D~Rc7WK7J)HWER=d^@q`A84th zbtl5CeTTC`nRcvjw;p&ai&rs-x5Gl3q4{S1=k4-PDmO`0>Gzi=Sxko3%@gy|VuX3> ztj-onC1JcXg2tU_rwAQ+%YG}gaas9CnbAYSXDForvW`aS=vxgz{m&FVw3ku*W2aqpa^deq#1vMr!mfrA)fk(VMniQ53x?O{&QNNHGl3 zP`h)AT7_NVJNiPq!iAA^?SZYZ;3bH81~Lr*#B$g!gF>F(4%K94#39EB({G@e=6pxL z1dqSCuuKSlEln;YMImkmH{XoFFiMT7SE-nqE+^A9&3Ab-J#X*plyzx&%nH6-a12;jSm6>h}1(XGdtvr_>z#nvT2QlA7Q z^ju(RyXcNMOY18P9(z4ldpdpMM0T@9dWxc-CH5io3?;&?sSa5X5{+gy6~BzZ!kIADb-75_3ATTZ$+X0fPcS{wTb0 zapikn)K^ezS@>Un)f8@M@HqI6f1&Vd6F151Me%K{gJpodM3dk29-*O=Dk4g%vbeG#o6JJ{-mc`vM`D@@-;AMn6eDB{i z9jZ^23j6iAW0cq{Lai^VzwJ!y<@_bwNWL#O+~XK`nsh6Lx*JU(_wC#i0VeRZnucgb z6k=zW^4nBlAfpUQVRrbI{|_8#ONUN{skkDE zw>1#J1W2`>#TDc3G_oq+@Go8JPlbLuf`8vfMycHoDL}?uPFP%i)1*CL@=regl?L6P zUsM0jLMVcx8O35=Wc=T766chsKxN8}cnfF~k;5;Vcmy%`6nVF=IJxU1C{UT!d(0@~ z0DE;pl0SI_&4uKke&yi~G+?O@LMOhHtt&zZgbPH{DOi4hS1Q@5{DUZ~?p{2EdnF%pJ9GNn_N8>P0CB2zYasGSR?1KRSIS&|g+=Mu@ zGqR93{qk;#D4Y|#$GWD>S5L0}_2<&@0~&`1xT|Q)pNZS=g6);hD0{KHeok%?_ohlE z*;?P+t5&a<+h0ui8D``UWHJSC6Ph1@nTLpAGU01lVC1iU z9uo{KG(Apk#`K3fe`r(1OG86$+war+wZ(qw!5<~!&oyIDnniUq_!i#@$Lj@u!Xo{U z1iZzN9k*vfr}Ap*v4A>qSAG#=C2>4jI^$@l`y3{Yu++TzlFDCQFetEyI3joevlYh7 zSJ!s_+M=Qh<@gaR!(MS#iFn8uLAa3&BEGpNd?}+cpAA`(=ZzVUPAnW)-%mv^itC#0 zG(mCD+IX|{4JW#2aX_x-nG!Fp8`-*|tAbj5zefTdeVO0W4dI`0>}LTc3@liamGACx z6))5|urDXcA;8HFM02#wjn8{>_}4b!UR2^N8CCN?4Y&}edTF$7c06|6LSNhq*F{8M zU-P~yI46Llo@AczS^v&#AL^#05Z{#g%M&!;&2vAE3j&cm-cZ~ut*T$i(n4PlY%)OQ zz)&OzrtC}rPBy^j++WizfI1B97qR&Q3WH55xFAavzcVGA0+) zX7R2j_#a4Q;0bR?d$6`!dCE}8ckBb+3X&yY9(VVN%Pl$+^aJKepTJwZS_UE5VH=o| z&d-i1r=SUs(R(wkjtw>jimxTOInIC;s^?<+N*i7r`fqbTYOnyS8}ra2Mqs?-Z3N!^252C^yQso-u0w{KK;#9yR|l3S^(tbHnMI{xUx0b^Z^-bd&s)(v0U(ure8J5r zbu?st^X##Y#FT+i(?xKP5C;LplWpZh{~DkBRi{QF4R1$AOr@Khh@y|1`yW>lU%J7r zENO3m^ZJ3kJ%q;iDif@qLQM2z=h=lQ=J-_|$h!(wJNzy$r)2IFjy6effcP?YlYO}! zaQg&H0h}!%-T*1d^GSO;`jU1P%HVZL@(V3UZ8S!H_@W*>4cD{%f#+y*$8S*yh_*)t zatd3SbG)4&VGl0t@0syrfOlf4%;Et(Glw6VHO!~~nJXMS-bb?+NAb#uPIuFIt;+hI zg=&Xj8-d|e7hke|?Yk%$q`1)!z>Gt>nXtc#g0JXb5|HaXWwEZD#FW4=lrhf}oKEsJoa)7>{H+8NNSZD(Sl4v|F(LuT3 zB@n}V04FoL^OV056jkhfL^RwfA=NkTeo<3zs~`t64;qL zLh zSm7Fhv)l*&nfpiL0x$%)-i|rFb7KB33ja@|(&N1o^rUo9xx;~iXcQtv{XZRih!Su24S+4NI_MyLrb%n;Kav3k18G-3S686SC8N}!0=%X8u`2_v3 zZbZCNzfmu+-aN&CJ9zl_-Q3{bKdjIzIB&_%{X{T@M@sXa@UZ3&J;_EvbBv(K#Z-8B zDBzH-05IB_xxc4B0i)j90;&CLbsPzwA@D~gg#9+22c~tg;&UPhb)N9>uw)4EcGgn& z_3|R$*L^PV5=Qz1wsmiCQ-AL4EShF|g-exjV-X{R7d5x>cQ++?s}DARx(!96c=WI5V8*&ZvF`?+0rq zg^OcEmY3f=Cs+tZRSN43u<;OE%JM0p;RXME8$hV*bB(^KmtZQ8O=D$QHAngwm5=(q zVb%__^l>XpWSv8C(zA0=h?$tA2?B;zGY_K)k;aLnop=5#FESkhzW=}hAm|8ax(aF% zhww%`C{NN86)~^LOz_zsX3baAxCjWmXbsL7IN)QsyWjPd2s5Zv8TWQvfymO+G0Fa=$5yUuOh8Mhui^*%Le$3cX?pg;1&8rCHG=_EhT9}5w(UBvQL&b zx5qr>@KE@Ozixy%#^psnM4e+_DCQmb>`$~~1dj^vwkI+tiUJ!3*U+yylG~ya6;0$T5%<9_)i_HioJcwjqyfln7?*!#L`~%+m zFJMp)`i6fY}fbmOWL*Q408EmA&()k3RrlUQG@y6Pkp*Mv@0}$aFAn6xB zyJMbGGR(Qur;GgD4r&GP^P_O};jRrMjIDw$OiTto2F+Q;B9}W=m%MDYeOhz3HBpgb ztqb1aNkyFPE|55`B*zu!q<;ZV{N6$Don&=ITsQ}XqyuqD{LaSo_7DJk^v@!}8gCa7mu#akM+fMlHlN)x#ZB^45Fxr;j{7z|m)0+I;SeR}2m)FEh!w_-G&ZSG%Fy2yAXN1nl;+X|N6zb)Q`3lF!KR zOgnAV`2ue!jN2F711R9Qpb4fxhtK&;jpk4QbcbGCR>lt}Ml+HkoLuUl7zYM{z(J6~c~3V$>`9vswSKzwsu3~wLlza zKIlCUJMb!qt2RS4&WQvdbAd8wZ(TMoiW9_Ag4c^{k{#Te-7`oaCeWN&;y5SxljSjP zNyu%QPLZy_;AHWDYeP`fc>C7?mgL~yKa=|?7pxOXDTKrIp;KG-^F>QXz6MMo+@ZCT zZ%QLQpF6SUo$KsMa044Ug||ZOWNW;}IUg%w9p;bqeeZIBo6a?!u3i2irjdg#L?qx@ z^GN|oybX}HtvbQ2N?Cs&pqLactT__Z;H5JeuaA}4r)Hq4TLyih8LppM-VNrV!T8Ip zlEH2H9JVWnB*P_-4^fia*)Z+yfjHT-kJ#R+o1+hfAC-2(F!e+1bAp8c zVJ~VBLxYAaEEJm|QU66IU^;^sP$;W<9hKITtt-9s!vTA5yN5&_bS zBFQb0pA(+u6AXJ=5Zk0G)AAgEeD@22gaYRWdYOEZJMVYde11A#KBZm-p}e+yCZO+T z)Km}}8f`T@0xmK+C6&BBRVvp=T%_j)n%$c<(k>5*<!y^Dy}Iejs+YrfC; z1-tSZ+)QXl4eJj(x);W)GBeHToRjfAVQkHc&-^dDyUp}^emR!J5IXfeija52v7&oN zK!FqBLw!g1#JFm<g#;7V2{RE-^C^T#2If28s!439yP;4j7muSA zAI_7F+5lbQ^E||Y#-iY6v@TrwF(O+9*|F%I6a3ch+k@#w(yaT0%)|8vtU|WkQC{Xx zE*|o%=|}#%Jy~-aPW3ac4bQ>dd|XMiaoG(IQdvXVpM`3i{rsz zDOl0^qOcNJmH}}|XX_&P!M(VsPPO$h zSE%hj%>P7P*_og`p^&KsG9BACAy#0xO;TUatZDXdA&Ob^3ajNth4V*5fcJaiqr8Uj zDR9R07h`QB(5r-3k!vpzkgoe&v<*02x|{>mG{gj5uLou+^ujr7{Gpi7u_U@fHcKDp zrLb`@CX=5Zsf)x^1hWCfeZWLd0DL4UN9GgX=g@TKW0KbN38LGROZ`5WnC5u=@)~g8 zP*szR{tfWcH=S+cPD-T% z7A9N4E~2QT^7+4(?hY70Cp{91pumkez1@nhp)$LoM{W|?q8&*i07=^u(q>dAq;q%G zVY@N`1L_PF^TYpn6Mz_7LWk)+;a#36^n+$#s?bl)igl+vw_A&q`?sGjAezf&L;d6c2OFGGkZxmoaZ=C=Hm+o2!FA=jg9tLL~;}gHvg<^qcuW3L=5AMyL z7|~2C%&yIb&-rPexKVS6dvRte`j6{&Zf1%_aL)1O7<|BVqBR#E=*9D?f(VG~V6tnz_}US#!NNg_N`WQRAQ!|&6d(?m-BDN0-_w|k z{@)&q%Cmw5Z4`LQd>`#j$rL(;$vW*TdLk!`R~a9eT0_=+n$V>~#|%6q^~pb-;meZH z7rW0Y1Su*Bj#}*qOUzFeaXFUAF}YC`4i{lzc7^bhGPbG6_sK+q^F2&^d8b;a2&k?a z+l-EkkNx+e+$v$Gx~m*2XCssat>(3nS0nuE*+EM3xw5Fq5%ws_B?LUOqQ8&3~k&CVfBzKxk z(21WRvshF0|_~C+L>@jQW*`aJv?u;Z}cN!a?=9 zda(B!94hEswLV20pM~7-sr#&R-m6+61?9F=QVvsIJ3b$B`;@(+KH*#rPPX<@ zg>o*sC+4*mqYq7=@T{;Z^VTqLvO(khw)B*5?DFxkf)2@5p|NHLG@2Kl-a%RR`T9*h zyQ1>8)F+x#ca`nV;c+n^r0eY{GV`%Muf*ir4#O3i!6q1Rd(m!>AiFDR7VDgJeEMV@ z|5t*cY66>A6%z>V|DYooxb(OxN+cjVVK&r#d_VYC)iGWOUe9_AJ7AzK;6OsZVC-`e zN}h8daFV(WJUrrjOpGU@Xs2d5+Omn2+WDXf44^Dl_H<1_dj}-8?mNlCIG8TOAKMUgn)i4Qfd zr48;9@rjly&a)bnuS(x#}z-N4yI!t&P6|)2G)h>mFRZO^$GNb_t zEy?0NU)Fu*pt*ais)+@)SD9={BexS7C)zrY13T~kHRjzE`XpZ!xS-&{*w@)3HBoJD zmx6$9ibP9%4#6(&;}|>8kNI^EUt&RntrBTJ9+pkOdrrbK?g_KFv>rK3&IhRUmoJ*P}V?co@13 z5B2F*#Nve$(WpV!X9MnDA5wWx#vGTqZla z8KCcTlkp)w^On^ilq-xhz0TWLMa_J|S6;eOkb&-}&ibtKzryFW0q%ry((d4!m`p)1 z)Nbr8*6-op3J(85ohjTZQCJBE0}^;9E4!r4#g&PuTdkf8HRd`7)Jn3vN7$>b{zC%-MCh*SnOj-*M(hX{^NNP|D+usH58Fp6^`!_b3A z`LuEPbv}(Eg(un&$puFKbJK;ql}1P7Xc-VVlkoxWf>dbuWYq8QIxN#CfXgeD2A#hP z7$D03ZDkYh%jMNXHx2RkmVNRGlif*V2wa|zAPFXiA}Hpg`?;RCp}hCNpGk937@<>> zs7Skj{b=CAIa!M9 z^D%iX#PWC$i9A%>W-OwsWE77v9Dx~$Px8B*KJN=&QOduiP2_pxCvKP z`ohk(o_uEdGz4Ih`QBE2io+Nm<3%3$e4kke+vUm!3~y?t4!rR%%`NzxV#*z{d1X(# z`t^K9#^6#z-6uB+1$+)h0a^1IZ@1j1kCHp95_!CAcH|>$7;2|pLPq=EUovr_Yi|UH z5OZ>HaPaSWArcn*ByXy~?h>v~6%2y2h}BLK`^k%+=#tIY@a+&FBZa3`TqIDC)IL%;^^|kfv#scdmPLIfwcR|y;lU15S|;e$*WwBYlV`9%55 z6qJ18qL`2m26~bP23+Nf*eDgSHZ#2QuY~CkhQkQvOs1fYAmJET=kjB{E#)Hf1qJai2ct%7h_U-a@N0A$(uHEK zvF7vD^y%Yse{~{^!tAK~h<$jlqtZY0KiP2Q0sxA_?nXfeJt?$sXz)YRr_p5~;Tb-~ zl1msLL>Tztp^#}Hr8>Yp`9u@Mr&wVX;YjB8@@uuTF8qB?k}g%Er}@mU8qe~Xv!ek^ z7WnzmN2HR10 z=4TjgBLi2oF=|0HXF-{VOzLwWvzE&le2u(t%Xp-)x8MOYhj&ZLutb50&k$S{sx+Dm z3(0vfHsQYa@15i85=o`Z1OpQ%kASCo--2qSyAPKZFOTqvU4{W{{0$a;8s#!JyPW<^ zw1O}o;IR}!65bns+TV?lis(|9*Uz8Z0{{swDKRg&e@?=eNBSi2IWuuGg|8M?_hA#h zR}%%Myg-r!*qV6KQ}@PyKa9+SmM(>2JnIA&iBQatQbkI}O`D z?noB&-vf%#;O9pgG`#%tsIzcR_Ixsx)4=vW$0YWkvE%^Mza`EJB?ioVjR1h;HzoX& z$Gd-X%&_DWsy#WA_He@Z8?fXv6=V`U&gY9H!~wkRVED5kFyM2;L2FtZCSwg_Cj|C90#XdA6oOfd1c8JDjxDdrzcmAYJXWKw^ zMPSf?Wn+NydpM!7Wn!Ne=8fZ;i=nfFVgbW{{P+DemK4711CK2DGwRL62Y6g%hj<6_ zvVpVJ>4Orq>uh5l;*+5%mMSu#&+^H%2TMM0jQ`=E{F{_yfDw1$BXc9&m-J0bbx|L4E* zx9G^=<=~s5)WHA9p5I)2pf@B!GN@>^Llv7^JWm{3J`b!1lwa^^Qrl_*q`&;fe}^|2 z;OncrfH`)fE=NAccc7FP&wI!hb3M%G>#06*0OCXWh!Z>e{2%_|ie&Jz_eF`IlGmpS z4@_2LAuRfQ|G^FXAOBK4GI-hT9EY+b_&HsLsj!gv46iGv3_PFA6UhEXpv49@Pp}Tw ze8vr)bhXM#1xj>1}|L@oGM1U5@%qZ$WI8(#1^GEZo2Q^2@CXj@rd#*!%sNXSdft9f;D~qWn-j*1kh-|S)51u;O0;Kav!m``)hD0iw8C# zBj1to+T3l8eo2dz$OgWArcQF7JtoU1ffmX>S@6k?mKqX3)GiZ(DfZew=NtLQj1eLI zVRHX-j{A*nQhxcRAvv{=^_k`UAU_g7#8xAUdG_jLyuPh(zbn6M%l)=sqU6ntAOVD| zH=sC#{q~(4AEqaF_&jfaPazFRpZh^3%nG-cfSLm<;9U}OIC!{E8b0xTT6Q*J2>?OM z@)1nIW|ufXe4yJTj?{f3i4p=E8e}!_FK5A+sG$2Ldu81xo(D}s*XKoY(Zh<*e|wO? z4{`FJpCP0LNRsYw?|j8MupDpq<{s^=`otPL1%oqlJ{AlFV?1h98)U`&oP;+yC$mR{ zFHWLr7fi1gFRp>@!g-EQYr+o!1OVUZiQ-%u!fPE2+rI%y1j^K$^fm=0AHZ&TrVE`9%NZc`F(g3b5t#3Ek=WJl|Vg z`iE}Nb$O@@_PHDBcssX2Ri959dHNRujNt8ry#=`*l%55j4K*z6&WPqffi8&J>%l@Dk)9!;{_xGB;u)_m<8tO1%2AZIc-`_~? z)3Ccb3H-TYd+~TleBv-rdGR*yip(YB`}`AJdT4ES3y*wo+izZBLgM^{>}T4pMLW%g z=bLSx1HMMJobN&aXs{K<3nYcQ2OZw(gDhO9J3fok_#k{dk3@irmxE+e;i$g( zz)ILndlG{}Pf3%QO`lJAAWZuVp;$6@R#2Au5jyb$28p0NB)rpGpcDvPpHQE8kBi&@ z&{&UG5V~4EvvpAPiNOU?{MrI_?VMbZ2nA;wt4sX|{_zDiXXEjOkpbeL6-T51c-s{);cYP9VfnfVw4E~TgY`Dcv_^dfD`5dy|L0=PgW^hV2eHMyO zRJd1s#b@&307a?)9J{t>#Od@CoMUrjJlyZ}S1LY#n?3?FE=^3&pi*K!$S3PhlCAk9 zBF5&;K3G*#>VIgwAA=M|IJ(_kZ{#nV#q$-QguS?Wji zd3pgW5O-FC!pCdg?cX!b7!6yeHFHFmXZqI#pNbJVX@vC2&u>um$yjLNRFQq4Xu;C;Tk)0XcOMiLkmFU}}lOEUPzCc>q{mcot2 z6MUwml;JN6jjQJSygbfyHW?Jm3uR0D2ppWo<4_3qRDyR--|8!p4Awar)`YG|CR(R# zG$8jm9*7N}Jm5(hR(!JKlUzU%jC*R9`U|>uev1R#xrBHAJ~6b76s)YNvq?Jm8PNo_ zbX}wNPw@F7fiBoCg2FR>ny)u3^`Ez^Y5I^8K+>+haTI5?UOF67cqnaA!9 zs>anW4tPjDV%T7QJ1~x1&1WGeZY~tOR)J3{igL!4`hUhg8#-Gr&Lh5uzyCfsACM>h z_7=b;LJf70U*OXc_!OU^HlnfsYe1C0gg%3qmPMBSzdLnw9)dd1J2UV2>tQyGa{qip z41U$6=aObzd=(4rk^<#5!Ufd-Tv_ImrcY`u`JD|){qXLQf+*;Z{LIDMe(D!(Q~N9m zWiBM}SD#MeRMGcop$eZ_^SscrKF>$rrw9LP4Zx|krT&8M&ErjyY3^XX@3(=_9d*?Z zz~a#|5}P%uSK8mhsEzNnm%vz@+j@YrSAEBQ3`#kyih~+kVFe+csnox z5v$WO6(;}vQI@jqbG$rDd6>_Hk%6UssKa(;igHdJAO-Q>e^+S320$JN=JmqF#(� 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 _wsUrls = ['wss://ws-api.oneme.ru:443/websocket']; int _currentUrlIndex = 0; - List get wsUrls => _wsUrls; int get currentUrlIndex => _currentUrlIndex; IOWebSocketChannel? _channel; @@ -38,12 +35,10 @@ class ApiService { Timer? _pingTimer; int _seq = 0; - final StreamController _contactUpdatesController = StreamController.broadcast(); Stream get contactUpdates => _contactUpdatesController.stream; - final StreamController _errorController = StreamController.broadcast(); Stream get errorStream => _errorController.stream; @@ -52,14 +47,12 @@ class ApiService { Stream get reconnectionComplete => _reconnectionCompleteController.stream; - final Map _presenceData = {}; String? authToken; String? userId; String? get token => authToken; - String? _currentPasswordTrackId; String? _currentPasswordHint; String? _currentPasswordEmail; @@ -71,17 +64,14 @@ class ApiService { final Map> _messageCache = {}; - final Map _contactCache = {}; DateTime? _lastContactsUpdate; static const Duration _contactCacheExpiry = Duration( minutes: 5, ); // Кэш на 5 минут - bool _isLoadingBlockedContacts = false; - bool _isSessionReady = false; final _messageController = StreamController>.broadcast(); @@ -93,11 +83,9 @@ class ApiService { final _connectionLogController = StreamController.broadcast(); Stream get connectionLog => _connectionLogController.stream; - final List _connectionLogCache = []; List get connectionLogCache => _connectionLogCache; - void _log(String message) { print(message); // Оставляем для дебага в консоли _connectionLogCache.add(message); @@ -121,13 +109,10 @@ class ApiService { bool get isActuallyConnected { try { - if (_channel == null || !_isSessionOnline) { return false; } - - return true; } catch (e) { print("🔴 Ошибка при проверке состояния канала: $e"); @@ -135,14 +120,12 @@ class ApiService { } } - Completer>? _inflightChatsCompleter; Map? _lastChatsPayload; DateTime? _lastChatsAt; final Duration _chatsCacheTtl = const Duration(seconds: 5); bool _chatsFetchedInThisSession = false; - Map? get lastChatsPayload => _lastChatsPayload; Future _connectWithFallback() async { @@ -171,14 +154,12 @@ class ApiService { _connectionLogController.add(errorMessage); _currentUrlIndex++; - if (_currentUrlIndex < _wsUrls.length) { await Future.delayed(const Duration(milliseconds: 500)); } } } - _log('❌ Все серверы недоступны'); _connectionStatusController.add('Все серверы недоступны'); throw Exception('Не удалось подключиться ни к одному серверу'); @@ -207,11 +188,9 @@ class ApiService { 'Sec-WebSocket-Extensions': 'permessage-deflate', }; - final proxySettings = await ProxyService.instance.loadProxySettings(); if (proxySettings.isEnabled && proxySettings.host.isNotEmpty) { - print( 'Используем HTTP/HTTPS прокси ${proxySettings.host}:${proxySettings.port}', ); @@ -223,7 +202,6 @@ class ApiService { customClient: customHttpClient, ); } else { - print('Подключение без прокси'); _channel = IOWebSocketChannel.connect(uri, headers: headers); } @@ -241,7 +219,6 @@ class ApiService { bool _isReconnecting = false; String generateRandomDeviceId() { - return const Uuid().v4(); } @@ -256,11 +233,9 @@ class ApiService { final String? idFromSpoofing = spoofedData['device_id'] as String?; if (idFromSpoofing != null && idFromSpoofing.isNotEmpty) { - finalDeviceId = idFromSpoofing; print('Используется deviceId из сессии: $finalDeviceId'); } else { - finalDeviceId = generateRandomDeviceId(); print('device_id не найден в кэше, сгенерирован новый: $finalDeviceId'); } @@ -301,13 +276,10 @@ class ApiService { _isSessionOnline = false; _isSessionReady = false; - authToken = null; - clearAllCaches(); - _messageController.add({ 'type': 'session_terminated', 'message': 'Твоя сессия больше не активна, войди снова', @@ -319,20 +291,16 @@ class ApiService { _isSessionOnline = false; _isSessionReady = false; - authToken = null; final prefs = await SharedPreferences.getInstance(); await prefs.remove('authToken'); - clearAllCaches(); - _channel?.sink.close(); _channel = null; _pingTimer?.cancel(); - _messageController.add({ 'type': 'invalid_token', 'message': 'Токен недействителен, требуется повторная авторизация', @@ -346,7 +314,6 @@ class ApiService { _lastChatsAt = null; _chatsFetchedInThisSession = false; - final prefs = await SharedPreferences.getInstance(); await prefs.remove('authToken'); @@ -354,7 +321,6 @@ class ApiService { _connectionStatusController.add("disconnected"); } - Future _sendHandshake() async { if (_handshakeSent) { print('Handshake уже отправлен, пропускаем...'); @@ -381,9 +347,7 @@ class ApiService { print('Handshake отправлен, ожидаем ответ...'); } - Future requestOtp(String phoneNumber) async { - if (_channel == null) { print('WebSocket не подключен, подключаемся...'); try { @@ -404,12 +368,10 @@ class ApiService { _sendMessage(17, payload); } - void requestSessions() { _sendMessage(96, {}); } - void terminateAllSessions() { _sendMessage(97, {}); } @@ -442,7 +404,6 @@ class ApiService { await subscribeToChat(targetChatId, true); } - Future clearChatHistory(int chatId, {bool forAll = false}) async { await waitUntilOnline(); final payload = { @@ -496,9 +457,7 @@ class ApiService { } } - void markMessageAsRead(int chatId, String messageId) { - waitUntilOnline().then((_) { final payload = { "type": "READ_MESSAGE", @@ -513,9 +472,7 @@ class ApiService { }); } - void getBlockedContacts() async { - if (_isLoadingBlockedContacts) { print( 'ApiService: запрос заблокированных контактов уже выполняется, пропускаем', @@ -525,20 +482,13 @@ class ApiService { _isLoadingBlockedContacts = true; print('ApiService: запрашиваем заблокированные контакты'); - _sendMessage(36, { - 'status': 'BLOCKED', - 'count': 100, - 'from': 0, - - }); - + _sendMessage(36, {'status': 'BLOCKED', 'count': 100, 'from': 0}); Future.delayed(const Duration(seconds: 2), () { _isLoadingBlockedContacts = false; }); } - void notifyContactUpdate(Contact contact) { print( 'ApiService отправляет обновление контакта: ${contact.name} (ID: ${contact.id}), isBlocked: ${contact.isBlocked}, isBlockedByMe: ${contact.isBlockedByMe}', @@ -546,7 +496,6 @@ class ApiService { _contactUpdatesController.add(contact); } - DateTime? getLastSeen(int userId) { final userPresence = _presenceData[userId.toString()]; if (userPresence != null && userPresence['seen'] != null) { @@ -557,13 +506,11 @@ class ApiService { return null; } - void updatePresenceData(Map presenceData) { _presenceData.addAll(presenceData); print('ApiService обновил presence данные: $_presenceData'); } - void sendReaction(int chatId, String messageId, String emoji) { final payload = { "chatId": chatId, @@ -574,28 +521,18 @@ class ApiService { print('Отправляем реакцию: $emoji на сообщение $messageId в чате $chatId'); } - void removeReaction(int chatId, String messageId) { final payload = {"chatId": chatId, "messageId": messageId}; _sendMessage(179, payload); print('Удаляем реакцию с сообщения $messageId в чате $chatId'); } - void createGroup(String name, List participantIds) { final payload = {"name": name, "participantIds": participantIds}; _sendMessage(48, payload); print('Создаем группу: $name с участниками: $participantIds'); } - - - - - - - - void updateGroup(int chatId, {String? name, List? participantIds}) { final payload = { "chatId": chatId, @@ -606,7 +543,6 @@ class ApiService { print('Обновляем группу $chatId: $payload'); } - void createGroupWithMessage(String name, List participantIds) { final cid = DateTime.now().millisecondsSinceEpoch; final payload = { @@ -628,14 +564,12 @@ class ApiService { print('Создаем группу: $name с участниками: $participantIds'); } - void renameGroup(int chatId, String newName) { final payload = {"chatId": chatId, "theme": newName}; _sendMessage(55, payload); print('Переименовываем группу $chatId в: $newName'); } - void addGroupMember( int chatId, List userIds, { @@ -651,7 +585,6 @@ class ApiService { print('Добавляем участников $userIds в группу $chatId'); } - void removeGroupMember( int chatId, List userIds, { @@ -667,16 +600,12 @@ class ApiService { print('Удаляем участников $userIds из группы $chatId'); } - void leaveGroup(int chatId) { final payload = {"chatId": chatId}; _sendMessage(58, payload); print('Выходим из группы $chatId'); } - - - void getGroupMembers(int chatId, {int marker = 0, int count = 50}) { final payload = { "type": "MEMBER", @@ -690,7 +619,6 @@ class ApiService { ); } - Future getChatIdByUserId(int userId) async { await waitUntilOnline(); @@ -740,7 +668,6 @@ class ApiService { } } - Future> getChatsOnly({bool force = false}) async { if (authToken == null) { final prefs = await SharedPreferences.getInstance(); @@ -748,7 +675,6 @@ class ApiService { } if (authToken == null) throw Exception("Auth token not found"); - if (!force && _lastChatsPayload != null && _lastChatsAt != null) { if (DateTime.now().difference(_lastChatsAt!) < _chatsCacheTtl) { return _lastChatsPayload!; @@ -798,7 +724,6 @@ class ApiService { }; _lastChatsPayload = result; - final contacts = contactListJson .map((json) => Contact.fromJson(json)) .toList(); @@ -811,9 +736,7 @@ class ApiService { } } - Future verifyCode(String token, String code) async { - _currentPasswordTrackId = null; _currentPasswordHint = null; _currentPasswordEmail = null; @@ -830,7 +753,6 @@ class ApiService { } } - final payload = { 'token': token, 'verifyCode': code, @@ -841,7 +763,6 @@ class ApiService { print('Код верификации отправлен с payload: $payload'); } - Future sendPassword(String trackId, String password) async { await waitUntilOnline(); @@ -851,7 +772,6 @@ class ApiService { print('Пароль отправлен с payload: $payload'); } - Map getPasswordAuthData() { return { 'trackId': _currentPasswordTrackId, @@ -860,14 +780,12 @@ class ApiService { }; } - void clearPasswordAuthData() { _currentPasswordTrackId = null; _currentPasswordHint = null; _currentPasswordEmail = null; } - Future setAccountPassword(String password, String hint) async { await waitUntilOnline(); @@ -877,7 +795,6 @@ class ApiService { print('Запрос на установку пароля отправлен с payload: $payload'); } - Future> joinGroupByLink(String link) async { await waitUntilOnline(); @@ -917,7 +834,6 @@ class ApiService { } } - Future searchContactByPhone(String phone) async { await waitUntilOnline(); @@ -927,18 +843,15 @@ class ApiService { print('Запрос на поиск контакта отправлен с payload: $payload'); } - Future searchChannels(String query) async { await waitUntilOnline(); - final payload = {'contactIds': []}; _sendMessage(32, payload); print('Запрос на поиск каналов отправлен с payload: $payload'); } - Future enterChannel(String link) async { await waitUntilOnline(); @@ -948,7 +861,6 @@ class ApiService { print('Запрос на вход в канал отправлен с payload: $payload'); } - Future subscribeToChannel(String link) async { await waitUntilOnline(); @@ -966,25 +878,21 @@ class ApiService { throw Exception("Auth token not found - please re-authenticate"); } - if (!force && _lastChatsPayload != null && _lastChatsAt != null) { if (DateTime.now().difference(_lastChatsAt!) < _chatsCacheTtl) { return _lastChatsPayload!; } } - if (_chatsFetchedInThisSession && _lastChatsPayload != null && !force) { return _lastChatsPayload!; } - if (_inflightChatsCompleter != null) { return _inflightChatsCompleter!.future; } _inflightChatsCompleter = Completer>(); - if (_isSessionOnline && _isSessionReady && _lastChatsPayload != null && @@ -997,7 +905,6 @@ class ApiService { try { Map chatResponse; - final int opcode; final Map payload; @@ -1005,13 +912,11 @@ class ApiService { final deviceId = prefs.getString('spoof_deviceid') ?? generateRandomDeviceId(); - if (prefs.getString('spoof_deviceid') == null) { await prefs.setString('spoof_deviceid', deviceId); } if (!_chatsFetchedInThisSession) { - opcode = 19; payload = { "chatsCount": 100, @@ -1021,16 +926,12 @@ class ApiService { "interactive": true, "presenceSync": 0, "token": authToken, - - }; - if (userId != null) { payload["userId"] = userId; } } else { - return await getChatsOnly(force: force); } @@ -1054,10 +955,8 @@ class ApiService { _sessionId = DateTime.now().millisecondsSinceEpoch; _lastActionTime = _sessionId; - sendNavEvent('COLD_START'); - _sendInitialSetupRequests(); } else { print( @@ -1065,12 +964,10 @@ class ApiService { ); } - if (_onlineCompleter != null && !_onlineCompleter!.isCompleted) { _onlineCompleter!.complete(); } - _startPinging(); _processMessageQueue(); } @@ -1112,7 +1009,6 @@ class ApiService { final List contactListJson = contactResponse['payload']?['contacts'] ?? []; - if (presence != null) { updatePresenceData(presence); } @@ -1126,7 +1022,6 @@ class ApiService { }; _lastChatsPayload = result; - final contacts = contactListJson .map((json) => Contact.fromJson(json)) .toList(); @@ -1144,7 +1039,6 @@ class ApiService { } } - Future> getMessageHistory( int chatId, { bool force = false, @@ -1158,7 +1052,6 @@ class ApiService { final payload = { "chatId": chatId, - "from": DateTime.now() .add(const Duration(days: 1)) .millisecondsSinceEpoch, @@ -1173,12 +1066,10 @@ class ApiService { .firstWhere((msg) => msg['seq'] == seq) .timeout(const Duration(seconds: 15)); - if (response['cmd'] == 3) { final error = response['payload']; print('Ошибка получения истории сообщений: $error'); - if (error['error'] == 'proto.state') { print( 'Ошибка состояния сессии при получении истории, переподключаемся...', @@ -1206,7 +1097,6 @@ class ApiService { } } - Future?> loadOldMessages( int chatId, String fromMessageId, @@ -1230,7 +1120,6 @@ class ApiService { .firstWhere((msg) => msg['seq'] == seq) .timeout(const Duration(seconds: 15)); - if (response['cmd'] == 3) { final error = response['payload']; print('Ошибка получения старых сообщений: $error'); @@ -1248,7 +1137,6 @@ class ApiService { _isAppInForeground = isForeground; } - void sendNavEvent(String event, {int? screenTo, int? screenFrom}) { if (_userId == null) return; @@ -1292,6 +1180,37 @@ class ApiService { }); } + void createFolder( + String title, { + List? include, + List? filters, + }) { + final folderId = const Uuid().v4(); + final payload = { + "id": folderId, + "title": title, + "include": include ?? [], + "filters": filters ?? [], + }; + _sendMessage(274, payload); + print('Создаем папку: $title (ID: $folderId)'); + } + + void updateFolder( + String folderId, { + String? title, + List? include, + List? filters, + }) { + final payload = { + "id": folderId, + if (title != null) "title": title, + if (include != null) "include": include, + if (filters != null) "filters": filters, + }; + _sendMessage(274, payload); + print('Обновляем папку: $folderId'); + } Future _sendInitialSetupRequests() async { print("Запускаем отправку единичных запросов при старте..."); @@ -1327,7 +1246,6 @@ class ApiService { print("Кэш чатов очищен."); } - Contact? getCachedContact(int contactId) { if (_contactCache.containsKey(contactId)) { final contact = _contactCache[contactId]!; @@ -1337,12 +1255,9 @@ class ApiService { return null; } - Future> getNetworkStatistics() async { - final prefs = await SharedPreferences.getInstance(); - final totalTraffic = prefs.getDouble('network_total_traffic') ?? (150.0 * 1024 * 1024); // 150 MB по умолчанию @@ -1353,12 +1268,10 @@ class ApiService { final syncTraffic = prefs.getDouble('network_sync_traffic') ?? (totalTraffic * 0.1); - final currentSpeed = _isSessionOnline ? 512.0 * 1024 : 0.0; // 512 KB/s если онлайн - final ping = 25; return { @@ -1378,14 +1291,12 @@ class ApiService { }; } - bool isContactCacheValid() { if (_lastContactsUpdate == null) return false; return DateTime.now().difference(_lastContactsUpdate!) < _contactCacheExpiry; } - void updateContactCache(List contacts) { _contactCache.clear(); for (final contact in contacts) { @@ -1395,20 +1306,17 @@ class ApiService { print('Кэш контактов обновлен: ${contacts.length} контактов'); } - void updateCachedContact(Contact contact) { _contactCache[contact.id] = contact; print('Контакт ${contact.id} обновлен в кэше: ${contact.name}'); } - void clearContactCache() { _contactCache.clear(); _lastContactsUpdate = null; print("Кэш контактов очищен."); } - void clearAllCaches() { clearContactCache(); clearChatsCache(); @@ -1417,32 +1325,25 @@ class ApiService { print("Все кэши очищены из-за ошибки подключения."); } - Future clearAllData() async { try { - clearAllCaches(); - authToken = null; - final prefs = await SharedPreferences.getInstance(); await prefs.clear(); - _pingTimer?.cancel(); await _channel?.sink.close(); _channel = null; - _isSessionOnline = false; _isSessionReady = false; _chatsFetchedInThisSession = false; _reconnectAttempts = 0; _currentUrlIndex = 0; - _messageQueue.clear(); _presenceData.clear(); @@ -1453,7 +1354,6 @@ class ApiService { } } - void sendMessage( int chatId, String text, { @@ -1474,7 +1374,6 @@ class ApiService { "notify": true, }; - clearChatsCache(); if (_isSessionOnline) { @@ -1494,7 +1393,6 @@ class ApiService { _messageQueue.clear(); } - Future editMessage(int chatId, String messageId, String newText) async { final payload = { "chatId": chatId, @@ -1504,13 +1402,10 @@ class ApiService { "attachments": [], }; - clearChatsCache(); - await waitUntilOnline(); - if (!_isSessionOnline) { print('Сессия не онлайн, пытаемся переподключиться...'); await reconnect(); @@ -1524,12 +1419,10 @@ class ApiService { .firstWhere((msg) => msg['seq'] == seq) .timeout(const Duration(seconds: 10)); - if (response['cmd'] == 3) { final error = response['payload']; print('Ошибка редактирования сообщения: $error'); - if (error['error'] == 'proto.state') { print('Ошибка состояния сессии, переподключаемся...'); await reconnect(); @@ -1537,7 +1430,6 @@ class ApiService { return false; // Попробуем еще раз } - if (error['error'] == 'error.edit.invalid.message') { print( 'Сообщение не может быть отредактировано: ${error['localizedMessage']}', @@ -1557,7 +1449,6 @@ class ApiService { } } - for (int attempt = 0; attempt < 3; attempt++) { print( 'Попытка редактирования сообщения $messageId (попытка ${attempt + 1}/3)', @@ -1579,7 +1470,6 @@ class ApiService { print('Не удалось отредактировать сообщение $messageId после 3 попыток'); } - Future deleteMessage( int chatId, String messageId, { @@ -1591,13 +1481,10 @@ class ApiService { "forMe": forMe, }; - clearChatsCache(); - await waitUntilOnline(); - if (!_isSessionOnline) { print('Сессия не онлайн, пытаемся переподключиться...'); await reconnect(); @@ -1611,12 +1498,10 @@ class ApiService { .firstWhere((msg) => msg['seq'] == seq) .timeout(const Duration(seconds: 10)); - if (response['cmd'] == 3) { final error = response['payload']; print('Ошибка удаления сообщения: $error'); - if (error['error'] == 'proto.state') { print('Ошибка состояния сессии, переподключаемся...'); await reconnect(); @@ -1633,7 +1518,6 @@ class ApiService { } } - for (int attempt = 0; attempt < 3; attempt++) { print('Попытка удаления сообщения $messageId (попытка ${attempt + 1}/3)'); bool ok = await sendOnce(); @@ -1653,7 +1537,6 @@ class ApiService { print('Не удалось удалить сообщение $messageId после 3 попыток'); } - void sendTyping(int chatId, {String type = "TEXT"}) { final payload = {"chatId": chatId, "type": type}; if (_isSessionOnline) { @@ -1661,7 +1544,6 @@ class ApiService { } } - void updateProfileText( String firstName, String lastName, @@ -1675,22 +1557,18 @@ class ApiService { _sendMessage(16, payload); } - Future updateProfilePhoto(String firstName, String lastName) async { try { - final picker = ImagePicker(); final XFile? image = await picker.pickImage(source: ImageSource.gallery); if (image == null) return; - print("Запрашиваем URL для загрузки фото..."); final int seq = _sendMessage(80, {"count": 1}); final response = await messages.firstWhere((msg) => msg['seq'] == seq); final String uploadUrl = response['payload']['url']; print("URL получен: $uploadUrl"); - print("Загружаем фото на сервер..."); var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', image.path)); @@ -1705,7 +1583,6 @@ class ApiService { final String photoToken = uploadResult['photos'].values.first['token']; print("Фото загружено, получен токен: $photoToken"); - print("Привязываем фото к профилю..."); final payload = { "firstName": firstName, @@ -1720,7 +1597,6 @@ class ApiService { } } - Future sendPhotoMessage( int chatId, { String? localPath, @@ -1733,7 +1609,6 @@ class ApiService { if (localPath != null) { image = XFile(localPath); } else { - final picker = ImagePicker(); image = await picker.pickImage(source: ImageSource.gallery); if (image == null) return; @@ -1745,7 +1620,6 @@ class ApiService { final resp80 = await messages.firstWhere((m) => m['seq'] == seq80); final String uploadUrl = resp80['payload']['url']; - var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', image.path)); var streamed = await request.send(); @@ -1760,7 +1634,6 @@ class ApiService { if (photos.isEmpty) throw Exception('Не получен токен фото'); final String photoToken = (photos.values.first as Map)['token']; - final int cid = cidOverride ?? DateTime.now().millisecondsSinceEpoch; final payload = { "chatId": chatId, @@ -1806,7 +1679,6 @@ class ApiService { } } - Future sendPhotoMessages( int chatId, { required List localPaths, @@ -1817,7 +1689,6 @@ class ApiService { try { await waitUntilOnline(); - final int cid = DateTime.now().millisecondsSinceEpoch; _emitLocal({ 'ver': 11, @@ -1841,7 +1712,6 @@ class ApiService { }, }); - final List> photoTokens = []; for (final path in localPaths) { final int seq80 = _sendMessage(80, {"count": 1}); @@ -1885,14 +1755,12 @@ class ApiService { } } - Future sendFileMessage( int chatId, { String? caption, int? senderId, // my user id to mark local echo as mine }) async { try { - FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.any, ); @@ -1908,7 +1776,6 @@ class ApiService { await waitUntilOnline(); - final int seq87 = _sendMessage(87, {"count": 1}); final resp87 = await messages.firstWhere((m) => m['seq'] == seq87); @@ -1924,7 +1791,6 @@ class ApiService { print('Получен fileId: $fileId и URL: $uploadUrl'); - var request = http.MultipartRequest('POST', Uri.parse(uploadUrl)); request.files.add(await http.MultipartFile.fromPath('file', filePath)); var streamed = await request.send(); @@ -1937,8 +1803,6 @@ class ApiService { print('Файл успешно загружен на сервер.'); - - final int cid = DateTime.now().millisecondsSinceEpoch; final payload = { "chatId": chatId, @@ -1955,7 +1819,6 @@ class ApiService { clearChatsCache(); - _emitLocal({ 'ver': 11, 'cmd': 1, @@ -2021,7 +1884,6 @@ class ApiService { } Future hasToken() async { - if (authToken == null) { final prefs = await SharedPreferences.getInstance(); authToken = prefs.getString('authToken'); @@ -2039,7 +1901,6 @@ class ApiService { } Future> fetchContactsByIds(List contactIds) async { - if (contactIds.isEmpty) { return []; } @@ -2048,12 +1909,10 @@ class ApiService { try { final int contactSeq = _sendMessage(32, {"contactIds": contactIds}); - final contactResponse = await messages .firstWhere((msg) => msg['seq'] == contactSeq) .timeout(const Duration(seconds: 10)); - if (contactResponse['cmd'] == 3) { print( "Ошибка при получении контактов по ID: ${contactResponse['payload']}", @@ -2067,7 +1926,6 @@ class ApiService { .map((json) => Contact.fromJson(json)) .toList(); - for (final contact in contacts) { _contactCache[contact.id] = contact; } @@ -2096,7 +1954,6 @@ class ApiService { } Future connect() async { - if (_channel != null && _isSessionOnline) { print("WebSocket уже подключен, пропускаем подключение"); return; @@ -2104,11 +1961,9 @@ class ApiService { print("Запускаем подключение к WebSocket..."); - _isSessionOnline = false; _isSessionReady = false; - _connectionStatusController.add("connecting"); await _connectWithFallback(); } @@ -2122,9 +1977,6 @@ class ApiService { await _connectWithFallback(); } - - - void sendFullJsonRequest(String jsonString) { if (_channel == null) { throw Exception('WebSocket is not connected. Connect first.'); @@ -2133,9 +1985,6 @@ class ApiService { _channel!.sink.add(jsonString); } - - - int sendRawRequest(int opcode, Map payload) { if (_channel == null) { print('WebSocket не подключен!'); @@ -2146,23 +1995,17 @@ class ApiService { return _sendMessage(opcode, payload); } - - int sendAndTrackFullJsonRequest(String jsonString) { if (_channel == null) { throw Exception('WebSocket is not connected. Connect first.'); } - final message = jsonDecode(jsonString) as Map; - final int currentSeq = _seq++; - message['seq'] = currentSeq; - final encodedMessage = jsonEncode(message); _log('➡️ SEND (custom): $encodedMessage'); @@ -2170,7 +2013,6 @@ class ApiService { _channel!.sink.add(encodedMessage); - return currentSeq; } @@ -2188,10 +2030,8 @@ class ApiService { }; final encodedMessage = jsonEncode(message); if (opcode == 1) { - _log('➡️ SEND (ping) seq: $_seq'); } else if (opcode == 18 || opcode == 19) { - Map loggablePayload = Map.from(payload); if (loggablePayload.containsKey('token')) { String token = loggablePayload['token'] as String; @@ -2216,16 +2056,13 @@ class ApiService { (message) { if (message == null) return; if (message is String && message.trim().isEmpty) { - return; } - String loggableMessage = message; try { final decoded = jsonDecode(message) as Map; if (decoded['opcode'] == 2) { - loggableMessage = '⬅️ RECV (pong) seq: ${decoded['seq']}'; } else { Map loggableDecoded = Map.from(decoded); @@ -2255,13 +2092,11 @@ class ApiService { } _log(loggableMessage); - try { final decodedMessage = message is String ? jsonDecode(message) : message; - if (decodedMessage is Map && decodedMessage['opcode'] == 97 && decodedMessage['cmd'] == 1 && @@ -2287,7 +2122,6 @@ class ApiService { _processMessageQueue(); } - if (decodedMessage is Map && decodedMessage['cmd'] == 3) { final error = decodedMessage['payload']; print('Ошибка сервера: $error'); @@ -2302,7 +2136,6 @@ class ApiService { _errorController.add('FAIL_WRONG_PASSWORD'); } - if (error != null && error['error'] == 'password.invalid') { _errorController.add('Неверный пароль'); } @@ -2335,7 +2168,6 @@ class ApiService { } } - if (decodedMessage is Map && decodedMessage['opcode'] == 18 && decodedMessage['cmd'] == 1 && @@ -2351,7 +2183,6 @@ class ApiService { 'Получен запрос на ввод пароля: trackId=${challenge['trackId']}, hint=${challenge['hint']}, email=${challenge['email']}', ); - _messageController.add({ 'type': 'password_required', 'trackId': _currentPasswordTrackId, @@ -2362,168 +2193,144 @@ class ApiService { } } - if (decodedMessage is Map && decodedMessage['opcode'] == 22 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Настройки приватности успешно обновлены: $payload'); - _messageController.add({ 'type': 'privacy_settings_updated', 'settings': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 116 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Пароль успешно установлен: $payload'); - _messageController.add({ 'type': 'password_set_success', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 57 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Успешно присоединились к группе: $payload'); - _messageController.add({ 'type': 'group_join_success', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 46 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Контакт найден: $payload'); - _messageController.add({ 'type': 'contact_found', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 46 && decodedMessage['cmd'] == 3) { final payload = decodedMessage['payload']; print('Контакт не найден: $payload'); - _messageController.add({ 'type': 'contact_not_found', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 32 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Каналы найдены: $payload'); - _messageController.add({ 'type': 'channels_found', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 32 && decodedMessage['cmd'] == 3) { final payload = decodedMessage['payload']; print('Каналы не найдены: $payload'); - _messageController.add({ 'type': 'channels_not_found', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 89 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Вход в канал успешен: $payload'); - _messageController.add({ 'type': 'channel_entered', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 89 && decodedMessage['cmd'] == 3) { final payload = decodedMessage['payload']; print('Ошибка входа в канал: $payload'); - _messageController.add({ 'type': 'channel_error', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 57 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Подписка на канал успешна: $payload'); - _messageController.add({ 'type': 'channel_subscribed', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 57 && decodedMessage['cmd'] == 3) { final payload = decodedMessage['payload']; print('Ошибка подписки на канал: $payload'); - _messageController.add({ 'type': 'channel_error', 'payload': payload, }); } - if (decodedMessage is Map && decodedMessage['opcode'] == 59 && decodedMessage['cmd'] == 1) { final payload = decodedMessage['payload']; print('Получены участники группы: $payload'); - _messageController.add({ 'type': 'group_members', 'payload': payload, @@ -2578,13 +2385,10 @@ class ApiService { _onlineCompleter = Completer(); _chatsFetchedInThisSession = false; - clearAllCaches(); - _currentUrlIndex = 0; - _reconnectDelaySeconds = (_reconnectDelaySeconds * 2).clamp(1, 30); final jitter = (DateTime.now().millisecondsSinceEpoch % 1000) / 1000.0; final delay = Duration(seconds: _reconnectDelaySeconds + jitter.round()); @@ -2611,32 +2415,27 @@ class ApiService { print('Запрашиваем URL для videoId: $videoId (seq: $seq)'); try { - final response = await messages .firstWhere((msg) => msg['seq'] == seq && msg['opcode'] == 83) .timeout(const Duration(seconds: 15)); - if (response['cmd'] == 3) { throw Exception( 'Ошибка получения URL видео: ${response['payload']?['message']}', ); } - final videoPayload = response['payload'] as Map?; if (videoPayload == null) { throw Exception('Получен пустой payload для видео'); } - String? videoUrl = videoPayload['MP4_720'] as String? ?? videoPayload['MP4_480'] as String? ?? videoPayload['MP4_1080'] as String? ?? videoPayload['MP4_360'] as String?; - if (videoUrl == null) { final mp4Key = videoPayload.keys.firstWhere( (k) => k.startsWith('MP4_'), @@ -2673,12 +2472,10 @@ class ApiService { _onlineCompleter = Completer(); _chatsFetchedInThisSession = false; - _channel?.sink.close(status.goingAway); _channel = null; _streamSubscription = null; - _connectionStatusController.add("disconnected"); } @@ -2687,11 +2484,9 @@ class ApiService { return data?.text; } - void forceReconnect() { print("Принудительное переподключение..."); - _pingTimer?.cancel(); _reconnectTimer?.cancel(); if (_channel != null) { @@ -2700,7 +2495,6 @@ class ApiService { _channel = null; } - _isReconnecting = false; _reconnectAttempts = 0; _reconnectDelaySeconds = 2; @@ -2710,35 +2504,28 @@ class ApiService { _currentUrlIndex = 0; _onlineCompleter = Completer(); // Re-create completer - clearAllCaches(); _messageQueue.clear(); _presenceData.clear(); - _connectionStatusController.add("connecting"); _log("Запускаем новую сессию подключения..."); - _connectWithFallback(); } - Future performFullReconnection() async { print("🔄 Начинаем полное переподключение..."); try { - _pingTimer?.cancel(); _reconnectTimer?.cancel(); _streamSubscription?.cancel(); - if (_channel != null) { _channel!.sink.close(); _channel = null; } - _isReconnecting = false; _reconnectAttempts = 0; _reconnectDelaySeconds = 2; @@ -2750,7 +2537,6 @@ class ApiService { _onlineCompleter = Completer(); _seq = 0; - _lastChatsPayload = null; _lastChatsAt = null; @@ -2760,15 +2546,12 @@ class ApiService { _connectionStatusController.add("disconnected"); - await connect(); print("✅ Полное переподключение завершено"); - await Future.delayed(const Duration(milliseconds: 1500)); - if (!_reconnectionCompleteController.isClosed) { print("📢 Отправляем уведомление о завершении переподключения"); _reconnectionCompleteController.add(null); @@ -2779,7 +2562,6 @@ class ApiService { } } - Future updatePrivacySettings({ String? hidden, String? searchByPhone, @@ -2807,7 +2589,6 @@ class ApiService { print('Обновляем настройки приватности: $settings'); - if (hidden != null) { await _updateSinglePrivacySetting({'HIDDEN': hidden == 'true'}); } @@ -2821,7 +2602,6 @@ class ApiService { await _updateSinglePrivacySetting({'CHATS_INVITE': chatsInvite}); } - if (chatsPushNotification != null) { await _updateSinglePrivacySetting({ 'PUSH_NEW_CONTACTS': chatsPushNotification, @@ -2841,7 +2621,6 @@ class ApiService { } } - Future _updateSinglePrivacySetting(Map setting) async { await waitUntilOnline(); diff --git a/lib/chat_screen.dart b/lib/chat_screen.dart index e424296..a53aa39 100644 --- a/lib/chat_screen.dart +++ b/lib/chat_screen.dart @@ -21,7 +21,6 @@ import 'package:video_player/video_player.dart'; bool _debugShowExactDate = false; - void toggleDebugExactDate() { _debugShowExactDate = !_debugShowExactDate; print('Debug режим точной даты: $_debugShowExactDate'); @@ -88,13 +87,12 @@ class _ChatScreenState extends State { ItemPositionsListener.create(); final ValueNotifier _showScrollToBottomNotifier = ValueNotifier(false); - late Contact _currentContact; - Message? _replyingToMessage; final Map _contactDetailsCache = {}; + final Set _loadingContactIds = {}; final Map _lastReadMessageIdByParticipant = {}; @@ -143,6 +141,30 @@ class _ChatScreenState extends State { } } + Future _loadContactIfNeeded(int contactId) async { + if (_contactDetailsCache.containsKey(contactId) || + _loadingContactIds.contains(contactId)) { + return; + } + + _loadingContactIds.add(contactId); + + try { + final contacts = await ApiService.instance.fetchContactsByIds([ + contactId, + ]); + if (contacts.isNotEmpty && mounted) { + final contact = contacts.first; + _contactDetailsCache[contact.id] = contact; + setState(() {}); + } + } catch (e) { + print('Ошибка загрузки контакта $contactId: $e'); + } finally { + _loadingContactIds.remove(contactId); + } + } + @override void initState() { super.initState(); @@ -375,7 +397,6 @@ class _ChatScreenState extends State { if (!mounted) return; print("✅ Получено ${allMessages.length} сообщений с сервера."); - final Set senderIds = {}; for (final message in allMessages) { senderIds.add(message.senderId); @@ -389,7 +410,6 @@ class _ChatScreenState extends State { } senderIds.remove(0); // Удаляем системный ID, если он есть - final idsToFetch = senderIds .where((id) => !_contactDetailsCache.containsKey(id)) .toList(); @@ -475,8 +495,6 @@ class _ChatScreenState extends State { _buildChatItems(); _isLoadingMore = false; setState(() {}); - - } bool _isSameDay(DateTime date1, DateTime date2) { @@ -532,12 +550,10 @@ class _ChatScreenState extends State { print('DEBUG GROUPING: isGrouped=$isGrouped'); } - final isFirstInGroup = previousMessage == null || !_isMessageGrouped(currentMessage, previousMessage); - final isLastInGroup = i == source.length - 1 || !_isMessageGrouped(source[i + 1], currentMessage); @@ -1155,7 +1171,6 @@ class _ChatScreenState extends State { await Future.delayed(const Duration(milliseconds: 500)); if (mounted) { - Navigator.of(context).pop(); widget.onChatUpdated?.call(); @@ -1213,11 +1228,9 @@ class _ChatScreenState extends State { onPressed: () { Navigator.of(context).pop(); // Закрываем диалог подтверждения try { - ApiService.instance.leaveGroup(widget.chatId); if (mounted) { - Navigator.of(context).pop(); widget.onChatUpdated?.call(); @@ -1388,30 +1401,43 @@ class _ChatScreenState extends State { if (isMe) { final messageId = item.message.id; if (messageId.startsWith('local_')) { - - readStatus = MessageReadStatus.sending; } else { - - readStatus = MessageReadStatus.sent; - - - - - - - - - - } } + String? forwardedFrom; + String? forwardedFromAvatarUrl; if (message.isForwarded) { - final originalSenderId = - message.link?['message']?['sender'] as int?; - if (originalSenderId != null) {} + final link = message.link; + if (link is Map) { + final chatName = link['chatName'] as String?; + final chatIconUrl = link['chatIconUrl'] as String?; + + if (chatName != null) { + forwardedFrom = chatName; + forwardedFromAvatarUrl = chatIconUrl; + } else { + final forwardedMessage = + link['message'] as Map?; + final originalSenderId = + forwardedMessage?['sender'] as int?; + if (originalSenderId != null) { + final originalSenderContact = + _contactDetailsCache[originalSenderId]; + if (originalSenderContact == null) { + _loadContactIfNeeded(originalSenderId); + forwardedFrom = 'Участник $originalSenderId'; + forwardedFromAvatarUrl = null; + } else { + forwardedFrom = originalSenderContact.name; + forwardedFromAvatarUrl = + originalSenderContact.photoBaseUrl; + } + } + } + } } String? senderName; if (widget.isGroupChat && !isMe) { @@ -1500,6 +1526,8 @@ class _ChatScreenState extends State { isGroupChat: widget.isGroupChat, isChannel: widget.isChannel, senderName: senderName, + forwardedFrom: forwardedFrom, + forwardedFromAvatarUrl: forwardedFromAvatarUrl, contactDetailsCache: _contactDetailsCache, onReplyTap: _scrollToMessage, useAutoReplyColor: context @@ -1659,7 +1687,6 @@ class _ChatScreenState extends State { leading: widget.isDesktopMode ? null // В десктопном режиме нет кнопки "Назад" : IconButton( - icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop(), ), @@ -1908,7 +1935,6 @@ class _ChatScreenState extends State { ), ) else - _ContactPresenceSubtitle( chatId: widget.chatId, userId: widget.contact.id, @@ -1998,7 +2024,6 @@ class _ChatScreenState extends State { ], ); case ChatWallpaperType.video: - if (Platform.isWindows) { return Container( color: Theme.of(context).colorScheme.surface, @@ -2195,16 +2220,13 @@ class _ChatScreenState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: Focus( focusNode: _textFocusNode, // 2. focusNode теперь здесь onKeyEvent: (node, event) { - if (event is KeyDownEvent) { if (event.logicalKey == LogicalKeyboardKey.enter) { - final bool isShiftPressed = HardwareKeyboard.instance.logicalKeysPressed .contains( @@ -2216,7 +2238,6 @@ class _ChatScreenState extends State { ); if (!isShiftPressed) { - _sendMessage(); return KeyEventResult.handled; } @@ -3292,7 +3313,6 @@ class GroupProfileDraggableDialog extends StatelessWidget { ), child: Column( children: [ - Container( margin: const EdgeInsets.only(top: 8), width: 40, @@ -3303,7 +3323,6 @@ class GroupProfileDraggableDialog extends StatelessWidget { ), ), - Padding( padding: const EdgeInsets.all(20), child: Hero( @@ -3325,7 +3344,6 @@ class GroupProfileDraggableDialog extends StatelessWidget { ), ), - Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( @@ -3343,8 +3361,6 @@ class GroupProfileDraggableDialog extends StatelessWidget { IconButton( icon: Icon(Icons.settings, color: colors.primary), onPressed: () async { - - final myId = 0; // This should be passed or retrieved Navigator.of(context).pop(); @@ -3367,13 +3383,11 @@ class GroupProfileDraggableDialog extends StatelessWidget { const SizedBox(height: 20), - Expanded( child: ListView( controller: scrollController, padding: const EdgeInsets.symmetric(horizontal: 20), children: [ - if (contact.description != null && contact.description!.isNotEmpty) Text( @@ -3545,7 +3559,6 @@ class ContactProfileDialog extends StatelessWidget { }, ) else - const SizedBox(height: 16), if (!isChannel) @@ -3900,7 +3913,6 @@ class _RemoveMemberDialogState extends State<_RemoveMemberDialog> { } } - class _PromoteAdminDialog extends StatelessWidget { final List> members; final Function(int) onPromoteToAdmin; @@ -3964,7 +3976,6 @@ class _ControlMessageChip extends StatelessWidget { }); String _formatControlMessage() { - final controlAttach = message.attaches.firstWhere( (a) => a['_type'] == 'CONTROL', ); @@ -3974,7 +3985,6 @@ class _ControlMessageChip extends StatelessWidget { final isMe = message.senderId == myId; final senderDisplayName = isMe ? 'Вы' : senderName; - String _formatUserList(List userIds) { if (userIds.isEmpty) { return ''; @@ -4120,7 +4130,6 @@ class _ControlMessageChip extends StatelessWidget { return '$senderName присоединился(ась) к группе'; default: - final eventTypeStr = eventType?.toString() ?? 'неизвестное'; return 'Событие: $eventTypeStr'; } @@ -4153,15 +4162,12 @@ class _ControlMessageChip extends StatelessWidget { } void openUserProfileById(BuildContext context, int userId) { - final contact = ApiService.instance.getCachedContact(userId); if (contact != null) { - final isGroup = contact.id < 0; // Groups have negative IDs if (isGroup) { - showModalBottomSheet( context: context, isScrollControlled: true, @@ -4169,7 +4175,6 @@ void openUserProfileById(BuildContext context, int userId) { builder: (context) => GroupProfileDraggableDialog(contact: contact), ); } else { - Navigator.of(context).push( PageRouteBuilder( opaque: false, @@ -4185,7 +4190,6 @@ void openUserProfileById(BuildContext context, int userId) { ); } } else { - showDialog( context: context, builder: (context) => AlertDialog( diff --git a/lib/chats_screen.dart b/lib/chats_screen.dart index d82d27c..7c34b6a 100644 --- a/lib/chats_screen.dart +++ b/lib/chats_screen.dart @@ -24,6 +24,7 @@ import 'package:gwid/models/channel.dart'; import 'package:gwid/search_channels_screen.dart'; import 'package:gwid/downloads_screen.dart'; import 'package:gwid/user_id_lookup_screen.dart'; +import 'package:gwid/widgets/message_preview_dialog.dart'; class SearchResult { final Chat chat; @@ -237,12 +238,10 @@ class _ChatsScreenState extends State ); } - void _listenForUpdates() { _apiSubscription = ApiService.instance.messages.listen((message) { if (!mounted) return; - if (message['type'] == 'invalid_token') { print( 'Получено событие недействительного токена, перенаправляем на вход', @@ -254,18 +253,22 @@ class _ChatsScreenState extends State } final opcode = message['opcode']; + final cmd = message['cmd']; final payload = message['payload']; if (payload == null) return; final chatIdValue = payload['chatId']; - if (chatIdValue == null) return; - final int chatId = chatIdValue; + final int? chatId = chatIdValue != null ? chatIdValue as int? : null; - if (opcode == 129) { + if (opcode == 272 || opcode == 274) { + } else if (chatId == null) { + return; + } + + if (opcode == 129 && chatId != null) { _setTypingForChat(chatId); } - - if (opcode == 128) { + if (opcode == 128 && chatId != null) { final newMessage = Message.fromJson(payload['message']); ApiService.instance.clearCacheForChat(chatId); @@ -284,10 +287,8 @@ class _ChatsScreenState extends State if (_isSavedMessages(updatedChat)) { if (updatedChat.id == 0) { - _allChats.insert(0, updatedChat); } else { - final savedIndex = _allChats.indexWhere( (c) => _isSavedMessages(c) && c.id == 0, ); @@ -295,7 +296,6 @@ class _ChatsScreenState extends State _allChats.insert(insertIndex, updatedChat); } } else { - final savedIndex = _allChats.indexWhere( (c) => _isSavedMessages(c), ); @@ -305,9 +305,7 @@ class _ChatsScreenState extends State _filterChats(); }); } - } - - else if (opcode == 67) { + } else if (opcode == 67 && chatId != null) { final editedMessage = Message.fromJson(payload['message']); ApiService.instance.clearCacheForChat(chatId); @@ -322,10 +320,8 @@ class _ChatsScreenState extends State if (_isSavedMessages(updatedChat)) { if (updatedChat.id == 0) { - _allChats.insert(0, updatedChat); } else { - final savedIndex = _allChats.indexWhere( (c) => _isSavedMessages(c) && c.id == 0, ); @@ -343,9 +339,7 @@ class _ChatsScreenState extends State }); } } - } - - else if (opcode == 66) { + } else if (opcode == 66 && chatId != null) { final deletedMessageIds = List.from( payload['messageIds'] ?? [], ); @@ -356,7 +350,6 @@ class _ChatsScreenState extends State final oldChat = _allChats[chatIndex]; if (deletedMessageIds.contains(oldChat.lastMessage.id)) { - ApiService.instance.getChatsAndContacts(force: true).then((data) { if (mounted) { final chats = data['chats'] as List; @@ -380,23 +373,19 @@ class _ChatsScreenState extends State } } - - if (opcode == 129) { + if (opcode == 129 && chatId != null) { _setTypingForChat(chatId); } - if (opcode == 132) { final bool isOnline = payload['online'] == true; - final dynamic contactIdAny = payload['contactId'] ?? payload['userId']; if (contactIdAny != null) { final int? cid = contactIdAny is int ? contactIdAny : int.tryParse(contactIdAny.toString()); if (cid != null) { - final currentTime = DateTime.now().millisecondsSinceEpoch ~/ 1000; // Конвертируем в секунды @@ -444,20 +433,17 @@ class _ChatsScreenState extends State } } - if (opcode == 36 && payload['contacts'] != null) { final List blockedContactsJson = payload['contacts'] as List; final blockedContacts = blockedContactsJson .map((json) => Contact.fromJson(json)) .toList(); - for (final blockedContact in blockedContacts) { print( 'Обновляем контакт ${blockedContact.name} (ID: ${blockedContact.id}): isBlocked=${blockedContact.isBlocked}, isBlockedByMe=${blockedContact.isBlockedByMe}', ); if (_contacts.containsKey(blockedContact.id)) { - _contacts[blockedContact.id] = blockedContact; print( 'Обновлен существующий контакт: ${_contacts[blockedContact.id]?.name}', @@ -465,7 +451,6 @@ class _ChatsScreenState extends State ApiService.instance.notifyContactUpdate(blockedContact); } else { - _contacts[blockedContact.id] = blockedContact; print( 'Добавлен новый заблокированный контакт: ${blockedContact.name}', @@ -478,14 +463,12 @@ class _ChatsScreenState extends State if (mounted) setState(() {}); } - if (opcode == 48) { print('Получен ответ на создание группы: $payload'); _refreshChats(); } - if (opcode == 272) { print('Получен ответ на обновление папок: $payload'); @@ -502,7 +485,11 @@ class _ChatsScreenState extends State if (mounted) { setState(() { _folders = folders; + final foldersOrder = + payload['foldersOrder'] as List?; + _sortFoldersByOrder(foldersOrder); }); + _updateFolderTabController(); _filterChats(); } } @@ -514,6 +501,54 @@ class _ChatsScreenState extends State } } + if (opcode == 274 && cmd == 1) { + print('Получен ответ на создание/обновление папки: $payload'); + + try { + final folderJson = payload['folder'] as Map?; + if (folderJson != null) { + final updatedFolder = ChatFolder.fromJson(folderJson); + final folderId = updatedFolder.id; + + if (mounted) { + final existingIndex = _folders.indexWhere( + (f) => f.id == folderId, + ); + final isNewFolder = existingIndex == -1; + + setState(() { + if (existingIndex != -1) { + _folders[existingIndex] = updatedFolder; + } else { + _folders.add(updatedFolder); + } + + final foldersOrder = payload['foldersOrder'] as List?; + _sortFoldersByOrder(foldersOrder); + }); + + _updateFolderTabController(); + _filterChats(); + + if (isNewFolder) { + final newFolderIndex = _folders.indexWhere( + (f) => f.id == folderId, + ); + if (newFolderIndex != -1) { + final targetIndex = newFolderIndex + 1; + if (_folderTabController.length > targetIndex) { + _folderTabController.animateTo(targetIndex); + } + } + } + } + } + } catch (e) { + print( + 'Ошибка обработки созданной/обновленной папки из opcode 274: $e', + ); + } + } if (message['type'] == 'channels_found') { final payload = message['payload']; @@ -544,7 +579,6 @@ class _ChatsScreenState extends State } void _refreshChats() { - _chatsFuture = ApiService.instance.getChatsAndContacts(force: true); _chatsFuture.then((data) { if (mounted) { @@ -578,7 +612,6 @@ class _ChatsScreenState extends State ), child: Column( children: [ - Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -622,7 +655,6 @@ class _ChatsScreenState extends State ), ), - Expanded(child: _buildChannelsList()), ], ), @@ -633,7 +665,6 @@ class _ChatsScreenState extends State if (_channelsLoaded) return; try { - await ApiService.instance.searchChannels('каналы'); _channelsLoaded = true; } catch (e) { @@ -645,7 +676,6 @@ class _ChatsScreenState extends State final colors = Theme.of(context).colorScheme; if (_channels.isEmpty) { - return ListView( padding: const EdgeInsets.all(8), children: [ @@ -688,7 +718,6 @@ class _ChatsScreenState extends State ); } - return ListView.builder( padding: const EdgeInsets.all(8), itemCount: _channels.length, @@ -730,7 +759,6 @@ class _ChatsScreenState extends State overflow: TextOverflow.ellipsis, ), onTap: () { - ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Открытие канала: $title'), @@ -804,7 +832,6 @@ class _ChatsScreenState extends State child: Column( mainAxisSize: MainAxisSize.min, children: [ - Container( width: 40, height: 4, @@ -822,7 +849,6 @@ class _ChatsScreenState extends State ), const SizedBox(height: 20), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -841,7 +867,6 @@ class _ChatsScreenState extends State }, ), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -864,7 +889,6 @@ class _ChatsScreenState extends State }, ), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -887,7 +911,6 @@ class _ChatsScreenState extends State }, ), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -910,7 +933,6 @@ class _ChatsScreenState extends State }, ), - ListTile( leading: CircleAvatar( backgroundColor: Theme.of( @@ -947,7 +969,6 @@ class _ChatsScreenState extends State final int? myId = _myProfile?.id; - final List availableContacts = _contacts.values.where((contact) { final contactNameLower = contact.name.toLowerCase(); return contactNameLower != 'max' && @@ -1035,10 +1056,38 @@ class _ChatsScreenState extends State } bool _isGroupChat(Chat chat) { - return chat.type == 'CHAT' || chat.participantIds.length > 2; } + void _updateFolderTabController() { + final oldIndex = _folderTabController.index; + final newLength = 1 + _folders.length; + if (_folderTabController.length != newLength) { + _folderTabController.removeListener(_onFolderTabChanged); + _folderTabController.dispose(); + _folderTabController = TabController( + length: newLength, + vsync: this, + initialIndex: oldIndex < newLength ? oldIndex : 0, + ); + _folderTabController.addListener(_onFolderTabChanged); + } + } + + void _sortFoldersByOrder(List? foldersOrder) { + if (foldersOrder == null || foldersOrder.isEmpty) return; + + final orderedIds = foldersOrder.map((id) => id.toString()).toList(); + _folders.sort((a, b) { + final aIndex = orderedIds.indexOf(a.id); + final bIndex = orderedIds.indexOf(b.id); + if (aIndex == -1 && bIndex == -1) return 0; + if (aIndex == -1) return 1; + if (bIndex == -1) return -1; + return aIndex.compareTo(bIndex); + }); + } + void _loadFolders(Map data) { try { final config = data['config'] as Map?; @@ -1055,26 +1104,19 @@ class _ChatsScreenState extends State .toList(); setState(() { - final oldIndex = _folderTabController.index; _folders = folders; - final newLength = 1 + folders.length; - if (_folderTabController.length != newLength) { - _folderTabController.removeListener(_onFolderTabChanged); - _folderTabController.dispose(); - _folderTabController = TabController( - length: newLength, - vsync: this, - initialIndex: oldIndex < newLength ? oldIndex : 0, - ); - _folderTabController.addListener(_onFolderTabChanged); - } + + final foldersOrder = chatFolders['foldersOrder'] as List?; + _sortFoldersByOrder(foldersOrder); + + _updateFolderTabController(); if (_selectedFolderId == null) { if (_folderTabController.index != 0) { _folderTabController.animateTo(0); } } else { - final folderIndex = folders.indexWhere( + final folderIndex = _folders.indexWhere( (f) => f.id == _selectedFolderId, ); if (folderIndex != -1) { @@ -1197,7 +1239,6 @@ class _ChatsScreenState extends State return 0; // Остальные чаты сохраняют порядок }); } else if (_searchFocusNode.hasFocus && query.isEmpty) { - _filteredChats = []; } else if (query.isNotEmpty) { _filteredChats = chatsToFilter.where((chat) { @@ -1227,7 +1268,6 @@ class _ChatsScreenState extends State return 0; }); } else { - _filteredChats = []; } }); @@ -1261,9 +1301,7 @@ class _ChatsScreenState extends State return; } - setState(() { - - }); + setState(() {}); final results = []; final query = _searchQuery.toLowerCase(); @@ -1293,7 +1331,6 @@ class _ChatsScreenState extends State if (contact == null) continue; - if (contact.name.toLowerCase().contains(query)) { results.add( SearchResult( @@ -1306,7 +1343,6 @@ class _ChatsScreenState extends State continue; } - if (contact.description != null && contact.description?.toLowerCase().contains(query) == true) { results.add( @@ -1320,7 +1356,6 @@ class _ChatsScreenState extends State continue; } - if (chat.lastMessage.text.toLowerCase().contains(query) || (chat.lastMessage.text.contains("welcome.saved.dialog.message") && 'привет избранные майор'.contains(query.toLowerCase()))) { @@ -1338,10 +1373,8 @@ class _ChatsScreenState extends State } } - List filteredResults = results; if (_searchFilter == 'recent') { - final weekAgo = DateTime.now().subtract(const Duration(days: 7)); filteredResults = results.where((result) { final lastMessageTime = DateTime.fromMillisecondsSinceEpoch( @@ -1401,7 +1434,6 @@ class _ChatsScreenState extends State final orderedChats = []; final remainingChats = List.from(_allChats); - for (final id in chatIds) { final chatIndex = remainingChats.indexWhere((chat) => chat.id == id); if (chatIndex != -1) { @@ -1409,7 +1441,6 @@ class _ChatsScreenState extends State } } - orderedChats.addAll(remainingChats); _allChats = orderedChats; @@ -1532,14 +1563,12 @@ class _ChatsScreenState extends State child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - CircularProgressIndicator( strokeWidth: 3, valueColor: AlwaysStoppedAnimation(colors.primary), ), const SizedBox(height: 24), - Text( 'Подключение', style: TextStyle( @@ -1550,7 +1579,6 @@ class _ChatsScreenState extends State ), const SizedBox(height: 8), - Text( 'Устанавливаем соединение с сервером...', style: TextStyle(fontSize: 14, color: colors.onSurfaceVariant), @@ -1591,15 +1619,12 @@ class _ChatsScreenState extends State ); _contacts = {for (var c in contacts) c.id: c}; - final presence = snapshot.data!['presence'] as Map?; if (presence != null) { print('Получен presence: $presence'); - } - if (!_hasRequestedBlockedContacts) { _hasRequestedBlockedContacts = true; ApiService.instance.getBlockedContacts(); @@ -1607,7 +1632,6 @@ class _ChatsScreenState extends State _loadFolders(snapshot.data!); - _loadChatOrder().then((_) { setState(() { _filteredChats = List.from(_allChats); @@ -1615,11 +1639,9 @@ class _ChatsScreenState extends State }); } if (_filteredChats.isEmpty && _allChats.isEmpty) { - return const Center(child: CircularProgressIndicator()); } - if (_isSearchExpanded) { return _buildSearchResults(); } else { @@ -1674,7 +1696,6 @@ class _ChatsScreenState extends State final isDarkMode = themeProvider.themeMode == ThemeMode.dark; return Drawer( - child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -1695,7 +1716,6 @@ class _ChatsScreenState extends State mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start, children: [ - CircleAvatar( radius: 30, // Чуть крупнее backgroundColor: colors.primary, @@ -1742,7 +1762,6 @@ class _ChatsScreenState extends State ), const SizedBox(height: 12), - Text( _myProfile?.displayName ?? 'Загрузка...', style: TextStyle( @@ -1753,7 +1772,6 @@ class _ChatsScreenState extends State ), const SizedBox(height: 4), - Text( _myProfile?.formattedPhone ?? '', style: TextStyle( @@ -1767,7 +1785,6 @@ class _ChatsScreenState extends State Expanded( child: Column( - children: [ ListTile( leading: const Icon(Icons.person_outline), @@ -1795,7 +1812,6 @@ class _ChatsScreenState extends State onTap: () { Navigator.pop(context); // Закрыть Drawer - final screenSize = MediaQuery.of(context).size; final screenWidth = screenSize.width; final screenHeight = screenSize.height; @@ -1808,7 +1824,6 @@ class _ChatsScreenState extends State ); if (isDesktopOrTablet) { - showDialog( context: context, barrierDismissible: true, @@ -1820,7 +1835,6 @@ class _ChatsScreenState extends State ), ); } else { - Navigator.of(context).push( MaterialPageRoute( builder: (context) => SettingsScreen( @@ -1861,7 +1875,6 @@ class _ChatsScreenState extends State if (_searchQuery.isEmpty) { return Column( children: [ - _buildRecentChatsIcons(), const Divider(height: 1), @@ -2338,12 +2351,16 @@ class _ChatsScreenState extends State return ListView.builder( itemCount: chatsForFolder.length, itemBuilder: (context, index) { - return _buildChatListItem(chatsForFolder[index], index); + return _buildChatListItem(chatsForFolder[index], index, folder); }, ); } Widget _buildFolderTabs() { + if (_folderTabController.length <= 1) { + return const SizedBox.shrink(); + } + final colors = Theme.of(context).colorScheme; final List tabs = [ @@ -2377,24 +2394,252 @@ class _ChatsScreenState extends State bottom: BorderSide(color: colors.outline.withOpacity(0.2), width: 1), ), ), - child: TabBar( - controller: _folderTabController, - isScrollable: true, - labelColor: colors.primary, - unselectedLabelColor: colors.onSurfaceVariant, - indicator: UnderlineTabIndicator( - borderSide: BorderSide(width: 3, color: colors.primary), - insets: const EdgeInsets.symmetric(horizontal: 16), + child: Stack( + children: [ + Row( + children: [ + Expanded( + child: _folders.length <= 3 + ? Center( + child: TabBar( + controller: _folderTabController, + isScrollable: false, + tabAlignment: TabAlignment.center, + labelColor: colors.primary, + unselectedLabelColor: colors.onSurfaceVariant, + indicator: UnderlineTabIndicator( + borderSide: BorderSide( + width: 3, + color: colors.primary, + ), + insets: const EdgeInsets.symmetric(horizontal: 16), + ), + indicatorSize: TabBarIndicatorSize.label, + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + dividerColor: Colors.transparent, + tabs: tabs, + onTap: (index) {}, + ), + ) + : Transform.translate( + offset: const Offset(-42, 0), + child: TabBar( + controller: _folderTabController, + isScrollable: true, + labelColor: colors.primary, + unselectedLabelColor: colors.onSurfaceVariant, + indicator: UnderlineTabIndicator( + borderSide: BorderSide( + width: 3, + color: colors.primary, + ), + insets: const EdgeInsets.symmetric(horizontal: 16), + ), + indicatorSize: TabBarIndicatorSize.label, + labelStyle: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + ), + unselectedLabelStyle: const TextStyle( + fontWeight: FontWeight.normal, + fontSize: 14, + ), + dividerColor: Colors.transparent, + tabs: tabs, + onTap: (index) {}, + ), + ), + ), + ], + ), + Positioned( + right: 0, + top: 0, + bottom: 0, + child: IconButton( + icon: const Icon(Icons.add, size: 20), + onPressed: _showCreateFolderDialog, + tooltip: 'Создать папку', + padding: const EdgeInsets.symmetric(horizontal: 8), + constraints: const BoxConstraints(), + ), + ), + ], + ), + ); + } + + void _showCreateFolderDialog() { + final TextEditingController titleController = TextEditingController(); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Создать папку'), + content: TextField( + controller: titleController, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Название папки', + hintText: 'Введите название', + border: OutlineInputBorder(), + ), + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + ApiService.instance.createFolder(value.trim()); + Navigator.of(context).pop(); + } + }, ), - indicatorSize: TabBarIndicatorSize.label, - labelStyle: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), - unselectedLabelStyle: const TextStyle( - fontWeight: FontWeight.normal, - fontSize: 14, + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Отмена'), + ), + TextButton( + onPressed: () { + final title = titleController.text.trim(); + if (title.isNotEmpty) { + ApiService.instance.createFolder(title); + Navigator.of(context).pop(); + } + }, + child: const Text('Создать'), + ), + ], + ), + ); + } + + Future _showMessagePreview(Chat chat, ChatFolder? currentFolder) async { + await MessagePreviewDialog.show( + context, + chat, + _contacts, + _myProfile, + null, + (context) => _buildChatMenuContent(chat, currentFolder, context), + ); + } + + Widget _buildChatMenuContent( + Chat chat, + ChatFolder? currentFolder, + BuildContext context, + ) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Действия', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + ), + const Divider(), + if (currentFolder == null && _folders.isNotEmpty) + ListTile( + leading: const Icon(Icons.folder), + title: const Text('Добавить в папку'), + onTap: () { + Navigator.of(context).pop(); + _showFolderSelectionMenu(chat); + }, + ), + const SizedBox(height: 8), + ], + ), + ); + } + + void _showFolderSelectionMenu(Chat chat) { + if (_folders.isEmpty) return; + + showModalBottomSheet( + context: context, + builder: (context) { + final colors = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colors.onSurfaceVariant.withOpacity(0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + 'Выберите папку', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + ), + const Divider(), + ..._folders.map((folder) { + return ListTile( + leading: folder.emoji != null + ? Text( + folder.emoji!, + style: const TextStyle(fontSize: 24), + ) + : const Icon(Icons.folder), + title: Text(folder.title), + onTap: () { + Navigator.of(context).pop(); + _addChatToFolder(chat, folder); + }, + ); + }), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + } + + void _addChatToFolder(Chat chat, ChatFolder folder) { + final currentInclude = folder.include ?? []; + + if (currentInclude.contains(chat.id)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Чат уже находится в папке "${folder.title}"'), + duration: const Duration(seconds: 2), ), - dividerColor: Colors.transparent, - tabs: tabs, - onTap: (index) {}, + ); + return; + } + + final newInclude = List.from(currentInclude)..add(chat.id); + + ApiService.instance.updateFolder( + folder.id, + title: folder.title, + include: newInclude, + filters: folder.filters, + ); + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Чат добавлен в папку "${folder.title}"'), + duration: const Duration(seconds: 2), ), ); } @@ -2421,7 +2666,6 @@ class _ChatsScreenState extends State child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () async { - SchedulerBinding.instance.addPostFrameCallback((_) { if (mounted) { setState(() { @@ -2602,7 +2846,7 @@ class _ChatsScreenState extends State : [ IconButton( icon: Image.asset( - 'assets/images/spermum.webp', + 'assets/images/spermum.png', width: 28, height: 28, ), @@ -2621,7 +2865,6 @@ class _ChatsScreenState extends State tooltip: 'Загрузки', ), InkWell( - onTap: () { setState(() { _isSearchExpanded = true; @@ -2795,32 +3038,25 @@ class _ChatsScreenState extends State Widget _buildLastMessagePreview(Chat chat) { final message = chat.lastMessage; - - if (message.attaches.isNotEmpty) { - for (final attach in message.attaches) { final type = attach['_type']; if (type == 'CALL' || type == 'call') { - return _buildCallPreview(attach, message, chat); } } } - if (message.text.isEmpty && message.attaches.isNotEmpty) { return Text('Вложение', maxLines: 1, overflow: TextOverflow.ellipsis); } - return Text(message.text, maxLines: 1, overflow: TextOverflow.ellipsis); } Widget _buildSearchMessagePreview(Chat chat, String matchedText) { final message = chat.lastMessage; - if (message.attaches.isNotEmpty) { final callAttachments = message.attaches.where((attach) { final type = attach['_type']; @@ -2828,12 +3064,10 @@ class _ChatsScreenState extends State }).toList(); if (callAttachments.isNotEmpty) { - return _buildCallPreview(callAttachments.first, message, chat); } } - if (message.text.isEmpty && message.attaches.isNotEmpty) { return Text('Вложение', maxLines: 1, overflow: TextOverflow.ellipsis); } @@ -2855,10 +3089,8 @@ class _ChatsScreenState extends State IconData callIcon; Color? callColor; - switch (hangupType) { case 'HUNGUP': - final minutes = duration ~/ 60000; final seconds = (duration % 60000) ~/ 1000; final durationText = minutes > 0 @@ -2872,7 +3104,6 @@ class _ChatsScreenState extends State break; case 'MISSED': - final callTypeText = callType == 'VIDEO' ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; @@ -2882,7 +3113,6 @@ class _ChatsScreenState extends State break; case 'CANCELED': - final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отменен' : 'Звонок отменен'; @@ -2892,7 +3122,6 @@ class _ChatsScreenState extends State break; case 'REJECTED': - final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отклонен' : 'Звонок отклонен'; @@ -2902,7 +3131,6 @@ class _ChatsScreenState extends State break; default: - callText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; callColor = colors.onSurfaceVariant; @@ -2925,7 +3153,7 @@ class _ChatsScreenState extends State ); } - Widget _buildChatListItem(Chat chat, int index) { + Widget _buildChatListItem(Chat chat, int index, ChatFolder? currentFolder) { final colors = Theme.of(context).colorScheme; final bool isSavedMessages = _isSavedMessages(chat); @@ -2969,7 +3197,6 @@ class _ChatsScreenState extends State return ListTile( key: ValueKey(chat.id), - onTap: () { final theme = context.read(); if (theme.debugReadOnEnter) { @@ -3003,7 +3230,6 @@ class _ChatsScreenState extends State isBlockedByMe: false, ); - final participantCount = chat.participantsCount ?? chat.participantIds.length; @@ -3036,22 +3262,25 @@ class _ChatsScreenState extends State leading: Stack( clipBehavior: Clip.none, children: [ - CircleAvatar( - radius: 24, - backgroundColor: colors.primaryContainer, + GestureDetector( + onLongPress: () => _showMessagePreview(chat, currentFolder), + child: CircleAvatar( + radius: 24, + backgroundColor: colors.primaryContainer, - backgroundImage: avatarUrl != null ? NetworkImage(avatarUrl) : null, + backgroundImage: avatarUrl != null + ? NetworkImage(avatarUrl) + : null, - child: avatarUrl == null - ? (isSavedMessages || isGroupChat || isChannel) - - ? Icon(leadingIcon, color: colors.onPrimaryContainer) - - : Text( - title.isNotEmpty ? title[0].toUpperCase() : '?', - style: TextStyle(color: colors.onPrimaryContainer), - ) - : null, + child: avatarUrl == null + ? (isSavedMessages || isGroupChat || isChannel) + ? Icon(leadingIcon, color: colors.onPrimaryContainer) + : Text( + title.isNotEmpty ? title[0].toUpperCase() : '?', + style: TextStyle(color: colors.onPrimaryContainer), + ) + : null, + ), ), Positioned( right: -4, @@ -3241,7 +3470,6 @@ class _SferumWebViewPanelState extends State { ), child: Column( children: [ - Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -3255,7 +3483,7 @@ class _SferumWebViewPanelState extends State { child: Row( children: [ Image.asset( - 'assets/images/spermum.webp', + 'assets/images/spermum.png', width: 28, height: 28, ), diff --git a/lib/widgets/chat_message_bubble.dart b/lib/widgets/chat_message_bubble.dart index 46e19e2..87e04e8 100644 --- a/lib/widgets/chat_message_bubble.dart +++ b/lib/widgets/chat_message_bubble.dart @@ -1,4 +1,3 @@ -import 'dart:core'; import 'package:flutter/material.dart'; import 'dart:io' show File; import 'dart:convert' show base64Decode; @@ -24,7 +23,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:open_file/open_file.dart'; import 'package:gwid/full_screen_video_player.dart'; - +// Кэш для уже вычисленных цветов final _userColorCache = {}; bool _currentIsDark = false; @@ -34,7 +33,7 @@ enum MessageReadStatus { read, // Прочитано (2 галочки) } - +// Service для отслеживания прогресса загрузки файлов class FileDownloadProgressService { static final FileDownloadProgressService _instance = FileDownloadProgressService._internal(); @@ -44,17 +43,17 @@ class FileDownloadProgressService { final Map> _progressNotifiers = {}; bool _initialized = false; - + // Initialize on first access to load saved download status Future _ensureInitialized() async { if (_initialized) return; try { final prefs = await SharedPreferences.getInstance(); - + // Load fileId -> filePath mappings final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; - + // Mark all downloaded files as completed (progress = 1.0) for (final mapping in fileIdMap) { final parts = mapping.split(':'); if (parts.length >= 2) { @@ -103,20 +102,20 @@ class FileDownloadProgressService { Color _getUserColor(int userId, BuildContext context) { final bool isDark = Theme.of(context).brightness == Brightness.dark; - + // Очищаем кэш при смене темы if (isDark != _currentIsDark) { _userColorCache.clear(); _currentIsDark = isDark; } - + // Возвращаем из кэша, если уже вычисляли if (_userColorCache.containsKey(userId)) { return _userColorCache[userId]!; } final List materialYouColors = isDark ? [ - + // Темная тема const Color(0xFFEF5350), // Красный const Color(0xFFEC407A), // Розовый const Color(0xFFAB47BC), // Фиолетовый @@ -140,7 +139,7 @@ Color _getUserColor(int userId, BuildContext context) { const Color(0xFFC5E1A5), // Светло-зеленый пастельный ] : [ - + // Светлая тема const Color(0xFFF44336), // Красный const Color(0xFFE91E63), // Розовый const Color(0xFF9C27B0), // Фиолетовый @@ -167,7 +166,7 @@ Color _getUserColor(int userId, BuildContext context) { final colorIndex = userId % materialYouColors.length; final color = materialYouColors[colorIndex]; - + // Сохраняем в кэш _userColorCache[userId] = color; return color; @@ -191,6 +190,7 @@ class ChatMessageBubble extends StatelessWidget { final bool isChannel; final String? senderName; final String? forwardedFrom; + final String? forwardedFromAvatarUrl; final Map? contactDetailsCache; final Function(String)? onReplyTap; final bool useAutoReplyColor; @@ -220,6 +220,7 @@ class ChatMessageBubble extends StatelessWidget { this.isChannel = false, this.senderName, this.forwardedFrom, + this.forwardedFromAvatarUrl, this.contactDetailsCache, this.onReplyTap, this.useAutoReplyColor = true, @@ -268,6 +269,32 @@ class ChatMessageBubble extends StatelessWidget { .toList() ?? []; + String forwardedSenderName; + String? forwardedSenderAvatarUrl = forwardedFromAvatarUrl; + + if (forwardedFrom != null) { + forwardedSenderName = forwardedFrom!; + } else { + final chatName = link['chatName'] as String?; + final chatIconUrl = link['chatIconUrl'] as String?; + + if (chatName != null) { + forwardedSenderName = chatName; + forwardedSenderAvatarUrl ??= chatIconUrl; + } else { + final originalSenderId = forwardedMessage['sender'] as int?; + final cache = contactDetailsCache; + if (originalSenderId != null && cache != null) { + final originalSenderContact = cache[originalSenderId]; + forwardedSenderName = + originalSenderContact?.name ?? 'Участник $originalSenderId'; + forwardedSenderAvatarUrl ??= originalSenderContact?.photoBaseUrl; + } else { + forwardedSenderName = 'Неизвестный'; + } + } + } + return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), decoration: BoxDecoration( @@ -282,7 +309,7 @@ class ChatMessageBubble extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - + // "Заголовок" с именем автора и аватаркой Row( mainAxisSize: MainAxisSize.min, children: [ @@ -292,10 +319,61 @@ class ChatMessageBubble extends StatelessWidget { color: textColor.withOpacity(0.6 * messageTextOpacity), ), const SizedBox(width: 6), + if (forwardedSenderAvatarUrl != null) + Container( + width: 20, + height: 20, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: textColor.withOpacity(0.2 * messageTextOpacity), + width: 1, + ), + ), + child: ClipOval( + child: Image.network( + forwardedSenderAvatarUrl, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) { + return Container( + color: textColor.withOpacity( + 0.1 * messageTextOpacity, + ), + child: Icon( + Icons.person, + size: 12, + color: textColor.withOpacity( + 0.5 * messageTextOpacity, + ), + ), + ); + }, + ), + ), + ) + else + Container( + width: 20, + height: 20, + margin: const EdgeInsets.only(right: 6), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: textColor.withOpacity(0.1 * messageTextOpacity), + border: Border.all( + color: textColor.withOpacity(0.2 * messageTextOpacity), + width: 1, + ), + ), + child: Icon( + Icons.person, + size: 12, + color: textColor.withOpacity(0.5 * messageTextOpacity), + ), + ), Flexible( child: Text( - - forwardedFrom ?? 'Неизвестный', + forwardedSenderName, style: TextStyle( fontSize: 13, fontWeight: FontWeight.bold, @@ -308,7 +386,7 @@ class ChatMessageBubble extends StatelessWidget { ), const SizedBox(height: 6), - + // Содержимое пересланного сообщения (фото и/или текст) if (attaches.isNotEmpty) ...[ ..._buildPhotosWithCaption( context, @@ -341,9 +419,9 @@ class ChatMessageBubble extends StatelessWidget { }) { final borderRadius = BorderRadius.circular(12); - + // Логика открытия плеера void openFullScreenVideo() async { - + // Показываем индикатор загрузки, пока получаем URL showDialog( context: context, barrierDismissible: false, @@ -377,7 +455,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // Виджет-контейнер (GestureDetector + Stack) return GestureDetector( onTap: openFullScreenVideo, child: AspectRatio( @@ -388,8 +466,8 @@ class ChatMessageBubble extends StatelessWidget { alignment: Alignment.center, fit: StackFit.expand, children: [ - - + // [!code ++] (НОВЫЙ БЛОК) + // Если у нас есть ХОТЬ ЧТО-ТО (блюр или URL), показываем ProgressiveImage (highQualityUrl != null && highQualityUrl.isNotEmpty) || (lowQualityBytes != null) ? _ProgressiveNetworkImage( @@ -402,7 +480,7 @@ class ChatMessageBubble extends StatelessWidget { fit: BoxFit.cover, keepAlive: false, ) - + // ИНАЧЕ показываем нашу стандартную заглушку (а не пустоту) : Container( color: Colors.black26, child: const Center( @@ -413,12 +491,12 @@ class ChatMessageBubble extends StatelessWidget { ), ), ), + // [!code ++] (КОНЕЦ НОВОГО БЛОКА) - - + // Иконка Play поверх (она будет поверх заглушки или картинки) Container( decoration: BoxDecoration( - + // Небольшое затемнение, чтобы иконка была виднее color: Colors.black.withOpacity(0.15), ), child: Icon( @@ -467,20 +545,20 @@ class ChatMessageBubble extends StatelessWidget { (isDarkMode ? const Color(0xFF90CAF9) : const Color(0xFF1976D2)); } - + // Вычисляем оптимальную ширину на основе длины текста final textLength = replyText.length; final minWidth = 120.0; // Минимальная ширина для коротких сообщений - + // Адаптивная ширина: минимум 120px, растет в зависимости от длины текста double adaptiveWidth = minWidth; if (textLength > 0) { - + // Базовый расчет: примерно 8px на символ + отступы adaptiveWidth = (textLength * 8.0 + 32).clamp(minWidth, double.infinity); } return GestureDetector( onTap: () { - + // Вызываем callback для прокрутки к оригинальному сообщению if (replyMessageId != null && onReplyTap != null) { onReplyTap!(replyMessageId); } @@ -511,7 +589,7 @@ class ChatMessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, mainAxisSize: MainAxisSize.min, children: [ - + // Ник автора сообщения Row( mainAxisSize: MainAxisSize.min, children: [ @@ -535,7 +613,7 @@ class ChatMessageBubble extends StatelessWidget { ], ), const SizedBox(height: 2), - + // Текст сообщения Align( alignment: Alignment.centerLeft, child: Text( @@ -554,7 +632,7 @@ class ChatMessageBubble extends StatelessWidget { } /* void _showMessageContextMenu(BuildContext context) { - + // Список реакций, отсортированный по популярности const reactions = [ '👍', '❤️', @@ -617,7 +695,7 @@ class ChatMessageBubble extends StatelessWidget { '👁️', ]; - + // Проверяем, есть ли уже реакция от пользователя final hasUserReaction = message.reactionInfo != null && message.reactionInfo!['yourReaction'] != null; @@ -635,9 +713,9 @@ class ChatMessageBubble extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - + // Реакции if (onReaction != null) ...[ - + // Контейнер для прокручиваемого списка эмодзи SizedBox( height: 80, // Задаем высоту для ряда с реакциями child: SingleChildScrollView( @@ -669,7 +747,7 @@ class ChatMessageBubble extends StatelessWidget { ), ), ), - + // Кнопка удаления реакции, если есть реакция от пользователя if (hasUserReaction && onRemoveReaction != null) ...[ Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), @@ -696,7 +774,7 @@ class ChatMessageBubble extends StatelessWidget { ], const Divider(height: 1), ], - + // Действия с сообщением (остаются без изменений) if (onReply != null) ListTile( leading: const Icon(Icons.reply), @@ -832,10 +910,10 @@ class ChatMessageBubble extends StatelessWidget { return GestureDetector( onTap: () { if (isUserReaction) { - + // Если это наша реакция - удаляем onRemoveReaction?.call(); } else { - + // Если это чужая реакция - добавляем такую же onReaction?.call(emoji); } }, @@ -995,7 +1073,7 @@ class ChatMessageBubble extends StatelessWidget { List> attaches, Color textColor, ) { - + // 1. Ищем вложение с клавиатурой final keyboardAttach = attaches.firstWhere( (a) => a['_type'] == 'INLINE_KEYBOARD', orElse: () => @@ -1006,7 +1084,7 @@ class ChatMessageBubble extends StatelessWidget { return []; // Нет клавиатуры } - + // 2. Парсим структуру кнопок final keyboardData = keyboardAttach['keyboard'] as Map?; final buttonRows = keyboardData?['buttons'] as List?; @@ -1016,19 +1094,19 @@ class ChatMessageBubble extends StatelessWidget { final List rows = []; - + // 3. Создаем виджеты для каждого ряда кнопок for (final row in buttonRows) { if (row is List && row.isNotEmpty) { final List buttonsInRow = []; - + // 4. Создаем виджеты для каждой кнопки в ряду for (final buttonData in row) { if (buttonData is Map) { final String? text = buttonData['text'] as String?; final String? type = buttonData['type'] as String?; final String? url = buttonData['url'] as String?; - + // Нас интересуют только кнопки-ссылки (как в вашем JSON) if (text != null && type == 'LINK' && url != null) { buttonsInRow.add( Expanded( @@ -1042,7 +1120,7 @@ class ChatMessageBubble extends StatelessWidget { horizontal: 8, vertical: 12, ), - + // Стилизуем под цвет сообщения backgroundColor: textColor.withOpacity(0.1), foregroundColor: textColor.withOpacity(0.9), ), @@ -1059,7 +1137,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // Добавляем готовый ряд кнопок if (buttonsInRow.isNotEmpty) { rows.add( Padding( @@ -1074,7 +1152,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // Возвращаем Column с рядами кнопок if (rows.isNotEmpty) { return [ Padding( @@ -1087,7 +1165,7 @@ class ChatMessageBubble extends StatelessWidget { return []; } - + // Helper-метод для открытия ссылок Future _launchURL(BuildContext context, String url) async { final uri = Uri.parse(url); if (await canLaunchUrl(uri)) { @@ -1135,7 +1213,7 @@ class ChatMessageBubble extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.end, children: [ if (!isMe && isGroupChat && !isChannel) ...[ - + //шлем в пезду аватарку если это я, анал. SizedBox( width: 40, child: @@ -1164,7 +1242,7 @@ class ChatMessageBubble extends StatelessWidget { : CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - + // Имя отправителя if (isGroupChat && !isMe && senderName != null) Padding( padding: const EdgeInsets.only(left: 2.0, bottom: 2.0), @@ -1385,7 +1463,7 @@ class ChatMessageBubble extends StatelessWidget { if (photos.isEmpty) return widgets; - + // Умная группировка фотографий widgets.add( _buildSmartPhotoGroup(context, photos, textColor, isUltraOptimized), ); @@ -1408,13 +1486,13 @@ class ChatMessageBubble extends StatelessWidget { if (videos.isEmpty) return widgets; for (final video in videos) { - + // 1. Извлекаем все, что нам нужно final videoId = video['videoId'] as int?; final previewData = video['previewData'] as String?; // Блюр-превью final thumbnailUrl = video['url'] ?? video['baseUrl'] as String?; // HQ-превью URL - + // 2. Декодируем блюр-превью Uint8List? previewBytes; if (previewData != null && previewData.startsWith('data:')) { final idx = previewData.indexOf('base64,'); @@ -1426,7 +1504,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // 3. Формируем URL для HQ-превью (как для фото) String? highQualityThumbnailUrl; if (thumbnailUrl != null && thumbnailUrl.isNotEmpty) { highQualityThumbnailUrl = thumbnailUrl; @@ -1439,7 +1517,7 @@ class ChatMessageBubble extends StatelessWidget { } } - + // 4. Создаем виджет if (videoId != null && chatId != null) { widgets.add( Padding( @@ -1454,7 +1532,7 @@ class ChatMessageBubble extends StatelessWidget { ), ); } else { - + // Заглушка, если вложение есть, а ID не найдены widgets.add( Container( padding: const EdgeInsets.all(16), @@ -1506,7 +1584,7 @@ class ChatMessageBubble extends StatelessWidget { Color textColor, bool isUltraOptimized, ) { - + // Стикеры обычно квадратные, около 200-250px final stickerSize = 250.0; return ConstrainedBox( @@ -1571,10 +1649,10 @@ class ChatMessageBubble extends StatelessWidget { IconData callIcon; Color callColor; - + // Определяем текст, иконку и цвет в зависимости от типа завершения звонка switch (hangupType) { case 'HUNGUP': - + // Звонок был завершен успешно final minutes = duration ~/ 60000; final seconds = (duration % 60000) ~/ 1000; final durationText = minutes > 0 @@ -1588,7 +1666,7 @@ class ChatMessageBubble extends StatelessWidget { break; case 'MISSED': - + // Пропущенный звонок final callTypeText = callType == 'VIDEO' ? 'Пропущенный видеозвонок' : 'Пропущенный звонок'; @@ -1598,7 +1676,7 @@ class ChatMessageBubble extends StatelessWidget { break; case 'CANCELED': - + // Звонок отменен final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отменен' : 'Звонок отменен'; @@ -1608,7 +1686,7 @@ class ChatMessageBubble extends StatelessWidget { break; case 'REJECTED': - + // Звонок отклонен final callTypeText = callType == 'VIDEO' ? 'Видеозвонок отклонен' : 'Звонок отклонен'; @@ -1618,7 +1696,7 @@ class ChatMessageBubble extends StatelessWidget { break; default: - + // Неизвестный тип завершения callText = callType == 'VIDEO' ? 'Видеозвонок' : 'Звонок'; callIcon = callType == 'VIDEO' ? Icons.videocam : Icons.call; callColor = textColor.withOpacity(0.6); @@ -1635,7 +1713,7 @@ class ChatMessageBubble extends StatelessWidget { padding: const EdgeInsets.all(12), child: Row( children: [ - + // Call icon Container( width: 48, height: 48, @@ -1646,7 +1724,7 @@ class ChatMessageBubble extends StatelessWidget { child: Icon(callIcon, color: callColor, size: 24), ), const SizedBox(width: 12), - + // Call info Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1714,14 +1792,14 @@ class ChatMessageBubble extends StatelessWidget { ) { final borderRadius = BorderRadius.circular(isUltraOptimized ? 8 : 12); - + // Get file extension final extension = _getFileExtension(fileName); final iconData = _getFileIcon(extension); - + // Format file size final sizeStr = _formatFileSize(fileSize); - + // Extract file data final fileId = fileData['fileId'] as int?; final token = fileData['token'] as String?; @@ -1738,7 +1816,7 @@ class ChatMessageBubble extends StatelessWidget { padding: const EdgeInsets.all(12), child: Row( children: [ - + // File icon Container( width: 48, height: 48, @@ -1753,7 +1831,7 @@ class ChatMessageBubble extends StatelessWidget { ), ), const SizedBox(width: 12), - + // File info with progress Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -1776,7 +1854,7 @@ class ChatMessageBubble extends StatelessWidget { .getProgress(fileId.toString()), builder: (context, progress, child) { if (progress < 0) { - + // Not downloading return Text( sizeStr, style: TextStyle( @@ -1785,7 +1863,7 @@ class ChatMessageBubble extends StatelessWidget { ), ); } else if (progress < 1.0) { - + // Downloading return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -1805,7 +1883,7 @@ class ChatMessageBubble extends StatelessWidget { ], ); } else { - + // Completed return Row( children: [ Icon( @@ -1837,7 +1915,7 @@ class ChatMessageBubble extends StatelessWidget { ], ), ), - + // Download icon if (fileId != null) ValueListenableBuilder( valueListenable: FileDownloadProgressService().getProgress( @@ -1932,7 +2010,7 @@ class ChatMessageBubble extends StatelessWidget { String fileName, int? chatId, ) async { - + // 1. Проверяем fileId, он нужен в любом случае if (fileId == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -1952,27 +2030,27 @@ class ChatMessageBubble extends StatelessWidget { final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; final fileIdString = fileId.toString(); - + // Ищем запись для нашего fileId final mapping = fileIdMap.firstWhere( (m) => m.startsWith('$fileIdString:'), orElse: () => '', // Возвращаем пустую строку, если не найдено ); if (mapping.isNotEmpty) { - + // Извлекаем путь из 'fileId:path/to/file' final filePath = mapping.substring(fileIdString.length + 1); final file = io.File(filePath); - + // Проверяем, существует ли файл физически if (await file.exists()) { print( 'Файл $fileName (ID: $fileId) найден локально: $filePath. Открываем...', ); - + // Файл существует, открываем его final result = await OpenFile.open(filePath); if (result.type != ResultType.done && context.mounted) { - + // Показываем ошибку, если не удалось открыть (например, нет приложения) ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text('Не удалось открыть файл: ${result.message}'), @@ -1982,7 +2060,7 @@ class ChatMessageBubble extends StatelessWidget { } return; // Важно: выходим из функции, чтобы не скачивать заново } else { - + // Файл был в списке, но удален. Очистим некорректную запись. print( 'Файл $fileName (ID: $fileId) был в SharedPreferences, но удален. Начинаем загрузку.', ); @@ -1992,10 +2070,10 @@ class ChatMessageBubble extends StatelessWidget { } } catch (e) { print('Ошибка при проверке локального файла: $e. Продолжаем загрузку...'); - + // Если при проверке что-то пошло не так, просто продолжаем и скачиваем файл. } - + // Если файл не найден локально, продолжаем стандартную процедуру скачивания print( 'Файл $fileName (ID: $fileId) не найден. Запрашиваем URL у сервера...', ); @@ -2027,10 +2105,10 @@ class ChatMessageBubble extends StatelessWidget { } try { - + // Request file URL from server using opcode 88 final messageId = message.id; - + // Send request for file URL via WebSocket final seq = ApiService.instance.sendRawRequest(88, { "fileId": fileId, "chatId": chatId, @@ -2049,7 +2127,7 @@ class ChatMessageBubble extends StatelessWidget { return; } - + // Wait for response with opcode 88 final response = await ApiService.instance.messages .firstWhere( (msg) => msg['seq'] == seq && msg['opcode'] == 88, @@ -2071,7 +2149,7 @@ class ChatMessageBubble extends StatelessWidget { throw Exception('Не получена ссылка на файл'); } - + // Download file to Downloads folder with progress await _downloadFile(downloadUrl, fileName, fileId.toString(), context); } catch (e) { if (context.mounted) { @@ -2091,10 +2169,10 @@ class ChatMessageBubble extends StatelessWidget { String fileId, BuildContext context, ) async { - + // Download in background without blocking dialog _startBackgroundDownload(url, fileName, fileId, context); - + // Show immediate success snackbar if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -2111,11 +2189,11 @@ class ChatMessageBubble extends StatelessWidget { String fileId, BuildContext context, ) async { - + // Initialize progress FileDownloadProgressService().updateProgress(fileId, 0.0); try { - + // Get Downloads directory io.Directory? downloadDir; if (io.Platform.isAndroid) { @@ -2124,7 +2202,7 @@ class ChatMessageBubble extends StatelessWidget { final directory = await getApplicationDocumentsDirectory(); downloadDir = directory; } else if (io.Platform.isWindows || io.Platform.isLinux) { - + // For desktop platforms, use Downloads directory final homeDir = io.Platform.environment['HOME'] ?? io.Platform.environment['USERPROFILE'] ?? @@ -2138,11 +2216,11 @@ class ChatMessageBubble extends StatelessWidget { throw Exception('Downloads directory not found'); } - + // Create the file path final filePath = '${downloadDir.path}/$fileName'; final file = io.File(filePath); - + // Download the file with progress tracking final request = http.Request('GET', Uri.parse(url)); final streamedResponse = await request.send(); @@ -2160,21 +2238,21 @@ class ChatMessageBubble extends StatelessWidget { bytes.addAll(chunk); received += chunk.length; - + // Update progress if content length is known if (contentLength > 0) { final progress = received / contentLength; FileDownloadProgressService().updateProgress(fileId, progress); } } - + // Write file to disk final data = Uint8List.fromList(bytes); await file.writeAsBytes(data); - + // Mark as completed FileDownloadProgressService().updateProgress(fileId, 1.0); - + // Save file path and fileId mapping to SharedPreferences for tracking final prefs = await SharedPreferences.getInstance(); final List downloadedFiles = prefs.getStringList('downloaded_files') ?? []; @@ -2183,7 +2261,7 @@ class ChatMessageBubble extends StatelessWidget { await prefs.setStringList('downloaded_files', downloadedFiles); } - + // Also save fileId -> filePath mapping to track downloaded files by fileId final fileIdMap = prefs.getStringList('file_id_to_path_map') ?? []; final mappingKey = '$fileId:${file.path}'; if (!fileIdMap.contains(mappingKey)) { @@ -2191,7 +2269,7 @@ class ChatMessageBubble extends StatelessWidget { await prefs.setStringList('file_id_to_path_map', fileIdMap); } - + // Show success message if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -2202,7 +2280,7 @@ class ChatMessageBubble extends StatelessWidget { ); } } catch (e) { - + // Clear progress on error FileDownloadProgressService().clearProgress(fileId); if (context.mounted) { @@ -2304,7 +2382,7 @@ class ChatMessageBubble extends StatelessWidget { constraints: const BoxConstraints(maxHeight: 180), child: Row( children: [ - + // Левая большая фотка Expanded( flex: 2, child: RepaintBoundary( @@ -2318,7 +2396,7 @@ class ChatMessageBubble extends StatelessWidget { ), ), const SizedBox(width: 2), - + // Правая колонка с двумя маленькими Expanded( flex: 1, child: Column( @@ -2363,7 +2441,7 @@ class ChatMessageBubble extends StatelessWidget { constraints: const BoxConstraints(maxHeight: 180), child: Column( children: [ - + // Верхний ряд Expanded( child: Row( children: [ @@ -2394,7 +2472,7 @@ class ChatMessageBubble extends StatelessWidget { ), ), const SizedBox(height: 2), - + // Нижний ряд Expanded( child: Row( children: [ @@ -2434,7 +2512,7 @@ class ChatMessageBubble extends StatelessWidget { List> photos, BorderRadius borderRadius, ) { - + // Для 5+ фотографий показываем сетку 2x2 + счетчик return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 180), child: Column( @@ -2571,16 +2649,16 @@ class ChatMessageBubble extends StatelessWidget { child = _imagePlaceholder(); } - + // Используем навигатор для перехода на новый полноэкранный виджет Navigator.of(context).push( PageRouteBuilder( opaque: false, // Делаем страницу прозрачной для красивого перехода barrierColor: Colors.black, pageBuilder: (BuildContext context, _, __) { - + // Возвращаем наш новый экран просмотра return FullScreenPhotoViewer(imageChild: child, attach: attach); }, - + // Добавляем плавное появление transitionsBuilder: (_, animation, __, page) { return FadeTransition(opacity: animation, child: page); }, @@ -2589,8 +2667,8 @@ class ChatMessageBubble extends StatelessWidget { } Widget _buildPhotoWidget(BuildContext context, Map attach) { - - + // Сначала обрабатываем локальные данные (base64), если они есть. + // Это обеспечивает мгновенный показ размытого превью. Uint8List? previewBytes; final preview = attach['previewData']; if (preview is String && preview.startsWith('data:')) { @@ -2600,14 +2678,14 @@ class ChatMessageBubble extends StatelessWidget { try { previewBytes = base64Decode(b64); } catch (_) { - + // Ошибка декодирования, ничего страшного } } } final url = attach['url'] ?? attach['baseUrl']; if (url is String && url.isNotEmpty) { - + // Обработка локальных файлов (если фото отправляется с устройства) if (url.startsWith('file://')) { final path = url.replaceFirst('file://', ''); return Image.file( @@ -2621,8 +2699,8 @@ class ChatMessageBubble extends StatelessWidget { ); } - - + // Формируем специальный URL для предпросмотра в чате: + // средний размер, высокое качество, формат JPEG для эффективности. String previewQualityUrl = url; if (!url.contains('?')) { previewQualityUrl = '$url?size=medium&quality=high&format=jpeg'; @@ -2634,7 +2712,7 @@ class ChatMessageBubble extends StatelessWidget { final optimize = themeProvider.optimizeChats || themeProvider.ultraOptimizeChats; - + // Используем наш новый URL для загрузки качественного превью return _ProgressiveNetworkImage( key: ValueKey(previewQualityUrl), // Ключ по новому URL url: previewQualityUrl, // Передаем новый URL @@ -2648,12 +2726,12 @@ class ChatMessageBubble extends StatelessWidget { ); } - + // Если URL нет, но есть base64 данные, покажем их if (previewBytes != null) { return Image.memory(previewBytes, fit: BoxFit.cover, width: 180); } - + // В самом крайнем случае показываем стандартный плейсхолдер return _imagePlaceholder(); } @@ -2667,7 +2745,7 @@ class ChatMessageBubble extends StatelessWidget { ); } - + // Лёгкий прогрессивный загрузчик: показывает превью, тянет оригинал с прогрессом и кэширует в памяти процесса Color _getBubbleColor( bool isMe, @@ -3057,26 +3135,26 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> void initState() { super.initState(); - - - + // [!code ++] (НОВЫЙ БЛОК) + // Если URL пустой, нечего загружать. + // Полагаемся только на previewBytes. if (widget.url.isEmpty) { return; } + // [!code ++] (КОНЕЦ НОВОГО БЛОКА) - - + // Если есть в глобальном кэше — используем сразу final cached = GlobalImageStore.getData(widget.url); if (cached != null) { _fullBytes = cached; - + // no return, продолжаем проверить диск на всякий } - + // Если есть в кэше — используем if (_memoryCache.containsKey(widget.url)) { _fullBytes = _memoryCache[widget.url]; } if (widget.startDownloadNextFrame) { - + // Загружаем в следующем кадре WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) _tryLoadFromDiskThenDownload(); }); @@ -3086,14 +3164,14 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> } Future _tryLoadFromDiskThenDownload() async { - - + // [!code ++] (НОВЫЙ БЛОК) + // Не пытаемся грузить, если URL пустой if (widget.url.isEmpty) { return; } + // [!code ++] (КОНЕЦ НОВОГО БЛОКА) - - + // Попытка прочитать из дискового кэша try { final dir = await getTemporaryDirectory(); final name = crypto.md5.convert(widget.url.codeUnits).toString(); @@ -3138,7 +3216,7 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> final data = Uint8List.fromList(bytes); _memoryCache[widget.url] = data; GlobalImageStore.setData(widget.url, data); - + // Пишем на диск try { final path = _diskPath; if (path != null) { @@ -3171,7 +3249,7 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> child: const Icon(Icons.broken_image_outlined, color: Colors.black38), ); } - + // Полное качество есть — показываем return RepaintBoundary( child: SizedBox( width: width, @@ -3183,7 +3261,7 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> child: Stack( fit: StackFit.expand, children: [ - + // 1) Стабильный нижний слой — превью или нейтральный фон if (widget.previewBytes != null) Image.memory( widget.previewBytes!, @@ -3192,15 +3270,15 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> ) else Container(color: Colors.black12), - + // 2) Верхний слой — оригинал. Он появляется, но не убирает превью, чтобы не мигать if (_fullBytes != null) Image.memory( _fullBytes!, fit: widget.fit, filterQuality: FilterQuality.high, ), - - + // нижний прогресс убран, чтобы не перерисовывать слой картинки во время slide; + // прогресс выводится рядом со временем сообщения ], ), ), @@ -3214,7 +3292,7 @@ class _ProgressiveNetworkImageState extends State<_ProgressiveNetworkImage> void didUpdateWidget(covariant _ProgressiveNetworkImage oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.keepAlive != widget.keepAlive) { - + // Пересоберём keepAlive флаг updateKeepAlive(); } } @@ -3257,13 +3335,13 @@ class _CustomEmojiButtonState extends State<_CustomEmojiButton> super.dispose(); } - + // Логика нажатия упрощена void _handleTap() { - + // Анимация масштабирования для обратной связи _scaleController.forward().then((_) { _scaleController.reverse(); }); - + // Сразу открываем диалог _showCustomEmojiDialog(); } @@ -3293,7 +3371,7 @@ class _CustomEmojiButtonState extends State<_CustomEmojiButton> color: Theme.of(context).colorScheme.surfaceContainerHighest, borderRadius: BorderRadius.circular(20), ), - + // Стрелка заменена на иконку "добавить" child: Icon( Icons.add_reaction_outlined, size: 24, @@ -3469,7 +3547,7 @@ class _MessageContextMenuState extends State<_MessageContextMenu> late AnimationController _animationController; late Animation _scaleAnimation; - + // Короткий список для быстрого доступа static const List _quickReactions = [ '👍', '❤️', @@ -3479,7 +3557,7 @@ class _MessageContextMenuState extends State<_MessageContextMenu> '🤔', ]; - + // Полный список всех реакций static const List _allReactions = [ '👍', '❤️', @@ -3826,14 +3904,14 @@ class FullScreenPhotoViewer extends StatefulWidget { class _FullScreenPhotoViewerState extends State { late final TransformationController _transformationController; - + // Переменная для контроля, можно ли двигать изображение bool _isPanEnabled = false; @override void initState() { super.initState(); _transformationController = TransformationController(); - + // "Слушаем" изменения зума _transformationController.addListener(_onTransformChanged); } @@ -3845,12 +3923,12 @@ class _FullScreenPhotoViewerState extends State { } void _onTransformChanged() { - + // Получаем текущий масштаб final currentScale = _transformationController.value.getMaxScaleOnAxis(); - + // Разрешаем двигать, только если масштаб больше 1 final shouldPan = currentScale > 1.0; - + // Обновляем состояние, только если оно изменилось if (shouldPan != _isPanEnabled) { setState(() { _isPanEnabled = shouldPan; @@ -3862,7 +3940,7 @@ class _FullScreenPhotoViewerState extends State { if (widget.attach == null) return; try { - + // Get Downloads directory io.Directory? downloadDir; if (io.Platform.isAndroid) { @@ -3894,13 +3972,13 @@ class _FullScreenPhotoViewerState extends State { throw Exception('Downloads directory not found'); } - + // Get photo URL final url = widget.attach!['url'] ?? widget.attach!['baseUrl']; if (url == null || url.isEmpty) { throw Exception('Photo URL not found'); } - + // Extract file extension from URL or use .jpg as default String extension = 'jpg'; final uri = Uri.tryParse(url); if (uri != null && uri.pathSegments.isNotEmpty) { @@ -3911,18 +3989,18 @@ class _FullScreenPhotoViewerState extends State { } } - + // Generate filename with timestamp final timestamp = DateTime.now().millisecondsSinceEpoch; final fileName = 'photo_$timestamp.$extension'; final filePath = '${downloadDir.path}/$fileName'; final file = io.File(filePath); - + // Download the image final response = await http.get(Uri.parse(url)); if (response.statusCode == 200) { await file.writeAsBytes(response.bodyBytes); - + // Save to SharedPreferences final prefs = await SharedPreferences.getInstance(); final List downloadedFiles = prefs.getStringList('downloaded_files') ?? []; @@ -3974,7 +4052,7 @@ class _FullScreenPhotoViewerState extends State { child: Center(child: widget.imageChild), ), ), - + // Top bar with close button and download button SafeArea( child: Padding( padding: const EdgeInsets.all(16.0), @@ -4018,14 +4096,14 @@ class _RotatingIcon extends StatefulWidget { class _RotatingIconState extends State<_RotatingIcon> with SingleTickerProviderStateMixin { - + // Важно добавить 'with SingleTickerProviderStateMixin' late final AnimationController _controller; @override void initState() { super.initState(); _controller = AnimationController( - + // Длительность одного оборота (2 секунды) duration: const Duration(seconds: 2), vsync: this, )..repeat(); // Запускаем анимацию на бесконечное повторение @@ -4039,7 +4117,7 @@ class _RotatingIconState extends State<_RotatingIcon> @override Widget build(BuildContext context) { - + // RotationTransition - это виджет, который вращает своего "ребенка" return RotationTransition( turns: _controller, // Анимация вращения child: Icon(widget.icon, size: widget.size, color: widget.color), diff --git a/lib/widgets/message_preview_dialog.dart b/lib/widgets/message_preview_dialog.dart new file mode 100644 index 0000000..aec191f --- /dev/null +++ b/lib/widgets/message_preview_dialog.dart @@ -0,0 +1,412 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:gwid/models/chat.dart'; +import 'package:gwid/models/message.dart'; +import 'package:gwid/models/contact.dart'; +import 'package:gwid/models/profile.dart'; +import 'package:gwid/api_service.dart'; +import 'package:gwid/widgets/chat_message_bubble.dart'; +import 'package:gwid/chat_screen.dart'; + +class MessagePreviewDialog { + static String _formatTimestamp(int timestamp) { + final dt = DateTime.fromMillisecondsSinceEpoch(timestamp); + final now = DateTime.now(); + if (now.day == dt.day && now.month == dt.month && now.year == dt.year) { + return DateFormat('HH:mm', 'ru').format(dt); + } else { + final yesterday = now.subtract(const Duration(days: 1)); + if (dt.day == yesterday.day && + dt.month == yesterday.month && + dt.year == yesterday.year) { + return 'Вчера'; + } else { + return DateFormat('d MMM', 'ru').format(dt); + } + } + } + + static bool _isSavedMessages(Chat chat) { + return chat.id == 0; + } + + static bool _isGroupChat(Chat chat) { + return chat.type == 'CHAT' || chat.participantIds.length > 2; + } + + static bool _isSameDay(DateTime date1, DateTime date2) { + return date1.year == date2.year && + date1.month == date2.month && + date1.day == date2.day; + } + + static bool _isMessageGrouped( + Message currentMessage, + Message? previousMessage, + ) { + if (previousMessage == null) return false; + + final currentTime = DateTime.fromMillisecondsSinceEpoch( + currentMessage.time, + ); + final previousTime = DateTime.fromMillisecondsSinceEpoch( + previousMessage.time, + ); + + final timeDifference = currentTime.difference(previousTime).inMinutes; + + return currentMessage.senderId == previousMessage.senderId && + timeDifference <= 5; + } + + static String _formatDateSeparator(DateTime date) { + final now = DateTime.now(); + if (_isSameDay(date, now)) { + return 'Сегодня'; + } else { + final yesterday = now.subtract(const Duration(days: 1)); + if (_isSameDay(date, yesterday)) { + return 'Вчера'; + } else { + return DateFormat('d MMM yyyy', 'ru').format(date); + } + } + } + + static String _getChatTitle(Chat chat, Map contacts) { + final bool isSavedMessages = _isSavedMessages(chat); + final bool isGroupChat = _isGroupChat(chat); + final bool isChannel = chat.type == 'CHANNEL'; + + if (isSavedMessages) { + return "Избранное"; + } else if (isChannel) { + return chat.title ?? "Канал"; + } else if (isGroupChat) { + return chat.title?.isNotEmpty == true ? chat.title! : "Группа"; + } else { + final myId = chat.ownerId; + final otherParticipantId = chat.participantIds.firstWhere( + (id) => id != myId, + orElse: () => myId, + ); + final contact = contacts[otherParticipantId]; + return contact?.name ?? "Неизвестный чат"; + } + } + + static Future show( + BuildContext context, + Chat chat, + Map contacts, + Profile? myProfile, + VoidCallback? onClose, + Widget Function(BuildContext)? menuBuilder, + ) async { + final colors = Theme.of(context).colorScheme; + + List messages = []; + bool isLoading = true; + + try { + messages = await ApiService.instance.getMessageHistory( + chat.id, + force: false, + ); + if (messages.length > 10) { + messages = messages.sublist(messages.length - 10); + } + } catch (e) { + print('Ошибка загрузки сообщений для предпросмотра: $e'); + } finally { + isLoading = false; + } + + final Set senderIds = messages.map((m) => m.senderId).toSet(); + senderIds.remove(0); + + final Set forwardedSenderIds = {}; + for (final message in messages) { + if (message.isForwarded && message.link != null) { + final link = message.link; + if (link is Map) { + final chatName = link['chatName'] as String?; + if (chatName == null) { + final forwardedMessage = link['message'] as Map?; + final originalSenderId = forwardedMessage?['sender'] as int?; + if (originalSenderId != null) { + forwardedSenderIds.add(originalSenderId); + } + } + } + } + } + + final allIdsToFetch = { + ...senderIds, + ...forwardedSenderIds, + }.where((id) => !contacts.containsKey(id)).toList(); + + if (allIdsToFetch.isNotEmpty) { + try { + final newContacts = await ApiService.instance.fetchContactsByIds( + allIdsToFetch, + ); + for (final contact in newContacts) { + contacts[contact.id] = contact; + } + } catch (e) { + print('Ошибка загрузки контактов для предпросмотра: $e'); + } + } + + final chatTitle = _getChatTitle(chat, contacts); + final bool isGroupChat = _isGroupChat(chat); + final bool isChannel = chat.type == 'CHANNEL'; + final myId = myProfile?.id ?? chat.ownerId; + + if (!context.mounted) return; + + List chatItems = []; + for (int i = 0; i < messages.length; i++) { + final currentMessage = messages[i]; + final previousMessage = (i > 0) ? messages[i - 1] : null; + + final currentDate = DateTime.fromMillisecondsSinceEpoch( + currentMessage.time, + ).toLocal(); + final previousDate = previousMessage != null + ? DateTime.fromMillisecondsSinceEpoch(previousMessage.time).toLocal() + : null; + + if (previousMessage == null || !_isSameDay(currentDate, previousDate!)) { + chatItems.add(DateSeparatorItem(currentDate)); + } + + final isGrouped = _isMessageGrouped(currentMessage, previousMessage); + final isFirstInGroup = previousMessage == null || !isGrouped; + final isLastInGroup = + i == messages.length - 1 || + !_isMessageGrouped(messages[i + 1], currentMessage); + + chatItems.add( + MessageItem( + currentMessage, + isFirstInGroup: isFirstInGroup, + isLastInGroup: isLastInGroup, + isGrouped: isGrouped, + ), + ); + } + + await showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (context) { + return DraggableScrollableSheet( + initialChildSize: 0.75, + minChildSize: 0.5, + maxChildSize: 0.9, + builder: (context, scrollController) { + return Container( + decoration: BoxDecoration( + color: colors.surface, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(20), + ), + ), + child: Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 8, bottom: 8), + width: 40, + height: 4, + decoration: BoxDecoration( + color: colors.onSurfaceVariant.withOpacity(0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: colors.outline.withOpacity(0.2), + width: 1, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + chatTitle, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: colors.onSurface, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + IconButton( + icon: const Icon(Icons.close), + color: colors.onSurfaceVariant, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () { + Navigator.of(context).pop(); + onClose?.call(); + }, + ), + ], + ), + ), + Expanded( + child: isLoading + ? const Center(child: CircularProgressIndicator()) + : messages.isEmpty + ? Center( + child: Text( + 'Нет сообщений', + style: TextStyle( + color: colors.onSurfaceVariant, + fontSize: 14, + ), + ), + ) + : ListView.builder( + controller: scrollController, + reverse: true, + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 8, + ), + itemCount: chatItems.length, + itemBuilder: (context, index) { + final mappedIndex = chatItems.length - 1 - index; + final item = chatItems[mappedIndex]; + + if (item is DateSeparatorItem) { + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: colors.surfaceVariant, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + _formatDateSeparator(item.date), + style: TextStyle( + fontSize: 12, + color: colors.onSurfaceVariant, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ); + } + + if (item is MessageItem) { + final message = item.message; + final isMe = message.senderId == myId; + final senderContact = + contacts[message.senderId]; + final senderName = isMe + ? 'Вы' + : (senderContact?.name ?? 'Неизвестный'); + + String? forwardedFrom; + String? forwardedFromAvatarUrl; + if (message.isForwarded) { + final link = message.link; + if (link is Map) { + final chatName = + link['chatName'] as String?; + final chatIconUrl = + link['chatIconUrl'] as String?; + + if (chatName != null) { + forwardedFrom = chatName; + forwardedFromAvatarUrl = chatIconUrl; + } else { + final forwardedMessage = + link['message'] + as Map?; + final originalSenderId = + forwardedMessage?['sender'] as int?; + if (originalSenderId != null) { + final originalSenderContact = + contacts[originalSenderId]; + forwardedFrom = + originalSenderContact?.name ?? + 'Участник $originalSenderId'; + forwardedFromAvatarUrl = + originalSenderContact?.photoBaseUrl; + } + } + } + } + + return ChatMessageBubble( + message: message, + isMe: isMe, + readStatus: null, + deferImageLoading: true, + myUserId: myId, + chatId: chat.id, + onReply: null, + onEdit: null, + canEditMessage: null, + onDeleteForMe: null, + onDeleteForAll: null, + onReaction: null, + onRemoveReaction: null, + isGroupChat: isGroupChat, + isChannel: isChannel, + senderName: senderName, + forwardedFrom: forwardedFrom, + forwardedFromAvatarUrl: + forwardedFromAvatarUrl, + contactDetailsCache: contacts, + onReplyTap: null, + useAutoReplyColor: false, + customReplyColor: null, + isFirstInGroup: item.isFirstInGroup, + isLastInGroup: item.isLastInGroup, + isGrouped: item.isGrouped, + avatarVerticalOffset: -8.0, + ); + } + + return const SizedBox.shrink(); + }, + ), + ), + if (menuBuilder != null) ...[ + Divider(height: 1, color: colors.outline.withOpacity(0.2)), + menuBuilder(context), + ], + ], + ), + ); + }, + ); + }, + ); + } +} diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index adca3f8..85e283b 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -7,7 +7,7 @@ project(runner LANGUAGES CXX) set(BINARY_NAME "Komet") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID -set(APPLICATION_ID "com.gwid.com.gwid") +set(APPLICATION_ID "com.gwid.app.gwid") diff --git a/pubspec.lock b/pubspec.lock index e573b40..687c3a2 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -801,10 +801,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: @@ -1310,10 +1310,10 @@ packages: dependency: transitive description: name: test_api - sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" url: "https://pub.dev" source: hosted - version: "0.7.7" + version: "0.7.6" timezone: dependency: "direct main" description: