diff --git a/assets/icons/video_call/vc_control_enter.svg b/assets/icons/video_call/vc_control_enter.svg
new file mode 100644
index 00000000..e396beb2
--- /dev/null
+++ b/assets/icons/video_call/vc_control_enter.svg
@@ -0,0 +1,3 @@
+
diff --git a/lib/core/app_config.dart b/lib/core/app_config.dart
index df87d259..d410565f 100644
--- a/lib/core/app_config.dart
+++ b/lib/core/app_config.dart
@@ -8,6 +8,7 @@ import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_
import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart';
import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
+import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
@@ -35,6 +36,7 @@ class AppConfig {
/// Список глобальных BlocProvider'ов
/// Используются lazy: true для отложенной инициализации, чтобы не блокировать старт приложения
List get globalBlocProviders => [
+ BlocProvider.value(value: sl()),
BlocProvider(lazy: true, create: (context) => sl()),
BlocProvider(lazy: true, create: (context) => sl()),
BlocProvider(lazy: true, create: (context) => sl()),
diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart
index 6f26ab95..e9f932b4 100644
--- a/lib/core/di/cubit_registrations.dart
+++ b/lib/core/di/cubit_registrations.dart
@@ -51,6 +51,7 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
+import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
import 'package:get_it/get_it.dart';
@@ -71,6 +72,7 @@ class CubitRegistrations {
sl.registerLazySingleton(() => ProfileAvatarCubit(profileRepository: sl()));
sl.registerLazySingleton(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
sl.registerLazySingleton(() => InternetConnectionCubit());
+ sl.registerLazySingleton(() => VideoCallSessionCubit(videoCallRepository: sl()));
}
/// Регистрация Cubit'ов как фабрик
diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart
index b321a15c..42e29f10 100644
--- a/lib/core/routing/other_routes.dart
+++ b/lib/core/routing/other_routes.dart
@@ -234,6 +234,7 @@ class OtherRoutes {
return VideoCallScreen(
conversationUuid: extra['conversationUuid'] as String,
counterpartName: extra['counterpartName'] as String?,
+ counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?,
localParticipantName: extra['localParticipantName'] as String?,
onBackToChat: extra['onBackToChat'] as void Function()?,
onHangUp: extra['onHangUp'] as void Function()?,
diff --git a/lib/core/theme/custom_text_style.dart b/lib/core/theme/custom_text_style.dart
index f0d0661b..1e09ba93 100644
--- a/lib/core/theme/custom_text_style.dart
+++ b/lib/core/theme/custom_text_style.dart
@@ -80,6 +80,48 @@ class CustomTextStyles {
static TextStyle get titleSmallSecondaryContainer_1 => theme.textTheme.titleSmall!.copyWith(color: theme.colorScheme.secondaryContainer);
static TextStyle get titleSmallSegoeUIOnPrimary => theme.textTheme.titleSmall!.segoeUI.copyWith(color: theme.colorScheme.onPrimary);
+
+ static TextStyle get videoCallFlowPlaceholderTitle => theme.textTheme.headlineSmall!.copyWith(
+ fontWeight: FontWeight.w600,
+ height: 32 / 24,
+ color: appTheme.black900,
+ );
+
+ static TextStyle get videoCallFlowPlaceholderConnectionLostBody => theme.textTheme.bodyLarge!.copyWith(
+ fontWeight: FontWeight.w400,
+ fontSize: 16,
+ height: 24 / 16,
+ letterSpacing: 0.5,
+ color: appTheme.black900,
+ );
+
+ static TextStyle get videoCallFlowPlaceholderConnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
+ fontSize: 13,
+ fontWeight: FontWeight.w400,
+ height: 24 / 13,
+ letterSpacing: 0.5,
+ color: appTheme.black900,
+ );
+
+ static TextStyle get videoCallFlowPlaceholderReconnectingSubtitle => theme.textTheme.bodyMedium!.copyWith(
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ height: 24 / 14,
+ color: appTheme.gray600,
+ );
+
+ static TextStyle get videoCallFlowPlaceholderConnectionLostDetail => theme.textTheme.bodyMedium!.copyWith(
+ fontSize: 13,
+ fontWeight: FontWeight.w400,
+ height: 1.35,
+ color: appTheme.red,
+ );
+
+ static TextStyle get videoCallFlowPlaceholderFilledButtonLabel => theme.textTheme.titleMedium!.copyWith(
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ color: appTheme.whiteA700,
+ );
}
extension on TextStyle {
diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart
index 4c7b96e6..6ab605f8 100644
--- a/lib/features/chat/presentation/pages/chat_screen.dart
+++ b/lib/features/chat/presentation/pages/chat_screen.dart
@@ -1,3 +1,4 @@
+import 'dart:async';
import 'dart:io';
import 'dart:math';
@@ -24,6 +25,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/bloc/video_call_session/video_call_session_cubit.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';
@@ -239,11 +241,19 @@ class _ChatScreenState extends State {
padding: const EdgeInsetsDirectional.only(end: 8),
child: _ChatHeaderVideoCallButton(
onPressed: () {
+ unawaited(
+ sl().startCall(
+ conversationUuid: widget.uuid,
+ counterpartName: state.chatRoom.users.receiver.name,
+ localParticipantName: state.chatRoom.users.sender.name,
+ ),
+ );
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: {
'conversationUuid': widget.uuid,
'counterpartName': state.chatRoom.users.receiver.name,
+ 'counterpartAvatarUrl': state.chatRoom.users.receiver.profilePhotoUrl,
'localParticipantName': state.chatRoom.users.sender.name,
},
);
diff --git a/lib/features/menu/presentation/pages/menu_screen.dart b/lib/features/menu/presentation/pages/menu_screen.dart
index 05cabce2..41f405fd 100644
--- a/lib/features/menu/presentation/pages/menu_screen.dart
+++ b/lib/features/menu/presentation/pages/menu_screen.dart
@@ -15,6 +15,7 @@ import 'package:eatvio_app/features/consultant_rating/presentation/consultant_ra
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart';
import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart';
+import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart';
import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart';
import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart';
@@ -165,6 +166,7 @@ class MenuScreen extends StatelessWidget {
} catch (e, s) {
await sl().report(e, s, origin: 'MenuScreen');
} finally {
+ await sl().endCall();
await sl().clear();
await TokenStorage.removeTokenInStorage();
if (context.mounted) {
diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart
new file mode 100644
index 00000000..03136e76
--- /dev/null
+++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart
@@ -0,0 +1,726 @@
+import 'dart:async';
+
+import 'package:eatvio_app/core/app_router.dart';
+import 'package:eatvio_app/core/cubit/base_cubit.dart';
+import 'package:eatvio_app/core/theme/theme_helper.dart';
+import 'package:eatvio_app/core/widgets/show_error_dialog.dart';
+import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
+import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.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:livekit_client/livekit_client.dart' as lk;
+import 'package:permission_handler/permission_handler.dart';
+
+// ignore_for_file: use_build_context_synchronously
+
+class VideoCallSessionCubit extends BaseCubit {
+ VideoCallSessionCubit({required VideoCallRepository videoCallRepository})
+ : _videoCallRepository = videoCallRepository,
+ super(const VideoCallSessionState());
+
+ final VideoCallRepository _videoCallRepository;
+
+ 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;
+
+ lk.Room? _room;
+ lk.EventsListener? _roomEventsListener;
+ OverlayEntry? _poorConnectionEntry;
+ Timer? _mutedMicPollTimer;
+ DateTime? _lastMutedMicOverlayAt;
+ bool _recordingOverlayScheduled = false;
+ bool _mutedMicStatsInFlight = false;
+ num? _lastMutedMicTotalEnergy;
+ bool _liveKitDisposed = true;
+ bool _callEndedNotified = true;
+
+ lk.Room? get liveKitRoom => _room;
+
+ lk.CameraCaptureOptions _cameraCaptureOptions({
+ lk.CameraPosition? cameraPosition,
+ }) {
+ return lk.CameraCaptureOptions(
+ cameraPosition:
+ cameraPosition ??
+ (state.useFrontCamera
+ ? lk.CameraPosition.front
+ : lk.CameraPosition.back),
+ params: lk.VideoParametersPresets.h1080_169,
+ );
+ }
+
+ BuildContext? get _navigatorContext => AppRouter.navigatorKey.currentContext;
+
+ Future startCall({
+ required String conversationUuid,
+ String? counterpartName,
+ String? localParticipantName,
+ }) async {
+ if (_room != null &&
+ !_liveKitDisposed &&
+ state.conversationUuid == conversationUuid &&
+ _room!.connectionState == lk.ConnectionState.connected) {
+ emit(
+ state.copyWith(
+ counterpartName: counterpartName ?? state.counterpartName,
+ localParticipantName:
+ localParticipantName ?? state.localParticipantName,
+ ),
+ );
+ return;
+ }
+
+ await endCall();
+
+ _resetSessionFlagsForNewRoom();
+ _callEndedNotified = false;
+ _liveKitDisposed = false;
+ _room = lk.Room(
+ roomOptions: const lk.RoomOptions(
+ adaptiveStream: true,
+ defaultCameraCaptureOptions: lk.CameraCaptureOptions(
+ params: lk.VideoParametersPresets.h1080_169,
+ ),
+ defaultAudioCaptureOptions: _micCaptureOptions,
+ ),
+ );
+ _attachRoomEventListeners();
+ _startMutedMicPollTimer();
+
+ emit(
+ state.copyWith(
+ conversationUuid: conversationUuid,
+ counterpartName: counterpartName,
+ localParticipantName: localParticipantName,
+ connectError: null,
+ connectPhase: VideoCallConnectPhase.connecting,
+ uiMode: VideoCallUiMode.hidden,
+ linkIssue: VideoCallLinkIssue.none,
+ callStageEntered: false,
+ ),
+ );
+
+ await _connectLiveKit();
+ }
+
+ void _resetSessionFlagsForNewRoom() {
+ _lastMutedMicOverlayAt = null;
+ _lastMutedMicTotalEnergy = null;
+ _recordingOverlayScheduled = false;
+ }
+
+ Future onCallScreenVisible({
+ required String conversationUuid,
+ String? counterpartName,
+ String? localParticipantName,
+ }) async {
+ if (state.conversationUuid != conversationUuid ||
+ _room == null ||
+ _liveKitDisposed) {
+ await startCall(
+ conversationUuid: conversationUuid,
+ counterpartName: counterpartName,
+ localParticipantName: localParticipantName,
+ );
+ } else {
+ emit(
+ state.copyWith(
+ counterpartName: counterpartName ?? state.counterpartName,
+ localParticipantName:
+ localParticipantName ?? state.localParticipantName,
+ ),
+ );
+ }
+
+ emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ unawaited(_applyVideoCallSystemUi());
+ });
+ }
+
+ void onCallScreenDisposed() {
+ if (state.uiMode != VideoCallUiMode.fullscreen) return;
+ if (state.connectPhase == VideoCallConnectPhase.connected &&
+ state.linkIssue != VideoCallLinkIssue.lost) {
+ if (state.callStageEntered) {
+ unawaited(minimizeToPip());
+ } else {
+ unawaited(endCall());
+ }
+ } else if (state.connectPhase == VideoCallConnectPhase.connecting) {
+ unawaited(endCall());
+ } else if (state.linkIssue == VideoCallLinkIssue.lost) {
+ unawaited(endCall());
+ }
+ }
+
+ Future minimizeToPip() async {
+ if (state.connectPhase != VideoCallConnectPhase.connected) return;
+ if (state.linkIssue == VideoCallLinkIssue.lost) return;
+ if (!state.callStageEntered) return;
+ emit(state.copyWith(uiMode: VideoCallUiMode.pip));
+ await _restoreSystemUiAfterCall();
+ }
+
+ Future leaveCallRoute() async {
+ if (state.connectPhase == VideoCallConnectPhase.connected &&
+ state.linkIssue != VideoCallLinkIssue.lost) {
+ if (state.callStageEntered) {
+ await minimizeToPip();
+ } else {
+ await endCall();
+ }
+ } else {
+ await endCall();
+ }
+ }
+
+ void enterCallStage() {
+ if (isClosed) return;
+ if (state.connectPhase != VideoCallConnectPhase.connected) return;
+ if (state.callStageEntered) return;
+ emit(state.copyWith(callStageEntered: true));
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ if (isClosed) return;
+ _scheduleRecordingNoticeOnce();
+ });
+ }
+
+ Future retryAfterConnectionLoss() async {
+ if (state.conversationUuid.isEmpty) return;
+ _disposeRoomEphemerals();
+ await _disconnectLiveKitOnly();
+ _resetSessionFlagsForNewRoom();
+ _liveKitDisposed = false;
+ _room = lk.Room(
+ roomOptions: const lk.RoomOptions(
+ adaptiveStream: true,
+ defaultCameraCaptureOptions: lk.CameraCaptureOptions(
+ params: lk.VideoParametersPresets.h1080_169,
+ ),
+ defaultAudioCaptureOptions: _micCaptureOptions,
+ ),
+ );
+ _attachRoomEventListeners();
+ _startMutedMicPollTimer();
+ emit(
+ state.copyWith(
+ connectPhase: VideoCallConnectPhase.connecting,
+ linkIssue: VideoCallLinkIssue.none,
+ connectError: null,
+ callStageEntered: false,
+ ),
+ );
+ await _connectLiveKit();
+ }
+
+ void _disposeRoomEphemerals() {
+ _mutedMicPollTimer?.cancel();
+ _mutedMicPollTimer = null;
+ final listener = _roomEventsListener;
+ _roomEventsListener = null;
+ if (listener != null) {
+ unawaited(listener.dispose());
+ }
+ final poor = _poorConnectionEntry;
+ _poorConnectionEntry = null;
+ if (poor != null && poor.mounted) {
+ poor.remove();
+ }
+ }
+
+ Future endCall() async {
+ final uuid = state.conversationUuid;
+ _disposeRoomEphemerals();
+ await _disconnectLiveKitOnly();
+ if (!_callEndedNotified && uuid.isNotEmpty) {
+ _callEndedNotified = true;
+ try {
+ await _videoCallRepository.notifyCallEnded(uuid);
+ } catch (_) {}
+ }
+ await _restoreSystemUiAfterCall();
+ _liveKitDisposed = true;
+ emit(const VideoCallSessionState());
+ }
+
+ void expandLocal() {
+ emit(
+ state.copyWith(
+ splitParticipantView: false,
+ mainStageShowsLocalVideo: true,
+ ),
+ );
+ }
+
+ void expandRemote() {
+ emit(
+ state.copyWith(
+ splitParticipantView: false,
+ mainStageShowsLocalVideo: false,
+ ),
+ );
+ }
+
+ void shrinkToSplit() {
+ emit(state.copyWith(splitParticipantView: true));
+ }
+
+ void toggleStageSwap() {
+ emit(
+ state.copyWith(mainStageShowsLocalVideo: !state.mainStageShowsLocalVideo),
+ );
+ }
+
+ Future toggleMic() async {
+ if (_liveKitDisposed ||
+ _room == null ||
+ _room!.connectionState != lk.ConnectionState.connected) {
+ emit(state.copyWith(micEnabled: !state.micEnabled));
+ _lastMutedMicTotalEnergy = null;
+ return;
+ }
+ final next = !state.micEnabled;
+ if (next) {
+ final granted = await _ensureMicrophonePermission();
+ if (!granted) return;
+ }
+ emit(state.copyWith(micEnabled: next));
+ _lastMutedMicTotalEnergy = null;
+ await _room!.localParticipant?.setMicrophoneEnabled(
+ next,
+ audioCaptureOptions: _micCaptureOptions,
+ );
+ }
+
+ Future toggleVideo() async {
+ if (_liveKitDisposed ||
+ _room == null ||
+ _room!.connectionState != lk.ConnectionState.connected) {
+ emit(state.copyWith(videoEnabled: !state.videoEnabled));
+ return;
+ }
+ final next = !state.videoEnabled;
+ if (next) {
+ final granted = await _ensureCameraPermission();
+ if (!granted) return;
+ }
+ emit(state.copyWith(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 == null ||
+ _room!.connectionState != lk.ConnectionState.connected) {
+ emit(state.copyWith(useFrontCamera: !state.useFrontCamera));
+ return;
+ }
+ if (!state.videoEnabled) return;
+
+ final lp = _room!.localParticipant;
+ if (lp == null) return;
+
+ final currentPosition = state.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);
+ emit(
+ state.copyWith(
+ useFrontCamera: newPosition == lk.CameraPosition.front,
+ ),
+ );
+ } catch (_) {}
+ return;
+ }
+
+ try {
+ await lp.setCameraEnabled(
+ true,
+ cameraCaptureOptions: _cameraCaptureOptions(
+ cameraPosition: newPosition,
+ ),
+ );
+ emit(
+ state.copyWith(useFrontCamera: newPosition == lk.CameraPosition.front),
+ );
+ } catch (_) {}
+ }
+
+ void _startMutedMicPollTimer() {
+ _mutedMicPollTimer?.cancel();
+ _mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) {
+ if (isClosed ||
+ _liveKitDisposed ||
+ state.micEnabled ||
+ _mutedMicStatsInFlight) {
+ return;
+ }
+ if (_room == null ||
+ _room!.connectionState != lk.ConnectionState.connected) {
+ return;
+ }
+ _mutedMicStatsInFlight = true;
+ unawaited(
+ _pollMutedMicFromWebRtcStats().whenComplete(() {
+ _mutedMicStatsInFlight = false;
+ }),
+ );
+ });
+ }
+
+ void _attachRoomEventListeners() {
+ final room = _room;
+ if (room == null) return;
+ _roomEventsListener = room.createListener()
+ ..on((_) {
+ if (isClosed) return;
+ if (state.connectPhase != VideoCallConnectPhase.connected) return;
+ emit(state.copyWith(linkIssue: VideoCallLinkIssue.reconnecting));
+ })
+ ..on((_) {
+ if (isClosed) return;
+ emit(state.copyWith(linkIssue: VideoCallLinkIssue.none));
+ })
+ ..on((_) {
+ if (isClosed) return;
+ if (_liveKitDisposed) return;
+ if (state.connectPhase != VideoCallConnectPhase.connected) return;
+ emit(state.copyWith(linkIssue: VideoCallLinkIssue.lost));
+ })
+ ..on((e) {
+ if (e.participant != room.localParticipant) return;
+ if (isClosed) return;
+ _syncPoorConnectionOverlay(e.connectionQuality);
+ })
+ ..on((e) {
+ if (isClosed || state.micEnabled) return;
+ final lp = room.localParticipant;
+ if (lp == null) return;
+ if (e.speakers.any((p) => p.sid == lp.sid)) {
+ _maybeShowMutedMicOverlay();
+ }
+ });
+ }
+
+ void _scheduleRecordingNoticeOnce() {
+ if (_recordingOverlayScheduled) return;
+ final ctx = _navigatorContext;
+ if (ctx == null) return;
+ _recordingOverlayScheduled = true;
+ VideoCallTopOverlay.insertBanner(
+ ctx,
+ message: S.of(ctx).video_call_recording_notice,
+ backgroundColor: appTheme.videoCallNoticeBannerBackground,
+ svgAsset: VideoCallTopOverlay.warningSvg,
+ autoRemoveAfter: const Duration(seconds: 3),
+ );
+ }
+
+ void _syncPoorConnectionOverlay(lk.ConnectionQuality q) {
+ final ctx = _navigatorContext;
+ if (ctx == null) return;
+ final bad =
+ q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost;
+ if (bad) {
+ if (_poorConnectionEntry != null) return;
+ _poorConnectionEntry = VideoCallTopOverlay.insertBanner(
+ ctx,
+ message: S.of(ctx).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 (isClosed || state.micEnabled) return;
+ final ctx = _navigatorContext;
+ if (ctx == null) return;
+ final now = DateTime.now();
+ if (_lastMutedMicOverlayAt != null &&
+ now.difference(_lastMutedMicOverlayAt!) < _mutedMicOverlayMinInterval) {
+ return;
+ }
+ _lastMutedMicOverlayAt = now;
+ VideoCallTopOverlay.insertBanner(
+ ctx,
+ message: S.of(ctx).video_call_muted_mic_warning,
+ backgroundColor: appTheme.videoCallNoticeBannerBackground,
+ svgAsset: VideoCallTopOverlay.warningSvg,
+ autoRemoveAfter: const Duration(seconds: 2),
+ );
+ }
+
+ Future _pollMutedMicFromWebRtcStats() async {
+ if (isClosed || _liveKitDisposed || state.micEnabled) return;
+ final room = _room;
+ if (room == null || 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 (isClosed || _liveKitDisposed || state.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,
+ ]);
+ await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
+ } on PlatformException catch (_) {
+ 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 {
+ final room = _room;
+ if (room == null) return;
+ emit(state.copyWith(connectError: null));
+ try {
+ final tokenResponse = await _videoCallRepository.getParticipantToken(
+ state.conversationUuid,
+ );
+ if (isClosed || _liveKitDisposed) return;
+
+ var micOn = state.micEnabled;
+ var videoOn = state.videoEnabled;
+ if (micOn) {
+ final granted = await _ensureMicrophonePermission();
+ if (isClosed || _liveKitDisposed) return;
+ if (!granted) micOn = false;
+ }
+ if (videoOn) {
+ final granted = await _ensureCameraPermission();
+ if (isClosed || _liveKitDisposed) return;
+ if (!granted) videoOn = false;
+ }
+ if (micOn != state.micEnabled || videoOn != state.videoEnabled) {
+ emit(state.copyWith(micEnabled: micOn, videoEnabled: videoOn));
+ }
+
+ await room.connect(tokenResponse.wsUrl, tokenResponse.token);
+ if (isClosed || _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 (isClosed || _liveKitDisposed) return;
+
+ if (videoOn) {
+ try {
+ await lp?.setCameraEnabled(
+ true,
+ cameraCaptureOptions: _cameraCaptureOptions(),
+ );
+ } catch (_) {
+ effectiveVideo = false;
+ }
+ }
+
+ if (effectiveMic != state.micEnabled ||
+ effectiveVideo != state.videoEnabled) {
+ emit(
+ state.copyWith(
+ micEnabled: effectiveMic,
+ videoEnabled: effectiveVideo,
+ ),
+ );
+ }
+
+ if (!isClosed) {
+ emit(
+ state.copyWith(
+ connectPhase: VideoCallConnectPhase.connected,
+ linkIssue: VideoCallLinkIssue.none,
+ ),
+ );
+ _syncPoorConnectionOverlay(
+ room.localParticipant?.connectionQuality ??
+ lk.ConnectionQuality.unknown,
+ );
+ }
+ } catch (e) {
+ if (!isClosed) {
+ emit(
+ state.copyWith(
+ connectError: e.toString(),
+ connectPhase: VideoCallConnectPhase.idle,
+ linkIssue: VideoCallLinkIssue.lost,
+ ),
+ );
+ }
+ }
+ }
+
+ Future _disconnectLiveKitOnly() async {
+ if (_room == null) return;
+ _liveKitDisposed = true;
+ final room = _room!;
+ _room = null;
+ try {
+ if (room.connectionState != lk.ConnectionState.disconnected) {
+ await room.disconnect();
+ }
+ } catch (_) {}
+ try {
+ await room.dispose();
+ } catch (_) {}
+ }
+
+ bool _permissionNeedsSettings(PermissionStatus status) =>
+ status.isPermanentlyDenied || status.isRestricted;
+
+ Future _ensureCameraPermission() async {
+ final ctx = _navigatorContext;
+ if (ctx == null) return false;
+ var status = await Permission.camera.status;
+ if (status.isGranted) return true;
+ status = await Permission.camera.request();
+ if (status.isGranted) return true;
+ final ctx2 = _navigatorContext;
+ if (ctx2 == null) return false;
+ final s = S.of(ctx2);
+ await showErrorDialog(
+ ctx2,
+ 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 {
+ final ctx = _navigatorContext;
+ if (ctx == null) return false;
+ var status = await Permission.microphone.status;
+ if (status.isGranted) return true;
+ status = await Permission.microphone.request();
+ if (status.isGranted) return true;
+ final ctx2 = _navigatorContext;
+ if (ctx2 == null) return false;
+ final s = S.of(ctx2);
+ await showErrorDialog(
+ ctx2,
+ title: s.video_call_permission_microphone_title,
+ description: s.video_call_permission_microphone_description,
+ buttonTitle: s.allowAccess,
+ needToOpenSettings: _permissionNeedsSettings(status),
+ );
+ return false;
+ }
+
+ String resolvedLocalDisplayName() {
+ final fromNav = state.localParticipantName?.trim();
+ if (fromNav != null && fromNav.isNotEmpty) return fromNav;
+ final n = (_room?.localParticipant?.name ?? '').trim();
+ if (n.isNotEmpty) return n;
+ final ctx = _navigatorContext;
+ if (ctx != null) {
+ return S.of(ctx).video_call_you;
+ }
+ return 'You';
+ }
+
+ String resolvedRemoteDisplayName(String fallback) {
+ final room = _room;
+ if (room != null) {
+ for (final p in room.remoteParticipants.values) {
+ final n = p.name.trim();
+ if (n.isNotEmpty) return n;
+ }
+ }
+ final c = state.counterpartName?.trim();
+ if (c != null && c.isNotEmpty) return c;
+ return fallback;
+ }
+
+ @override
+ Future close() async {
+ _mutedMicPollTimer?.cancel();
+ await endCall();
+ return super.close();
+ }
+}
diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart
new file mode 100644
index 00000000..431d2e72
--- /dev/null
+++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart
@@ -0,0 +1,32 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'video_call_session_state.freezed.dart';
+
+enum VideoCallUiMode {
+ hidden,
+ fullscreen,
+ pip,
+}
+
+enum VideoCallConnectPhase { idle, connecting, connected }
+
+enum VideoCallLinkIssue { none, reconnecting, lost }
+
+@freezed
+abstract class VideoCallSessionState with _$VideoCallSessionState {
+ const factory VideoCallSessionState({
+ @Default('') String conversationUuid,
+ String? counterpartName,
+ String? localParticipantName,
+ @Default(true) bool micEnabled,
+ @Default(true) bool videoEnabled,
+ @Default(true) bool splitParticipantView,
+ @Default(false) bool mainStageShowsLocalVideo,
+ @Default(true) bool useFrontCamera,
+ String? connectError,
+ @Default(VideoCallUiMode.hidden) VideoCallUiMode uiMode,
+ @Default(VideoCallConnectPhase.idle) VideoCallConnectPhase connectPhase,
+ @Default(VideoCallLinkIssue.none) VideoCallLinkIssue linkIssue,
+ @Default(false) bool callStageEntered,
+ }) = _VideoCallSessionState;
+}
diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart
index 327bdafd..53d22515 100644
--- a/lib/features/video_calls/presentation/pages/video_call_screen.dart
+++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart
@@ -2,23 +2,24 @@ 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/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
+import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
+import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart';
+import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart';
+import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
+import 'package:flutter_bloc/flutter_bloc.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.counterpartAvatarUrl,
this.localParticipantName,
this.onBackToChat,
this.onHangUp,
@@ -29,6 +30,7 @@ class VideoCallScreen extends StatefulWidget {
final String conversationUuid;
final String? counterpartName;
+ final String? counterpartAvatarUrl;
final String? localParticipantName;
final VoidCallback? onBackToChat;
final VoidCallback? onHangUp;
@@ -38,437 +40,53 @@ class VideoCallScreen extends StatefulWidget {
}
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;
+ static const SystemUiOverlayStyle _videoCallOverlayStyle =
+ SystemUiOverlayStyle(
+ statusBarColor: Colors.transparent,
+ statusBarIconBrightness: Brightness.light,
+ statusBarBrightness: Brightness.dark,
+ systemNavigationBarColor: Colors.transparent,
+ systemNavigationBarIconBrightness: Brightness.light,
+ systemNavigationBarContrastEnforced: false,
+ );
- 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,
- );
- }
+ VideoCallSessionCubit get _cubit => sl();
@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,
+ unawaited(
+ _cubit.onCallScreenVisible(
+ conversationUuid: widget.conversationUuid,
+ counterpartName: widget.counterpartName,
+ localParticipantName: widget.localParticipantName,
+ ),
);
- } 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());
+ _cubit.onCallScreenDisposed();
super.dispose();
}
Future _handleBackToChat() async {
widget.onBackToChat?.call();
- await _cleanupLiveKitAndNotifyBackend();
+ await _cubit.leaveCallRoute();
if (!mounted) return;
if (context.canPop()) context.pop();
}
Future _handleHangUp() async {
widget.onHangUp?.call();
- await _cleanupLiveKitAndNotifyBackend();
+ await _cubit.endCall();
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);
@@ -477,54 +95,179 @@ class _VideoCallScreenState extends State {
final bottomInset = viewPadding.bottom;
final counterpartFallback = s.video_call_counterpart_default;
- final controlLabelStyle = TextStyles.textSMedium.copyWith(color: appTheme.whiteA700, height: 20 / 14);
+ 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,
+ return PopScope(
+ canPop: false,
+ onPopInvokedWithResult: (bool didPop, Object? result) async {
+ if (didPop) return;
+ await _cubit.leaveCallRoute();
+ if (!context.mounted) return;
+ if (context.canPop()) context.pop();
+ },
+ child: BlocBuilder(
+ bloc: _cubit,
+ buildWhen: (a, b) =>
+ a.connectPhase != b.connectPhase ||
+ a.connectError != b.connectError ||
+ a.linkIssue != b.linkIssue ||
+ a.counterpartName != b.counterpartName ||
+ a.callStageEntered != b.callStageEntered ||
+ a.micEnabled != b.micEnabled ||
+ a.videoEnabled != b.videoEnabled ||
+ a.splitParticipantView != b.splitParticipantView ||
+ a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo ||
+ a.useFrontCamera != b.useFrontCamera,
+ builder: (context, sessionState) {
+ final room = _cubit.liveKitRoom;
+
+ final showLost =
+ sessionState.linkIssue == VideoCallLinkIssue.lost ||
+ (sessionState.connectPhase == VideoCallConnectPhase.idle &&
+ sessionState.connectError != null);
+
+ if (showLost) {
+ return AnnotatedRegion(
+ value: _videoCallOverlayStyle,
+ child: Scaffold(
+ backgroundColor: appTheme.whiteA700,
+ body: VideoCallConnectionLostScreen(
+ detailMessage: sessionState.connectError,
+ onRetry: () => unawaited(_cubit.retryAfterConnectionLoss()),
+ onLeave: () => unawaited(_handleHangUp()),
+ ),
+ ),
);
- },
- ),
+ }
+
+ final showReconnecting =
+ sessionState.connectPhase == VideoCallConnectPhase.connected &&
+ sessionState.linkIssue == VideoCallLinkIssue.reconnecting;
+
+ if (showReconnecting) {
+ return AnnotatedRegion(
+ value: _videoCallOverlayStyle,
+ child: Scaffold(
+ backgroundColor: appTheme.whiteA700,
+ body: const VideoCallConnectingScreen(isReconnecting: true),
+ ),
+ );
+ }
+
+ final showEntranceLobby =
+ !sessionState.callStageEntered &&
+ sessionState.linkIssue == VideoCallLinkIssue.none &&
+ (sessionState.connectPhase == VideoCallConnectPhase.connecting ||
+ sessionState.connectPhase == VideoCallConnectPhase.connected);
+
+ if (showEntranceLobby) {
+ final remoteName = _cubit.resolvedRemoteDisplayName(
+ counterpartFallback,
+ );
+ final canEnterCall =
+ sessionState.connectPhase == VideoCallConnectPhase.connected;
+ return AnnotatedRegion(
+ value: _videoCallOverlayStyle,
+ child: Scaffold(
+ backgroundColor: appTheme.whiteA700,
+ body: VideoCallConnectingEntranceScreen(
+ counterpartDisplayName: remoteName,
+ counterpartAvatarUrl: widget.counterpartAvatarUrl,
+ micEnabled: sessionState.micEnabled,
+ videoEnabled: sessionState.videoEnabled,
+ canEnterCall: canEnterCall,
+ onEnterCall: _cubit.enterCallStage,
+ labels: (
+ chat: s.video_call_chat,
+ sound: s.video_call_sound,
+ video: s.video_call_video,
+ leave: s.video_call_leave,
+ ),
+ controlLabelStyle: controlLabelStyle,
+ onToggleMic: () => unawaited(_cubit.toggleMic()),
+ onToggleVideo: () => unawaited(_cubit.toggleVideo()),
+ onHangUp: () => unawaited(_handleHangUp()),
+ bottomInset: bottomInset,
+ ),
+ ),
+ );
+ }
+
+ if (room == null &&
+ sessionState.linkIssue == VideoCallLinkIssue.none &&
+ sessionState.connectError == null) {
+ return AnnotatedRegion(
+ value: _videoCallOverlayStyle,
+ child: Scaffold(
+ backgroundColor: appTheme.whiteA700,
+ body: VideoCallLoadingScreen(
+ onClose: () => unawaited(_handleHangUp()),
+ ),
+ ),
+ );
+ }
+
+ final activeRoom = room;
+ if (activeRoom == null) {
+ return AnnotatedRegion(
+ value: _videoCallOverlayStyle,
+ child: Scaffold(
+ backgroundColor: appTheme.whiteA700,
+ body: VideoCallLoadingScreen(
+ onClose: () => unawaited(_handleHangUp()),
+ ),
+ ),
+ );
+ }
+
+ return AnnotatedRegion(
+ value: _videoCallOverlayStyle,
+ child: Scaffold(
+ backgroundColor: appTheme.whiteA700,
+ body: ListenableBuilder(
+ listenable: activeRoom,
+ builder: (context, _) {
+ return VideoCallStageLayout(
+ room: activeRoom,
+ connectError: sessionState.connectError,
+ localDisplayName: _cubit.resolvedLocalDisplayName(),
+ remoteDisplayName: _cubit.resolvedRemoteDisplayName(
+ counterpartFallback,
+ ),
+ micEnabled: sessionState.micEnabled,
+ videoEnabled: sessionState.videoEnabled,
+ splitParticipantView: sessionState.splitParticipantView,
+ mainStageShowsLocalVideo:
+ sessionState.mainStageShowsLocalVideo,
+ useFrontCamera: sessionState.useFrontCamera,
+ onExpandLocal: () => _cubit.expandLocal(),
+ onExpandRemote: () => _cubit.expandRemote(),
+ onShrinkFromFullscreen: () => _cubit.shrinkToSplit(),
+ onToggleStageSwap: () => _cubit.toggleStageSwap(),
+ onChat: () => unawaited(_handleBackToChat()),
+ onToggleMic: () => unawaited(_cubit.toggleMic()),
+ onToggleVideo: () => unawaited(_cubit.toggleVideo()),
+ onHangUp: () => unawaited(_handleHangUp()),
+ onSwitchCamera: () =>
+ unawaited(_cubit.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_connecting_entrance_screen.dart b/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart
new file mode 100644
index 00000000..350e02e0
--- /dev/null
+++ b/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart
@@ -0,0 +1,340 @@
+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_controls.dart';
+import 'package:eatvio_app/generated/l10n.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+class VideoCallConnectingEntranceScreen extends StatelessWidget {
+ const VideoCallConnectingEntranceScreen({
+ super.key,
+ required this.counterpartDisplayName,
+ this.counterpartAvatarUrl,
+ required this.micEnabled,
+ required this.videoEnabled,
+ required this.canEnterCall,
+ required this.onEnterCall,
+ required this.labels,
+ required this.controlLabelStyle,
+ required this.onToggleMic,
+ required this.onToggleVideo,
+ required this.onHangUp,
+ required this.bottomInset,
+ });
+
+ final String counterpartDisplayName;
+ final String? counterpartAvatarUrl;
+ final bool micEnabled;
+ final bool videoEnabled;
+ final bool canEnterCall;
+ final VoidCallback onEnterCall;
+ final VideoCallControlLabels labels;
+ final TextStyle controlLabelStyle;
+ final VoidCallback onToggleMic;
+ final VoidCallback onToggleVideo;
+ final VoidCallback onHangUp;
+ final double bottomInset;
+
+ static const double _bottomPanelHeight = 132;
+ static const double _topContentSpacing = 60;
+
+ String _firstChar(String s) {
+ if (s.isEmpty) return '';
+ return String.fromCharCode(s.runes.first);
+ }
+
+ String _initials(String name) {
+ final trimmed = name.trim();
+ if (trimmed.isEmpty) return '?';
+ final parts = trimmed.split(RegExp(r'\s+'));
+ if (parts.length >= 2) {
+ final a = _firstChar(parts[0]);
+ final b = _firstChar(parts[1]);
+ if (a.isNotEmpty && b.isNotEmpty) {
+ return (a + b).toUpperCase();
+ }
+ }
+ final single = parts[0];
+ final runes = single.runes.toList();
+ if (runes.length >= 2) {
+ return String.fromCharCodes([runes[0], runes[1]]).toUpperCase();
+ }
+ final c = _firstChar(single);
+ return c.isNotEmpty ? c.toUpperCase() : '?';
+ }
+
+ Widget _initialsPlaceholder(String initials) {
+ return ColoredBox(
+ color: appTheme.backgroundAvatar,
+ child: Center(
+ child: Text(
+ initials,
+ style: TextStyles.textFS24FW700.copyWith(
+ color: appTheme.blueA400,
+ fontWeight: FontWeight.w600,
+ fontSize: 28,
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _avatar(BuildContext context, String name, String? urlRaw) {
+ final url = urlRaw?.trim();
+ final initials = _initials(name);
+ final border = Border.all(color: appTheme.blueA400, width: 1);
+ if (url != null && url.isNotEmpty) {
+ return Container(
+ width: 100,
+ height: 100,
+ decoration: BoxDecoration(shape: BoxShape.circle, border: border),
+ child: ClipOval(
+ child: Image.network(
+ url,
+ fit: BoxFit.cover,
+ errorBuilder: (context, error, stackTrace) =>
+ _initialsPlaceholder(initials),
+ ),
+ ),
+ );
+ }
+ return Container(
+ width: 100,
+ height: 100,
+ decoration: BoxDecoration(
+ shape: BoxShape.circle,
+ color: appTheme.backgroundAvatar,
+ border: border,
+ ),
+ alignment: Alignment.center,
+ child: Text(
+ initials,
+ style: TextStyles.textFS24FW700.copyWith(
+ color: appTheme.blueA400,
+ fontWeight: FontWeight.w600,
+ fontSize: 28,
+ ),
+ ),
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final s = S.of(context);
+ return ColoredBox(
+ color: appTheme.whiteA700,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ SafeArea(
+ child: Padding(
+ padding: EdgeInsets.only(
+ bottom: _bottomPanelHeight + bottomInset,
+ ),
+ child: SingleChildScrollView(
+ physics: const ClampingScrollPhysics(),
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ const SizedBox(height: _topContentSpacing),
+ _avatar(context, counterpartDisplayName, counterpartAvatarUrl),
+ const SizedBox(height: 16),
+ Row(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ width: 8,
+ height: 8,
+ decoration: BoxDecoration(
+ color: appTheme.greenA700,
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 8),
+ Text(
+ s.video_call_status_online,
+ style: CustomTextStyles.bodySmallMulishBlack900
+ .copyWith(height: 14 / 12),
+ ),
+ ],
+ ),
+ const SizedBox(height: 20),
+ Text(
+ s.video_call_screen_connecting_title,
+ textAlign: TextAlign.center,
+ style: CustomTextStyles.videoCallFlowPlaceholderTitle,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ s.video_call_recording_notice,
+ textAlign: TextAlign.center,
+ style: CustomTextStyles.videoCallFlowPlaceholderConnectingSubtitle,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ counterpartDisplayName,
+ textAlign: TextAlign.center,
+ style:
+ CustomTextStyles.videoCallFlowPlaceholderConnectingSubtitle
+ .copyWith(fontWeight: FontWeight.w700),
+ ),
+ const SizedBox(height: 24),
+ ],
+ ),
+ ),
+ ),
+ ),
+ Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ height: _bottomPanelHeight + bottomInset,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Colors.black.withValues(alpha: 0),
+ Colors.black.withValues(alpha: 0.52),
+ ],
+ ),
+ ),
+ child: Padding(
+ padding: EdgeInsets.fromLTRB(12, 16, 12, 12 + bottomInset),
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ children: [
+ Expanded(
+ child: Center(
+ child: IgnorePointer(
+ ignoring: !canEnterCall,
+ child: Opacity(
+ opacity: canEnterCall ? 1 : 0.45,
+ child: VideoCallControlTile(
+ label: s.video_call_enter,
+ labelStyle: controlLabelStyle,
+ onTap: onEnterCall,
+ face: ClipRRect(
+ borderRadius: BorderRadius.circular(12),
+ child: SizedBox(
+ width: 48,
+ height: 48,
+ child: ColoredBox(
+ color: appTheme.greenA700,
+ child: Center(
+ child: SvgPicture.asset(
+ 'assets/icons/video_call/vc_control_enter.svg',
+ width: 28,
+ height: 28,
+ fit: BoxFit.contain,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: Center(
+ child: 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,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: Center(
+ child: 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,
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ Expanded(
+ child: Center(
+ child: 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),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart b/lib/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart
new file mode 100644
index 00000000..1207d56b
--- /dev/null
+++ b/lib/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart
@@ -0,0 +1,305 @@
+import 'package:eatvio_app/core/theme/custom_text_style.dart';
+import 'package:eatvio_app/core/theme/theme_helper.dart';
+import 'package:eatvio_app/generated/l10n.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+const double _kVideoCallFlowBottomBarHeight = 132;
+
+Widget _videoCallFlowBottomBarGradient({
+ required double bottomInset,
+ required Widget child,
+}) {
+ return Positioned(
+ left: 0,
+ right: 0,
+ bottom: 0,
+ height: _kVideoCallFlowBottomBarHeight + bottomInset,
+ child: DecoratedBox(
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: [
+ Colors.black.withValues(alpha: 0),
+ Colors.black.withValues(alpha: 0.52),
+ ],
+ ),
+ ),
+ child: Padding(
+ padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottomInset),
+ child: child,
+ ),
+ ),
+ );
+}
+
+ButtonStyle _videoCallFlowBarButtonStyle({required Color backgroundColor}) {
+ return FilledButton.styleFrom(
+ backgroundColor: backgroundColor,
+ foregroundColor: appTheme.whiteA700,
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ minimumSize: const Size(0, 48),
+ tapTargetSize: MaterialTapTargetSize.shrinkWrap,
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(8),
+ ),
+ );
+}
+
+class _VideoCallFlowScaffold extends StatelessWidget {
+ const _VideoCallFlowScaffold({required this.child});
+
+ final Widget child;
+
+ @override
+ Widget build(BuildContext context) {
+ return ColoredBox(
+ color: appTheme.whiteA700,
+ child: SafeArea(
+ child: Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 28),
+ child: Column(
+ children: [
+ const SizedBox(height: 40),
+ Image.asset(
+ 'assets/icons/video_call/eatvio_c.png',
+ width: 88,
+ height: 88,
+ fit: BoxFit.contain,
+ ),
+ const SizedBox(height: 32),
+ Expanded(child: Center(child: child)),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+class VideoCallLoadingScreen extends StatelessWidget {
+ const VideoCallLoadingScreen({super.key, required this.onClose});
+
+ final VoidCallback onClose;
+
+ @override
+ Widget build(BuildContext context) {
+ final s = S.of(context);
+ final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
+ return ColoredBox(
+ color: appTheme.whiteA700,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ SafeArea(
+ bottom: false,
+ child: Padding(
+ padding: EdgeInsets.only(
+ bottom: _kVideoCallFlowBottomBarHeight + bottomInset,
+ ),
+ child: Center(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox(
+ width: 40,
+ height: 40,
+ child: CircularProgressIndicator(
+ strokeWidth: 3,
+ color: appTheme.blueA400,
+ ),
+ ),
+ const SizedBox(height: 28),
+ Text(
+ s.video_call_screen_loading_title,
+ textAlign: TextAlign.center,
+ style: CustomTextStyles.videoCallFlowPlaceholderTitle,
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ _videoCallFlowBottomBarGradient(
+ bottomInset: bottomInset,
+ child: Align(
+ alignment: Alignment.bottomCenter,
+ child: FilledButton.icon(
+ onPressed: onClose,
+ style: _videoCallFlowBarButtonStyle(
+ backgroundColor: appTheme.videoCallControlOffBackground,
+ ),
+ icon: Icon(
+ Icons.close,
+ size: 18,
+ color: appTheme.whiteA700,
+ ),
+ label: Text(
+ s.video_call_close,
+ style: CustomTextStyles.videoCallFlowPlaceholderFilledButtonLabel,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class VideoCallConnectingScreen extends StatelessWidget {
+ const VideoCallConnectingScreen({super.key, required this.isReconnecting});
+
+ final bool isReconnecting;
+
+ @override
+ Widget build(BuildContext context) {
+ final s = S.of(context);
+ final title = isReconnecting
+ ? s.video_call_screen_reconnecting_title
+ : s.video_call_screen_connecting_title;
+ final subtitle = isReconnecting
+ ? s.video_call_screen_reconnecting_subtitle
+ : s.video_call_screen_connecting_subtitle;
+ return _VideoCallFlowScaffold(
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SizedBox(
+ width: 44,
+ height: 44,
+ child: CircularProgressIndicator(
+ strokeWidth: 3,
+ color: appTheme.blueA400,
+ ),
+ ),
+ const SizedBox(height: 28),
+ Text(
+ title,
+ textAlign: TextAlign.center,
+ style: CustomTextStyles.videoCallFlowPlaceholderTitle,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ subtitle,
+ textAlign: TextAlign.center,
+ style: isReconnecting
+ ? CustomTextStyles.videoCallFlowPlaceholderReconnectingSubtitle
+ : CustomTextStyles.videoCallFlowPlaceholderConnectingSubtitle,
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class VideoCallConnectionLostScreen extends StatelessWidget {
+ const VideoCallConnectionLostScreen({
+ super.key,
+ required this.detailMessage,
+ required this.onRetry,
+ required this.onLeave,
+ });
+
+ final String? detailMessage;
+ final VoidCallback onRetry;
+ final VoidCallback onLeave;
+
+ @override
+ Widget build(BuildContext context) {
+ final s = S.of(context);
+ final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
+ return ColoredBox(
+ color: appTheme.whiteA700,
+ child: Stack(
+ fit: StackFit.expand,
+ children: [
+ SafeArea(
+ bottom: false,
+ child: Padding(
+ padding: EdgeInsets.only(
+ left: 24,
+ right: 24,
+ bottom: _kVideoCallFlowBottomBarHeight + bottomInset,
+ ),
+ child: Center(
+ child: SingleChildScrollView(
+ physics: const ClampingScrollPhysics(),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ SvgPicture.asset(
+ 'assets/icons/video_call/poor_connection.svg',
+ width: 120,
+ height: 107,
+ fit: BoxFit.contain,
+ ),
+ const SizedBox(height: 16),
+ Text(
+ s.video_call_connection_lost_title,
+ textAlign: TextAlign.center,
+ style: CustomTextStyles.videoCallFlowPlaceholderTitle,
+ ),
+ const SizedBox(height: 8),
+ Text(
+ s.video_call_connection_lost_message,
+ textAlign: TextAlign.center,
+ style: CustomTextStyles
+ .videoCallFlowPlaceholderConnectionLostBody,
+ ),
+ if (detailMessage != null &&
+ detailMessage!.trim().isNotEmpty) ...[
+ const SizedBox(height: 16),
+ Text(
+ detailMessage!,
+ textAlign: TextAlign.center,
+ style: CustomTextStyles
+ .videoCallFlowPlaceholderConnectionLostDetail,
+ ),
+ ],
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ _videoCallFlowBottomBarGradient(
+ bottomInset: bottomInset,
+ child: Row(
+ crossAxisAlignment: CrossAxisAlignment.end,
+ mainAxisAlignment: MainAxisAlignment.spaceBetween,
+ children: [
+ FilledButton(
+ onPressed: onRetry,
+ style: _videoCallFlowBarButtonStyle(
+ backgroundColor: appTheme.greenA700,
+ ),
+ child: Text(
+ s.video_call_try_again,
+ style: CustomTextStyles.videoCallFlowPlaceholderFilledButtonLabel,
+ ),
+ ),
+ FilledButton.icon(
+ onPressed: onLeave,
+ style: _videoCallFlowBarButtonStyle(
+ backgroundColor: appTheme.videoCallControlOffBackground,
+ ),
+ icon: Icon(
+ Icons.close,
+ size: 18,
+ color: appTheme.whiteA700,
+ ),
+ label: Text(
+ s.video_call_close,
+ style: CustomTextStyles.videoCallFlowPlaceholderFilledButtonLabel,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart
new file mode 100644
index 00000000..5db968ec
--- /dev/null
+++ b/lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart
@@ -0,0 +1,167 @@
+import 'dart:math' as math;
+
+import 'package:eatvio_app/core/app_router.dart';
+import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
+import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
+import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.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:flutter_bloc/flutter_bloc.dart';
+import 'package:go_router/go_router.dart';
+import 'package:livekit_client/livekit_client.dart' as lk;
+
+class VideoCallGlobalPipLayer extends StatefulWidget {
+ const VideoCallGlobalPipLayer({super.key});
+
+ @override
+ State createState() =>
+ _VideoCallGlobalPipLayerState();
+}
+
+class _VideoCallGlobalPipLayerState extends State {
+ static const double _pipW = 142;
+ static const double _pipH = 201;
+ static const double _edgeInset = 8;
+ static const double _pipInitialExtraLeft = 28;
+ static const double _pipInitialExtraDown = 16;
+
+ double? _pipLeft;
+ double? _pipTop;
+ Offset? _pipPanPointerGlobal;
+ double? _pipPanOriginLeft;
+ double? _pipPanOriginTop;
+
+ void _resetPipPanState() {
+ _pipPanPointerGlobal = null;
+ _pipPanOriginLeft = null;
+ _pipPanOriginTop = null;
+ }
+
+ void _openFullscreenRoute(VideoCallSessionState s) {
+ final ctx = AppRouter.navigatorKey.currentContext;
+ if (ctx == null) return;
+ GoRouter.of(ctx).pushNamed(
+ VideoCallScreen.name,
+ extra: {
+ 'conversationUuid': s.conversationUuid,
+ 'counterpartName': s.counterpartName,
+ 'localParticipantName': s.localParticipantName,
+ },
+ );
+ }
+
+ Widget _pipPreview(VideoCallSessionState sessionState, lk.Room room) {
+ if (!sessionState.videoEnabled) {
+ return const VideoCallNoVideoBackground(logoSize: 56);
+ }
+ 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(logoSize: 56);
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return BlocBuilder(
+ buildWhen: (a, b) =>
+ a.uiMode != b.uiMode ||
+ a.connectPhase != b.connectPhase ||
+ a.callStageEntered != b.callStageEntered ||
+ a.videoEnabled != b.videoEnabled,
+ builder: (context, state) {
+ if (state.uiMode != VideoCallUiMode.pip ||
+ state.connectPhase != VideoCallConnectPhase.connected ||
+ !state.callStageEntered) {
+ return const SizedBox.shrink();
+ }
+ final cubit = context.read();
+ final room = cubit.liveKitRoom;
+ if (room == null) return const SizedBox.shrink();
+
+ final mq = MediaQuery.of(context);
+ final maxW = mq.size.width;
+ final maxH = mq.size.height;
+ final topInset = mq.viewPadding.top > 0 ? mq.viewPadding.top : 16.0;
+ final bottomInset = mq.viewPadding.bottom;
+ final defaultPipLeft = maxW - _pipW - _edgeInset - _pipInitialExtraLeft;
+ final minPipTop = topInset + _edgeInset;
+ final defaultPipTop = minPipTop + _pipInitialExtraDown;
+ final maxPipLeft = math.max(_edgeInset, maxW - _pipW - _edgeInset);
+ final maxPipTop = math.max(
+ minPipTop,
+ maxH - _pipH - _edgeInset - bottomInset,
+ );
+ final pipLeft =
+ ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num)
+ .toDouble();
+ final pipTop =
+ ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num)
+ .toDouble();
+
+ return Material(
+ type: MaterialType.transparency,
+ child: ListenableBuilder(
+ listenable: room,
+ builder: (context, _) {
+ return Stack(
+ clipBehavior: Clip.none,
+ children: [
+ Positioned(
+ left: pipLeft,
+ top: pipTop,
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTap: () => _openFullscreenRoute(state),
+ 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: ClipRRect(
+ borderRadius: BorderRadius.circular(8),
+ child: SizedBox(
+ width: _pipW,
+ height: _pipH,
+ child: _pipPreview(state, room),
+ ),
+ ),
+ ),
+ ),
+ ],
+ );
+ },
+ ),
+ );
+ },
+ );
+ }
+}
diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart
index 096562d6..f4c23b3e 100644
--- a/lib/generated/intl/messages_en.dart
+++ b/lib/generated/intl/messages_en.dart
@@ -875,9 +875,17 @@ class MessageLookup extends MessageLookupByLibrary {
),
"video_call": MessageLookupByLibrary.simpleMessage("Video call"),
"video_call_chat": MessageLookupByLibrary.simpleMessage("Chat"),
+ "video_call_close": MessageLookupByLibrary.simpleMessage("Close"),
+ "video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage(
+ "It looks like there is an internet issue. We\'re trying to restore the connection...",
+ ),
+ "video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage(
+ "Connection lost",
+ ),
"video_call_counterpart_default": MessageLookupByLibrary.simpleMessage(
"Participant",
),
+ "video_call_enter": MessageLookupByLibrary.simpleMessage("Enter"),
"video_call_leave": MessageLookupByLibrary.simpleMessage("Leave"),
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
"Attention!\nYour microphone is muted",
@@ -904,7 +912,21 @@ class MessageLookup extends MessageLookupByLibrary {
"video_call_role_consultant": MessageLookupByLibrary.simpleMessage(
"Eatvio consultant",
),
+ "video_call_screen_connecting_subtitle":
+ MessageLookupByLibrary.simpleMessage("This may take a few seconds"),
+ "video_call_screen_connecting_title": MessageLookupByLibrary.simpleMessage(
+ "Connecting to\nthe consultation",
+ ),
+ "video_call_screen_loading_title": MessageLookupByLibrary.simpleMessage(
+ "Loading…",
+ ),
+ "video_call_screen_reconnecting_subtitle":
+ MessageLookupByLibrary.simpleMessage("Check your internet and wait"),
+ "video_call_screen_reconnecting_title":
+ MessageLookupByLibrary.simpleMessage("Reconnecting"),
"video_call_sound": MessageLookupByLibrary.simpleMessage("Sound"),
+ "video_call_status_online": MessageLookupByLibrary.simpleMessage("Online"),
+ "video_call_try_again": MessageLookupByLibrary.simpleMessage("Retry"),
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
"video_call_video": MessageLookupByLibrary.simpleMessage("Video"),
"video_call_you": MessageLookupByLibrary.simpleMessage("You"),
diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart
index 86adc84c..b0be5980 100644
--- a/lib/generated/intl/messages_ru.dart
+++ b/lib/generated/intl/messages_ru.dart
@@ -355,9 +355,17 @@ class MessageLookup extends MessageLookupByLibrary {
),
"video_call": MessageLookupByLibrary.simpleMessage("Видеозвонок"),
"video_call_chat": MessageLookupByLibrary.simpleMessage("Чат"),
+ "video_call_close": MessageLookupByLibrary.simpleMessage("Закрыть"),
+ "video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage(
+ "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...",
+ ),
+ "video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage(
+ "Связь прервалась",
+ ),
"video_call_counterpart_default": MessageLookupByLibrary.simpleMessage(
"Собеседник",
),
+ "video_call_enter": MessageLookupByLibrary.simpleMessage("Войти"),
"video_call_leave": MessageLookupByLibrary.simpleMessage("Выйти"),
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
"Внимание!\nУ вас выключен микрофон",
@@ -384,7 +392,21 @@ class MessageLookup extends MessageLookupByLibrary {
"video_call_role_consultant": MessageLookupByLibrary.simpleMessage(
"Консультант Итвио",
),
+ "video_call_screen_connecting_subtitle":
+ MessageLookupByLibrary.simpleMessage("Это займёт несколько секунд"),
+ "video_call_screen_connecting_title": MessageLookupByLibrary.simpleMessage(
+ "Подключение\nк консультации",
+ ),
+ "video_call_screen_loading_title": MessageLookupByLibrary.simpleMessage(
+ "Загрузка…",
+ ),
+ "video_call_screen_reconnecting_subtitle":
+ MessageLookupByLibrary.simpleMessage("Проверьте интернет и подождите"),
+ "video_call_screen_reconnecting_title":
+ MessageLookupByLibrary.simpleMessage("Восстанавливаем соединение"),
"video_call_sound": MessageLookupByLibrary.simpleMessage("Звук"),
+ "video_call_status_online": MessageLookupByLibrary.simpleMessage("Онлайн"),
+ "video_call_try_again": MessageLookupByLibrary.simpleMessage("Повторить"),
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
"video_call_video": MessageLookupByLibrary.simpleMessage("Видео"),
"video_call_you": MessageLookupByLibrary.simpleMessage("Вы"),
diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart
index 3bca8ece..869a24fc 100644
--- a/lib/generated/l10n.dart
+++ b/lib/generated/l10n.dart
@@ -4079,6 +4079,15 @@ class S {
);
}
+ String get video_call_status_online {
+ return Intl.message(
+ 'Online',
+ name: 'video_call_status_online',
+ desc: '',
+ args: [],
+ );
+ }
+
/// `Chat`
String get video_call_chat {
return Intl.message('Chat', name: 'video_call_chat', desc: '', args: []);
@@ -4148,6 +4157,86 @@ class S {
args: [],
);
}
+
+ String get video_call_screen_loading_title {
+ return Intl.message(
+ 'Loading…',
+ name: 'video_call_screen_loading_title',
+ desc: '',
+ args: [],
+ );
+ }
+
+ String get video_call_screen_connecting_title {
+ return Intl.message(
+ 'Connecting to\nthe consultation',
+ name: 'video_call_screen_connecting_title',
+ desc: '',
+ args: [],
+ );
+ }
+
+ String get video_call_screen_connecting_subtitle {
+ return Intl.message(
+ 'This may take a few seconds',
+ name: 'video_call_screen_connecting_subtitle',
+ desc: '',
+ args: [],
+ );
+ }
+
+ String get video_call_screen_reconnecting_title {
+ return Intl.message(
+ 'Reconnecting',
+ name: 'video_call_screen_reconnecting_title',
+ desc: '',
+ args: [],
+ );
+ }
+
+ String get video_call_screen_reconnecting_subtitle {
+ return Intl.message(
+ 'Check your internet and wait',
+ name: 'video_call_screen_reconnecting_subtitle',
+ desc: '',
+ args: [],
+ );
+ }
+
+ String get video_call_connection_lost_title {
+ return Intl.message(
+ 'Connection lost',
+ name: 'video_call_connection_lost_title',
+ desc: '',
+ args: [],
+ );
+ }
+
+ String get video_call_connection_lost_message {
+ return Intl.message(
+ 'It looks like there is an internet issue. We\'re trying to restore the connection...',
+ name: 'video_call_connection_lost_message',
+ desc: '',
+ args: [],
+ );
+ }
+
+ String get video_call_try_again {
+ return Intl.message(
+ 'Retry',
+ name: 'video_call_try_again',
+ desc: '',
+ args: [],
+ );
+ }
+
+ String get video_call_enter {
+ return Intl.message('Enter', name: 'video_call_enter', desc: '', args: []);
+ }
+
+ String get video_call_close {
+ return Intl.message('Close', name: 'video_call_close', desc: '', args: []);
+ }
}
class AppLocalizationDelegate extends LocalizationsDelegate {
diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb
index 27c662dd..71d50fa0 100644
--- a/lib/l10n/intl_en.arb
+++ b/lib/l10n/intl_en.arb
@@ -480,6 +480,7 @@
"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_status_online": "Online",
"video_call_chat": "Chat",
"video_call_sound": "Sound",
"video_call_video": "Video",
@@ -488,6 +489,16 @@
"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."
+ "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.",
+ "video_call_screen_loading_title": "Loading…",
+ "video_call_screen_connecting_title": "Connecting to\nthe consultation",
+ "video_call_screen_connecting_subtitle": "This may take a few seconds",
+ "video_call_screen_reconnecting_title": "Reconnecting",
+ "video_call_screen_reconnecting_subtitle": "Check your internet and wait",
+ "video_call_connection_lost_title": "Connection lost",
+ "video_call_connection_lost_message": "It looks like there is an internet issue. We're trying to restore the connection...",
+ "video_call_try_again": "Retry",
+ "video_call_enter": "Enter",
+ "video_call_close": "Close"
}
diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb
index 687f142c..f9232441 100644
--- a/lib/l10n/intl_ru.arb
+++ b/lib/l10n/intl_ru.arb
@@ -225,6 +225,7 @@
"video_call_poor_connection": "У вас плохое интернет соединение",
"video_call_recording_notice": "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.",
"video_call_muted_mic_warning": "Внимание!\nУ вас выключен микрофон",
+ "video_call_status_online": "Онлайн",
"video_call_chat": "Чат",
"video_call_sound": "Звук",
"video_call_video": "Видео",
@@ -233,5 +234,15 @@
"video_call_permission_camera_title": "Доступ к камере",
"video_call_permission_camera_description": "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.",
"video_call_permission_microphone_title": "Доступ к микрофону",
- "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства."
+ "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
+ "video_call_screen_loading_title": "Загрузка…",
+ "video_call_screen_connecting_title": "Подключение\nк консультации",
+ "video_call_screen_connecting_subtitle": "Это займёт несколько секунд",
+ "video_call_screen_reconnecting_title": "Восстанавливаем соединение",
+ "video_call_screen_reconnecting_subtitle": "Проверьте интернет и подождите",
+ "video_call_connection_lost_title": "Связь прервалась",
+ "video_call_connection_lost_message": "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...",
+ "video_call_try_again": "Повторить",
+ "video_call_enter": "Войти",
+ "video_call_close": "Закрыть"
}
diff --git a/lib/main.dart b/lib/main.dart
index 3ab5cf6e..760b1c54 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -4,6 +4,7 @@ import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/notifications/push_navigation_handler.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
+import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_portal/flutter_portal.dart';
@@ -41,6 +42,15 @@ class _MyAppState extends State {
debugShowCheckedModeBanner: false,
localizationsDelegates: sl().localizationDelegates,
supportedLocales: sl().supportedLocales,
+ builder: (context, child) {
+ return Stack(
+ clipBehavior: Clip.none,
+ children: [
+ if (child != null) child,
+ const VideoCallGlobalPipLayer(),
+ ],
+ );
+ },
),
),
),