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