feat(video_calls): first impl for main video calls screen

This commit is contained in:
misha
2026-05-05 08:09:24 +03:00
parent 50aefde842
commit 91ad51d340
49 changed files with 2179 additions and 6 deletions

View File

@@ -1,6 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,3 @@
<svg width="18" height="14" viewBox="0 0 18 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 12.0833V7.91666C0 7.56944 0.121528 7.2743 0.364583 7.03125C0.607639 6.78819 0.902778 6.66666 1.25 6.66666C1.59722 6.66666 1.89236 6.78819 2.13542 7.03125C2.37847 7.2743 2.5 7.56944 2.5 7.91666V12.0833C2.5 12.4306 2.37847 12.7257 2.13542 12.9687C1.89236 13.2118 1.59722 13.3333 1.25 13.3333C0.902778 13.3333 0.607639 13.2118 0.364583 12.9687C0.121528 12.7257 0 12.4306 0 12.0833ZM5 12.0833V5.83333C5 5.48611 5.12153 5.19097 5.36458 4.94791C5.60764 4.70486 5.90278 4.58333 6.25 4.58333C6.59722 4.58333 6.89236 4.70486 7.13542 4.94791C7.37847 5.19097 7.5 5.48611 7.5 5.83333V12.0833C7.5 12.4306 7.37847 12.7257 7.13542 12.9687C6.89236 13.2118 6.59722 13.3333 6.25 13.3333C5.90278 13.3333 5.60764 13.2118 5.36458 12.9687C5.12153 12.7257 5 12.4306 5 12.0833ZM10 12.0833V3.75C10 3.40278 10.1215 3.10764 10.3646 2.86458C10.6076 2.62153 10.9028 2.5 11.25 2.5C11.5972 2.5 11.8924 2.62153 12.1354 2.86458C12.3785 3.10764 12.5 3.40278 12.5 3.75V12.0833C12.5 12.4306 12.3785 12.7257 12.1354 12.9687C11.8924 13.2118 11.5972 13.3333 11.25 13.3333C10.9028 13.3333 10.6076 13.2118 10.3646 12.9687C10.1215 12.7257 10 12.4306 10 12.0833ZM15 6.8125V1.25C15 0.902777 15.1215 0.607639 15.3646 0.364583C15.6076 0.121528 15.9028 0 16.25 0C16.5972 0 16.8924 0.121528 17.1354 0.364583C17.3785 0.607639 17.5 0.902777 17.5 1.25V6.54166C17.3611 6.51389 17.2257 6.49305 17.0938 6.47916C16.9618 6.46528 16.8194 6.45833 16.6667 6.45833C16.375 6.45833 16.0868 6.48958 15.8021 6.55208C15.5174 6.61458 15.25 6.70139 15 6.8125ZM16.0729 13.0937C15.9132 12.934 15.8333 12.7361 15.8333 12.5C15.8333 12.2639 15.9132 12.066 16.0729 11.9062C16.2326 11.7465 16.4306 11.6667 16.6667 11.6667C16.9028 11.6667 17.1007 11.7465 17.2604 11.9062C17.4201 12.066 17.5 12.2639 17.5 12.5C17.5 12.7361 17.4201 12.934 17.2604 13.0937C17.1007 13.2535 16.9028 13.3333 16.6667 13.3333C16.4306 13.3333 16.2326 13.2535 16.0729 13.0937ZM15.8333 10V8.75C15.8333 8.51389 15.9132 8.31597 16.0729 8.15625C16.2326 7.99652 16.4306 7.91666 16.6667 7.91666C16.9028 7.91666 17.1007 7.99652 17.2604 8.15625C17.4201 8.31597 17.5 8.51389 17.5 8.75V10C17.5 10.2361 17.4201 10.434 17.2604 10.5937C17.1007 10.7535 16.9028 10.8333 16.6667 10.8333C16.4306 10.8333 16.2326 10.7535 16.0729 10.5937C15.9132 10.434 15.8333 10.2361 15.8333 10Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.292969 2.29289C0.683493 1.90237 1.31651 1.90237 1.70703 2.29289L3.41406 3.99992H11.4424C12.3865 3.99991 13.1558 3.99969 13.7803 4.05071C14.4257 4.10345 15.0042 4.21564 15.543 4.49016C16.3897 4.92159 17.0783 5.61023 17.5098 6.45696C17.7843 6.99573 17.8965 7.57424 17.9492 8.21965C17.9518 8.25175 17.9517 8.28444 17.9541 8.31731L22.4189 5.12981C23.0808 4.65704 24 5.12991 24 5.94328V18.0566C24 18.8699 23.0808 19.3428 22.4189 18.87L18 15.7138V18.5859L19.707 20.2929C20.0976 20.6834 20.0976 21.3164 19.707 21.707C19.3165 22.0975 18.6835 22.0975 18.293 21.707L0.292969 3.70696C-0.097555 3.31643 -0.0975551 2.68342 0.292969 2.29289ZM1 7.99895C1.55212 7.99895 1.99974 8.44689 2 8.99895V13.3993C2 14.3959 2.00036 15.0836 2.04395 15.6171C2.08658 16.1387 2.16554 16.4249 2.27246 16.6347C2.51215 17.105 2.89491 17.4868 3.36523 17.7265C3.57511 17.8334 3.86102 17.9124 4.38281 17.955C4.91619 17.9986 5.60333 17.9989 6.59961 17.9989H11C11.5521 17.9989 11.9997 18.4469 12 18.9989C12 19.5512 11.5523 19.9989 11 19.9989H6.55762C5.61342 19.999 4.84421 19.9992 4.21973 19.9482C3.57428 19.8954 2.99583 19.7832 2.45703 19.5087C1.61035 19.0773 0.921646 18.3886 0.490235 17.5419C0.215871 17.0032 0.103504 16.4245 0.0507818 15.7792C-0.000207456 15.1548 -1.10191e-05 14.3854 5.0477e-07 13.4413V8.99895C0.00026378 8.44689 0.447878 7.99895 1 7.99895ZM16 16.5859V10.5995C16 9.60324 15.9986 8.91611 15.9551 8.38274C15.9125 7.86113 15.8344 7.57502 15.7275 7.36516C15.4879 6.89475 15.1052 6.51207 14.6348 6.27239C14.4249 6.16547 14.1389 6.0865 13.6172 6.04387C13.0837 6.00029 12.396 5.99992 11.3994 5.99992H5.41406L16 16.5859Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 855 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.83398 9.03418C6.1464 8.72177 6.6534 8.72178 6.96582 9.03418C7.27824 9.3466 7.27824 9.8536 6.96582 10.166L2.73047 14.4004H5.59961C6.04144 14.4004 6.40039 14.7584 6.40039 15.2002C6.40032 15.642 6.04139 16 5.59961 16H0.799805C0.750353 16 0.702243 15.994 0.655273 15.9854C0.650431 15.9845 0.645453 15.9844 0.640625 15.9834C0.632394 15.9817 0.624348 15.9795 0.616211 15.9775C0.599845 15.9737 0.583526 15.9697 0.567383 15.9648C0.525709 15.9522 0.485753 15.936 0.447266 15.917C0.370564 15.8792 0.298188 15.8294 0.234375 15.7656C0.170233 15.7015 0.119834 15.6289 0.0820312 15.5518C0.0522722 15.4911 0.0292924 15.4265 0.015625 15.3584C0.0146554 15.3536 0.0145519 15.3486 0.0136719 15.3438C0.0052094 15.2971 7.59671e-06 15.2493 0 15.2002V10.4004C0 9.95863 0.358067 9.59972 0.799805 9.59961C1.24163 9.59961 1.59961 9.95856 1.59961 10.4004V13.2686L5.83398 9.03418ZM15.2002 0C15.2493 1.2747e-05 15.2971 0.0052045 15.3438 0.0136719C15.3486 0.0145519 15.3536 0.0146554 15.3584 0.015625C15.3666 0.017277 15.3747 0.0195822 15.3828 0.0214844C15.3992 0.0253083 15.4155 0.0293148 15.4316 0.0341797C15.4733 0.0467652 15.5133 0.0631386 15.5518 0.0820312C15.6289 0.119834 15.7015 0.170233 15.7656 0.234375C15.8294 0.298188 15.8792 0.370564 15.917 0.447266C15.9252 0.463964 15.9334 0.480716 15.9404 0.498047C15.9612 0.549032 15.9755 0.601773 15.9854 0.655273C15.994 0.702239 16 0.750358 16 0.799805V5.59961C16 6.04137 15.6419 6.40028 15.2002 6.40039C14.7584 6.40039 14.4004 6.04144 14.4004 5.59961V2.73047L10.166 6.96582C9.8536 7.27824 9.3466 7.27824 9.03418 6.96582C8.72178 6.6534 8.72177 6.1464 9.03418 5.83398L13.2695 1.59961H10.4004C9.95856 1.59961 9.59961 1.24163 9.59961 0.799805C9.59972 0.358067 9.95863 0 10.4004 0H15.2002Z" fill="#EFF0FA"/>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1,9 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M16 6.3V6C16 3.79 14.21 2 12 2C9.79 2 8 3.79 8 6V11" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9.04004 14.19C9.77004 15 10.83 15.5 12 15.5C14.21 15.5 16 13.71 16 11.5V11" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M6.77979 16.9499C8.14979 18.2199 9.97978 18.9999 11.9998 18.9999C16.2198 18.9999 19.6498 15.5699 19.6498 11.3499V9.6499" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.3501 9.6499V11.3499C4.3501 12.4099 4.5601 13.4099 4.9501 14.3299" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M20.0702 2.83984L3.93018 18.9898" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M11 3V6" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 19V22" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,5 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 15.5C14.21 15.5 16 13.71 16 11.5V6C16 3.79 14.21 2 12 2C9.79 2 8 3.79 8 6V11.5C8 13.71 9.79 15.5 12 15.5Z" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.35059 9.6499V11.3499C4.35059 15.5699 7.78059 18.9999 12.0006 18.9999C16.2206 18.9999 19.6506 15.5699 19.6506 11.3499V9.6499" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M12 19V22" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 629 B

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.40023 8.79965C6.44894 8.79967 6.49651 8.80496 6.54281 8.81332C6.54796 8.81425 6.5533 8.81424 6.55844 8.81527C6.56666 8.81692 6.57472 8.81923 6.58285 8.82113C6.5992 8.82496 6.61555 8.82896 6.63168 8.83383C6.64056 8.83651 6.64929 8.83963 6.65805 8.84262C6.69881 8.85648 6.73865 8.87374 6.77719 8.89437C6.80436 8.90894 6.83012 8.9257 6.85531 8.9432C6.89387 8.97001 6.93129 8.99965 6.96566 9.03402C6.9997 9.06805 7.02889 9.10526 7.05551 9.1434C7.05897 9.14836 7.06289 9.153 7.06625 9.15804C7.12722 9.24979 7.16631 9.35128 7.18539 9.4559C7.19389 9.50255 7.20001 9.55035 7.20004 9.59945V14.4002C7.19984 14.8418 6.84178 15.1998 6.40023 15.2C5.95852 15.2 5.59964 14.8419 5.59945 14.4002V11.5311L1.36508 15.7655C1.05264 16.0777 0.546577 16.0778 0.234217 15.7655C-0.0781257 15.4531 -0.0780189 14.947 0.234217 14.6346L4.46762 10.4002H1.59945C1.15782 10.4 0.799647 10.0411 0.799647 9.59945C0.799876 9.15796 1.15796 8.79988 1.59945 8.79965H6.40023ZM14.6346 0.234217C14.947 -0.0780189 15.4531 -0.0781257 15.7655 0.234217C16.0778 0.546577 16.0777 1.05264 15.7655 1.36508L11.5321 5.59945H14.4002C14.8419 5.59964 15.2 5.95852 15.2 6.40023C15.1998 6.84178 14.8418 7.19984 14.4002 7.20004H9.59945C9.55035 7.20001 9.50255 7.19389 9.4559 7.18539C9.45074 7.18445 9.44541 7.18448 9.44027 7.18344C9.43204 7.18177 9.42399 7.17949 9.41586 7.17758C9.3995 7.17372 9.38317 7.16978 9.36703 7.16488C9.35943 7.16257 9.35207 7.1596 9.34457 7.15707C9.30221 7.14282 9.2605 7.12587 9.22054 7.10433C9.19365 7.08982 9.16833 7.0729 9.1434 7.05551C9.10526 7.02889 9.06805 6.9997 9.03402 6.96566C8.99965 6.93129 8.97001 6.89387 8.9432 6.85531C8.9257 6.83012 8.90894 6.80436 8.89437 6.77719C8.85465 6.70298 8.82796 6.62378 8.81332 6.54281C8.80496 6.49651 8.79967 6.44894 8.79965 6.40023V1.59945C8.79988 1.15796 9.15796 0.799876 9.59945 0.799647C10.0411 0.799647 10.4 1.15782 10.4002 1.59945V4.46762L14.6346 0.234217Z" fill="#EFF0FA"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 0C15.523 0 20 4.477 20 10C20 15.523 15.523 20 10 20C4.477 20 0 15.523 0 10C0 4.477 4.477 0 10 0ZM10 2C7.87827 2 5.84344 2.84285 4.34315 4.34315C2.84285 5.84344 2 7.87827 2 10C2 12.1217 2.84285 14.1566 4.34315 15.6569C5.84344 17.1571 7.87827 18 10 18C12.1217 18 14.1566 17.1571 15.6569 15.6569C17.1571 14.1566 18 12.1217 18 10C18 7.87827 17.1571 5.84344 15.6569 4.34315C14.1566 2.84285 12.1217 2 10 2ZM10 13C10.2652 13 10.5196 13.1054 10.7071 13.2929C10.8946 13.4804 11 13.7348 11 14C11 14.2652 10.8946 14.5196 10.7071 14.7071C10.5196 14.8946 10.2652 15 10 15C9.73478 15 9.48043 14.8946 9.29289 14.7071C9.10536 14.5196 9 14.2652 9 14C9 13.7348 9.10536 13.4804 9.29289 13.2929C9.48043 13.1054 9.73478 13 10 13ZM10 4C10.2652 4 10.5196 4.10536 10.7071 4.29289C10.8946 4.48043 11 4.73478 11 5V11C11 11.2652 10.8946 11.5196 10.7071 11.7071C10.5196 11.8946 10.2652 12 10 12C9.73478 12 9.48043 11.8946 9.29289 11.7071C9.10536 11.5196 9 11.2652 9 11V5C9 4.73478 9.10536 4.48043 9.29289 4.29289C9.48043 4.10536 9.73478 4 10 4Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -35,6 +35,8 @@
<string>This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us.</string>
<key>NSCameraUsageDescription</key>
<string>Предоставление доступа позволит Вам делать фотографии и добавлять к записям</string>
<key>NSMicrophoneUsageDescription</key>
<string>Нужен для звука в видеозвонках с консультантом</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string></string>
<key>NSPhotoLibraryUsageDescription</key>

View File

@@ -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>(() => ChatRepository(chatService: sl()));
sl.registerLazySingleton<VideoCallRepository>(() => VideoCallRepository(videoCallService: sl()));
sl.registerLazySingleton<FeedRepository>(() => FeedRepository(feedService: sl()));
sl.registerLazySingleton<ProfileRepository>(() => ProfileRepository(profileService: sl()));
sl.registerLazySingleton<CalendarRepository>(() => CalendarRepository(calendarService: sl()));

View File

@@ -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>(() => ChatService(sl()));
sl.registerLazySingleton<VideoCallService>(() => VideoCallService(sl()));
sl.registerLazySingleton<FeedService>(() => FeedService(sl()));
sl.registerLazySingleton<ProfileService>(() => ProfileService(sl()));
sl.registerLazySingleton<CalendarService>(() => CalendarService(sl()));

View File

@@ -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<String, dynamic>) {
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()),

View File

@@ -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';

View File

@@ -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();

View File

@@ -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<ChatScreen> {
],
),
),
actions: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: _ChatHeaderVideoCallButton(
onPressed: () {
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
'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<ChatScreen> {
);
}
}
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),
),
),
),
),
);
}
}

View File

@@ -106,9 +106,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
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))),

View File

@@ -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<String, Object?> 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<String, Object?> json) =>
_$VideoCallAdminTokenResponseFromJson(json);
}
@freezed
abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
const factory VideoCallEndedResponse({required bool sent}) =
_VideoCallEndedResponse;
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallEndedResponseFromJson(json);
}

View File

@@ -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<VideoCallScreen> createState() => _VideoCallScreenState();
}
class _VideoCallScreenState extends State<VideoCallScreen> {
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<lk.RoomEvent>? _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<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
if (e.participant != _room.localParticipant) return;
if (!mounted) return;
_syncPoorConnectionOverlay(e.connectionQuality);
})
..on<lk.ActiveSpeakersChangedEvent>((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<void> _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<void> _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<void> _restoreSystemUiAfterCall() async {
try {
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
} on PlatformException catch (_) {}
}
Future<void> _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<void> _disconnectLiveKitOnly() async {
if (_liveKitDisposed) return;
_liveKitDisposed = true;
try {
if (_room.connectionState != lk.ConnectionState.disconnected) {
await _room.disconnect();
}
} catch (_) {}
try {
await _room.dispose();
} catch (_) {}
}
Future<void> _notifyCallEndedOnce() async {
if (_callEndedNotified) return;
_callEndedNotified = true;
try {
await _videoCallRepository.notifyCallEnded(widget.conversationUuid);
} catch (_) {}
}
Future<void> _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<void> _handleBackToChat() async {
widget.onBackToChat?.call();
await _cleanupLiveKitAndNotifyBackend();
if (!mounted) return;
if (context.canPop()) context.pop();
}
Future<void> _handleHangUp() async {
widget.onHangUp?.call();
await _cleanupLiveKitAndNotifyBackend();
if (!mounted) return;
if (context.canPop()) context.pop();
}
Future<void> _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<void> _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<void> _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<bool> _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<bool> _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<SystemUiOverlayStyle>(
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,
);
},
),
),
);
}
}

View File

@@ -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),
),
);
}
}

View File

@@ -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),
);
}
}

View File

@@ -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,
),
),
),
),
),
);
}
}

View File

@@ -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,
),
),
);
}
}

View File

@@ -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,
),
),
),
],
);
},
);
}
}

View File

@@ -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<VideoCallStageLayout> createState() => _VideoCallStageLayoutState();
}
class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
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<Widget> _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<Widget> _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)),
),
),
],
);
}
}

View File

@@ -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<void>.delayed(autoRemoveAfter, () {
if (entry.mounted) {
entry.remove();
}
});
}
return entry;
}
}

View File

@@ -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<String, dynamic> _emptyJsonBody = <String, dynamic>{};
Future<VideoCallTokenResponse> getParticipantToken(String conversationUuid) {
return videoCallService.getParticipantToken(
conversationUuid: conversationUuid,
);
}
Future<VideoCallAdminTokenResponse> getAdminToken(String conversationUuid) {
return videoCallService.getAdminToken(conversationUuid: conversationUuid);
}
Future<VideoCallEndedResponse> notifyCallEnded(String conversationUuid) {
return videoCallService.notifyCallEnded(
conversationUuid: conversationUuid,
body: _emptyJsonBody,
);
}
}

View File

@@ -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<VideoCallTokenResponse> getParticipantToken({
@Path('conversation') required String conversationUuid,
});
@GET('/api/v2/chat/{conversation}/video-call/admin-token')
Future<VideoCallAdminTokenResponse> getAdminToken({
@Path('conversation') required String conversationUuid,
});
@POST('/api/v2/chat/{conversation}/video-call/ended')
Future<VideoCallEndedResponse> notifyCallEnded({
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
}

View File

@@ -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(
"Насилие и вражда",
),

View File

@@ -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("Вес"),

View File

@@ -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<S> {

View File

@@ -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."
}

View File

@@ -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": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства."
}

View File

@@ -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/

View File

@@ -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);
});
});
}

View File

@@ -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<VideoCallService>()])
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<VideoCallTokenResponse>());
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<Exception>()),
);
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<VideoCallAdminTokenResponse>());
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<VideoCallEndedResponse>());
expect(result.sent, isTrue);
final captured = verify(
mockService.notifyCallEnded(
conversationUuid: conversationUuid,
body: captureAnyNamed('body'),
),
).captured;
expect(captured, hasLength(1));
expect(captured.single, isA<Map<String, dynamic>>());
expect(captured.single as Map<String, dynamic>, 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<Exception>()),
);
});
});
}

View File

@@ -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<VideoCallService>()])
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<VideoCallTokenResponse>());
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<Exception>()),
);
});
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<VideoCallAdminTokenResponse>());
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 = <String, dynamic>{};
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<VideoCallEndedResponse>());
expect(result.sent, isTrue);
verify(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body)).called(1);
});
test('notifyCallEnded - пробрасывает ошибку', () async {
const conversationUuid = 'conv-uuid-error';
const body = <String, dynamic>{};
when(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body))
.thenThrow(Exception('Backend error'));
expect(
() => mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body),
throwsA(isA<Exception>()),
);
});
});
}

View File

@@ -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);
});
});
}

View File

@@ -0,0 +1,5 @@
{
"adminToken": "ey-admin-token",
"roomName": "conv-uuid-123",
"apiUrl": "https://livekit.eatvio.test"
}

View File

@@ -0,0 +1,3 @@
{
"sent": true
}

View File

@@ -0,0 +1,5 @@
{
"token": "ey-participant-token",
"roomName": "conv-uuid-123",
"wsUrl": "wss://livekit.eatvio.test/conv-uuid-123"
}