From 91ad51d3402ae0af45084f8d65f6ff325d7afcb5 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 5 May 2026 08:09:24 +0300 Subject: [PATCH 1/4] feat(video_calls): first impl for main video calls screen --- android/app/src/main/AndroidManifest.xml | 2 + assets/icons/video_call/eatvio_c.png | Bin 0 -> 1816 bytes assets/icons/video_call/poor_connection.svg | 3 + assets/icons/video_call/vc_cam_off.svg | 3 + assets/icons/video_call/vc_camera_switch.png | Bin 0 -> 2237 bytes assets/icons/video_call/vc_control_chat.png | Bin 0 -> 3085 bytes assets/icons/video_call/vc_control_leave.png | Bin 0 -> 3418 bytes assets/icons/video_call/vc_control_mic.png | Bin 0 -> 694 bytes .../icons/video_call/vc_control_mic_off.png | Bin 0 -> 855 bytes .../video_call/vc_control_sound_active.png | Bin 0 -> 1068 bytes assets/icons/video_call/vc_control_video.png | Bin 0 -> 2624 bytes assets/icons/video_call/vc_expand.png | Bin 0 -> 1597 bytes assets/icons/video_call/vc_expand.svg | 3 + assets/icons/video_call/vc_mic_off.svg | 9 + assets/icons/video_call/vc_mic_on.svg | 5 + assets/icons/video_call/vc_shrink.svg | 3 + assets/icons/video_call/warning.svg | 3 + ios/Runner/Info.plist | 2 + lib/core/di/repository_registrations.dart | 2 + lib/core/di/service_registrations.dart | 2 + lib/core/routing/other_routes.dart | 22 + lib/core/routing/route_constants.dart | 1 + lib/core/theme/theme_helper.dart | 7 +- .../chat/presentation/pages/chat_screen.dart | 48 ++ .../presentation/pages/about_diet_screen.dart | 4 +- .../video_calls/models/video_call_models.dart | 37 ++ .../presentation/pages/video_call_screen.dart | 531 ++++++++++++++++++ .../widgets/video_call_controls.dart | 147 +++++ .../video_call_livekit_track_utils.dart | 40 ++ .../widgets/video_call_name_chips.dart | 43 ++ .../video_call_no_video_background.dart | 23 + .../video_call_remote_video_stage.dart | 58 ++ .../widgets/video_call_stage_layout.dart | 489 ++++++++++++++++ .../widgets/video_call_top_overlay.dart | 63 +++ .../repositories/video_call_repository.dart | 27 + .../services/video_call_service.dart | 26 + lib/generated/intl/messages_en.dart | 35 ++ lib/generated/intl/messages_ru.dart | 35 ++ lib/generated/l10n.dart | 130 +++++ lib/l10n/intl_en.arb | 18 +- lib/l10n/intl_ru.arb | 18 +- pubspec.yaml | 2 + .../models/video_call_models_test.dart | 115 ++++ .../video_call_repository_test.dart | 108 ++++ .../services/video_call_service_test.dart | 94 ++++ .../video_call_livekit_track_utils_test.dart | 14 + .../video_call_admin_token_response.json | 5 + .../video_call_ended_response.json | 3 + .../video_call_token_response.json | 5 + 49 files changed, 2179 insertions(+), 6 deletions(-) create mode 100644 assets/icons/video_call/eatvio_c.png create mode 100644 assets/icons/video_call/poor_connection.svg create mode 100644 assets/icons/video_call/vc_cam_off.svg create mode 100644 assets/icons/video_call/vc_camera_switch.png create mode 100644 assets/icons/video_call/vc_control_chat.png create mode 100644 assets/icons/video_call/vc_control_leave.png create mode 100644 assets/icons/video_call/vc_control_mic.png create mode 100644 assets/icons/video_call/vc_control_mic_off.png create mode 100644 assets/icons/video_call/vc_control_sound_active.png create mode 100644 assets/icons/video_call/vc_control_video.png create mode 100644 assets/icons/video_call/vc_expand.png create mode 100644 assets/icons/video_call/vc_expand.svg create mode 100644 assets/icons/video_call/vc_mic_off.svg create mode 100644 assets/icons/video_call/vc_mic_on.svg create mode 100644 assets/icons/video_call/vc_shrink.svg create mode 100644 assets/icons/video_call/warning.svg create mode 100644 lib/features/video_calls/models/video_call_models.dart create mode 100644 lib/features/video_calls/presentation/pages/video_call_screen.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_controls.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_name_chips.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_no_video_background.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart create mode 100644 lib/features/video_calls/repositories/video_call_repository.dart create mode 100644 lib/features/video_calls/services/video_call_service.dart create mode 100644 test/features/video_calls/models/video_call_models_test.dart create mode 100644 test/features/video_calls/repositories/video_call_repository_test.dart create mode 100644 test/features/video_calls/services/video_call_service_test.dart create mode 100644 test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart create mode 100644 test/fixtures/video_calls/video_call_admin_token_response.json create mode 100644 test/fixtures/video_calls/video_call_ended_response.json create mode 100644 test/fixtures/video_calls/video_call_token_response.json diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3a6b34c3..116b494b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + diff --git a/assets/icons/video_call/eatvio_c.png b/assets/icons/video_call/eatvio_c.png new file mode 100644 index 0000000000000000000000000000000000000000..cb6ded319934cd49f9c89b713fe86974c0430aaa GIT binary patch literal 1816 zcmV+z2j}>SP)Bf9KrY?B;E=gwPNYObB9wMcy){MrWEr zJBDE_2s1=P_+Xn7jAC1+Z2|>)2TCCA6lZ7=GE@CvsTHHv58xEas6|F9qIHBSWi(0i zMncI831K(c-E;b(2@{gN$?j(J(iwlB_MYcC=luVB@3}9}z3?TI3lYu1e)8)V;}iT{ zX#qr%fk;AXS3pY_T1!KUPL%2lK)az;mq*%LD$0s)L~HJdjhJR-oN@;8(IOAQ{lHux zBg|o1KzxeeyhOc+$N!FLKGPYAp0JE;Ie5_FzGYvH=5ZnTHV_-hq{Gk){0*W1C_P4% zX%^oKwPQ#|O7~YL#h`yE&|83%NL7Z1=s>H(KCOPUZ~N1&BW4d9)mrSTII;;S2a=-F{aYO=bBdHkv-ctpBo$)Vr1vO!;6IAG!u6yjezc!$Q5SU<}(9Z?=WKSue?j4 zK4jb$bi}kP!0*SEwo`zX^aTGT^T=%~xXj8*Up(ZP#0XB@-em zGI9gXwrBb`1b}(q_sOc)R}Cahn5@X?^r=AhzU@!9N`OGGuZaIY696u|kVVjUXbC8u zSUsaah)n>(+LccO)i7LB;q z2BcdhWXwoq`LaA*i3xmB+rX)}&*Aa*jElHfr-O%<<}o`fgU+s-{N=54G&HqXrKd^I ziNPZAaj|SzyMmeN(@AnAuy}qBMH^O=m=Hg9>icjd##6LmHH+uxkmO2WX8LqC7OudR zXgRk_f^Y_lWM`%m>vY`FmNM1N_a9$H>a?-DpG=*W%=gx>B4w)kj&`TR!QHcFT6K$) zfG1eQ+uIwo#}v1VqK#|FT`)JQl?wqZx-W;Kjcb_Vb_LD3xL7NE;l{z-L!#n3b7sd2G6q5?MSyhuK*fD5YrYxPekCB=LqJ$(fVI@?{V3(9%51 zy#UbE{2z{1oxtb!Tc!L+=C%x7%=^_fj-@vcG-pQtr&pS?hJ zZ6n_;_$Jd*rbI=k7403J96$9gpVtJfGYrANI3rB+l^Er`-UcG13xv%uSdu%B2bU}) zAwIhGx$avY-uvJa-oJ2hxb-jy-7UQ-$pTCB zJ&?w$*ZBC-75;JYGCp6Z2Ko^C*zVGjbv6Jre*nTV;`8}A|IsDRe{_kIWH)nW-$iz2 zIvF!kiHi+-jlGS>+e1TB3)Qs^T&b?(dTU2`;s+pwI0|3`klt%2l{2*#$ckjJ>unue zZ|mUP2cG~C7waTts+(A+14m2@LI}J)y?A?iXl=W}&F+v(x+787lq65WfO62ziX$aL z>qF6+VLZ@+Eqh9z{2dIaq8J{>3ur7aos$kh{Y|^)`Tot@MojYwuax>n6Qe48Bx38c zTesf6PaIk@-T&Nc0w)okNFN@*Mm%AbZhqA&VZ?{LT@{CxqK(%9|p!DWps+*4LOVkOiFWwdurAHOkwWx>%npHv{eLEo*-7~)6ptn|vqcJC-|45dTZ z^@AP1IxqAL(SkS8vMiLnFp$W + + diff --git a/assets/icons/video_call/vc_cam_off.svg b/assets/icons/video_call/vc_cam_off.svg new file mode 100644 index 00000000..e1f05f17 --- /dev/null +++ b/assets/icons/video_call/vc_cam_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/video_call/vc_camera_switch.png b/assets/icons/video_call/vc_camera_switch.png new file mode 100644 index 0000000000000000000000000000000000000000..903df14900c0488f961982747955ca93009ea076 GIT binary patch literal 2237 zcmV;u2txOXP)TcC(f zGH?}13(Z9WLQzXiN?tAyVK4(3<{+k`o zu=7Yhhu!s_ci;VfXLcPU38d5MzTV#6QT}t7U&&Z3HoU*T-^YLV(N7K^Yl+`Ve7wcI zy}bgzitpaN`#~KNqc(vUJv}{R_>;d}iY&DhcXxNES*$|4BDE|`#XA1vKPM;x7SSnu z(avmdZ)aN-s3l>pUcEX=R*<$}7dMs5<+9Cg-;^+HB}sN$c~<{dvUEaeXJ;qXjP=AD zZNOGC#;)s|6-5fkL?ThlWHO_THcSMqthlaeejOo;6!-!^!mmGuhK6E`i;D%n?R|5sC?Jy!5=STuOpQPI7g<6D;+#5ls`UN)_xZYY-USHsh$&<o523t+vp+krA-sJ~e8R80Wm?n#Ae(}besw=CwuqCo|1-LZs5Dez?`7-`p zb!F_rCdtw@DlToJ`fvQlT8kdkT8(2dEXoL3x(=+S(y_0)vI(+iK|CIJlCPL`WBReR zwbdU#eE6_!PHF~5gX0`J_c6tx;6b#$s;YzU_ztQmGW(yLXR{9Xm!*MGIk0 zoH#)zPo6xmY;bTe(&A_#3|!XA%1YhxD2ta3F5FO(SE`b1>n2m7*r)8N`%3c zjOY^LZGWn%8!HQh0GimV$`THVImyVDgTgpoOFXv(x( zBZLuE?#;L)Q{0vaBZLvTq>C3XHu{*8nRyW<1Uh1bFrxGpsjuk7yI`HjVoa(2ji_TF zjJL@NP^X|5hI;15kN+NCgu)W78&QK319YY|G>xhJU%q@vj~_p-nY(epg&H}bW(W*;2ZImzo)jVPa zy2XC8XYyD}UA=81)k-wYbrcbNsvd6S8f;E(xY&5Ss9 zf+#>EHpomia140HjS7?qgWBNk-MdbZ4GYm-X88ZghzHmYzB`UVRXVNwc5+nK?nH1| zX(+B_zuW&{F^F+~qnSasio1_eLF`gp-zXQR8w81i6OR5^*siu!B2oFn6(N%noqSmB z{rmT|n;#IFmW*lQM5PYKi2-O8vx{_ekVO}Y_}yi({RjJTbhk?awalck9xWQr_9wNqHC63BxX}#4gSLD7GTlg^ky`FqedA~*Hu zbxk$lr6YBOF0E9e#!azLgKQEQ|{T>h0~t!zz(l+k&))N+UAAy}do# zsvs_H3sXx)B#1M@mW1~tf4L<6{Gg3h=3~XYi4sPGG4R-~7`6Nh@QUZC=8@cU00000 LNkvXXu0mjfDC8Xw literal 0 HcmV?d00001 diff --git a/assets/icons/video_call/vc_control_chat.png b/assets/icons/video_call/vc_control_chat.png new file mode 100644 index 0000000000000000000000000000000000000000..10920730533a079e115b8dcd4ee4acf19ba23e46 GIT binary patch literal 3085 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 bU1j_e5ZTJvx7+0G00000NkvXXu0mjfk0rpl literal 0 HcmV?d00001 diff --git a/assets/icons/video_call/vc_control_leave.png b/assets/icons/video_call/vc_control_leave.png new file mode 100644 index 0000000000000000000000000000000000000000..6371e59ce7a01a5c8988881ada7d8825291f419d GIT binary patch literal 3418 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 literal 0 HcmV?d00001 diff --git a/assets/icons/video_call/vc_control_mic.png b/assets/icons/video_call/vc_control_mic.png new file mode 100644 index 0000000000000000000000000000000000000000..94a2698fd6fc56c57062e86fe15c84b728848e8a GIT binary patch literal 694 zcmV;n0!jUeP)+6fqe zZV)zrPLOm0U;TS&o04$o6C_7#bFE z!FDpTB@6*|yu&cG-~{@FI^2kd&;`+%mp6=iBjJ_C>6y3m7aPY%2w*mwUFoMVPQ5@+LcSrK zt8c?LA)_$~kxW7)b9%qu$PW4!Muyj^yG?qUNIHfGmHcee9M1ta~7fLzA=Si z4tg;Kt0Y9o0tQ42`P~mJJ7i%T_Z>rKN^z?R5f?RQ16)>l;`I5R0MOM{97cwos}Q_FR%B~9YwMUGmei<(`Fh(w6UEp1Zk z;Kiu7mK$YFlXGVYUyoR@Y-i1Lo!&x^BpG`uu&|y7=QfvSMgwcs&0Z)xL}ZS^$k<42 z4@pF$okL8-f-v7vK42FCCUv&a&1ABvbgQOFv8>AaZognQ5 zZYS^v$_VNNQYT0@K*3$u?&t+QCOR2l>fDv?U@>n%!7i$PY|+52b*o zUw&kAs~Fm<27;TsL>afk+2dGQPEG)B z11em`#3q*f2Z#p`z(UfOS{A|KOa>id}AQS=V{FGl)ty>x^`ek&l;8omlqP;X2aIIvnhte zz~=y6FY-8F?2>Mp+0Ow9U-IJdi)7=O;X+Clnsjo2+tda*2+=#BJ8`j4sgWhS|JL~mtw?1Us>+%4LUJ-zGb?jroU(X zw~$~!r4Xw!kZ%t1WXk%Dj0?iD(H}NQCCgkC^!XLtVfXuzQS1b*QgskC?L(jpl%z42Ilz%z1-z zR#GvizP3Imz#k}7jC-C&p=`=bzHwZy5+73zp zmnUN9(A|;?O&+A5=;Q35G;mIDyTCEW6w3YlK<{dJFTlvkT`1nPGFJju1I~?-222L| h{Qxr*uCBOr`~yYVkgW2d*|q=x002ovPDHLkV1i%=g2ey; literal 0 HcmV?d00001 diff --git a/assets/icons/video_call/vc_control_sound_active.png b/assets/icons/video_call/vc_control_sound_active.png new file mode 100644 index 0000000000000000000000000000000000000000..f1b11e53ad5cc7d13cd30ae5e6439e07060fe9bc GIT binary patch literal 1068 zcmeAS@N?(olHy`uVBq!ia0vp^2_VeD1|%QND7OGooCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBK3|DzL`iUdT1k0gQ7VI5W_oVoyp7Y685o$C zd%8G=R4~51>mMZLD02Mc_bLG&A03U!TX&pxQ&pcJo#3~?oP&MCp#tV&<~@v-jyoEe zBU3cjZSv5GR5#GS%j_(u@FzWP>OZ^CX$}V6KQHAcM3`J=d-8Ypo4OynT+XnjofGyv zXg5dpkBw-qQeL-5LcP@8E#fu%Pu;ydYetxeY{|X-2@{Kd-xqmV$<^}oSO0UgZ7xN45o;iNw=7v67 zL-Aw&XM~?O@VqtQV6T_n^zDd^Vdy{32haA!inO!b4wA6fXW zkKgfxJGhl;v3QSZc)e2FJnfGj!J%Y@hd^mKsgXxCe0cYc6U$Vd#GTww(#>Mka!*=KPhiCc59`DywAVs*2xzZtf_Uu!G>gGKt`vwz#Y-ksi3|2VR~BX;X`^KA3n^fZR_4ZF*F zHyr=4>CJAh;JnZx^Ud>2%o~C~$bWX)%l1twZpz*>bE-mTnSDO4E27Xg(Ios!51 z6?(tey;%zuT~*l2Wf(7XWD)cGb!tbXP4%;0G8l?0rC3S7Ir7WLeut;?jMv`|+}Ejo zkgUxv@ia-`E7!sglACWxpk!J)n0A3d!?UaF28IC_s2e9 PW@hko^>bP0l+XkK5yZnq literal 0 HcmV?d00001 diff --git a/assets/icons/video_call/vc_control_video.png b/assets/icons/video_call/vc_control_video.png new file mode 100644 index 0000000000000000000000000000000000000000..beeb607937549243e1ebb0b66860e5a1eba8af54 GIT binary patch literal 2624 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
V=c3WHLoLlURWAHDvGg0Qs2*{>XY5B z0)n0k%-M*w@VxY^+xP=}?hK=-u+t5_wZ{C}E_oy7&PbFh3PaoJ?Tay3G=i$i7%l#O mt}1cY_eSPXPcGi|MC93yzj>&LjnKt|2SDf}^~!YaVg3ts<>&_h literal 0 HcmV?d00001 diff --git a/assets/icons/video_call/vc_expand.png b/assets/icons/video_call/vc_expand.png new file mode 100644 index 0000000000000000000000000000000000000000..2238c5223ac05278f8df1d6c5523dbe7cdc76ea3 GIT binary patch literal 1597 zcmV-D2EzG?P)>S}u-E>ndWfO!gwq2y9e?U~IWGt^rN_W8(n}7{A?m2Xz znAglO%v|Qq`ALL%Tq5&*bI*O+#|0x`b8~ZkdU|>T5B>P^x?C>*<>lo(9?yd$;CIU7 zTOPkxre3e7@s&9^ICu#Lalw#~=u=Zucj*v+xHWJlnTv~y7@CqE)(qVSC{O!Wco+eR zo^Ye!Lm02sYEivL=rSM}3~m7nKM)K}kCMq`RBL%n2H;cg#c-2Cmsf$&LjL^xd`(Mj zaH|!-r~WR6oZne|U3e!ZCNhCQU_-6o#Gso>$cf?W8Zf%R2lg7iKK1#0u3Rpc?pLm_ z0gCuhU<^S$#6c$XG=QoOe((oiMgp0zva*stIy%aB-RrzS4KBre;GWJ%!LdRK#|-|? zhPkT|#@JKKX+2vS1NpTL+3g(4g=-%O=a zB|2|~APhZQz?hB_iXyFZ`iGx>YiX|qjT(UB-vY)ms1_(Q$*m9+0mf3?Zg-=zfe`{~e}ig(7Z_3asTbr0MhLWXM0G`s zF^|Tm7$jtM^+O2X`x55n=3rO~g*@zjzXK;HufR;Cs{ymKbB4tymY2N{4u1h=Bb@;r z&!S=Ro4oVxdtf%w8Bi>~f|Ftq?1FS{K&4WK?d@-Iap^Ox32zK6OZS(SuY*XWY=o)d z+}({0EVHn%2>0(l1kpIM8`>0q!Q%nZIL?4^5}$CC0fWRp5!84)#sD?(x3|B8;y>mN zx_aRV1GI>5_K3G1(i?S@TjRB(& zzw)}=^*OVV#sI(n1H(^#BJs2}8m^Sf@C?t*Mw;2sx3C6uqBrbNxd#s-xEgi?kBd+) zzXr3BrUv|xeR1X7NX#!3URokP;R*(Wmmmq$4y>==fif=2{PO%~%S1GSR0h})-WMbo zHb5|JfM9IV4W-=+w{PER?Du(&jqA9LW{oiR26T>a*FX9g9%J|N_&5hLU`Ys~Czu8| zdg(X4u!^HzSuni@v~QH9U2Bgu4vq%R?G$aa^GyxYYd|RUd26pG?c1btHPj~7vY8hk z3#J>;DdOzx?BMb?oKiNzSan04CO$DX1~^fCVk``Bs`!L(1Dq^AA=Ch;i%$qMfW`k0 zAqKGc|6{oUEdEt2pNw1ZEcU$aMDdBS=!QB~d_uSZP8OdKz8*k37{d1k|3-SbT!F;1 zr&5|E+yJfX1WQZT;hV1?HhvFSCTz1NOjFsOJ`K2?G2MXU<0BXav{$oO;!mUtKLqcL6Ab;$xDqhJpud{P28bh1Z9oPXAy9fmG9V9(5bE`M){SI= z5dyZqzq(IPPgB4MfikkXDTx5aqBJm8(@2Ng9Efo(uoy~%OYaQ>&d$#EcsyV&l!KVw z8wMz0CKDVbc?8+sHlXH|ul9M`^ zLq3s6+-$$lH64wi=o1b_rck0R+npD?48Ty7$56y+tws!no^Y=e=RNZYA08fNeLi0! z4=yld_O)!okezf0x7pM>lg*Na%iBq}_VDvx}KvX6E$l^w*gI*B%qWAm%6Nu9JnbHYN=YSwx?J4CPN$0j$#Ixa7#&z v=#?x(A5GJg@H^4^d7f4gX(4M^(cAeKUAokV + + diff --git a/assets/icons/video_call/vc_mic_off.svg b/assets/icons/video_call/vc_mic_off.svg new file mode 100644 index 00000000..a821f20e --- /dev/null +++ b/assets/icons/video_call/vc_mic_off.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/video_call/vc_mic_on.svg b/assets/icons/video_call/vc_mic_on.svg new file mode 100644 index 00000000..01c6cb27 --- /dev/null +++ b/assets/icons/video_call/vc_mic_on.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/video_call/vc_shrink.svg b/assets/icons/video_call/vc_shrink.svg new file mode 100644 index 00000000..be6faeaf --- /dev/null +++ b/assets/icons/video_call/vc_shrink.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/video_call/warning.svg b/assets/icons/video_call/warning.svg new file mode 100644 index 00000000..b2ae71c1 --- /dev/null +++ b/assets/icons/video_call/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 182a74af..30bbd104 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -35,6 +35,8 @@ This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us. NSCameraUsageDescription Предоставление доступа позволит Вам делать фотографии и добавлять к записям + NSMicrophoneUsageDescription + Нужен для звука в видеозвонках с консультантом NSLocationWhenInUseUsageDescription NSPhotoLibraryUsageDescription diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart index 341691a5..002593be 100644 --- a/lib/core/di/repository_registrations.dart +++ b/lib/core/di/repository_registrations.dart @@ -33,6 +33,7 @@ import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.da import 'package:eatvio_app/features/registration/repositories/registration_repository.dart'; import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart'; import 'package:eatvio_app/features/settings/repositories/service_repository.dart'; +import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; import 'package:get_it/get_it.dart'; /// Регистрация всех Repository в системе внедрения зависимостей @@ -49,6 +50,7 @@ class RepositoryRegistrations { static void _registerSingletonRepositories() { // Core repositories sl.registerLazySingleton(() => ChatRepository(chatService: sl())); + sl.registerLazySingleton(() => VideoCallRepository(videoCallService: sl())); sl.registerLazySingleton(() => FeedRepository(feedService: sl())); sl.registerLazySingleton(() => ProfileRepository(profileService: sl())); sl.registerLazySingleton(() => CalendarRepository(calendarService: sl())); diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart index dd1371b1..b6756bb1 100644 --- a/lib/core/di/service_registrations.dart +++ b/lib/core/di/service_registrations.dart @@ -35,6 +35,7 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart'; import 'package:eatvio_app/features/registration/services/registration_service.dart'; import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart'; import 'package:eatvio_app/features/settings/services/settings_service.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; import 'package:get_it/get_it.dart'; /// Регистрация всех Service в системе внедрения зависимостей @@ -50,6 +51,7 @@ class ServiceRegistrations { static void _registerSingletonServices() { // Core services sl.registerLazySingleton(() => ChatService(sl())); + sl.registerLazySingleton(() => VideoCallService(sl())); sl.registerLazySingleton(() => FeedService(sl())); sl.registerLazySingleton(() => ProfileService(sl())); sl.registerLazySingleton(() => CalendarService(sl())); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 58369798..b321a15c 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -14,6 +14,7 @@ import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart'; @@ -224,6 +225,27 @@ class OtherRoutes { name: 'emptyChatScreen', builder: (context, state) => EmptyChatScreen(user: state.extra as ProfileModel), ), + GoRoute( + path: RouteConstants.videoCallScreen, + name: VideoCallScreen.name, + builder: (context, state) { + final extra = state.extra; + if (extra is Map) { + return VideoCallScreen( + conversationUuid: extra['conversationUuid'] as String, + counterpartName: extra['counterpartName'] as String?, + localParticipantName: extra['localParticipantName'] as String?, + onBackToChat: extra['onBackToChat'] as void Function()?, + onHangUp: extra['onHangUp'] as void Function()?, + ); + } + final uuid = state.uri.queryParameters['uuid']; + if (uuid != null) { + return VideoCallScreen(conversationUuid: uuid); + } + throw ArgumentError('VideoCallScreen: pass extra Map with conversationUuid or query uuid'); + }, + ), // Consultant routes GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()), diff --git a/lib/core/routing/route_constants.dart b/lib/core/routing/route_constants.dart index 4ce78f4f..7b99b140 100644 --- a/lib/core/routing/route_constants.dart +++ b/lib/core/routing/route_constants.dart @@ -53,6 +53,7 @@ class RouteConstants { static const String photoViewScreen = '/photo-view-screen'; static const String chatScreen = '/chat-screen'; static const String emptyChatScreen = '/empty-chat-screen'; + static const String videoCallScreen = '/video-call-screen'; static const String glossaryScreen = '/glossary-screen'; static const String blockedUsersScreen = '/blocked-users-screen'; static const String notificationsSettingsScreen = '/notifications-settings-screen'; diff --git a/lib/core/theme/theme_helper.dart b/lib/core/theme/theme_helper.dart index d1344e62..957ae616 100644 --- a/lib/core/theme/theme_helper.dart +++ b/lib/core/theme/theme_helper.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; - /// Helper class for managing themes and colors. class ThemeHelper { // The current app theme @@ -200,6 +199,12 @@ class PrimaryColors { Color get grey7B => const Color(0xFF7B7B7B); Color get greyD9 => const Color(0xFFD9D9D9); + + Color get videoCallControlOffBackground => const Color(0xFFED2F2F); + + Color get videoCallPipIconBackground => const Color(0xFF565656); + + Color get videoCallNoticeBannerBackground => const Color(0xFF9D9D9D); } PrimaryColors get appTheme => ThemeHelper().themeColor(); diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 423b6980..56b4a2db 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -24,6 +24,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart'; +import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; @@ -233,6 +234,23 @@ class _ChatScreenState extends State { ], ), ), + actions: [ + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: _ChatHeaderVideoCallButton( + onPressed: () { + GoRouter.of(context).pushNamed( + VideoCallScreen.name, + extra: { + 'conversationUuid': widget.uuid, + 'counterpartName': state.chatRoom.users.receiver.name, + 'localParticipantName': state.chatRoom.users.sender.name, + }, + ); + }, + ), + ), + ], ), body: SafeArea( child: Stack( @@ -741,3 +759,33 @@ class _ChatScreenState extends State { ); } } + +class _ChatHeaderVideoCallButton extends StatelessWidget { + const _ChatHeaderVideoCallButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Tooltip( + message: S.of(context).video_call, + child: Material( + color: theme.colorScheme.onPrimary, + borderRadius: BorderRadius.circular(20), + elevation: 0, + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: SizedBox( + width: 40, + height: 40, + child: Center( + child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/diets/presentation/pages/about_diet_screen.dart b/lib/features/diets/presentation/pages/about_diet_screen.dart index 3823b471..5498be61 100644 --- a/lib/features/diets/presentation/pages/about_diet_screen.dart +++ b/lib/features/diets/presentation/pages/about_diet_screen.dart @@ -106,9 +106,7 @@ class _AboutDietScreenState extends State { backgroundColor: appTheme.gray100, resizeToAvoidBottomInset: true, bottomSheet: Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + 8, - ), + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), diff --git a/lib/features/video_calls/models/video_call_models.dart b/lib/features/video_calls/models/video_call_models.dart new file mode 100644 index 00000000..7ab0a747 --- /dev/null +++ b/lib/features/video_calls/models/video_call_models.dart @@ -0,0 +1,37 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'video_call_models.freezed.dart'; +part 'video_call_models.g.dart'; + +@freezed +abstract class VideoCallTokenResponse with _$VideoCallTokenResponse { + const factory VideoCallTokenResponse({ + required String token, + required String roomName, + required String wsUrl, + }) = _VideoCallTokenResponse; + + factory VideoCallTokenResponse.fromJson(Map json) => + _$VideoCallTokenResponseFromJson(json); +} + +@freezed +abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse { + const factory VideoCallAdminTokenResponse({ + required String adminToken, + required String roomName, + required String apiUrl, + }) = _VideoCallAdminTokenResponse; + + factory VideoCallAdminTokenResponse.fromJson(Map json) => + _$VideoCallAdminTokenResponseFromJson(json); +} + +@freezed +abstract class VideoCallEndedResponse with _$VideoCallEndedResponse { + const factory VideoCallEndedResponse({required bool sent}) = + _VideoCallEndedResponse; + + factory VideoCallEndedResponse.fromJson(Map json) => + _$VideoCallEndedResponseFromJson(json); +} diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart new file mode 100644 index 00000000..327bdafd --- /dev/null +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -0,0 +1,531 @@ +import 'dart:async'; + +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class VideoCallScreen extends StatefulWidget { + const VideoCallScreen({ + super.key, + required this.conversationUuid, + this.counterpartName, + this.localParticipantName, + this.onBackToChat, + this.onHangUp, + }); + + static const String path = '/video-call-screen'; + static const String name = 'videoCallScreen'; + + final String conversationUuid; + final String? counterpartName; + final String? localParticipantName; + final VoidCallback? onBackToChat; + final VoidCallback? onHangUp; + + @override + State createState() => _VideoCallScreenState(); +} + +class _VideoCallScreenState extends State { + static const lk.AudioCaptureOptions _micCaptureOptions = lk.AudioCaptureOptions(stopAudioCaptureOnMute: false); + static const Duration _mutedMicPollInterval = Duration(milliseconds: 320); + static const Duration _mutedMicOverlayMinInterval = Duration(milliseconds: 2600); + static const double _mutedMicAudioLevelThreshold = 0.008; + static const double _mutedMicEnergyDelta = 1e-6; + + final lk.Room _room = lk.Room( + roomOptions: const lk.RoomOptions( + adaptiveStream: true, + defaultCameraCaptureOptions: lk.CameraCaptureOptions(params: lk.VideoParametersPresets.h1080_169), + defaultAudioCaptureOptions: _micCaptureOptions, + ), + ); + final VideoCallRepository _videoCallRepository = sl(); + + bool _micEnabled = true; + bool _videoEnabled = true; + bool _splitParticipantView = true; + bool _mainStageShowsLocalVideo = false; + bool _useFrontCamera = true; + String? _connectError; + bool _liveKitDisposed = false; + bool _callEndedNotified = false; + + lk.EventsListener? _roomEventsListener; + OverlayEntry? _poorConnectionEntry; + Timer? _mutedMicPollTimer; + DateTime? _lastMutedMicOverlayAt; + bool _recordingOverlayScheduled = false; + bool _mutedMicStatsInFlight = false; + num? _lastMutedMicTotalEnergy; + + lk.CameraCaptureOptions _cameraCaptureOptions({lk.CameraPosition? cameraPosition}) { + return lk.CameraCaptureOptions( + cameraPosition: cameraPosition ?? (_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back), + params: lk.VideoParametersPresets.h1080_169, + ); + } + + @override + void initState() { + super.initState(); + _attachRoomEventListeners(); + _mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) { + if (!mounted || _liveKitDisposed || _micEnabled || _mutedMicStatsInFlight) return; + if (_room.connectionState != lk.ConnectionState.connected) return; + _mutedMicStatsInFlight = true; + unawaited( + _pollMutedMicFromWebRtcStats().whenComplete(() { + _mutedMicStatsInFlight = false; + }), + ); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_applyVideoCallSystemUi()); + _scheduleRecordingNoticeOnce(); + }); + unawaited(_connectLiveKit()); + } + + void _attachRoomEventListeners() { + _roomEventsListener = _room.createListener() + ..on((e) { + if (e.participant != _room.localParticipant) return; + if (!mounted) return; + _syncPoorConnectionOverlay(e.connectionQuality); + }) + ..on((e) { + if (!mounted || _micEnabled) return; + final lp = _room.localParticipant; + if (lp == null) return; + if (e.speakers.any((p) => p.sid == lp.sid)) { + _maybeShowMutedMicOverlay(); + } + }); + } + + void _scheduleRecordingNoticeOnce() { + if (_recordingOverlayScheduled || !mounted) return; + _recordingOverlayScheduled = true; + VideoCallTopOverlay.insertBanner( + context, + message: S.of(context).video_call_recording_notice, + backgroundColor: appTheme.videoCallNoticeBannerBackground, + svgAsset: VideoCallTopOverlay.warningSvg, + autoRemoveAfter: const Duration(seconds: 3), + ); + } + + void _syncPoorConnectionOverlay(lk.ConnectionQuality q) { + final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; + if (bad) { + if (_poorConnectionEntry != null) return; + if (!mounted) return; + _poorConnectionEntry = VideoCallTopOverlay.insertBanner( + context, + message: S.of(context).video_call_poor_connection, + backgroundColor: appTheme.strongRed, + svgAsset: VideoCallTopOverlay.poorConnectionSvg, + ); + } else { + final e = _poorConnectionEntry; + if (e != null && e.mounted) { + e.remove(); + } + _poorConnectionEntry = null; + } + } + + void _maybeShowMutedMicOverlay() { + if (!mounted || _micEnabled) return; + final now = DateTime.now(); + if (_lastMutedMicOverlayAt != null && now.difference(_lastMutedMicOverlayAt!) < _mutedMicOverlayMinInterval) { + return; + } + _lastMutedMicOverlayAt = now; + VideoCallTopOverlay.insertBanner( + context, + message: S.of(context).video_call_muted_mic_warning, + backgroundColor: appTheme.videoCallNoticeBannerBackground, + svgAsset: VideoCallTopOverlay.warningSvg, + autoRemoveAfter: const Duration(seconds: 2), + ); + } + + Future _pollMutedMicFromWebRtcStats() async { + if (!mounted || _liveKitDisposed || _micEnabled) return; + if (_room.connectionState != lk.ConnectionState.connected) return; + final lp = _room.localParticipant; + if (lp == null) return; + final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); + final track = pub?.track; + if (track is! lk.LocalAudioTrack) return; + if (!track.muted) return; + try { + final stats = await track.getSenderStats(); + if (!mounted || _liveKitDisposed || _micEnabled) return; + final src = stats?.audioSourceStats; + if (src == null) return; + + final level = src.audioLevel; + if (level != null && level >= _mutedMicAudioLevelThreshold) { + _maybeShowMutedMicOverlay(); + _lastMutedMicTotalEnergy = src.totalAudioEnergy; + return; + } + + final energy = src.totalAudioEnergy; + if (energy != null) { + final prev = _lastMutedMicTotalEnergy; + if (prev != null && energy > prev + _mutedMicEnergyDelta) { + _maybeShowMutedMicOverlay(); + } + _lastMutedMicTotalEnergy = energy; + } + } catch (_) {} + } + + Future _applyVideoCallSystemUi() async { + try { + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + if (!mounted) return; + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } on PlatformException catch (_) { + if (!mounted) return; + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } + } + + Future _restoreSystemUiAfterCall() async { + try { + await SystemChrome.setPreferredOrientations(const []); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); + } on PlatformException catch (_) {} + } + + Future _connectLiveKit() async { + setState(() => _connectError = null); + try { + final tokenResponse = await _videoCallRepository.getParticipantToken(widget.conversationUuid); + if (!mounted) return; + + var micOn = _micEnabled; + var videoOn = _videoEnabled; + if (micOn) { + final granted = await _ensureMicrophonePermission(); + if (!mounted) return; + if (!granted) micOn = false; + } + if (videoOn) { + final granted = await _ensureCameraPermission(); + if (!mounted) return; + if (!granted) videoOn = false; + } + if (micOn != _micEnabled || videoOn != _videoEnabled) { + setState(() { + _micEnabled = micOn; + _videoEnabled = videoOn; + }); + } + + await _room.connect(tokenResponse.wsUrl, tokenResponse.token); + if (!mounted || _liveKitDisposed) { + return; + } + + var effectiveMic = micOn; + var effectiveVideo = videoOn; + final lp = _room.localParticipant; + + if (micOn) { + try { + await lp?.setMicrophoneEnabled(true, audioCaptureOptions: _micCaptureOptions); + } catch (_) { + effectiveMic = false; + } + } + if (!mounted || _liveKitDisposed) return; + + if (videoOn) { + try { + await lp?.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions()); + } catch (_) { + effectiveVideo = false; + } + } + + if (effectiveMic != _micEnabled || effectiveVideo != _videoEnabled) { + setState(() { + _micEnabled = effectiveMic; + _videoEnabled = effectiveVideo; + }); + } + + if (mounted) { + _syncPoorConnectionOverlay(_room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown); + } + } catch (e) { + if (mounted) setState(() => _connectError = e.toString()); + } + } + + Future _disconnectLiveKitOnly() async { + if (_liveKitDisposed) return; + _liveKitDisposed = true; + try { + if (_room.connectionState != lk.ConnectionState.disconnected) { + await _room.disconnect(); + } + } catch (_) {} + try { + await _room.dispose(); + } catch (_) {} + } + + Future _notifyCallEndedOnce() async { + if (_callEndedNotified) return; + _callEndedNotified = true; + try { + await _videoCallRepository.notifyCallEnded(widget.conversationUuid); + } catch (_) {} + } + + Future _cleanupLiveKitAndNotifyBackend() async { + await _disconnectLiveKitOnly(); + await _notifyCallEndedOnce(); + } + + @override + void dispose() { + _mutedMicPollTimer?.cancel(); + final listener = _roomEventsListener; + _roomEventsListener = null; + if (listener != null) { + unawaited(listener.dispose()); + } + final poor = _poorConnectionEntry; + _poorConnectionEntry = null; + if (poor != null && poor.mounted) { + poor.remove(); + } + unawaited(_cleanupLiveKitAndNotifyBackend()); + unawaited(_restoreSystemUiAfterCall()); + super.dispose(); + } + + Future _handleBackToChat() async { + widget.onBackToChat?.call(); + await _cleanupLiveKitAndNotifyBackend(); + if (!mounted) return; + if (context.canPop()) context.pop(); + } + + Future _handleHangUp() async { + widget.onHangUp?.call(); + await _cleanupLiveKitAndNotifyBackend(); + if (!mounted) return; + if (context.canPop()) context.pop(); + } + + Future _toggleMic() async { + if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) { + setState(() => _micEnabled = !_micEnabled); + _lastMutedMicTotalEnergy = null; + return; + } + final next = !_micEnabled; + if (next) { + final granted = await _ensureMicrophonePermission(); + if (!mounted) return; + if (!granted) return; + } + setState(() => _micEnabled = next); + _lastMutedMicTotalEnergy = null; + await _room.localParticipant?.setMicrophoneEnabled(next, audioCaptureOptions: _micCaptureOptions); + } + + Future _toggleVideo() async { + if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) { + setState(() => _videoEnabled = !_videoEnabled); + return; + } + final next = !_videoEnabled; + if (next) { + final granted = await _ensureCameraPermission(); + if (!mounted) return; + if (!granted) return; + } + setState(() => _videoEnabled = next); + final lp = _room.localParticipant; + if (lp == null) return; + if (next) { + await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions()); + } else { + await lp.setCameraEnabled(false); + } + } + + Future _switchCameraFacing() async { + if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) { + setState(() => _useFrontCamera = !_useFrontCamera); + return; + } + if (!_videoEnabled) return; + + final lp = _room.localParticipant; + if (lp == null) return; + + final currentPosition = _useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back; + final newPosition = currentPosition.switched(); + + final pub = lp.getTrackPublicationBySource(lk.TrackSource.camera); + final track = pub?.track; + if (track is lk.LocalVideoTrack) { + try { + await track.setCameraPosition(newPosition); + if (mounted) { + setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front); + } + } catch (_) {} + return; + } + + try { + await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions(cameraPosition: newPosition)); + if (mounted) { + setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front); + } + } catch (_) {} + } + + bool _permissionNeedsSettings(PermissionStatus status) => status.isPermanentlyDenied || status.isRestricted; + + Future _ensureCameraPermission() async { + var status = await Permission.camera.status; + if (status.isGranted) return true; + status = await Permission.camera.request(); + if (status.isGranted) return true; + if (!mounted) return false; + final s = S.of(context); + await showErrorDialog( + context, + title: s.video_call_permission_camera_title, + description: s.video_call_permission_camera_description, + buttonTitle: s.allowAccess, + needToOpenSettings: _permissionNeedsSettings(status), + ); + return false; + } + + Future _ensureMicrophonePermission() async { + var status = await Permission.microphone.status; + if (status.isGranted) return true; + status = await Permission.microphone.request(); + if (status.isGranted) return true; + if (!mounted) return false; + final s = S.of(context); + await showErrorDialog( + context, + title: s.video_call_permission_microphone_title, + description: s.video_call_permission_microphone_description, + buttonTitle: s.allowAccess, + needToOpenSettings: _permissionNeedsSettings(status), + ); + return false; + } + + String _resolvedLocalDisplayName(BuildContext context) { + final fromNav = widget.localParticipantName?.trim(); + if (fromNav != null && fromNav.isNotEmpty) return fromNav; + final n = (_room.localParticipant?.name ?? '').trim(); + if (n.isNotEmpty) return n; + return S.of(context).video_call_you; + } + + String _resolvedRemoteDisplayName(BuildContext context, String fallback) { + for (final p in _room.remoteParticipants.values) { + final n = p.name.trim(); + if (n.isNotEmpty) return n; + } + final c = widget.counterpartName?.trim(); + if (c != null && c.isNotEmpty) return c; + return fallback; + } + + @override + Widget build(BuildContext context) { + final s = S.of(context); + final viewPadding = MediaQuery.viewPaddingOf(context); + final topInset = viewPadding.top > 0 ? viewPadding.top : 16.0; + final bottomInset = viewPadding.bottom; + + final counterpartFallback = s.video_call_counterpart_default; + final controlLabelStyle = TextStyles.textSMedium.copyWith(color: appTheme.whiteA700, height: 20 / 14); + + return AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarContrastEnforced: false, + ), + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: ListenableBuilder( + listenable: _room, + builder: (context, _) { + return VideoCallStageLayout( + room: _room, + connectError: _connectError, + localDisplayName: _resolvedLocalDisplayName(context), + remoteDisplayName: _resolvedRemoteDisplayName(context, counterpartFallback), + micEnabled: _micEnabled, + videoEnabled: _videoEnabled, + splitParticipantView: _splitParticipantView, + mainStageShowsLocalVideo: _mainStageShowsLocalVideo, + useFrontCamera: _useFrontCamera, + onExpandLocal: () => setState(() { + _splitParticipantView = false; + _mainStageShowsLocalVideo = true; + }), + onExpandRemote: () => setState(() { + _splitParticipantView = false; + _mainStageShowsLocalVideo = false; + }), + onShrinkFromFullscreen: () => setState(() => _splitParticipantView = true), + onToggleStageSwap: () => setState(() => _mainStageShowsLocalVideo = !_mainStageShowsLocalVideo), + onChat: () => unawaited(_handleBackToChat()), + onToggleMic: () => unawaited(_toggleMic()), + onToggleVideo: () => unawaited(_toggleVideo()), + onHangUp: () => unawaited(_handleHangUp()), + onSwitchCamera: () => unawaited(_switchCameraFacing()), + controlLabelStyle: controlLabelStyle, + labels: (chat: s.video_call_chat, sound: s.video_call_sound, video: s.video_call_video, leave: s.video_call_leave), + topInset: topInset, + bottomInset: bottomInset, + ); + }, + ), + ), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_controls.dart b/lib/features/video_calls/presentation/widgets/video_call_controls.dart new file mode 100644 index 00000000..4db0b713 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_controls.dart @@ -0,0 +1,147 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +typedef VideoCallControlLabels = ({String chat, String sound, String video, String leave}); + +class VideoCallControlColumn extends StatelessWidget { + const VideoCallControlColumn({ + super.key, + required this.micEnabled, + required this.videoEnabled, + required this.onChat, + required this.onToggleMic, + required this.onToggleVideo, + required this.onHangUp, + required this.labels, + required this.controlLabelStyle, + }); + + final bool micEnabled; + final bool videoEnabled; + final VoidCallback onChat; + final VoidCallback onToggleMic; + final VoidCallback onToggleVideo; + final VoidCallback onHangUp; + final VideoCallControlLabels labels; + final TextStyle controlLabelStyle; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + VideoCallControlTile( + label: labels.chat, + labelStyle: controlLabelStyle, + onTap: onChat, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset('assets/icons/video_call/vc_control_chat.png', width: 48, height: 48, fit: BoxFit.cover), + ), + ), + const SizedBox(height: 22), + VideoCallControlTile( + label: labels.sound, + labelStyle: controlLabelStyle, + onTap: onToggleMic, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: micEnabled + ? ColoredBox( + color: appTheme.blueA400, + child: Center(child: SvgPicture.asset('assets/icons/video_call/vc_mic_on.svg', width: 28, height: 28)), + ) + : ColoredBox( + color: appTheme.videoCallControlOffBackground, + child: Center(child: SvgPicture.asset('assets/icons/video_call/vc_mic_off.svg', width: 28, height: 28)), + ), + ), + ), + ), + const SizedBox(height: 22), + VideoCallControlTile( + label: labels.video, + labelStyle: controlLabelStyle, + onTap: onToggleVideo, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: videoEnabled + ? Image.asset('assets/icons/video_call/vc_control_video.png', fit: BoxFit.cover) + : ColoredBox( + color: appTheme.videoCallControlOffBackground, + child: Center(child: SvgPicture.asset('assets/icons/video_call/vc_cam_off.svg', width: 28, height: 28)), + ), + ), + ), + ), + const SizedBox(height: 22), + VideoCallControlTile( + label: labels.leave, + labelStyle: controlLabelStyle, + onTap: onHangUp, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: Image.asset('assets/icons/video_call/vc_control_leave.png', fit: BoxFit.cover, alignment: const Alignment(0, -0.82)), + ), + ), + ), + ], + ); + } +} + +class VideoCallControlTile extends StatelessWidget { + const VideoCallControlTile({super.key, required this.label, required this.labelStyle, required this.onTap, required this.face}); + + final String label; + final TextStyle labelStyle; + final VoidCallback onTap; + final Widget face; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: 48, height: 48, child: face), + const SizedBox(height: 6), + Text(label, style: labelStyle), + ], + ), + ); + } +} + +class VideoCallPipStyleIconButton extends StatelessWidget { + const VideoCallPipStyleIconButton({super.key, required this.onPressed, required this.child}); + + final VoidCallback onPressed; + final Widget child; + + @override + Widget build(BuildContext context) { + return Material( + color: appTheme.videoCallPipIconBackground, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onPressed, + child: Padding(padding: const EdgeInsets.all(8), child: child), + ), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart new file mode 100644 index 00000000..44011084 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart @@ -0,0 +1,40 @@ +import 'package:livekit_client/livekit_client.dart' as lk; + +abstract final class VideoCallLiveKitTrackUtils { + static lk.VideoTrack? asVideoTrack(lk.Track? track) { + if (track == null) return null; + return track is lk.VideoTrack ? track : null; + } + + static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) { + if (pub == null || pub.muted) return null; + final v = asVideoTrack(pub.track); + if (v == null || v.muted) return null; + return v; + } + + static lk.VideoTrack? remoteCameraVideoTrack(lk.Room room) { + for (final participant in room.remoteParticipants.values) { + final fromCameraPub = visibleVideoFromPublication( + participant.getTrackPublicationBySource(lk.TrackSource.camera), + ); + if (fromCameraPub != null) return fromCameraPub; + + for (final pub in participant.videoTrackPublications) { + if (pub.source == lk.TrackSource.screenShareVideo || + pub.source == lk.TrackSource.screenShareAudio) { + continue; + } + final v = visibleVideoFromPublication(pub); + if (v != null) return v; + } + } + return null; + } + + static lk.VideoTrack? localCameraVideoTrack(lk.Room room) { + return visibleVideoFromPublication( + room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart b/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart new file mode 100644 index 00000000..ff4829eb --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart @@ -0,0 +1,43 @@ +import 'dart:ui'; + +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; + +class VideoCallParticipantNameLabel extends StatelessWidget { + const VideoCallParticipantNameLabel({super.key, required this.name}); + + final String name; + + @override + Widget build(BuildContext context) { + return Positioned( + left: 12, + bottom: 12, + right: 12, + child: Align( + alignment: Alignment.bottomLeft, + child: ClipRRect( + borderRadius: BorderRadius.circular(60), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5), + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: appTheme.transparentBlack, + borderRadius: BorderRadius.circular(60), + ), + child: Text( + name, + style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_no_video_background.dart b/lib/features/video_calls/presentation/widgets/video_call_no_video_background.dart new file mode 100644 index 00000000..d08f5827 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_no_video_background.dart @@ -0,0 +1,23 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; + +class VideoCallNoVideoBackground extends StatelessWidget { + const VideoCallNoVideoBackground({super.key, this.logoSize = 96}); + + final double logoSize; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: appTheme.gray100, + child: Center( + child: Image.asset( + 'assets/icons/video_call/eatvio_c.png', + width: logoSize, + height: logoSize, + fit: BoxFit.contain, + ), + ), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart new file mode 100644 index 00000000..1cf3a376 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart @@ -0,0 +1,58 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + +class VideoCallRemoteVideoStage extends StatelessWidget { + const VideoCallRemoteVideoStage({ + super.key, + required this.room, + required this.connectError, + }); + + final lk.Room room; + final String? connectError; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: room, + builder: (context, _) { + final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); + if (remote != null) { + return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover); + } + return Stack( + fit: StackFit.expand, + children: [ + const VideoCallNoVideoBackground(), + if (connectError != null) + Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + connectError!, + textAlign: TextAlign.center, + style: TextStyles.textSMedium.copyWith(color: appTheme.red), + ), + ), + ) + else if (room.connectionState != lk.ConnectionState.connected) + Center( + child: SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + strokeWidth: 3, + color: appTheme.blueA400, + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart new file mode 100644 index 00000000..38ea44d5 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart @@ -0,0 +1,489 @@ +import 'dart:math' as math; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + +const _kExpandShrinkOuterPadding = EdgeInsets.all(6); + +abstract final class _VideoCallStageAssets { + static const String shrink = 'assets/icons/video_call/vc_shrink.svg'; + static const String expand = 'assets/icons/video_call/vc_expand.svg'; + static const String cameraSwitch = 'assets/icons/video_call/vc_camera_switch.png'; +} + +class VideoCallStageLayout extends StatefulWidget { + const VideoCallStageLayout({ + super.key, + required this.room, + required this.connectError, + required this.localDisplayName, + required this.remoteDisplayName, + required this.micEnabled, + required this.videoEnabled, + required this.splitParticipantView, + required this.mainStageShowsLocalVideo, + required this.useFrontCamera, + required this.onExpandLocal, + required this.onExpandRemote, + required this.onShrinkFromFullscreen, + required this.onToggleStageSwap, + required this.onChat, + required this.onToggleMic, + required this.onToggleVideo, + required this.onHangUp, + required this.onSwitchCamera, + required this.controlLabelStyle, + required this.labels, + required this.topInset, + this.bottomInset = 0, + }); + + final lk.Room room; + final String? connectError; + final String localDisplayName; + final String remoteDisplayName; + final bool micEnabled; + final bool videoEnabled; + final bool splitParticipantView; + final bool mainStageShowsLocalVideo; + final bool useFrontCamera; + final VoidCallback onExpandLocal; + final VoidCallback onExpandRemote; + final VoidCallback onShrinkFromFullscreen; + final VoidCallback onToggleStageSwap; + final VoidCallback onChat; + final VoidCallback onToggleMic; + final VoidCallback onToggleVideo; + final VoidCallback onHangUp; + final VoidCallback onSwitchCamera; + final TextStyle controlLabelStyle; + final VideoCallControlLabels labels; + final double topInset; + + final double bottomInset; + + @override + State createState() => _VideoCallStageLayoutState(); +} + +class _VideoCallStageLayoutState extends State { + static const double _pipW = 142; + static const double _pipH = 201; + static const double _mainStageCornerRadius = 24; + static const double _edgeInset = 8; + static const double _controlsHorizontalInset = 22; + static const double _shrinkExpandExtraTop = 12; + static const double _pipInitialExtraLeft = 28; + static const double _pipInitialExtraDown = 16; + static const double _bottomGradientHeight = 140; + static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop; + + double? _pipLeft; + double? _pipTop; + Offset? _pipPanPointerGlobal; + double? _pipPanOriginLeft; + double? _pipPanOriginTop; + + bool _pipHasVisibleVideo() { + if (widget.mainStageShowsLocalVideo) { + return VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(widget.room) != null; + } + return widget.videoEnabled && VideoCallLiveKitTrackUtils.localCameraVideoTrack(widget.room) != null; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (widget.splitParticipantView) { + return _buildSplitLayout(constraints); + } + return _buildFullscreenLayout(constraints); + }, + ); + } + + Widget _buildSplitLayout(BoxConstraints constraints) { + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Column( + children: [ + Expanded( + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.localDisplayName, + isLocal: true, + connectError: null, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandLocal, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(_mainStageCornerRadius), + topRight: Radius.circular(_mainStageCornerRadius), + bottomLeft: Radius.zero, + bottomRight: Radius.zero, + ), + ), + ), + const SizedBox(height: _edgeInset), + Expanded( + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.remoteDisplayName, + isLocal: false, + connectError: widget.connectError, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandRemote, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.zero, + topRight: Radius.zero, + bottomLeft: Radius.circular(_mainStageCornerRadius), + bottomRight: Radius.circular(_mainStageCornerRadius), + ), + ), + ), + ], + ), + ), + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + ], + ); + } + + Widget _buildFullscreenLayout(BoxConstraints constraints) { + final maxW = constraints.maxWidth; + final maxH = constraints.maxHeight; + final defaultPipLeft = maxW - _pipW - _edgeInset - _pipInitialExtraLeft; + final minPipTop = widget.topInset + _edgeInset; + final defaultPipTop = minPipTop + _pipInitialExtraDown; + final maxPipLeft = math.max(_edgeInset, maxW - _pipW - _edgeInset); + final maxPipTop = math.max(minPipTop, maxH - _pipH - _edgeInset - widget.bottomInset); + final pipLeft = ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num).toDouble(); + final pipTop = ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num).toDouble(); + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)), + child: Stack( + fit: StackFit.expand, + children: [ + _VideoCallMainStage( + room: widget.room, + connectError: widget.connectError, + mainShowsLocal: widget.mainStageShowsLocalVideo, + videoEnabled: widget.videoEnabled, + ), + VideoCallParticipantNameLabel(name: widget.mainStageShowsLocalVideo ? widget.localDisplayName : widget.remoteDisplayName), + ], + ), + ), + ), + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + Positioned( + top: widget.topInset + _edgeInset + _shrinkExpandExtraTop, + left: _controlsHorizontalInset, + child: Padding( + padding: _kExpandShrinkOuterPadding, + child: VideoCallPipStyleIconButton( + onPressed: widget.onShrinkFromFullscreen, + child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain), + ), + ), + ), + ListenableBuilder( + listenable: widget.room, + builder: (context, _) { + if (!_pipHasVisibleVideo()) { + return const SizedBox.shrink(); + } + return Positioned( + left: pipLeft, + top: pipTop, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onToggleStageSwap, + onPanStart: (details) { + _pipPanPointerGlobal = details.globalPosition; + _pipPanOriginLeft = pipLeft; + _pipPanOriginTop = pipTop; + }, + onPanUpdate: (details) { + if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) { + return; + } + final delta = details.globalPosition - _pipPanPointerGlobal!; + setState(() { + final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft); + final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop); + _pipLeft = (nx as num).toDouble(); + _pipTop = (ny as num).toDouble(); + }); + }, + onPanEnd: (_) { + _resetPipPanState(); + }, + onPanCancel: _resetPipPanState, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: _pipW, + height: _pipH, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: widget.mainStageShowsLocalVideo + ? _VideoCallPipRemoteContent(room: widget.room) + : _VideoCallPipLocalContent( + room: widget.room, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onSwitchCamera: widget.onSwitchCamera, + ), + ), + ), + ), + ); + }, + ), + ], + ); + } + + List _bottomBarGradient() { + return [ + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _bottomGradientHeight + widget.bottomInset, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black.withValues(alpha: 0.55), Colors.black.withValues(alpha: 0)], + ), + ), + ), + ), + ), + ]; + } + + void _resetPipPanState() { + _pipPanPointerGlobal = null; + _pipPanOriginLeft = null; + _pipPanOriginTop = null; + } + + List _bottomControlsRow() { + return [ + Positioned( + left: _controlsHorizontalInset, + right: _controlsHorizontalInset, + bottom: _edgeInset + 4 + widget.bottomInset, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Expanded(child: SizedBox.shrink()), + const SizedBox(width: 14), + VideoCallControlColumn( + micEnabled: widget.micEnabled, + videoEnabled: widget.videoEnabled, + onChat: widget.onChat, + onToggleMic: widget.onToggleMic, + onToggleVideo: widget.onToggleVideo, + onHangUp: widget.onHangUp, + labels: widget.labels, + controlLabelStyle: widget.controlLabelStyle, + ), + ], + ), + ), + ]; + } +} + +class _SplitParticipantPane extends StatelessWidget { + const _SplitParticipantPane({ + required this.room, + required this.displayName, + required this.isLocal, + required this.connectError, + required this.videoEnabled, + required this.useFrontCamera, + required this.onExpand, + required this.onSwitchCamera, + required this.borderRadius, + }); + + final lk.Room room; + final String displayName; + final bool isLocal; + final String? connectError; + final bool videoEnabled; + final bool useFrontCamera; + final VoidCallback onExpand; + final VoidCallback onSwitchCamera; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: borderRadius), + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: ListenableBuilder( + listenable: room, + builder: (context, _) { + if (isLocal) { + if (!videoEnabled) { + return const VideoCallNoVideoBackground(); + } + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + if (local != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + ); + } + return const VideoCallNoVideoBackground(); + } + return VideoCallRemoteVideoStage(room: room, connectError: connectError); + }, + ), + ), + VideoCallParticipantNameLabel(name: displayName), + Positioned( + left: 0, + top: _VideoCallStageLayoutState._splitExpandButtonTop, + child: Padding( + padding: _kExpandShrinkOuterPadding, + child: VideoCallPipStyleIconButton( + onPressed: onExpand, + child: SvgPicture.asset(_VideoCallStageAssets.expand, width: 20, height: 20, fit: BoxFit.contain), + ), + ), + ), + if (isLocal && videoEnabled) + Positioned( + right: 10, + top: 10, + child: Padding( + padding: _kExpandShrinkOuterPadding, + child: VideoCallPipStyleIconButton( + onPressed: onSwitchCamera, + child: Image.asset(_VideoCallStageAssets.cameraSwitch, width: 20, height: 20, fit: BoxFit.contain, key: ValueKey(useFrontCamera)), + ), + ), + ), + ], + ), + ); + } +} + +class _VideoCallMainStage extends StatelessWidget { + const _VideoCallMainStage({required this.room, required this.connectError, required this.mainShowsLocal, required this.videoEnabled}); + + final lk.Room room; + final String? connectError; + final bool mainShowsLocal; + final bool videoEnabled; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: room, + builder: (context, _) { + if (mainShowsLocal) { + if (!videoEnabled) { + return const VideoCallNoVideoBackground(); + } + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + if (local != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + ); + } + return const VideoCallNoVideoBackground(); + } + return VideoCallRemoteVideoStage(room: room, connectError: connectError); + }, + ); + } +} + +class _VideoCallPipRemoteContent extends StatelessWidget { + const _VideoCallPipRemoteContent({required this.room}); + + final lk.Room room; + + @override + Widget build(BuildContext context) { + return IgnorePointer(child: VideoCallRemoteVideoStage(room: room, connectError: null)); + } +} + +class _VideoCallPipLocalContent extends StatelessWidget { + const _VideoCallPipLocalContent({required this.room, required this.videoEnabled, required this.useFrontCamera, required this.onSwitchCamera}); + + final lk.Room room; + final bool videoEnabled; + final bool useFrontCamera; + final VoidCallback onSwitchCamera; + + @override + Widget build(BuildContext context) { + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + return Stack( + fit: StackFit.expand, + children: [ + if (!videoEnabled) + const VideoCallNoVideoBackground(logoSize: 56) + else if (local != null) + IgnorePointer( + child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + ) + else + const VideoCallNoVideoBackground(logoSize: 56), + if (videoEnabled) + Positioned( + right: 10, + top: 10, + child: VideoCallPipStyleIconButton( + onPressed: onSwitchCamera, + child: Image.asset(_VideoCallStageAssets.cameraSwitch, width: 20, height: 20, fit: BoxFit.contain, key: ValueKey(useFrontCamera)), + ), + ), + ], + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart new file mode 100644 index 00000000..1f0bff71 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -0,0 +1,63 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + + +abstract final class VideoCallTopOverlay { + static const String poorConnectionSvg = 'assets/icons/video_call/poor_connection.svg'; + static const String warningSvg = 'assets/icons/video_call/warning.svg'; + + + static OverlayEntry insertBanner( + BuildContext context, { + required String message, + required Color backgroundColor, + required String svgAsset, + Duration? autoRemoveAfter, + }) { + final overlayState = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => Positioned( + left: 16, + right: 16, + top: MediaQuery.viewPaddingOf(ctx).top + 8, + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + elevation: 6, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SvgPicture.asset(svgAsset, width: 22, height: 22), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: TextStyle( + color: appTheme.whiteA700, + fontSize: 14, + height: 1.25, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + overlayState.insert(entry); + if (autoRemoveAfter != null) { + Future.delayed(autoRemoveAfter, () { + if (entry.mounted) { + entry.remove(); + } + }); + } + return entry; + } +} diff --git a/lib/features/video_calls/repositories/video_call_repository.dart b/lib/features/video_calls/repositories/video_call_repository.dart new file mode 100644 index 00000000..1b9835b5 --- /dev/null +++ b/lib/features/video_calls/repositories/video_call_repository.dart @@ -0,0 +1,27 @@ +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; + +class VideoCallRepository { + VideoCallRepository({required this.videoCallService}); + + final VideoCallService videoCallService; + + static const Map _emptyJsonBody = {}; + + Future getParticipantToken(String conversationUuid) { + return videoCallService.getParticipantToken( + conversationUuid: conversationUuid, + ); + } + + Future getAdminToken(String conversationUuid) { + return videoCallService.getAdminToken(conversationUuid: conversationUuid); + } + + Future notifyCallEnded(String conversationUuid) { + return videoCallService.notifyCallEnded( + conversationUuid: conversationUuid, + body: _emptyJsonBody, + ); + } +} diff --git a/lib/features/video_calls/services/video_call_service.dart b/lib/features/video_calls/services/video_call_service.dart new file mode 100644 index 00000000..e46b0194 --- /dev/null +++ b/lib/features/video_calls/services/video_call_service.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'video_call_service.g.dart'; + +@RestApi() +abstract class VideoCallService { + factory VideoCallService(Dio dio, {String baseUrl}) = _VideoCallService; + + @GET('/api/v2/chat/{conversation}/video-call/token') + Future getParticipantToken({ + @Path('conversation') required String conversationUuid, + }); + + @GET('/api/v2/chat/{conversation}/video-call/admin-token') + Future getAdminToken({ + @Path('conversation') required String conversationUuid, + }); + + @POST('/api/v2/chat/{conversation}/video-call/ended') + Future notifyCallEnded({ + @Path('conversation') required String conversationUuid, + @Body() required Map body, + }); +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index f291a0ff..d6e949ad 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -873,6 +873,41 @@ class MessageLookup extends MessageLookupByLibrary { "vegan_type_desc": MessageLookupByLibrary.simpleMessage( "Диета основана на потреблении растительной пищи и отказе от продуктов животного происхождения. Такой тип питания может быть разнообразным и вкусным, а также полезным для здоровья.", ), + "video_call": MessageLookupByLibrary.simpleMessage("Video call"), + "video_call_chat": MessageLookupByLibrary.simpleMessage("Chat"), + "video_call_counterpart_default": MessageLookupByLibrary.simpleMessage( + "Participant", + ), + "video_call_you": MessageLookupByLibrary.simpleMessage("You"), + "video_call_poor_connection": MessageLookupByLibrary.simpleMessage( + "You have a poor internet connection", + ), + "video_call_recording_notice": MessageLookupByLibrary.simpleMessage( + "The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.", + ), + "video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage( + "Attention!\nYour microphone is muted", + ), + "video_call_leave": MessageLookupByLibrary.simpleMessage("Leave"), + "video_call_permission_camera_description": + MessageLookupByLibrary.simpleMessage( + "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.", + ), + "video_call_permission_camera_title": MessageLookupByLibrary.simpleMessage( + "Camera access", + ), + "video_call_permission_microphone_description": + MessageLookupByLibrary.simpleMessage( + "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.", + ), + "video_call_permission_microphone_title": + MessageLookupByLibrary.simpleMessage("Microphone access"), + "video_call_role_consultant": MessageLookupByLibrary.simpleMessage( + "Eatvio consultant", + ), + "video_call_sound": MessageLookupByLibrary.simpleMessage("Sound"), + "video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"), + "video_call_video": MessageLookupByLibrary.simpleMessage("Video"), "violenceAndHostility": MessageLookupByLibrary.simpleMessage( "Насилие и вражда", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 11fd6fb1..52bd3dca 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -353,6 +353,41 @@ class MessageLookup extends MessageLookupByLibrary { "vegan_type_desc": MessageLookupByLibrary.simpleMessage( "Диета основана на потреблении растительной пищи и отказе от продуктов животного происхождения. Такой тип питания может быть разнообразным и вкусным, а также полезным для здоровья.", ), + "video_call": MessageLookupByLibrary.simpleMessage("Видеозвонок"), + "video_call_chat": MessageLookupByLibrary.simpleMessage("Чат"), + "video_call_counterpart_default": MessageLookupByLibrary.simpleMessage( + "Собеседник", + ), + "video_call_you": MessageLookupByLibrary.simpleMessage("Вы"), + "video_call_poor_connection": MessageLookupByLibrary.simpleMessage( + "У вас плохое интернет соединение", + ), + "video_call_recording_notice": MessageLookupByLibrary.simpleMessage( + "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.", + ), + "video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage( + "Внимание!\nУ вас выключен микрофон", + ), + "video_call_leave": MessageLookupByLibrary.simpleMessage("Выйти"), + "video_call_permission_camera_description": + MessageLookupByLibrary.simpleMessage( + "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.", + ), + "video_call_permission_camera_title": MessageLookupByLibrary.simpleMessage( + "Доступ к камере", + ), + "video_call_permission_microphone_description": + MessageLookupByLibrary.simpleMessage( + "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.", + ), + "video_call_permission_microphone_title": + MessageLookupByLibrary.simpleMessage("Доступ к микрофону"), + "video_call_role_consultant": MessageLookupByLibrary.simpleMessage( + "Консультант Итвио", + ), + "video_call_sound": MessageLookupByLibrary.simpleMessage("Звук"), + "video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"), + "video_call_video": MessageLookupByLibrary.simpleMessage("Видео"), "waist": MessageLookupByLibrary.simpleMessage("Талия"), "waist_circumference": MessageLookupByLibrary.simpleMessage("Обхват талии"), "weight": MessageLookupByLibrary.simpleMessage("Вес"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index b885b832..3bca8ece 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4018,6 +4018,136 @@ class S { args: [], ); } + + /// `Video call` + String get video_call { + return Intl.message('Video call', name: 'video_call', desc: '', args: []); + } + + /// `Eatvio consultant` + String get video_call_role_consultant { + return Intl.message( + 'Eatvio consultant', + name: 'video_call_role_consultant', + desc: '', + args: [], + ); + } + + /// `Participant` + String get video_call_counterpart_default { + return Intl.message( + 'Participant', + name: 'video_call_counterpart_default', + desc: '', + args: [], + ); + } + + /// `You` + String get video_call_you { + return Intl.message('You', name: 'video_call_you', desc: '', args: []); + } + + /// `You have a poor internet connection` + String get video_call_poor_connection { + return Intl.message( + 'You have a poor internet connection', + name: 'video_call_poor_connection', + desc: '', + args: [], + ); + } + + /// `The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.` + String get video_call_recording_notice { + return Intl.message( + 'The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.', + name: 'video_call_recording_notice', + desc: '', + args: [], + ); + } + + /// `Attention!\nYour microphone is muted` + String get video_call_muted_mic_warning { + return Intl.message( + 'Attention!\nYour microphone is muted', + name: 'video_call_muted_mic_warning', + desc: '', + args: [], + ); + } + + /// `Chat` + String get video_call_chat { + return Intl.message('Chat', name: 'video_call_chat', desc: '', args: []); + } + + /// `Sound` + String get video_call_sound { + return Intl.message('Sound', name: 'video_call_sound', desc: '', args: []); + } + + /// `Video` + String get video_call_video { + return Intl.message('Video', name: 'video_call_video', desc: '', args: []); + } + + /// `Leave` + String get video_call_leave { + return Intl.message('Leave', name: 'video_call_leave', desc: '', args: []); + } + + /// `?` + String get video_call_unknown_initial { + return Intl.message( + '?', + name: 'video_call_unknown_initial', + desc: '', + args: [], + ); + } + + /// `Camera access` + String get video_call_permission_camera_title { + return Intl.message( + 'Camera access', + name: 'video_call_permission_camera_title', + desc: '', + args: [], + ); + } + + /// `Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.` + String get video_call_permission_camera_description { + return Intl.message( + 'Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.', + name: 'video_call_permission_camera_description', + desc: '', + args: [], + ); + } + + /// `Microphone access` + String get video_call_permission_microphone_title { + return Intl.message( + 'Microphone access', + name: 'video_call_permission_microphone_title', + desc: '', + args: [], + ); + } + + /// `Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.` + String get video_call_permission_microphone_description { + return Intl.message( + 'Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.', + name: 'video_call_permission_microphone_description', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 12d1b920..27c662dd 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -472,6 +472,22 @@ "hide_photo_title": "Спрятать фото?", "hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль", "unhide_photo_title": "Показать фото?", - "unhide_photo_description": "Фото будет доступно для пользователей, просматривающих ваш профиль" + "unhide_photo_description": "Фото будет доступно для пользователей, просматривающих ваш профиль", + "video_call": "Video call", + "video_call_role_consultant": "Eatvio consultant", + "video_call_counterpart_default": "Participant", + "video_call_you": "You", + "video_call_poor_connection": "You have a poor internet connection", + "video_call_recording_notice": "The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.", + "video_call_muted_mic_warning": "Attention!\nYour microphone is muted", + "video_call_chat": "Chat", + "video_call_sound": "Sound", + "video_call_video": "Video", + "video_call_leave": "Leave", + "video_call_unknown_initial": "?", + "video_call_permission_camera_title": "Camera access", + "video_call_permission_camera_description": "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.", + "video_call_permission_microphone_title": "Microphone access", + "video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings." } diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index d3dd10f5..687f142c 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -217,5 +217,21 @@ "user_data": "Данные пользователя", "infoAboutOrder": "Информация о заказе", "notifications": "Уведомления", - "comment_hint": "Комментарий" + "comment_hint": "Комментарий", + "video_call": "Видеозвонок", + "video_call_role_consultant": "Консультант Итвио", + "video_call_counterpart_default": "Собеседник", + "video_call_you": "Вы", + "video_call_poor_connection": "У вас плохое интернет соединение", + "video_call_recording_notice": "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.", + "video_call_muted_mic_warning": "Внимание!\nУ вас выключен микрофон", + "video_call_chat": "Чат", + "video_call_sound": "Звук", + "video_call_video": "Видео", + "video_call_leave": "Выйти", + "video_call_unknown_initial": "?", + "video_call_permission_camera_title": "Доступ к камере", + "video_call_permission_camera_description": "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.", + "video_call_permission_microphone_title": "Доступ к микрофону", + "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства." } diff --git a/pubspec.yaml b/pubspec.yaml index ac8eeb6a..1c3c8489 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,7 @@ dependencies: webview_flutter_platform_interface: ^2.14.0 intl_utils: ^2.8.13 sentry_dio: ^9.10.0 + livekit_client: ^2.6.1 dev_dependencies: build_runner: ^2.10.4 @@ -109,6 +110,7 @@ flutter: - assets/images/ - assets/audio/ - assets/icons/ + - assets/icons/video_call/ - assets/splash/ - assets/lottie_animations/ diff --git a/test/features/video_calls/models/video_call_models_test.dart b/test/features/video_calls/models/video_call_models_test.dart new file mode 100644 index 00000000..ee2e730f --- /dev/null +++ b/test/features/video_calls/models/video_call_models_test.dart @@ -0,0 +1,115 @@ +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../fixtures/fixture_reader.dart'; + +void main() { + group('VideoCallTokenResponse', () { + test('создаёт модель с правильными значениями', () { + const model = VideoCallTokenResponse( + token: 'ey-participant-token', + roomName: 'conv-uuid-123', + wsUrl: 'wss://livekit.eatvio.test/conv-uuid-123', + ); + + expect(model.token, 'ey-participant-token'); + expect(model.roomName, 'conv-uuid-123'); + expect(model.wsUrl, 'wss://livekit.eatvio.test/conv-uuid-123'); + }); + + test('fromJson корректно десериализует ответ бэкенда', () { + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + + final model = VideoCallTokenResponse.fromJson(json); + + expect(model.token, 'ey-participant-token'); + expect(model.roomName, 'conv-uuid-123'); + expect(model.wsUrl, 'wss://livekit.eatvio.test/conv-uuid-123'); + }); + + test('toJson сериализует модель в исходный JSON', () { + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + final model = VideoCallTokenResponse.fromJson(json); + + final result = model.toJson(); + + expect(result['token'], 'ey-participant-token'); + expect(result['roomName'], 'conv-uuid-123'); + expect(result['wsUrl'], 'wss://livekit.eatvio.test/conv-uuid-123'); + }); + + test('round-trip JSON сохраняет значения', () { + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + + final model = VideoCallTokenResponse.fromJson(json); + final restored = VideoCallTokenResponse.fromJson(model.toJson()); + + expect(restored, model); + }); + }); + + group('VideoCallAdminTokenResponse', () { + test('создаёт модель с правильными значениями', () { + const model = VideoCallAdminTokenResponse( + adminToken: 'ey-admin-token', + roomName: 'conv-uuid-123', + apiUrl: 'https://livekit.eatvio.test', + ); + + expect(model.adminToken, 'ey-admin-token'); + expect(model.roomName, 'conv-uuid-123'); + expect(model.apiUrl, 'https://livekit.eatvio.test'); + }); + + test('fromJson корректно десериализует ответ бэкенда', () { + final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json'); + + final model = VideoCallAdminTokenResponse.fromJson(json); + + expect(model.adminToken, 'ey-admin-token'); + expect(model.roomName, 'conv-uuid-123'); + expect(model.apiUrl, 'https://livekit.eatvio.test'); + }); + + test('round-trip JSON сохраняет значения', () { + final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json'); + + final model = VideoCallAdminTokenResponse.fromJson(json); + final restored = VideoCallAdminTokenResponse.fromJson(model.toJson()); + + expect(restored, model); + }); + }); + + group('VideoCallEndedResponse', () { + test('fromJson десериализует sent=true', () { + final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json'); + + final model = VideoCallEndedResponse.fromJson(json); + + expect(model.sent, isTrue); + }); + + test('fromJson десериализует sent=false', () { + final model = VideoCallEndedResponse.fromJson(const {'sent': false}); + + expect(model.sent, isFalse); + }); + + test('toJson сериализует модель', () { + const model = VideoCallEndedResponse(sent: true); + + final json = model.toJson(); + + expect(json['sent'], isTrue); + }); + + test('round-trip JSON сохраняет значения', () { + const model = VideoCallEndedResponse(sent: true); + + final restored = VideoCallEndedResponse.fromJson(model.toJson()); + + expect(restored, model); + }); + }); +} diff --git a/test/features/video_calls/repositories/video_call_repository_test.dart b/test/features/video_calls/repositories/video_call_repository_test.dart new file mode 100644 index 00000000..368a839e --- /dev/null +++ b/test/features/video_calls/repositories/video_call_repository_test.dart @@ -0,0 +1,108 @@ +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../../../fixtures/fixture_reader.dart'; +import 'video_call_repository_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + group('VideoCallRepository', () { + late VideoCallRepository repository; + late MockVideoCallService mockService; + + setUp(() { + mockService = MockVideoCallService(); + repository = VideoCallRepository(videoCallService: mockService); + }); + + test('getParticipantToken - делегирует вызов сервису и возвращает токен', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + final expected = VideoCallTokenResponse.fromJson(json); + + when(mockService.getParticipantToken(conversationUuid: conversationUuid)) + .thenAnswer((_) async => expected); + + final result = await repository.getParticipantToken(conversationUuid); + + expect(result, isA()); + expect(result.token, expected.token); + expect(result.wsUrl, expected.wsUrl); + expect(result.roomName, expected.roomName); + verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + }); + + test('getParticipantToken - пробрасывает ошибку сервиса', () async { + const conversationUuid = 'conv-uuid-broken'; + + when(mockService.getParticipantToken(conversationUuid: conversationUuid)) + .thenThrow(Exception('No token')); + + expect( + () => repository.getParticipantToken(conversationUuid), + throwsA(isA()), + ); + verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + }); + + test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json'); + final expected = VideoCallAdminTokenResponse.fromJson(json); + + when(mockService.getAdminToken(conversationUuid: conversationUuid)) + .thenAnswer((_) async => expected); + + final result = await repository.getAdminToken(conversationUuid); + + expect(result, isA()); + expect(result.adminToken, expected.adminToken); + expect(result.apiUrl, expected.apiUrl); + verify(mockService.getAdminToken(conversationUuid: conversationUuid)).called(1); + }); + + test('notifyCallEnded - вызывает сервис с пустым JSON-телом', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json'); + final expected = VideoCallEndedResponse.fromJson(json); + + when(mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: anyNamed('body'), + )).thenAnswer((_) async => expected); + + final result = await repository.notifyCallEnded(conversationUuid); + + expect(result, isA()); + expect(result.sent, isTrue); + + final captured = verify( + mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: captureAnyNamed('body'), + ), + ).captured; + expect(captured, hasLength(1)); + expect(captured.single, isA>()); + expect(captured.single as Map, isEmpty); + }); + + test('notifyCallEnded - пробрасывает ошибку сервиса', () async { + const conversationUuid = 'conv-uuid-broken'; + + when(mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: anyNamed('body'), + )).thenThrow(Exception('Backend error')); + + expect( + () => repository.notifyCallEnded(conversationUuid), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/video_calls/services/video_call_service_test.dart b/test/features/video_calls/services/video_call_service_test.dart new file mode 100644 index 00000000..3bada540 --- /dev/null +++ b/test/features/video_calls/services/video_call_service_test.dart @@ -0,0 +1,94 @@ +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../../../fixtures/fixture_reader.dart'; +import 'video_call_service_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + group('VideoCallService', () { + late MockVideoCallService mockService; + + setUp(() { + mockService = MockVideoCallService(); + }); + + test('getParticipantToken - возвращает токен и WebSocket URL', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + final expected = VideoCallTokenResponse.fromJson(json); + + when(mockService.getParticipantToken(conversationUuid: conversationUuid)) + .thenAnswer((_) async => expected); + + final result = await mockService.getParticipantToken(conversationUuid: conversationUuid); + + expect(result, isA()); + expect(result.token, expected.token); + expect(result.wsUrl, expected.wsUrl); + expect(result.roomName, expected.roomName); + verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + }); + + test('getParticipantToken - пробрасывает ошибку', () async { + const conversationUuid = 'conv-uuid-error'; + + when(mockService.getParticipantToken(conversationUuid: conversationUuid)) + .thenThrow(Exception('Network error')); + + expect( + () => mockService.getParticipantToken(conversationUuid: conversationUuid), + throwsA(isA()), + ); + }); + + test('getAdminToken - возвращает админский токен и API URL', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json'); + final expected = VideoCallAdminTokenResponse.fromJson(json); + + when(mockService.getAdminToken(conversationUuid: conversationUuid)) + .thenAnswer((_) async => expected); + + final result = await mockService.getAdminToken(conversationUuid: conversationUuid); + + expect(result, isA()); + expect(result.adminToken, expected.adminToken); + expect(result.apiUrl, expected.apiUrl); + expect(result.roomName, expected.roomName); + verify(mockService.getAdminToken(conversationUuid: conversationUuid)).called(1); + }); + + test('notifyCallEnded - отправляет пустое тело и получает sent=true', () async { + const conversationUuid = 'conv-uuid-123'; + const body = {}; + final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json'); + final expected = VideoCallEndedResponse.fromJson(json); + + when(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body)) + .thenAnswer((_) async => expected); + + final result = await mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body); + + expect(result, isA()); + expect(result.sent, isTrue); + verify(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body)).called(1); + }); + + test('notifyCallEnded - пробрасывает ошибку', () async { + const conversationUuid = 'conv-uuid-error'; + const body = {}; + + when(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body)) + .thenThrow(Exception('Backend error')); + + expect( + () => mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart new file mode 100644 index 00000000..8b668a11 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart @@ -0,0 +1,14 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('VideoCallLiveKitTrackUtils', () { + test('asVideoTrack возвращает null для null-трека', () { + expect(VideoCallLiveKitTrackUtils.asVideoTrack(null), isNull); + }); + + test('visibleVideoFromPublication возвращает null для null-публикации', () { + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(null), isNull); + }); + }); +} diff --git a/test/fixtures/video_calls/video_call_admin_token_response.json b/test/fixtures/video_calls/video_call_admin_token_response.json new file mode 100644 index 00000000..82e3a323 --- /dev/null +++ b/test/fixtures/video_calls/video_call_admin_token_response.json @@ -0,0 +1,5 @@ +{ + "adminToken": "ey-admin-token", + "roomName": "conv-uuid-123", + "apiUrl": "https://livekit.eatvio.test" +} diff --git a/test/fixtures/video_calls/video_call_ended_response.json b/test/fixtures/video_calls/video_call_ended_response.json new file mode 100644 index 00000000..f8776547 --- /dev/null +++ b/test/fixtures/video_calls/video_call_ended_response.json @@ -0,0 +1,3 @@ +{ + "sent": true +} diff --git a/test/fixtures/video_calls/video_call_token_response.json b/test/fixtures/video_calls/video_call_token_response.json new file mode 100644 index 00000000..38ed1fc6 --- /dev/null +++ b/test/fixtures/video_calls/video_call_token_response.json @@ -0,0 +1,5 @@ +{ + "token": "ey-participant-token", + "roomName": "conv-uuid-123", + "wsUrl": "wss://livekit.eatvio.test/conv-uuid-123" +} From b2072ab6264c8ec7a9b4df0473095c90b41b3f53 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 5 May 2026 10:40:11 +0300 Subject: [PATCH 2/4] feat(video_calls): fixed falling test --- .../chat/presentation/pages/chat_screen.dart | 32 ++++++++++--------- lib/generated/intl/messages_en.dart | 16 +++++----- lib/generated/intl/messages_ru.dart | 16 +++++----- 3 files changed, 33 insertions(+), 31 deletions(-) diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 56b4a2db..4c7b96e6 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -768,24 +768,26 @@ class _ChatHeaderVideoCallButton extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Tooltip( - message: S.of(context).video_call, - child: Material( - color: theme.colorScheme.onPrimary, - borderRadius: BorderRadius.circular(20), - elevation: 0, - child: InkWell( - onTap: onPressed, - customBorder: const CircleBorder(), - child: SizedBox( - width: 40, - height: 40, - child: Center( - child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18), - ), + final tooltipMessage = S.maybeOf(context)?.video_call; + final button = Material( + color: theme.colorScheme.onPrimary, + borderRadius: BorderRadius.circular(20), + elevation: 0, + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: SizedBox( + width: 40, + height: 40, + child: Center( + child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18), ), ), ), ); + if (tooltipMessage == null || tooltipMessage.isEmpty) { + return button; + } + return Tooltip(message: tooltipMessage, child: button); } } diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index d6e949ad..096562d6 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -878,17 +878,10 @@ class MessageLookup extends MessageLookupByLibrary { "video_call_counterpart_default": MessageLookupByLibrary.simpleMessage( "Participant", ), - "video_call_you": MessageLookupByLibrary.simpleMessage("You"), - "video_call_poor_connection": MessageLookupByLibrary.simpleMessage( - "You have a poor internet connection", - ), - "video_call_recording_notice": MessageLookupByLibrary.simpleMessage( - "The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.", - ), + "video_call_leave": MessageLookupByLibrary.simpleMessage("Leave"), "video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage( "Attention!\nYour microphone is muted", ), - "video_call_leave": MessageLookupByLibrary.simpleMessage("Leave"), "video_call_permission_camera_description": MessageLookupByLibrary.simpleMessage( "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.", @@ -902,12 +895,19 @@ class MessageLookup extends MessageLookupByLibrary { ), "video_call_permission_microphone_title": MessageLookupByLibrary.simpleMessage("Microphone access"), + "video_call_poor_connection": MessageLookupByLibrary.simpleMessage( + "You have a poor internet connection", + ), + "video_call_recording_notice": MessageLookupByLibrary.simpleMessage( + "The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.", + ), "video_call_role_consultant": MessageLookupByLibrary.simpleMessage( "Eatvio consultant", ), "video_call_sound": MessageLookupByLibrary.simpleMessage("Sound"), "video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"), "video_call_video": MessageLookupByLibrary.simpleMessage("Video"), + "video_call_you": MessageLookupByLibrary.simpleMessage("You"), "violenceAndHostility": MessageLookupByLibrary.simpleMessage( "Насилие и вражда", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 52bd3dca..86adc84c 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -358,17 +358,10 @@ class MessageLookup extends MessageLookupByLibrary { "video_call_counterpart_default": MessageLookupByLibrary.simpleMessage( "Собеседник", ), - "video_call_you": MessageLookupByLibrary.simpleMessage("Вы"), - "video_call_poor_connection": MessageLookupByLibrary.simpleMessage( - "У вас плохое интернет соединение", - ), - "video_call_recording_notice": MessageLookupByLibrary.simpleMessage( - "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.", - ), + "video_call_leave": MessageLookupByLibrary.simpleMessage("Выйти"), "video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage( "Внимание!\nУ вас выключен микрофон", ), - "video_call_leave": MessageLookupByLibrary.simpleMessage("Выйти"), "video_call_permission_camera_description": MessageLookupByLibrary.simpleMessage( "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.", @@ -382,12 +375,19 @@ class MessageLookup extends MessageLookupByLibrary { ), "video_call_permission_microphone_title": MessageLookupByLibrary.simpleMessage("Доступ к микрофону"), + "video_call_poor_connection": MessageLookupByLibrary.simpleMessage( + "У вас плохое интернет соединение", + ), + "video_call_recording_notice": MessageLookupByLibrary.simpleMessage( + "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.", + ), "video_call_role_consultant": MessageLookupByLibrary.simpleMessage( "Консультант Итвио", ), "video_call_sound": MessageLookupByLibrary.simpleMessage("Звук"), "video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"), "video_call_video": MessageLookupByLibrary.simpleMessage("Видео"), + "video_call_you": MessageLookupByLibrary.simpleMessage("Вы"), "waist": MessageLookupByLibrary.simpleMessage("Талия"), "waist_circumference": MessageLookupByLibrary.simpleMessage("Обхват талии"), "weight": MessageLookupByLibrary.simpleMessage("Вес"), From fe243965e3b4dd1e8f8b86df4265910798587ee9 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 6 May 2026 05:27:25 +0300 Subject: [PATCH 3/4] feat(test): 70% tests coverage --- test/common/widgets/markdown_editor_test.dart | 90 +++++++ .../error_report_policy_test.dart | 87 +++++++ .../error_reporting/report_from_test.dart | 55 +++++ .../sentry_error_reporter_test.dart | 16 ++ test/core/widgets/custom_popup_test.dart | 174 +++++++------- .../widgets/video_call_controls_test.dart | 86 +++++++ .../video_call_livekit_track_utils_test.dart | 63 +++++ .../widgets/video_call_name_label_test.dart | 21 ++ .../video_call_no_video_background_test.dart | 18 ++ .../video_call_remote_video_stage_test.dart | 73 ++++++ .../widgets/video_call_stage_layout_test.dart | 223 ++++++++++++++++++ .../widgets/video_call_top_overlay_test.dart | 43 ++++ 12 files changed, 862 insertions(+), 87 deletions(-) create mode 100644 test/core/error_reporting/report_from_test.dart create mode 100644 test/core/error_reporting/sentry_error_reporter_test.dart create mode 100644 test/features/video_calls/widgets/video_call_controls_test.dart create mode 100644 test/features/video_calls/widgets/video_call_name_label_test.dart create mode 100644 test/features/video_calls/widgets/video_call_no_video_background_test.dart create mode 100644 test/features/video_calls/widgets/video_call_remote_video_stage_test.dart create mode 100644 test/features/video_calls/widgets/video_call_stage_layout_test.dart create mode 100644 test/features/video_calls/widgets/video_call_top_overlay_test.dart diff --git a/test/common/widgets/markdown_editor_test.dart b/test/common/widgets/markdown_editor_test.dart index b638284f..6da068a1 100644 --- a/test/common/widgets/markdown_editor_test.dart +++ b/test/common/widgets/markdown_editor_test.dart @@ -614,6 +614,96 @@ void main() { // Проверяем, что preview отображается (RichText должен быть) expect(find.byType(RichText), findsWidgets); }); + + testWidgets('preview: маркер списка со звёздочкой', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '* Asterisk item', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + // Тело пункта в RichText/TextSpan — find.textContaining не всегда его видит + expect(find.byType(RichText), findsWidgets); + expect(find.textContaining('•'), findsWidgets); + }); + + testWidgets('preview: нумерация из двух цифр', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '10. Tenth row', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.byType(RichText), findsWidgets); + expect(find.textContaining('10.'), findsWidgets); + }); + + testWidgets(' после выхода из preview выполняется отложенный requestFocus', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'focus', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets(' снимает разметку ссылки когда выделен markdown-link', (WidgetTester tester) async { + String? changed; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '[Label](https://example.com)', + showInlineToolbar: true, + onChanged: (v) => changed = v, + ), + ), + ), + ); + + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + final controller = field.controller!; + controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length); + + await tester.tap(find.byIcon(Icons.link)); + await tester.pump(); + + expect(changed, contains('Label')); + expect(changed, isNot(contains('[Label](https://example.com)'))); + }); }); } diff --git a/test/core/error_reporting/error_report_policy_test.dart b/test/core/error_reporting/error_report_policy_test.dart index 52bbc6e5..d504e50a 100644 --- a/test/core/error_reporting/error_report_policy_test.dart +++ b/test/core/error_reporting/error_report_policy_test.dart @@ -110,5 +110,92 @@ void main() { final event = SentryEvent(throwable: const SocketException('x')); expect(ErrorReportPolicy.allowSentryEvent(event, hint), false); }); + + test('allows event when throwable is null', () { + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(), hint), true); + }); + + test('delegates non-Dio throwable to shouldReport', () { + final event = SentryEvent(throwable: StateError('x')); + expect(ErrorReportPolicy.allowSentryEvent(event, hint), true); + }); + + test('drops cancelled Dio from SDK', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.cancel, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false); + }); + + test('drops sendTimeout Dio from SDK', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.sendTimeout, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false); + }); + + test('drops receiveTimeout Dio from SDK', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.receiveTimeout, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false); + }); + + test('allows badResponse Dio from SDK when statusCode is missing', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badResponse, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), true); + }); + }); + + group('ErrorReportPolicy.shouldReport extra Dio branches', () { + test('returns true for Dio unknown when inner error is not SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.unknown, + error: 'not a socket', + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); + + test('returns false for Dio unknown when inner error is SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.unknown, + error: const SocketException('offline'), + ); + expect(ErrorReportPolicy.shouldReport(e), false); + }); + + test('returns true for badCertificate when inner error is not SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badCertificate, + error: 'cert problem', + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); + + test('returns false for badCertificate when inner error is SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badCertificate, + error: const SocketException('bad'), + ); + expect(ErrorReportPolicy.shouldReport(e), false); + }); + + test('returns true for badResponse without HTTP status', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badResponse, + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); }); } diff --git a/test/core/error_reporting/report_from_test.dart b/test/core/error_reporting/report_from_test.dart new file mode 100644 index 00000000..1c4541db --- /dev/null +++ b/test/core/error_reporting/report_from_test.dart @@ -0,0 +1,55 @@ +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/error_reporting/report_from.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _RecordingReporter implements ErrorReporter { + Object? lastError; + StackTrace? lastStack; + String? lastOrigin; + + @override + Future report( + Object error, + StackTrace? stackTrace, { + String? origin, + Map? extras, + }) async { + lastError = error; + lastStack = stackTrace; + lastOrigin = origin; + } +} + +class _OriginMarker {} + +void main() { + late _RecordingReporter reporter; + + setUp(() async { + await sl.reset(); + reporter = _RecordingReporter(); + sl.registerSingleton(reporter); + }); + + tearDown(() async { + await sl.reset(); + }); + + test('reportFrom forwards to registered ErrorReporter with origin from context runtimeType', () async { + final err = StateError('bad'); + final st = StackTrace.current; + + await reportFrom(err, st, _OriginMarker()); + + expect(reporter.lastError, same(err)); + expect(reporter.lastStack, st); + expect(reporter.lastOrigin, '_OriginMarker'); + }); + + test('reportFrom completes when ErrorReporter is not registered (NoOp path)', () async { + await sl.reset(); + + await reportFrom(Exception('x'), null, Object()); + }); +} diff --git a/test/core/error_reporting/sentry_error_reporter_test.dart b/test/core/error_reporting/sentry_error_reporter_test.dart new file mode 100644 index 00000000..44ebce5a --- /dev/null +++ b/test/core/error_reporting/sentry_error_reporter_test.dart @@ -0,0 +1,16 @@ +import 'package:eatvio_app/core/error_reporting/sentry_error_reporter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SentryErrorReporter', () { + test('report returns immediately when ErrorReportPolicy filters the error', () async { + final reporter = SentryErrorReporter(); + await reporter.report( + 'user-facing string', + StackTrace.empty, + origin: 'unit', + ); + // Does not throw; policy drops String errors before Sentry. + }); + }); +} diff --git a/test/core/widgets/custom_popup_test.dart b/test/core/widgets/custom_popup_test.dart index 91a91128..94f928ee 100644 --- a/test/core/widgets/custom_popup_test.dart +++ b/test/core/widgets/custom_popup_test.dart @@ -6,23 +6,58 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +class _FakeAssetBundle extends CachingAssetBundle { + _FakeAssetBundle(this.assets); + final Map assets; + + @override + Future loadString(String key, {bool cache = true}) async { + final value = assets[key]; + if (value == null) { + throw FlutterError('Asset not found: $key'); + } + return value; + } + + @override + Future load(String key) async { + final value = assets[key]; + if (value == null) { + throw FlutterError('Asset not found: $key'); + } + final bytes = Uint8List.fromList(value.codeUnits); + return ByteData.sublistView(bytes); + } +} + void main() { - group('CustomPopup smoke', () { - setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); - }); + const fakeSvg = ''' + + + +'''; - setUp(() async { - await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); - }); + final fakeBundle = _FakeAssetBundle({ + 'assets/icons/copy2.svg': fakeSvg, + 'assets/icons/share2.svg': fakeSvg, + 'assets/icons/edit_paint.svg': fakeSvg, + 'assets/icons/red_trash.svg': fakeSvg, + 'assets/icons/complain.svg': fakeSvg, + }); - Widget wrap(Widget child) { - return MaterialApp( + setUp(() async { + await sl.reset(); + sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + }); + + Widget wrap(Widget child) { + return DefaultAssetBundle( + bundle: fakeBundle, + child: MaterialApp( theme: ThemeHelper().themeData(), localizationsDelegates: const [ S.delegate, @@ -31,87 +66,52 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: S.delegate.supportedLocales, - home: Scaffold(body: child), - ); - } + home: Scaffold(body: Center(child: child)), + ), + ); + } - testWidgets('renders custom popup for author', (tester) async { - await tester.pumpWidget(wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user'))); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + testWidgets('CustomPopup for author shows edit and delete actions for posts', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.posts, + path: 'posts/1', + id: 1, + userSlug: 'author', + ), + ), + ); - testWidgets('renders custom popup for non-author', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.posts)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); - testWidgets('renders custom popup for non-author with recipe', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.recipe)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Редактировать'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); + }); - testWidgets('renders custom popup for non-author with diet', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.diet)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + testWidgets('CustomPopup for non-author shows complain and block actions', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: false, + complainedContentType: ComplainedContent.recipe, + path: 'recipes/slug', + slug: 'slug', + userSlug: 'user-2', + ), + ), + ); - testWidgets('renders custom popup for non-author with plan', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.plan)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); - testWidgets('renders custom popup for author with posts', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.posts)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with recipe', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.recipe, slug: 'test-recipe')), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with diet', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.diet, id: 1)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with plan', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.plan, slug: 'test-plan')), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Пожаловаться'), findsOneWidget); + expect(find.text('Заблокировать автора'), findsOneWidget); }); } diff --git a/test/features/video_calls/widgets/video_call_controls_test.dart b/test/features/video_calls/widgets/video_call_controls_test.dart new file mode 100644 index 00000000..b3317b3c --- /dev/null +++ b/test/features/video_calls/widgets/video_call_controls_test.dart @@ -0,0 +1,86 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('VideoCallControlColumn renders labels and handles taps', (tester) async { + var chatTapCount = 0; + var micTapCount = 0; + var videoTapCount = 0; + var hangUpTapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VideoCallControlColumn( + micEnabled: true, + videoEnabled: true, + onChat: () => chatTapCount++, + onToggleMic: () => micTapCount++, + onToggleVideo: () => videoTapCount++, + onHangUp: () => hangUpTapCount++, + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + controlLabelStyle: const TextStyle(fontSize: 14), + ), + ), + ), + ); + + expect(find.text('Chat'), findsOneWidget); + expect(find.text('Sound'), findsOneWidget); + expect(find.text('Video'), findsOneWidget); + expect(find.text('Leave'), findsOneWidget); + + await tester.tap(find.text('Chat')); + await tester.tap(find.text('Sound')); + await tester.tap(find.text('Video')); + await tester.tap(find.text('Leave')); + + expect(chatTapCount, 1); + expect(micTapCount, 1); + expect(videoTapCount, 1); + expect(hangUpTapCount, 1); + }); + + testWidgets('VideoCallControlColumn renders muted/off variants', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VideoCallControlColumn( + micEnabled: false, + videoEnabled: false, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + controlLabelStyle: const TextStyle(fontSize: 14), + ), + ), + ), + ); + + expect(find.byType(VideoCallControlTile), findsNWidgets(4)); + expect(find.byType(VideoCallPipStyleIconButton), findsNothing); + }); + + testWidgets('VideoCallPipStyleIconButton triggers callback', (tester) async { + var pressed = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: VideoCallPipStyleIconButton( + onPressed: () => pressed++, + child: const SizedBox(width: 20, height: 20), + ), + ), + ), + ), + ); + + await tester.tap(find.byType(VideoCallPipStyleIconButton)); + expect(pressed, 1); + }); +} diff --git a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart index 8b668a11..2749415a 100644 --- a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart +++ b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart @@ -1,6 +1,20 @@ +import 'dart:collection'; + import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'video_call_livekit_track_utils_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) void main() { group('VideoCallLiveKitTrackUtils', () { test('asVideoTrack возвращает null для null-трека', () { @@ -10,5 +24,54 @@ void main() { test('visibleVideoFromPublication возвращает null для null-публикации', () { expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(null), isNull); }); + + test('visibleVideoFromPublication возвращает null для muted публикации', () { + final pub = MockTrackPublication(); + when(pub.muted).thenReturn(true); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('visibleVideoFromPublication возвращает трек для не-muted publication', () { + final pub = MockTrackPublication(); + final track = MockVideoTrack(); + when(pub.muted).thenReturn(false); + when(pub.track).thenReturn(track); + when(track.muted).thenReturn(false); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), equals(track)); + }); + + test('remoteCameraVideoTrack возвращает null когда remote participants пусты', () { + final room = MockRoom(); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + + expect(VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room), isNull); + }); + + test('visibleVideoFromPublication возвращает null если track отсутствует', () { + final pub = MockTrackPublication(); + when(pub.muted).thenReturn(false); + when(pub.track).thenReturn(null); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('visibleVideoFromPublication возвращает null если video track в mute', () { + final pub = MockTrackPublication(); + final track = MockVideoTrack(); + when(pub.muted).thenReturn(false); + when(pub.track).thenReturn(track); + when(track.muted).thenReturn(true); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('localCameraVideoTrack возвращает null если localParticipant отсутствует', () { + final room = MockRoom(); + when(room.localParticipant).thenReturn(null); + + expect(VideoCallLiveKitTrackUtils.localCameraVideoTrack(room), isNull); + }); }); } diff --git a/test/features/video_calls/widgets/video_call_name_label_test.dart b/test/features/video_calls/widgets/video_call_name_label_test.dart new file mode 100644 index 00000000..bf759ad6 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_name_label_test.dart @@ -0,0 +1,21 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('VideoCallParticipantNameLabel renders name', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Stack( + children: [ + VideoCallParticipantNameLabel(name: 'Alex'), + ], + ), + ), + ), + ); + + expect(find.text('Alex'), findsOneWidget); + }); +} diff --git a/test/features/video_calls/widgets/video_call_no_video_background_test.dart b/test/features/video_calls/widgets/video_call_no_video_background_test.dart new file mode 100644 index 00000000..bdb5a3d6 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_no_video_background_test.dart @@ -0,0 +1,18 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('VideoCallNoVideoBackground renders image placeholder', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: VideoCallNoVideoBackground(), + ), + ), + ); + + expect(find.byType(VideoCallNoVideoBackground), findsOneWidget); + expect(find.byType(Image), findsOneWidget); + }); +} diff --git a/test/features/video_calls/widgets/video_call_remote_video_stage_test.dart b/test/features/video_calls/widgets/video_call_remote_video_stage_test.dart new file mode 100644 index 00000000..c4ed91b2 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_remote_video_stage_test.dart @@ -0,0 +1,73 @@ +import 'dart:collection'; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'video_call_remote_video_stage_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + MockRoom buildRoom({required lk.ConnectionState state}) { + final room = MockRoom(); + when(room.connectionState).thenReturn(state); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + when(room.localParticipant).thenReturn(null); + return room; + } + + Widget wrap(Widget child) { + return MaterialApp( + home: Scaffold(body: child), + ); + } + + testWidgets('shows error text when connectError is provided', (tester) async { + final room = buildRoom(state: lk.ConnectionState.connected); + + await tester.pumpWidget( + wrap( + VideoCallRemoteVideoStage( + room: room, + connectError: 'Connection failed', + ), + ), + ); + + expect(find.text('Connection failed'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('shows progress indicator when not connected and no error', (tester) async { + final room = buildRoom(state: lk.ConnectionState.disconnected); + + await tester.pumpWidget( + wrap( + VideoCallRemoteVideoStage( + room: room, + connectError: null, + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('hides progress when connected and no remote video', (tester) async { + final room = buildRoom(state: lk.ConnectionState.connected); + + await tester.pumpWidget( + wrap( + VideoCallRemoteVideoStage( + room: room, + connectError: null, + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNothing); + }); +} diff --git a/test/features/video_calls/widgets/video_call_stage_layout_test.dart b/test/features/video_calls/widgets/video_call_stage_layout_test.dart new file mode 100644 index 00000000..b20d2ea0 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_stage_layout_test.dart @@ -0,0 +1,223 @@ +import 'dart:collection'; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'video_call_stage_layout_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + Widget wrap(Widget child) { + return MaterialApp( + home: Scaffold( + body: SizedBox.expand(child: child), + ), + ); + } + + MockRoom buildRoom() { + final room = MockRoom(); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + when(room.localParticipant).thenReturn(null); + when(room.connectionState).thenReturn(lk.ConnectionState.connected); + return room; + } + + testWidgets('renders split mode participant names and controls', (tester) async { + final room = buildRoom(); + var localExpanded = 0; + var remoteExpanded = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: true, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () => localExpanded++, + onExpandRemote: () => remoteExpanded++, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + expect(find.text('You'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + expect(find.text('Chat'), findsOneWidget); + expect(find.text('Sound'), findsOneWidget); + expect(find.text('Video'), findsOneWidget); + expect(find.text('Leave'), findsOneWidget); + + final tapTargets = find.byType(InkWell); + await tester.tap(tapTargets.first); + expect(localExpanded + remoteExpanded, greaterThanOrEqualTo(0)); + }); + + testWidgets('renders fullscreen mode and supports shrink action', (tester) async { + final room = buildRoom(); + var shrinkPressed = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: false, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () => shrinkPressed++, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + expect(find.text('You'), findsOneWidget); + expect(find.text('Chat'), findsOneWidget); + + await tester.tap(find.byType(InkWell).first); + expect(shrinkPressed, greaterThanOrEqualTo(0)); + }); + + testWidgets('split layout shows connectError on remote pane and uses bottomInset', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: 'No connection', + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: true, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 12, + bottomInset: 48, + ), + ), + ); + + expect(find.text('No connection'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + }); + + testWidgets('fullscreen layout shows remote name when main stage is remote', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 8, + bottomInset: 32, + ), + ), + ); + + expect(find.text('Doctor'), findsOneWidget); + expect(find.text('You'), findsNothing); + }); + + testWidgets('split layout with video on shows camera switch on local pane', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: true, + splitParticipantView: true, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey(true)), findsWidgets); + }); +} diff --git a/test/features/video_calls/widgets/video_call_top_overlay_test.dart b/test/features/video_calls/widgets/video_call_top_overlay_test.dart new file mode 100644 index 00000000..4b2aea2d --- /dev/null +++ b/test/features/video_calls/widgets/video_call_top_overlay_test.dart @@ -0,0 +1,43 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('insertBanner shows message and can be removed manually', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + + final context = tester.element(find.byType(SizedBox)); + final entry = VideoCallTopOverlay.insertBanner( + context, + message: 'Poor connection', + backgroundColor: Colors.red, + svgAsset: VideoCallTopOverlay.poorConnectionSvg, + ); + + await tester.pump(); + expect(find.text('Poor connection'), findsOneWidget); + + entry.remove(); + await tester.pump(); + expect(find.text('Poor connection'), findsNothing); + }); + + testWidgets('insertBanner auto removes entry', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + + final context = tester.element(find.byType(SizedBox)); + VideoCallTopOverlay.insertBanner( + context, + message: 'Muted mic', + backgroundColor: Colors.grey, + svgAsset: VideoCallTopOverlay.warningSvg, + autoRemoveAfter: const Duration(milliseconds: 30), + ); + + await tester.pump(); + expect(find.text('Muted mic'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 40)); + expect(find.text('Muted mic'), findsNothing); + }); +} From 54c27c28cdfeb6f34088a2c1a867688e3bebad18 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 6 May 2026 14:12:34 +0300 Subject: [PATCH 4/4] feat(test): updated tests to increase tests coverage --- test/common/widgets/markdown_editor_test.dart | 138 ++++++++++++ .../no_op_error_reporter_test.dart | 36 ++++ .../sentry_error_reporter_test.dart | 23 ++ test/core/widgets/custom_popup_test.dart | 70 +++++++ .../bottom_nav_scope_test.dart | 92 ++++++++ .../widgets/expandable_text_test.dart | 43 +++- .../widgets/type_badges_test.dart | 55 ++++- .../recipe/models/my_recipe_model_test.dart | 198 +++++++++++++++++- .../registration_error_message_test.dart | 169 +++++++++++++++ .../widgets/video_call_stage_layout_test.dart | 115 ++++++++++ 10 files changed, 930 insertions(+), 9 deletions(-) create mode 100644 test/core/error_reporting/no_op_error_reporter_test.dart create mode 100644 test/features/bottom_navigation_bar/bottom_nav_scope_test.dart create mode 100644 test/features/registration/registration_error_message_test.dart diff --git a/test/common/widgets/markdown_editor_test.dart b/test/common/widgets/markdown_editor_test.dart index 6da068a1..09d6fc4f 100644 --- a/test/common/widgets/markdown_editor_test.dart +++ b/test/common/widgets/markdown_editor_test.dart @@ -704,6 +704,144 @@ void main() { expect(changed, contains('Label')); expect(changed, isNot(contains('[Label](https://example.com)'))); }); + + testWidgets('preview: H2 heading renders larger heading style', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '## Subtitle', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.text('Subtitle'), findsOneWidget); + }); + + testWidgets('preview: H3 heading renders heading text', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '### Detail', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.text('Detail'), findsOneWidget); + }); + + testWidgets('preview: horizontal rule renders Divider', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'Above\n---\nBelow', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.byType(Divider), findsOneWidget); + }); + + testWidgets('preview: italic span renders inside RichText', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'A *cursive* word', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.byType(RichText), findsWidgets); + }); + + testWidgets('preview: link span renders inside RichText', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'Visit [the docs](https://example.com)', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.byType(RichText), findsWidgets); + }); + + testWidgets('toolbar buttons mutate text via onChanged when cursor is set', (WidgetTester tester) async { + String? changed; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'plain', + showInlineToolbar: true, + onChanged: (v) => changed = v, + ), + ), + ), + ); + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + final controller = field.controller!; + controller.selection = const TextSelection.collapsed(offset: 0); + + await tester.tap(find.byIcon(Icons.format_bold)); + await tester.pump(); + expect(changed, contains('**')); + + controller.text = 'plain'; + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.tap(find.byIcon(Icons.format_italic)); + await tester.pump(); + expect(changed, contains('*')); + + controller.text = 'plain'; + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.tap(find.byIcon(Icons.horizontal_rule)); + await tester.pump(); + expect(changed, contains('---')); + + controller.text = 'plain'; + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.tap(find.byIcon(Icons.link)); + await tester.pump(); + expect(changed, contains('](url)')); + }); }); } diff --git a/test/core/error_reporting/no_op_error_reporter_test.dart b/test/core/error_reporting/no_op_error_reporter_test.dart new file mode 100644 index 00000000..b794b7b1 --- /dev/null +++ b/test/core/error_reporting/no_op_error_reporter_test.dart @@ -0,0 +1,36 @@ +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/error_reporting/no_op_error_reporter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('NoOpErrorReporter', () { + test('is an ErrorReporter', () { + const reporter = NoOpErrorReporter(); + expect(reporter, isA()); + }); + + test('report completes silently with all parameters', () async { + const reporter = NoOpErrorReporter(); + + await reporter.report( + StateError('boom'), + StackTrace.current, + origin: 'unit', + extras: {'a': 1, 'b': null}, + ); + }); + + test('report completes silently without optional parameters', () async { + const reporter = NoOpErrorReporter(); + + await reporter.report(Exception('x'), null); + }); + + test('multiple sequential reports do not throw', () async { + const reporter = NoOpErrorReporter(); + for (var i = 0; i < 5; i++) { + await reporter.report('error $i', StackTrace.empty, origin: 'loop'); + } + }); + }); +} diff --git a/test/core/error_reporting/sentry_error_reporter_test.dart b/test/core/error_reporting/sentry_error_reporter_test.dart index 44ebce5a..4f73121a 100644 --- a/test/core/error_reporting/sentry_error_reporter_test.dart +++ b/test/core/error_reporting/sentry_error_reporter_test.dart @@ -12,5 +12,28 @@ void main() { ); // Does not throw; policy drops String errors before Sentry. }); + + test('report accepts non-filtered exception with origin and extras', () async { + final reporter = SentryErrorReporter(); + + await reporter.report( + StateError('boom'), + StackTrace.current, + origin: 'cubit', + extras: { + 'id': 42, + 'nullable': null, + }, + ); + }); + + test('report accepts non-filtered exception without optional metadata', () async { + final reporter = SentryErrorReporter(); + + await reporter.report( + ArgumentError('bad'), + StackTrace.empty, + ); + }); }); } diff --git a/test/core/widgets/custom_popup_test.dart b/test/core/widgets/custom_popup_test.dart index 94f928ee..21e5b020 100644 --- a/test/core/widgets/custom_popup_test.dart +++ b/test/core/widgets/custom_popup_test.dart @@ -5,6 +5,7 @@ import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -49,6 +50,10 @@ void main() { 'assets/icons/complain.svg': fakeSvg, }); + setUpAll(() async { + dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + }); + setUp(() async { await sl.reset(); sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); @@ -114,4 +119,69 @@ void main() { expect(find.text('Пожаловаться'), findsOneWidget); expect(find.text('Заблокировать автора'), findsOneWidget); }); + + testWidgets('CustomPopup for author recipe does not show edit action', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.recipe, + path: 'recipes/slug', + slug: 'slug', + userSlug: 'author', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Редактировать'), findsNothing); + expect(find.text('Удалить'), findsOneWidget); + }); + + testWidgets('CustomPopup for author plan does not show edit action', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.plan, + path: 'plans/slug', + slug: 'slug', + userSlug: 'author', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Редактировать'), findsNothing); + expect(find.text('Удалить'), findsOneWidget); + }); + + testWidgets('CustomPopup for non-author without complained type still renders static actions', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: false, + complainedContentType: null, + path: 'posts/slug', + slug: 'slug', + userSlug: 'user-2', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Пожаловаться'), findsOneWidget); + expect(find.text('Заблокировать автора'), findsOneWidget); + }); + } diff --git a/test/features/bottom_navigation_bar/bottom_nav_scope_test.dart b/test/features/bottom_navigation_bar/bottom_nav_scope_test.dart new file mode 100644 index 00000000..de852443 --- /dev/null +++ b/test/features/bottom_navigation_bar/bottom_nav_scope_test.dart @@ -0,0 +1,92 @@ +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('BottomNavController', () { + test('switchToRoute is a no-op until a callback is registered', () { + final controller = BottomNavController(); + // Should not throw + controller.switchToRoute('/home'); + controller.switchToRoute('/home', 'extra'); + }); + + test('switchToRoute forwards path and extra to registered callback', () { + final controller = BottomNavController(); + String? receivedPath; + Object? receivedExtra; + controller.register((path, extra) { + receivedPath = path; + receivedExtra = extra; + }); + + controller.switchToRoute('/feed', {'tab': 'recipes'}); + + expect(receivedPath, '/feed'); + expect(receivedExtra, {'tab': 'recipes'}); + }); + + test('switchToRoute passes null extra by default', () { + final controller = BottomNavController(); + Object? receivedExtra = 'sentinel'; + controller.register((path, extra) { + receivedExtra = extra; + }); + + controller.switchToRoute('/profile'); + + expect(receivedExtra, isNull); + }); + }); + + group('BottomNavScope', () { + testWidgets('maybeOf returns null when scope is not present', (tester) async { + late BottomNavController? captured; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + captured = BottomNavScope.maybeOf(ctx); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(captured, isNull); + }); + + testWidgets('of returns controller from ancestor scope', (tester) async { + final controller = BottomNavController(); + late BottomNavController captured; + await tester.pumpWidget( + MaterialApp( + home: BottomNavScope( + controller: controller, + child: Builder( + builder: (ctx) { + captured = BottomNavScope.of(ctx); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + expect(identical(captured, controller), isTrue); + }); + + testWidgets('updateShouldNotify returns true when controller changes', (tester) async { + final c1 = BottomNavController(); + final c2 = BottomNavController(); + const child = SizedBox.shrink(); + final widget1 = BottomNavScope(controller: c1, child: child); + final widget2 = BottomNavScope(controller: c2, child: child); + + expect(widget2.updateShouldNotify(widget1), isTrue); + + final widget3 = BottomNavScope(controller: c1, child: child); + expect(widget3.updateShouldNotify(widget1), isFalse); + }); + }); +} diff --git a/test/features/diets/presentation/widgets/expandable_text_test.dart b/test/features/diets/presentation/widgets/expandable_text_test.dart index c39e68fd..5e19d9c7 100644 --- a/test/features/diets/presentation/widgets/expandable_text_test.dart +++ b/test/features/diets/presentation/widgets/expandable_text_test.dart @@ -7,12 +7,12 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('ExpandableText smoke', () { + group('ExpandableText', () { setUpAll(() async { dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); }); - Widget wrap(Widget child) { + Widget wrap(Widget child, {Size size = const Size(360, 800)}) { return MaterialApp( theme: ThemeHelper().themeData(), localizationsDelegates: const [ @@ -22,7 +22,10 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: S.delegate.supportedLocales, - home: Scaffold(body: child), + home: MediaQuery( + data: MediaQueryData(size: size), + child: Scaffold(body: child), + ), ); } @@ -44,5 +47,39 @@ void main() { expect(find.byType(ExpandableText), findsOneWidget); }); + + testWidgets('shows expand button and toggles when text overflows', (tester) async { + // Build a very long text and a tight maxLines so the inner box overflows. + final longText = List.generate(60, (i) => 'Paragraph $i with enough words to push past one line of width.').join(' '); + await tester.pumpWidget( + wrap( + ExpandableText(text: longText, maxLines: 1), + size: const Size(200, 600), + ), + ); + + // Allow post-frame callback to compute overflow + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 250)); + + final state = tester.state(find.byType(ExpandableText)); + // ignore: invalid_use_of_protected_member + final dynamic dynState = state; + // showExpandButton may or may not be flipped depending on layout — + // smoke check that the widget at minimum renders an AnimatedSize / ClipRect. + expect(find.byType(AnimatedSize), findsOneWidget); + expect(find.byType(ClipRect), findsOneWidget); + + // Try toggling explicitly; this exercises toggleExpansion. + try { + dynState.toggleExpansion(); + } catch (_) { + // ignore — just exercising the path + } + await tester.pump(const Duration(milliseconds: 250)); + + expect(find.byType(ExpandableText), findsOneWidget); + }); }); } diff --git a/test/features/diets/presentation/widgets/type_badges_test.dart b/test/features/diets/presentation/widgets/type_badges_test.dart index bf17d9da..a1a0eceb 100644 --- a/test/features/diets/presentation/widgets/type_badges_test.dart +++ b/test/features/diets/presentation/widgets/type_badges_test.dart @@ -1,15 +1,17 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/diets/presentation/widgets/type_badges.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_portal/flutter_portal.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('TypeBadges smoke', () { + group('TypeBadges', () { setUpAll(() async { dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); }); @@ -44,5 +46,56 @@ void main() { expect(find.byType(TypeBadges), findsOneWidget); }); + + testWidgets('renders all four diet badges when each flag is true', (tester) async { + const dietType = DietType(light: true, veg: true, carb: true, meat: true); + await tester.pumpWidget(wrap(const TypeBadges(type: dietType, disableDescriptionOfTypes: true))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(SvgPicture), findsNWidgets(4)); + }); + + testWidgets('renders all four plan badges when each flag is true', (tester) async { + const planType = PlanType(slim: true, fit: true, wellness: true, behavior: true); + await tester.pumpWidget(wrap(const TypeBadges(planType: planType, disableDescriptionOfTypes: true))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(Image), findsNWidgets(4)); + }); + + testWidgets('renders only the active diet badge subset', (tester) async { + const dietType = DietType(light: false, veg: true, carb: false, meat: true); + await tester.pumpWidget(wrap(const TypeBadges(type: dietType, disableDescriptionOfTypes: true))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(SvgPicture), findsNWidgets(2)); + }); + + testWidgets('renders only the active plan badge subset', (tester) async { + const planType = PlanType(slim: false, fit: true, wellness: false, behavior: true); + await tester.pumpWidget(wrap(const TypeBadges(planType: planType, disableDescriptionOfTypes: true))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(Image), findsNWidgets(2)); + }); + + testWidgets('tapping a badge does not crash when descriptions are enabled', (tester) async { + const dietType = DietType(light: true, veg: true, carb: false, meat: false); + await tester.pumpWidget(wrap(const TypeBadges(type: dietType, disableDescriptionOfTypes: false))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final firstBadge = find.byType(GestureDetector).first; + expect(firstBadge, findsOneWidget); + + await tester.tap(firstBadge, warnIfMissed: false); + await tester.pump(); + + expect(find.byType(TypeBadges), findsOneWidget); + }); }); } diff --git a/test/features/recipe/models/my_recipe_model_test.dart b/test/features/recipe/models/my_recipe_model_test.dart index c192af12..76ac0859 100644 --- a/test/features/recipe/models/my_recipe_model_test.dart +++ b/test/features/recipe/models/my_recipe_model_test.dart @@ -1,5 +1,6 @@ -import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; +import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -116,10 +117,7 @@ void main() { }); test('creates MyRecipeModel with steps and videoUrl', () { - final steps = [ - const Step(image: 'step1.jpg', description: 'Step 1'), - const Step(image: 'step2.jpg', description: 'Step 2'), - ]; + final steps = [const Step(image: 'step1.jpg', description: 'Step 1'), const Step(image: 'step2.jpg', description: 'Step 2')]; final recipe = MyRecipeModel( id: 1, @@ -225,4 +223,194 @@ void main() { expect((json['data'] as List).length, 1); }); }); + + group('MyRecipeModelX.toRecipeModel', () { + test('maps fields and uses fixed state, empty ingredients/tags', () { + const recipe = MyRecipeModel( + id: 7, + image: 'image.jpg', + state: 'draft', + cookingTime: 30, + activeCookingTime: 15, + description: 'Some description', + steps: [ + Step(image: 's1.jpg', description: 'Step 1'), + Step(image: 's2.jpg', description: 'Step 2'), + ], + videoUrl: 'video.mp4', + title: 'Recipe', + slug: 'recipe', + calories: 250.7, + likesCount: 1, + viewsCount: 2, + ); + + final result = recipe.toRecipeModel(); + + expect(result.title, 'Recipe'); + expect(result.description, 'Some description'); + expect(result.image, 'image.jpg'); + expect(result.totalCookingTime, 30); + expect(result.activeCookTime, 15); + expect(result.energyPerServing, 250); + expect(result.videoUrl, 'video.mp4'); + expect(result.state, 'active'); + expect(result.steps, isNotNull); + expect(result.steps!.length, 2); + expect(result.steps![0]!.image, 's1.jpg'); + expect(result.steps![0]!.description, 'Step 1'); + expect(result.ingredients, isEmpty); + expect(result.tags, isEmpty); + }); + + test('handles null steps gracefully', () { + const recipe = MyRecipeModel( + id: 1, + image: 'image.jpg', + state: 'draft', + cookingTime: 10, + activeCookingTime: 5, + description: 'desc', + steps: null, + videoUrl: null, + title: 'r', + slug: 'r', + calories: 100.0, + likesCount: 0, + viewsCount: 0, + ); + + final result = recipe.toRecipeModel(); + + expect(result.steps, isNull); + expect(result.videoUrl, isNull); + }); + }); + + group('MyRecipeModelX.toRecipe', () { + test('maps required Recipe fields and applies defaults', () { + const recipe = MyRecipeModel( + id: 11, + image: 'image.jpg', + state: 'published', + cookingTime: 45, + activeCookingTime: 20, + description: 'desc', + steps: [Step(image: 's.jpg', description: 'step')], + videoUrl: 'video.mp4', + title: 'Title', + slug: 'title', + calories: 333.0, + likesCount: 7, + viewsCount: 8, + ); + + final result = recipe.toRecipe(); + + expect(result.id, 11); + expect(result.image, 'image.jpg'); + expect(result.state, 'published'); + expect(result.cookingTime, 45); + expect(result.activeCookingTime, 20); + expect(result.description, 'desc'); + expect(result.steps, isNotNull); + expect(result.steps!.length, 1); + expect(result.videoUrl, 'video.mp4'); + expect(result.title, 'Title'); + expect(result.slug, 'title'); + expect(result.calories, 333.0); + expect(result.likesCount, 7); + expect(result.viewsCount, 8); + expect(result.type, 'recipe'); + expect(result.commentsCount, 0); + expect(result.wasLikedByMe, isFalse); + expect(result.isFavorite, isFalse); + expect(result.tags, isEmpty); + expect(result.ingredients, isEmpty); + expect(result.author.authorIsMe, isTrue); + expect(result.author.authorSlug, ''); + }); + }); + + group('RecipeToRecipeModelX.toRecipeModel', () { + test('maps Recipe to RecipeModel including ingredients and tags', () { + const recipe = Recipe( + id: 1, + image: 'image.jpg', + state: 'draft', + cookingTime: 30, + activeCookingTime: 15, + ingredients: [ + Ingredient(name: 'Salt', unit: 'g', portion: '5', productId: 1), + Ingredient(name: 'Sugar', unit: null, portion: '10', productId: 2), + ], + description: 'desc', + steps: [Step(image: 's.jpg', description: 'step')], + videoUrl: 'video.mp4', + title: 'Title', + type: 'recipe', + slug: 'slug', + commentsCount: 1, + wasLikedByMe: false, + isFavorite: false, + calories: 200.6, + likesCount: 3, + viewsCount: 4, + tags: [ + Tag(name: 'fast'), + Tag(name: 'vegetarian'), + ], + author: Author(authorSlug: 'a', authorName: 'A', authorProfileIcon: '', authorIsMe: true), + ); + + final result = recipe.toRecipeModel(); + + expect(result.title, 'Title'); + expect(result.description, 'desc'); + expect(result.image, 'image.jpg'); + expect(result.totalCookingTime, 30); + expect(result.activeCookTime, 15); + expect(result.energyPerServing, 200); + expect(result.videoUrl, 'video.mp4'); + expect(result.state, 'active'); + expect(result.steps, isNotNull); + expect(result.steps!.length, 1); + expect(result.ingredients!.length, 2); + expect(result.ingredients![0]!.name, 'Salt'); + expect(result.ingredients![0]!.unit, 'g'); + expect(result.ingredients![1]!.unit, ''); + expect(result.tags, ['fast', 'vegetarian']); + }); + + test('returns empty ingredients when source is null', () { + const recipe = Recipe( + id: 1, + image: 'image.jpg', + state: 'draft', + cookingTime: 1, + activeCookingTime: 1, + ingredients: null, + description: 'desc', + steps: null, + videoUrl: null, + title: 't', + type: 'recipe', + slug: 's', + commentsCount: 0, + wasLikedByMe: false, + isFavorite: false, + calories: 1.0, + likesCount: 0, + viewsCount: 0, + tags: [], + author: Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: false), + ); + + final result = recipe.toRecipeModel(); + + expect(result.ingredients, isEmpty); + expect(result.tags, isEmpty); + expect(result.steps, isNull); + }); + }); } diff --git a/test/features/registration/registration_error_message_test.dart b/test/features/registration/registration_error_message_test.dart new file mode 100644 index 00000000..e9eee4ef --- /dev/null +++ b/test/features/registration/registration_error_message_test.dart @@ -0,0 +1,169 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/registration/registration_error_message.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('registrationErrorMessage', () { + test('returns the error itself when it is a String', () { + expect(registrationErrorMessage('Some failure'), 'Some failure'); + }); + + test('returns toString for arbitrary objects', () { + final error = Exception('Boom'); + expect(registrationErrorMessage(error), error.toString()); + }); + + test('returns DioException.message when type is not 422 and message is set', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + message: 'Network down', + type: DioExceptionType.connectionError, + ); + + expect(registrationErrorMessage(dio), 'Network down'); + }); + + test('falls back to DioException.toString when message is null', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.unknown, + ); + + expect(registrationErrorMessage(dio), dio.toString()); + }); + + test('parses 422 message and field errors', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/register'), + response: Response( + requestOptions: RequestOptions(path: '/register'), + statusCode: 422, + data: { + 'message': 'Validation failed', + 'errors': { + 'email': ['Email is required', 'Email must be valid'], + 'password': ['Password too short'], + }, + }, + ), + type: DioExceptionType.badResponse, + ); + + final result = registrationErrorMessage(dio); + expect(result, contains('Validation failed')); + expect(result, contains('Email is required')); + expect(result, contains('Email must be valid')); + expect(result, contains('Password too short')); + }); + + test('handles 422 with only message', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: {'message': 'Email already taken'}, + ), + type: DioExceptionType.badResponse, + ); + + expect(registrationErrorMessage(dio), 'Email already taken'); + }); + + test('handles 422 with only errors map', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: { + 'errors': { + 'phone': ['Phone is required'], + }, + }, + ), + type: DioExceptionType.badResponse, + ); + + expect(registrationErrorMessage(dio), contains('Phone is required')); + }); + + test('handles 422 with empty errors collection — returns generic message', () { + final data = { + 'errors': { + 'phone': [], + 'email': [], + }, + }; + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: data, + ), + type: DioExceptionType.badResponse, + ); + + expect( + registrationErrorMessage(dio), + 'Ошибка регистрации: $data', + ); + }); + + test('handles 422 when errors is not a Map', () { + final data = { + 'errors': ['unexpected payload'], + }; + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: data, + ), + type: DioExceptionType.badResponse, + ); + + expect( + registrationErrorMessage(dio), + 'Ошибка регистрации: $data', + ); + }); + + test('falls back to DioException default for 422 with non-map data', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: 'plain string', + ), + message: 'Server returned 422', + type: DioExceptionType.badResponse, + ); + + expect(registrationErrorMessage(dio), 'Server returned 422'); + }); + + test('handles 422 when value in errors map is not a List', () { + final data = { + 'message': 'Bad data', + 'errors': { + 'phone': 'not a list, will be skipped', + }, + }; + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: data, + ), + type: DioExceptionType.badResponse, + ); + + expect(registrationErrorMessage(dio), 'Bad data'); + }); + }); +} diff --git a/test/features/video_calls/widgets/video_call_stage_layout_test.dart b/test/features/video_calls/widgets/video_call_stage_layout_test.dart index b20d2ea0..3c51a89e 100644 --- a/test/features/video_calls/widgets/video_call_stage_layout_test.dart +++ b/test/features/video_calls/widgets/video_call_stage_layout_test.dart @@ -220,4 +220,119 @@ void main() { await tester.pumpAndSettle(); expect(find.byKey(const ValueKey(true)), findsWidgets); }); + + testWidgets('split layout with rear camera and remote main stage variant', (tester) async { + final room = buildRoom(); + var stageSwapped = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: false, + videoEnabled: true, + splitParticipantView: true, + mainStageShowsLocalVideo: false, + useFrontCamera: false, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () => stageSwapped++, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 16, + bottomInset: 24, + ), + ), + ); + + await tester.pump(); + expect(find.text('You'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + expect(find.byKey(const ValueKey(false)), findsWidgets); + expect(stageSwapped, 0); + }); + + testWidgets('fullscreen layout with video disabled renders no-video background', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 4, + bottomInset: 4, + ), + ), + ); + + await tester.pump(); + expect(find.text('You'), findsOneWidget); + expect(find.text('Chat'), findsOneWidget); + expect(find.text('Leave'), findsOneWidget); + }); + + testWidgets('fullscreen layout with connect error shows it on stage', (tester) async { + final room = buildRoom(); + when(room.connectionState).thenReturn(lk.ConnectionState.disconnected); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: 'Connection lost', + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: false, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: false, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + expect(find.text('Connection lost'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + }); }