From 1acb158f3658ae6c1be200db955a2fe64b03b529 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 12 May 2026 07:47:41 +0300 Subject: [PATCH 01/56] feat(vidoe_calls): global cideo call state, connection, loading and error screens for video calls --- assets/icons/video_call/vc_control_enter.svg | 3 + lib/core/app_config.dart | 2 + lib/core/di/cubit_registrations.dart | 2 + lib/core/routing/other_routes.dart | 1 + lib/core/theme/custom_text_style.dart | 42 + .../chat/presentation/pages/chat_screen.dart | 10 + .../menu/presentation/pages/menu_screen.dart | 2 + .../video_call_session_cubit.dart | 726 ++++++++++++++++++ .../video_call_session_state.dart | 32 + .../presentation/pages/video_call_screen.dart | 655 +++++----------- ...video_call_connecting_entrance_screen.dart | 340 ++++++++ .../video_call_flow_placeholder_screens.dart | 305 ++++++++ .../widgets/video_call_global_pip_layer.dart | 167 ++++ lib/generated/intl/messages_en.dart | 22 + lib/generated/intl/messages_ru.dart | 22 + lib/generated/l10n.dart | 89 +++ lib/l10n/intl_en.arb | 13 +- lib/l10n/intl_ru.arb | 13 +- lib/main.dart | 10 + 19 files changed, 1998 insertions(+), 458 deletions(-) create mode 100644 assets/icons/video_call/vc_control_enter.svg create mode 100644 lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart create mode 100644 lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart 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(), + ], + ); + }, ), ), ), From 73cec1a01e1f9fecb10ef25015f479ea2fd0c720 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 25 May 2026 08:07:20 +0300 Subject: [PATCH 02/56] feat(vide_calls): gms calls handling --- android/app/proguard-rules.pro | 3 + android/app/src/main/AndroidManifest.xml | 8 + ios/Runner.xcodeproj/project.pbxproj | 6 +- ios/Runner/AppDelegate.swift | 4 + ios/Runner/Info.plist | 1 + lib/core/di/cubit_registrations.dart | 8 +- .../chat/presentation/pages/chat_screen.dart | 9 - .../managers/video_call_manager.dart | 1091 +++++++++++++++++ .../managers/video_call_manager_snapshot.dart | 59 + .../video_call_session_cubit.dart | 677 ++-------- .../video_call_session_state.dart | 10 +- .../presentation/pages/video_call_screen.dart | 11 +- ...video_call_connecting_entrance_screen.dart | 46 +- .../widgets/video_call_global_pip_layer.dart | 4 + .../video_call_livekit_track_utils.dart | 5 + .../video_call_remote_video_stage.dart | 5 +- .../widgets/video_call_stage_layout.dart | 73 ++ .../video_call_audio_session_coordinator.dart | 77 ++ .../video_call_foreground_bootstrap.dart | 35 + .../video_call_foreground_coordinator.dart | 79 ++ .../video_call_lifecycle_binding.dart | 29 + .../video_call_notification_navigator.dart | 43 + .../services/video_call_task_handler.dart | 45 + lib/generated/intl/messages_en.dart | 15 + lib/generated/intl/messages_ru.dart | 15 + lib/generated/l10n.dart | 61 + lib/l10n/intl_en.arb | 7 +- lib/l10n/intl_ru.arb | 7 +- lib/main.dart | 36 +- pubspec.yaml | 2 + ..._call_manager_audio_interruption_test.dart | 173 +++ .../video_call_manager_lifecycle_test.dart | 89 ++ .../video_call_session_cubit_test.dart | 146 +++ .../video_call_remote_video_stage_test.dart | 23 +- .../widgets/video_call_stage_layout_test.dart | 4 +- 35 files changed, 2288 insertions(+), 618 deletions(-) create mode 100644 lib/features/video_calls/managers/video_call_manager.dart create mode 100644 lib/features/video_calls/managers/video_call_manager_snapshot.dart create mode 100644 lib/features/video_calls/services/video_call_audio_session_coordinator.dart create mode 100644 lib/features/video_calls/services/video_call_foreground_bootstrap.dart create mode 100644 lib/features/video_calls/services/video_call_foreground_coordinator.dart create mode 100644 lib/features/video_calls/services/video_call_lifecycle_binding.dart create mode 100644 lib/features/video_calls/services/video_call_notification_navigator.dart create mode 100644 lib/features/video_calls/services/video_call_task_handler.dart create mode 100644 test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart create mode 100644 test/features/video_calls/presentation/video_call_manager_lifecycle_test.dart create mode 100644 test/features/video_calls/presentation/video_call_session_cubit_test.dart diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index b657fa16..b20e2682 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -4,6 +4,9 @@ -keep class org.bouncycastle.** { *; } -dontwarn org.bouncycastle.** +-keep class com.pravera.flutter_foreground_task.** { *; } +-dontwarn com.pravera.flutter_foreground_task.** + -keep class io.flutter.** { *; } -dontwarn io.flutter.** diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 116b494b..d63acff8 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,9 @@ + + + @@ -77,6 +80,11 @@ + + + + + + + + + + + + + + + + + ().buildType == AppBuildType.prod) { try { await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5)); diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index a8d1d5d0..1c3b98a8 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -1,6 +1,10 @@ import 'dart:developer' as developer; +import 'dart:async'; + import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; @@ -50,7 +54,25 @@ class PushNavigationHandler { } static void _handleMessageInternal(RemoteMessage message, {required String source}) { - final data = message.data; + final data = Map.from(message.data); + + final videoCallPayload = VideoCallPushPayload.tryParse(data); + if (videoCallPayload != null) { + developer.log( + 'PushNavigationHandler: opening incoming video call, source=$source, conversation=${videoCallPayload.conversationUuid}', + name: 'PushNavigationHandler', + ); + unawaited( + VideoCallNotificationNavigator.openIncomingCall( + conversationUuid: videoCallPayload.conversationUuid, + counterpartName: videoCallPayload.counterpartName, + counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl, + localParticipantName: videoCallPayload.localParticipantName, + ), + ); + return; + } + final actionUrl = data['action_url'] as String?; if (actionUrl == null || actionUrl.isEmpty) { developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler'); diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 961dcf07..87bafb3b 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -62,6 +62,9 @@ class VideoCallManager { bool _liveKitDisposed = true; bool _callEndedNotified = true; bool _callStageEntered = false; + bool _joinInProgress = false; + String? _preparedWsUrl; + String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; bool? _micEnabledBeforeSystemInterruption; @@ -171,18 +174,34 @@ class VideoCallManager { }) async { if (_room != null && !_liveKitDisposed && - _snapshot.conversationUuid == conversationUuid && - _room!.connectionState == lk.ConnectionState.connected) { - _emitSnapshot( - _snapshot.copyWith( - counterpartName: counterpartName ?? _snapshot.counterpartName, - localParticipantName: - localParticipantName ?? _snapshot.localParticipantName, - ), - ); - _ensureLifecycleAttached(); - unawaited(_startCallAudioSession()); - return; + _snapshot.conversationUuid == conversationUuid) { + if (_callStageEntered && + _room!.connectionState == lk.ConnectionState.connected) { + _emitSnapshot( + _snapshot.copyWith( + counterpartName: counterpartName ?? _snapshot.counterpartName, + localParticipantName: + localParticipantName ?? _snapshot.localParticipantName, + ), + ); + _ensureLifecycleAttached(); + unawaited(_startCallAudioSession()); + return; + } + if (!_callStageEntered && + _snapshot.connectPhase == VideoCallConnectPhase.connected && + _preparedWsUrl != null && + _preparedToken != null) { + _emitSnapshot( + _snapshot.copyWith( + counterpartName: counterpartName ?? _snapshot.counterpartName, + localParticipantName: + localParticipantName ?? _snapshot.localParticipantName, + ), + ); + _ensureLifecycleAttached(); + return; + } } await endCall(); @@ -217,7 +236,7 @@ class VideoCallManager { ), ); - await _connectLiveKit(); + await _prepareLiveKitConnection(); } Future onCallScreenVisible({ @@ -248,13 +267,33 @@ class VideoCallManager { } } - Future onEnterCallStage() async { - if (_callStageEntered) return; - if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; - _callStageEntered = true; - await _publishLocalMedia(); - await _startForegroundIfPossible(); - await _startCallAudioSession(); + Future onEnterCallStage() async { + if (_callStageEntered) return true; + if (_joinInProgress) return false; + if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return false; + + _joinInProgress = true; + try { + await _joinLiveKitRoom(); + if (_liveKitDisposed) return false; + + _callStageEntered = true; + await _publishLocalMedia(); + if (_liveKitDisposed || !_callStageEntered) return false; + await _startForegroundIfPossible(); + await _startCallAudioSession(); + return true; + } catch (e) { + _emitSnapshot( + _snapshot.copyWith( + connectError: e.toString(), + linkIssue: VideoCallLinkIssue.lost, + ), + ); + return false; + } finally { + _joinInProgress = false; + } } Future retryAfterConnectionLoss() async { @@ -282,7 +321,7 @@ class VideoCallManager { connectError: null, ), ); - await _connectLiveKit(); + await _prepareLiveKitConnection(); } Future endCall() async { @@ -306,6 +345,25 @@ class VideoCallManager { _emitSnapshot(VideoCallManagerSnapshot.initial); } + /// Decline from incoming UI before joining the LiveKit room. + Future declineIncomingCall(String conversationUuid) async { + final uuid = conversationUuid.trim(); + if (uuid.isEmpty) { + await endCall(); + return; + } + + if (_snapshot.conversationUuid == uuid) { + await endCall(); + return; + } + + await endCall(); + try { + await _videoCallRepository.notifyCallEnded(uuid); + } catch (_) {} + } + /// [currentUiEnabled] reflects what the user sees on the mic control /// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent. Future toggleMic(bool currentUiEnabled) async { @@ -550,10 +608,17 @@ class VideoCallManager { _recordingOverlayScheduled = false; _micEnabledBeforeSystemInterruption = null; _audioInterruptionHoldCount = 0; + _joinInProgress = false; + _clearPreparedConnectionCredentials(); _syncSystemInterruptionOverlay(show: false); _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); } + void _clearPreparedConnectionCredentials() { + _preparedWsUrl = null; + _preparedToken = null; + } + void _disposeRoomEphemerals() { _mutedMicPollTimer?.cancel(); _mutedMicPollTimer = null; @@ -704,7 +769,7 @@ class VideoCallManager { } catch (_) {} } - Future _connectLiveKit() async { + Future _prepareLiveKitConnection() async { final room = _room; if (room == null) return; _emitSnapshot(_snapshot.copyWith(connectError: null)); @@ -714,15 +779,15 @@ class VideoCallManager { ); if (_liveKitDisposed) return; + _preparedWsUrl = tokenResponse.wsUrl; + _preparedToken = tokenResponse.token; + await room.prepareConnection( tokenResponse.wsUrl, tokenResponse.token, ); if (_liveKitDisposed) return; - await room.connect(tokenResponse.wsUrl, tokenResponse.token); - if (_liveKitDisposed) return; - _emitSnapshot( _snapshot.copyWith( connectPhase: VideoCallConnectPhase.connected, @@ -730,16 +795,8 @@ class VideoCallManager { clearConnectError: true, ), ); - _syncPoorConnectionOverlay( - room.localParticipant?.connectionQuality ?? - lk.ConnectionQuality.unknown, - ); - - if (_callStageEntered) { - await _publishLocalMedia(); - await _startCallAudioSession(); - } } catch (e) { + _clearPreparedConnectionCredentials(); _emitSnapshot( _snapshot.copyWith( connectError: e.toString(), @@ -750,6 +807,35 @@ class VideoCallManager { } } + Future _joinLiveKitRoom() async { + final room = _room; + if (room == null) { + throw StateError('VideoCallManager: room is null'); + } + if (room.connectionState == lk.ConnectionState.connected) return; + + var wsUrl = _preparedWsUrl; + var token = _preparedToken; + if (wsUrl == null || token == null) { + final tokenResponse = await _videoCallRepository.getParticipantToken( + _snapshot.conversationUuid, + ); + if (_liveKitDisposed) return; + wsUrl = tokenResponse.wsUrl; + token = tokenResponse.token; + _preparedWsUrl = wsUrl; + _preparedToken = token; + } + + await room.connect(wsUrl, token); + if (_liveKitDisposed) return; + + _syncPoorConnectionOverlay( + room.localParticipant?.connectionQuality ?? + lk.ConnectionQuality.unknown, + ); + } + Future _disconnectLiveKitOnly() async { if (_room == null) return; await _stopCallAudioSession(); 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 index 82657459..5eb112db 100644 --- 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 @@ -192,8 +192,11 @@ class VideoCallSessionCubit extends BaseCubit { if (isClosed) return; if (state.connectPhase != VideoCallConnectPhase.connected) return; if (state.callStageEntered) return; + + final entered = await _videoCallManager.onEnterCallStage(); + if (!entered || isClosed) return; + emit(state.copyWith(callStageEntered: true)); - await _videoCallManager.onEnterCallStage(); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; _videoCallManager.scheduleRecordingNoticeOnce(); 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 index f02c6c16..a7c38545 100644 --- 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 @@ -33,6 +33,5 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { @Default(false) bool systemAudioInterrupted, }) = _VideoCallSessionState; - /// UI for mic control: off while GSM / system audio holds the session. bool get isMicActiveForUi => micEnabled && !systemAudioInterrupted; } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 0ccd085d..47a310c0 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -5,10 +5,11 @@ import 'dart:io'; import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; -import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.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/services/video_call_fcm_handler.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; @@ -29,16 +30,33 @@ class VideoCallCallKitCoordinator { StreamSubscription? _eventSubscription; final Map _callKitParamsById = {}; + final Set _acceptHandledCallKitIds = {}; + final Set _suppressedEndCallKitIds = {}; String? _activeCallKitId; bool _initialized = false; Future initialize() async { if (_initialized) return; - if (!Platform.isIOS) return; _initialized = true; _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); - await _firebaseApi.registerPushToken(); + + if (Platform.isIOS) { + await _firebaseApi.registerPushToken(); + return; + } + + if (Platform.isAndroid) { + VideoCallFcmHandler.registerForegroundListener(); + FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); + await FlutterCallkitIncoming.requestNotificationPermission({ + 'title': 'Уведомления', + 'rationaleMessagePermission': + 'Разрешите уведомления, чтобы получать входящие видеозвонки.', + 'postNotificationMessageRequired': + 'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.', + }); + } } void dispose() { @@ -67,11 +85,11 @@ class VideoCallCallKitCoordinator { case CallEventActionCallCallback(:final id): await _handleAccept(id); case CallEventActionCallDecline(:final id): - await _handleEnd(id); + await _handleDecline(id); case CallEventActionCallEnded(:final id): - await _handleEnd(id); + await _handleDecline(id); case CallEventActionCallTimeout(:final id): - await _handleEnd(id); + await _handleDecline(id); default: break; } @@ -84,7 +102,41 @@ class VideoCallCallKitCoordinator { CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; + Future _onNativeAcceptCallHandle(Map body) async { + final extra = Map.from(body); + final conversationUuid = _parseConversationUuid(extra); + if (conversationUuid.isEmpty) { + log( + 'VideoCallCallKitCoordinator: native accept without conversationUuid, extra=$extra', + name: 'VideoCallCallKit', + ); + return; + } + + await _deliverIncomingAccept( + callKitId: conversationUuid, + conversationUuid: conversationUuid, + counterpartName: _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]), + counterpartAvatarUrl: _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + Future _handleAccept(String callKitId) async { + if (_acceptHandledCallKitIds.contains(callKitId)) return; + _activeCallKitId = callKitId; final params = _paramsForCallId(callKitId); final extra = params?.extra ?? {}; @@ -101,55 +153,102 @@ class VideoCallCallKitCoordinator { return; } - final counterpartName = _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? params?.nameCaller; - final localParticipantName = _parseString(extra, const [ - 'localParticipantName', - 'local_participant_name', - ]); - final avatarUrl = _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? params?.avatar; - - await _cubit.startCall( + await _deliverIncomingAccept( + callKitId: callKitId, conversationUuid: conversationUuid, - counterpartName: counterpartName, - localParticipantName: localParticipantName, + counterpartName: _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? params?.nameCaller, + counterpartAvatarUrl: _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? params?.avatar, + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), ); - - await VideoCallNotificationNavigator.openIncomingCall( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - counterpartAvatarUrl: avatarUrl, - localParticipantName: localParticipantName, - ); - - _waitForConnectedThenEnterCall(callKitId); } - void _waitForConnectedThenEnterCall(String callKitId) { - void onSnapshot(VideoCallManagerSnapshot snapshot) { - if (snapshot.connectPhase != VideoCallConnectPhase.connected) return; - _manager.removeListener(onSnapshot); - unawaited(_cubit.enterCallStage()); - unawaited(FlutterCallkitIncoming.setCallConnected(callKitId)); - } + Future _deliverIncomingAccept({ + required String callKitId, + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + }) async { + if (_acceptHandledCallKitIds.contains(callKitId)) return; + _acceptHandledCallKitIds.add(callKitId); + _suppressedEndCallKitIds.add(callKitId); - if (_manager.snapshot.connectPhase == VideoCallConnectPhase.connected) { - onSnapshot(_manager.snapshot); + try { + await FlutterCallkitIncoming.endCall(callKitId); + + final binding = WidgetsBinding.instance; + final completer = Completer(); + binding.addPostFrameCallback((_) { + unawaited(_openConnectingScreenAfterAccept( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ).whenComplete(completer.complete)); + }); + await completer.future; + } finally { + unawaited( + Future.delayed(const Duration(seconds: 2), () { + _acceptHandledCallKitIds.remove(callKitId); + _suppressedEndCallKitIds.remove(callKitId); + }), + ); + } + } + + Future _openConnectingScreenAfterAccept({ + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + }) { + return VideoCallNotificationNavigator.openIncomingCall( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ); + } + + Future _handleDecline(String callKitId) async { + if (_suppressedEndCallKitIds.contains(callKitId)) { + log( + 'VideoCallCallKitCoordinator: ignore spurious end/decline during accept, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); return; } - _manager.addListener(onSnapshot); - } - Future _handleEnd(String callKitId) async { - await FlutterCallkitIncoming.endCall(callKitId); + final params = _paramsForCallId(callKitId); + final extra = params?.extra ?? {}; + final conversationUuid = _parseConversationUuid( + extra, + handle: params?.handle ?? callKitId, + ); + + _suppressedEndCallKitIds.add(callKitId); + try { + await FlutterCallkitIncoming.endCall(callKitId); + } finally { + unawaited( + Future.delayed(const Duration(seconds: 1), () { + _suppressedEndCallKitIds.remove(callKitId); + }), + ); + } _callKitParamsById.remove(callKitId); if (_activeCallKitId == callKitId) { _activeCallKitId = null; @@ -158,6 +257,12 @@ class VideoCallCallKitCoordinator { if (_manager.isActiveCall || _cubit.state.connectPhase != VideoCallConnectPhase.idle) { await _cubit.endCall(); + return; + } + + await _manager.declineIncomingCall(conversationUuid); + if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) { + await _cubit.onCallEndedExternally(); } } diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart new file mode 100644 index 00000000..6f058c5b --- /dev/null +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -0,0 +1,75 @@ +import 'dart:developer'; +import 'dart:io'; + +import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:eatvio_app/firebase_options.dart'; +import 'package:firebase_core/firebase_core.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/widgets.dart'; + +/// Top-level FCM background handler (separate isolate). +@pragma('vm:entry-point') +Future videoCallFirebaseMessagingBackgroundHandler( + RemoteMessage message, +) async { + WidgetsFlutterBinding.ensureInitialized(); + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + await VideoCallFcmHandler.handleRemoteMessage( + message, + source: 'background', + ); +} + +abstract final class VideoCallFcmHandler { + static bool _foregroundListenerRegistered = false; + + static void registerBackgroundHandler() { + FirebaseMessaging.onBackgroundMessage( + videoCallFirebaseMessagingBackgroundHandler, + ); + } + + static void registerForegroundListener() { + if (_foregroundListenerRegistered) return; + _foregroundListenerRegistered = true; + + FirebaseMessaging.onMessage.listen((message) { + handleRemoteMessage(message, source: 'foreground'); + }); + } + + static Future handleRemoteMessage( + RemoteMessage message, { + required String source, + }) async { + if (!Platform.isAndroid) return; + + final data = Map.from(message.data); + if (data.isEmpty) return; + + final payload = VideoCallPushPayload.tryParse(data); + if (payload == null) { + log( + 'VideoCallFcmHandler: skip non-video-call push, source=$source, data=$data', + name: 'VideoCallFcm', + ); + return; + } + + log( + 'VideoCallFcmHandler: show incoming call UI, source=$source, conversation=${payload.conversationUuid}', + name: 'VideoCallFcm', + ); + + try { + await VideoCallIncomingPushPresenter.showIncomingCall(payload); + } catch (e, st) { + log( + 'VideoCallFcmHandler: failed to show incoming call UI: $e', + name: 'VideoCallFcm', + stackTrace: st, + ); + } + } +} diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart new file mode 100644 index 00000000..c3325be9 --- /dev/null +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -0,0 +1,53 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:flutter_callkit_incoming/entities/entities.dart'; +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; + +abstract final class VideoCallIncomingPushPresenter { + static const String _androidAccentColor = '#2787F5'; + + static Future showIncomingCall(VideoCallPushPayload payload) async { + final callerName = + payload.counterpartName?.trim().isNotEmpty == true + ? payload.counterpartName!.trim() + : 'Итвио'; + + final params = CallKitParams( + id: payload.conversationUuid, + nameCaller: callerName, + appName: 'Итвио', + avatar: payload.counterpartAvatarUrl, + handle: payload.conversationUuid, + type: 0, + duration: 45000, + extra: { + 'conversationUuid': payload.conversationUuid, + if (payload.counterpartName != null) + 'counterpartName': payload.counterpartName, + if (payload.counterpartAvatarUrl != null) + 'counterpartAvatarUrl': payload.counterpartAvatarUrl, + if (payload.localParticipantName != null) + 'localParticipantName': payload.localParticipantName, + }, + android: const AndroidParams( + isCustomNotification: true, + isShowLogo: false, + isShowFullLockedScreen: false, + isFullScreen: false, + ringtonePath: 'system_ringtone_default', + backgroundColor: _androidAccentColor, + actionColor: _androidAccentColor, + textColor: '#ffffff', + textAccept: 'Answer', + textDecline: 'Decline', + isShowCallID: false, + ), + ios: const IOSParams( + handleType: 'generic', + supportsVideo: true, + ringtonePath: 'system_ringtone_default', + ), + ); + + await FlutterCallkitIncoming.showCallkitIncoming(params); + } +} diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index c648bfe0..09d88086 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -39,13 +39,6 @@ class VideoCallNotificationNavigator { counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, ); - - final manager = sl(); - await manager.onCallScreenVisible( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - localParticipantName: localParticipantName, - ); } static Future _pushCallScreen({ diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart new file mode 100644 index 00000000..3b0decac --- /dev/null +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -0,0 +1,145 @@ +class VideoCallPushPayload { + const VideoCallPushPayload({ + required this.conversationUuid, + this.counterpartName, + this.counterpartAvatarUrl, + this.localParticipantName, + }); + + final String conversationUuid; + final String? counterpartName; + final String? counterpartAvatarUrl; + final String? localParticipantName; + + static VideoCallPushPayload? tryParse(Map data) { + if (!isIncomingVideoCall(data)) return null; + + final conversationUuid = _conversationUuid(data); + if (conversationUuid == null || conversationUuid.isEmpty) return null; + + return VideoCallPushPayload( + conversationUuid: conversationUuid, + counterpartName: _string(data, const [ + 'counterpartName', + 'counterpart_name', + 'caller_name', + 'callerName', + 'nameCaller', + 'caller', + ]), + counterpartAvatarUrl: _resolveAvatarUrl( + _string(data, const [ + 'counterpartAvatarUrl', + 'counterpart_avatar_url', + 'avatar', + 'avatarUrl', + 'avatar_url', + 'caller_avatar', + ]), + ), + localParticipantName: _string(data, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + + static bool isIncomingVideoCall(Map data) { + final type = _string(data, const [ + 'type', + 'notification_type', + 'event', + 'action', + 'push_type', + 'call_type', + ]); + if (type != null) { + final normalized = type.toLowerCase().replaceAll('-', '_'); + if (normalized.contains('video_call')) { + if (normalized.contains('end') || normalized.contains('ended')) { + return false; + } + return true; + } + if (normalized == 'incoming_call' || + normalized == 'video_call_incoming' || + normalized == 'incoming_video_call') { + return true; + } + } + + if (_isTruthy(data['is_video_call']) || + _isTruthy(data['incoming_call']) || + _isTruthy(data['is_incoming_call'])) { + return _conversationUuid(data) != null; + } + + final actionUrl = _string(data, const ['action_url']); + if (actionUrl != null && + (actionUrl.contains('video-call') || actionUrl.contains('videoCall'))) { + return true; + } + + return false; + } + + static String? _conversationUuid(Map data) { + final fromData = _string(data, const [ + 'conversationUuid', + 'conversation_uuid', + 'conversation_id', + 'chat_uuid', + 'uuid', + ]); + if (fromData != null && fromData.isNotEmpty) return fromData; + + final actionUrl = _string(data, const ['action_url']); + if (actionUrl == null) return null; + + final uri = Uri.tryParse(actionUrl); + if (uri == null) return null; + + final fromQuery = uri.queryParameters['uuid'] ?? + uri.queryParameters['conversationUuid'] ?? + uri.queryParameters['conversation_uuid']; + if (fromQuery != null && fromQuery.trim().isNotEmpty) { + return fromQuery.trim(); + } + + final segments = uri.pathSegments; + for (var i = 0; i < segments.length - 1; i++) { + if (segments[i] == 'video-call' && segments[i + 1].isNotEmpty) { + return segments[i + 1]; + } + } + + return null; + } + + static String? _string(Map map, List keys) { + for (final key in keys) { + final value = map[key]; + if (value is String && value.trim().isNotEmpty) { + return value.trim(); + } + } + return null; + } + + static bool _isTruthy(Object? value) { + if (value == null) return false; + if (value is bool) return value; + final normalized = value.toString().trim().toLowerCase(); + return normalized == '1' || + normalized == 'true' || + normalized == 'yes'; + } + + static String? _resolveAvatarUrl(String? raw) { + if (raw == null || raw.isEmpty) return null; + if (raw.startsWith('http://') || raw.startsWith('https://')) { + return raw; + } + return raw; + } +} diff --git a/lib/main.dart b/lib/main.dart index ee56a8e7..2f8373da 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,8 @@ Future main() async { FlutterForegroundTask.initCommunicationPort(); VideoCallForegroundBootstrap.ensureInitialized(); await AppInitializer.initialize(buildType: AppBuildType.prod); + await sl().initialize(); + await PushNavigationHandler.initialize(); await AppInitializer.runAppWithSentry(() => runApp(const MyApp())); } @@ -34,8 +36,6 @@ class _MyAppState extends State { @override void initState() { super.initState(); - PushNavigationHandler.initialize(); - unawaited(sl().initialize()); } @override diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart new file mode 100644 index 00000000..9ddae36e --- /dev/null +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -0,0 +1,51 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('VideoCallPushPayload', () { + test('parses video_call type with conversation uuid', () { + final payload = VideoCallPushPayload.tryParse({ + 'type': 'video_call_incoming', + 'conversation_uuid': 'abc-123', + 'caller_name': 'Anna', + 'avatar': 'https://cdn.example/avatar.jpg', + }); + + expect(payload, isNotNull); + expect(payload!.conversationUuid, 'abc-123'); + expect(payload.counterpartName, 'Anna'); + expect(payload.counterpartAvatarUrl, 'https://cdn.example/avatar.jpg'); + }); + + test('parses action_url with video-call path', () { + final payload = VideoCallPushPayload.tryParse({ + 'action_url': 'https://eatvio.ru/video-call-screen?uuid=room-42', + 'counterpartName': 'Ivan', + }); + + expect(payload, isNotNull); + expect(payload!.conversationUuid, 'room-42'); + expect(payload.counterpartName, 'Ivan'); + }); + + test('ignores ended video call push', () { + expect( + VideoCallPushPayload.tryParse({ + 'type': 'video_call_ended', + 'conversation_uuid': 'abc-123', + }), + isNull, + ); + }); + + test('ignores regular chat push', () { + expect( + VideoCallPushPayload.tryParse({ + 'type': 'chat_message', + 'action_url': 'https://eatvio.ru/chat/123', + }), + isNull, + ); + }); + }); +} From de795c5274aa74abb9aa8b98e5ebf9963fd07021 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 09:32:43 +0300 Subject: [PATCH 12/56] feat(video_calls): added system android pip for calls --- android/app/src/main/AndroidManifest.xml | 1 + .../kotlin/com/eatvioapp/app/MainActivity.kt | 37 ++++++++ lib/core/di/cubit_registrations.dart | 5 +- lib/core/di/service_registrations.dart | 4 + .../managers/video_call_manager.dart | 41 ++++++++ .../video_call_session_cubit.dart | 43 +++++++-- .../widgets/video_call_app_shell.dart | 33 +++++++ .../widgets/video_call_global_pip_layer.dart | 26 +----- .../widgets/video_call_pip_video_preview.dart | 42 +++++++++ .../video_call_system_pip_content.dart | 47 ++++++++++ .../video_call_system_pip_listener.dart | 93 +++++++++++++++++++ .../video_call_system_pip_coordinator.dart | 63 +++++++++++++ lib/main.dart | 6 +- pubspec.yaml | 1 + .../video_call_session_cubit_test.dart | 27 ++++++ 15 files changed, 436 insertions(+), 33 deletions(-) create mode 100644 lib/features/video_calls/presentation/widgets/video_call_app_shell.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart create mode 100644 lib/features/video_calls/services/video_call_system_pip_coordinator.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 91bcaa52..daa5ec83 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -37,6 +37,7 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" + android:supportsPictureInPicture="true" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index 26bd8ed7..65903d96 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -1,8 +1,11 @@ package com.eatvioapp.app +import android.app.PictureInPictureParams import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager +import android.os.Build +import android.util.Rational import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel @@ -21,7 +24,10 @@ class MainActivity: FlutterActivity() { } private val channelName = "com.eatvioapp/hms_push" + private val videoCallPipChannelName = "com.eatvioapp/video_call_pip" private var methodChannel: MethodChannel? = null + private var videoCallPipChannel: MethodChannel? = null + private var videoCallPipEnabled: Boolean = false override fun onResume() { super.onResume() @@ -37,6 +43,16 @@ class MainActivity: FlutterActivity() { super.configureFlutterEngine(flutterEngine) methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) + videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName) + videoCallPipChannel?.setMethodCallHandler { call, result -> + when (call.method) { + "setPipEnabled" -> { + videoCallPipEnabled = call.arguments as? Boolean ?: false + result.success(null) + } + else -> result.notImplemented() + } + } methodChannel?.setMethodCallHandler { call, result -> when (call.method) { "getHuaweiToken" -> { @@ -94,6 +110,12 @@ class MainActivity: FlutterActivity() { handleIntentForActionUrl(intent) } + override fun onUserLeaveHint() { + super.onUserLeaveHint() + enterVideoCallPictureInPictureIfNeeded() + videoCallPipChannel?.invokeMethod("onUserLeaveHint", null) + } + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) // Обработка нового интента, когда Activity уже запущена (тап по уведомлению). @@ -122,4 +144,19 @@ class MainActivity: FlutterActivity() { // Проксируем URL во Flutter, где уже есть общая логика навигации по диплинкам. methodChannel?.invokeMethod("openActionUrl", actionUrl) } + + private fun enterVideoCallPictureInPictureIfNeeded() { + if (!videoCallPipEnabled) return + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + if (isInPictureInPictureMode) return + + try { + val params = PictureInPictureParams.Builder() + .setAspectRatio(Rational(2, 3)) + .build() + enterPictureInPictureMode(params) + } catch (e: Exception) { + android.util.Log.w("VideoCallPiP", "Unable to enter PiP from onUserLeaveHint", e) + } + } } diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 157b1e56..4e6ff7ee 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -77,7 +77,10 @@ class CubitRegistrations { () => VideoCallManager(videoCallRepository: sl()), ); sl.registerLazySingleton( - () => VideoCallSessionCubit(videoCallManager: sl()), + () => VideoCallSessionCubit( + videoCallManager: sl(), + systemPipCoordinator: sl(), + ), ); } diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart index a5ee09ee..d8f23ca2 100644 --- a/lib/core/di/service_registrations.dart +++ b/lib/core/di/service_registrations.dart @@ -37,6 +37,7 @@ import 'package:eatvio_app/features/registration_v2/services/registration_servic import 'package:eatvio_app/features/settings/services/settings_service.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart'; import 'package:get_it/get_it.dart'; /// Регистрация всех Service в системе внедрения зависимостей @@ -79,6 +80,9 @@ class ServiceRegistrations { sl.registerLazySingleton( () => VideoCallCallKitCoordinator(firebaseApi: sl()), ); + sl.registerLazySingleton( + () => VideoCallSystemPipCoordinator(), + ); sl.registerLazySingleton(() => ResetPasswordService(sl())); sl.registerLazySingleton(() => BlockedUserService(sl())); sl.registerLazySingleton(() => CommentsService(sl())); diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 87bafb3b..92fade24 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; @@ -46,6 +47,9 @@ class VideoCallManager { static const Duration _mutedMicOverlayMinInterval = Duration( milliseconds: 2600, ); + static const Duration _localMicActivationInterruptionGrace = Duration( + milliseconds: 1500, + ); static const double _mutedMicAudioLevelThreshold = 0.008; static const double _mutedMicEnergyDelta = 1e-6; @@ -69,6 +73,7 @@ class VideoCallManager { bool _videoPausedForBackground = false; bool? _micEnabledBeforeSystemInterruption; int _audioInterruptionHoldCount = 0; + DateTime? _ignoreAudioInterruptionUntil; OverlayEntry? _systemInterruptionEntry; VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial; @@ -367,6 +372,14 @@ class VideoCallManager { /// [currentUiEnabled] reflects what the user sees on the mic control /// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent. Future toggleMic(bool currentUiEnabled) async { + if (!currentUiEnabled && + _snapshot.systemAudioInterrupted && + _snapshot.micEnabled && + _isLocalMicrophonePublishing()) { + _clearSystemInterruptionState(); + _lastMutedMicTotalEnergy = null; + return; + } final next = !currentUiEnabled; if (_snapshot.systemAudioInterrupted || _audioInterruptionHoldCount > 0) { if (next) { @@ -397,6 +410,9 @@ class VideoCallManager { } _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; + if (next) { + _suppressOwnMicActivationInterruptions(); + } await _room!.localParticipant?.setMicrophoneEnabled( next, audioCaptureOptions: _micCaptureOptions, @@ -608,6 +624,7 @@ class VideoCallManager { _recordingOverlayScheduled = false; _micEnabledBeforeSystemInterruption = null; _audioInterruptionHoldCount = 0; + _ignoreAudioInterruptionUntil = null; _joinInProgress = false; _clearPreparedConnectionCredentials(); _syncSystemInterruptionOverlay(show: false); @@ -1056,6 +1073,9 @@ class VideoCallManager { _snapshot.connectPhase != VideoCallConnectPhase.connected) { return; } + if (_shouldIgnoreAudioInterruption(event)) { + return; + } if (event.begin) { _audioInterruptionHoldCount++; if (_audioInterruptionHoldCount == 1) { @@ -1115,6 +1135,7 @@ class VideoCallManager { if (lp == null) return; try { + _suppressOwnMicActivationInterruptions(); await lp.setMicrophoneEnabled( true, audioCaptureOptions: _micCaptureOptions, @@ -1131,6 +1152,24 @@ class VideoCallManager { } } + void _suppressOwnMicActivationInterruptions() { + if (!Platform.isAndroid) return; + _ignoreAudioInterruptionUntil = DateTime.now().add( + _localMicActivationInterruptionGrace, + ); + } + + bool _shouldIgnoreAudioInterruption(AudioInterruptionEvent event) { + if (!Platform.isAndroid || !event.begin) return false; + final until = _ignoreAudioInterruptionUntil; + if (until == null) return false; + if (DateTime.now().isBefore(until)) { + return true; + } + _ignoreAudioInterruptionUntil = null; + return false; + } + Future _restartLocalAudioTrack() async { if (!_canHandleCallAudio || !_snapshot.micEnabled) return; final lp = _room!.localParticipant; @@ -1139,6 +1178,7 @@ class VideoCallManager { final track = pub?.track; if (track is lk.LocalAudioTrack) { try { + _suppressOwnMicActivationInterruptions(); await track.restartTrack(); return; } catch (_) {} @@ -1148,6 +1188,7 @@ class VideoCallManager { false, audioCaptureOptions: _micCaptureOptions, ); + _suppressOwnMicActivationInterruptions(); await lp.setMicrophoneEnabled( true, audioCaptureOptions: _micCaptureOptions, 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 index 5eb112db..a7c73fc8 100644 --- 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 @@ -4,21 +4,29 @@ import 'package:eatvio_app/core/cubit/base_cubit.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.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/services/video_call_system_pip_coordinator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:livekit_client/livekit_client.dart' as lk; class VideoCallSessionCubit extends BaseCubit { - VideoCallSessionCubit({required VideoCallManager videoCallManager}) - : _videoCallManager = videoCallManager, - super(const VideoCallSessionState()) { + VideoCallSessionCubit({ + required VideoCallManager videoCallManager, + VideoCallSystemPipCoordinator? systemPipCoordinator, + }) : _videoCallManager = videoCallManager, + _systemPipCoordinator = + systemPipCoordinator ?? VideoCallSystemPipCoordinator(), + super(const VideoCallSessionState()) { _videoCallManager.addListener(_onManagerSnapshot); _syncFromManager(_videoCallManager.snapshot); } final VideoCallManager _videoCallManager; + final VideoCallSystemPipCoordinator _systemPipCoordinator; + bool _callScreenRouteVisible = false; lk.Room? get liveKitRoom => _videoCallManager.room; + bool get isCallScreenRouteVisible => _callScreenRouteVisible; static bool _isCallSessionInactive(VideoCallManagerSnapshot snapshot) { return snapshot.conversationUuid.isEmpty || @@ -56,6 +64,7 @@ class VideoCallSessionCubit extends BaseCubit { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { if (isClosed) return; if (_isCallSessionInactive(snapshot)) { + unawaited(_systemPipCoordinator.disarm()); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; @@ -79,6 +88,7 @@ class VideoCallSessionCubit extends BaseCubit { Future _onCallEndedExternally() async { if (isClosed) return; + await _systemPipCoordinator.disarm(); await _restoreSystemUiAfterCall(); emit(const VideoCallSessionState()); } @@ -102,6 +112,7 @@ class VideoCallSessionCubit extends BaseCubit { String? counterpartName, String? localParticipantName, }) async { + _callScreenRouteVisible = true; final isResumingActiveCall = state.conversationUuid == conversationUuid && state.callStageEntered && @@ -144,6 +155,7 @@ class VideoCallSessionCubit extends BaseCubit { } void onCallScreenDisposed() { + _callScreenRouteVisible = false; if (state.uiMode != VideoCallUiMode.fullscreen) return; if (state.connectPhase == VideoCallConnectPhase.connected && state.linkIssue != VideoCallLinkIssue.lost) { @@ -159,17 +171,32 @@ class VideoCallSessionCubit extends BaseCubit { } } - Future minimizeToPip() async { - if (state.connectPhase != VideoCallConnectPhase.connected) return; - if (state.linkIssue == VideoCallLinkIssue.lost) return; - if (!state.callStageEntered) return; + bool get _canMinimizeToPip => + state.connectPhase == VideoCallConnectPhase.connected && + state.linkIssue != VideoCallLinkIssue.lost && + state.callStageEntered; + + Future _applyPipUiState() async { emit(state.copyWith(uiMode: VideoCallUiMode.pip)); await _restoreSystemUiAfterCall(); } + /// Called when Android enters system PiP (e.g. Home gesture). + Future syncPipUiStateFromSystem() async { + if (!_canMinimizeToPip) return; + if (state.uiMode == VideoCallUiMode.pip) return; + await _applyPipUiState(); + } + + Future minimizeToPip() async { + if (!_canMinimizeToPip) return; + await _applyPipUiState(); + } + Future restoreFullscreenFromPip() async { if (state.uiMode != VideoCallUiMode.pip) return; emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); + unawaited(_systemPipCoordinator.armAutoPipOnLeave()); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); @@ -197,6 +224,7 @@ class VideoCallSessionCubit extends BaseCubit { if (!entered || isClosed) return; emit(state.copyWith(callStageEntered: true)); + unawaited(_systemPipCoordinator.armAutoPipOnLeave()); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; _videoCallManager.scheduleRecordingNoticeOnce(); @@ -209,6 +237,7 @@ class VideoCallSessionCubit extends BaseCubit { } Future endCall() async { + await _systemPipCoordinator.disarm(); await _videoCallManager.endCall(); } diff --git a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart new file mode 100644 index 00000000..5a972711 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart @@ -0,0 +1,33 @@ +import 'dart:io'; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart'; +import 'package:floating/floating.dart'; +import 'package:flutter/material.dart'; + +/// Root overlay shell: system PiP on Android, in-app PiP fallback on iOS. +class VideoCallAppShell extends StatelessWidget { + const VideoCallAppShell({super.key, required this.child}); + + final Widget? child; + + @override + Widget build(BuildContext context) { + final shell = Stack( + clipBehavior: Clip.none, + children: [ + child ?? const SizedBox.shrink(), + const VideoCallGlobalPipLayer(), + const VideoCallSystemPipListener(), + ], + ); + + if (!Platform.isAndroid) return shell; + + return PiPSwitcher( + childWhenDisabled: shell, + childWhenEnabled: const VideoCallSystemPipContent(), + ); + } +} 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 index f07ae217..e9c9b281 100644 --- 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 @@ -6,12 +6,10 @@ import 'package:eatvio_app/core/locator_service.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:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.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}); @@ -54,23 +52,6 @@ class _VideoCallGlobalPipLayerState extends State { ); } - 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( @@ -155,7 +136,10 @@ class _VideoCallGlobalPipLayerState extends State { child: SizedBox( width: _pipW, height: _pipH, - child: _pipPreview(state, room), + child: VideoCallPipVideoPreview( + sessionState: state, + room: room, + ), ), ), ), diff --git a/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart new file mode 100644 index 00000000..0653f7e2 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart @@ -0,0 +1,42 @@ +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_livekit_track_utils.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + +/// Shared compact preview for both in-app and Android system PiP. +class VideoCallPipVideoPreview extends StatelessWidget { + const VideoCallPipVideoPreview({ + super.key, + required this.sessionState, + required this.room, + }); + + final VideoCallSessionState sessionState; + final lk.Room room; + + @override + Widget build(BuildContext context) { + final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); + if (remote != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover), + ); + } + + if (sessionState.videoEnabled) { + 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); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart new file mode 100644 index 00000000..da20656f --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart @@ -0,0 +1,47 @@ +import 'package:eatvio_app/core/locator_service.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_pip_video_preview.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +/// Compact call UI rendered by [PiPSwitcher] while Android is in system PiP mode. +class VideoCallSystemPipContent extends StatelessWidget { + const VideoCallSystemPipContent({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: sl(), + buildWhen: (a, b) => + a.connectPhase != b.connectPhase || + a.linkIssue != b.linkIssue || + a.callStageEntered != b.callStageEntered || + a.videoEnabled != b.videoEnabled, + builder: (context, state) { + if (state.connectPhase != VideoCallConnectPhase.connected || + state.linkIssue == VideoCallLinkIssue.lost || + !state.callStageEntered) { + return const ColoredBox(color: Colors.black); + } + + final cubit = sl(); + final room = cubit.liveKitRoom; + if (room == null) { + return const ColoredBox(color: Colors.black); + } + + return Material( + color: Colors.black, + child: ListenableBuilder( + listenable: room, + builder: (context, _) => VideoCallPipVideoPreview( + sessionState: state, + room: room, + ), + ), + ); + }, + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart new file mode 100644 index 00000000..7760e27f --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart @@ -0,0 +1,93 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/locator_service.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/services/video_call_system_pip_coordinator.dart'; +import 'package:floating/floating.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; + +/// Syncs [VideoCallSessionCubit] with Android system PiP lifecycle events. +class VideoCallSystemPipListener extends StatefulWidget { + const VideoCallSystemPipListener({super.key}); + + @override + State createState() => + _VideoCallSystemPipListenerState(); +} + +class _VideoCallSystemPipListenerState extends State { + static const MethodChannel _userLeaveHintChannel = MethodChannel( + 'com.eatvioapp/video_call_pip', + ); + + StreamSubscription? _subscription; + PiPStatus? _lastStatus; + + @override + void initState() { + super.initState(); + if (!Platform.isAndroid) return; + + _userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall); + final coordinator = sl(); + _subscription = coordinator.statusStream.listen(_onPipStatus); + } + + Future _onNativePipMethodCall(MethodCall call) async { + if (call.method != 'onUserLeaveHint') return; + await sl().syncPipUiStateFromSystem(); + } + + Future _onPipStatus(PiPStatus status) async { + final cubit = sl(); + final wasEnabled = _lastStatus == PiPStatus.enabled; + _lastStatus = status; + + if (status == PiPStatus.enabled) { + await cubit.syncPipUiStateFromSystem(); + return; + } + + if (!wasEnabled || status != PiPStatus.disabled) return; + if (cubit.state.uiMode != VideoCallUiMode.pip) return; + + await cubit.restoreFullscreenFromPip(); + if (!mounted) return; + if (cubit.isCallScreenRouteVisible) return; + _openCallScreen(cubit.state); + } + + void _openCallScreen(VideoCallSessionState state) { + if (state.conversationUuid.isEmpty) return; + + final ctx = AppRouter.navigatorKey.currentContext; + if (ctx == null) return; + + GoRouter.of(ctx).pushNamed( + VideoCallScreen.name, + extra: { + 'conversationUuid': state.conversationUuid, + 'counterpartName': state.counterpartName, + 'localParticipantName': state.localParticipantName, + }, + ); + } + + @override + void dispose() { + if (Platform.isAndroid) { + _userLeaveHintChannel.setMethodCallHandler(null); + } + unawaited(_subscription?.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} diff --git a/lib/features/video_calls/services/video_call_system_pip_coordinator.dart b/lib/features/video_calls/services/video_call_system_pip_coordinator.dart new file mode 100644 index 00000000..ca7c9c4d --- /dev/null +++ b/lib/features/video_calls/services/video_call_system_pip_coordinator.dart @@ -0,0 +1,63 @@ +import 'dart:io'; + +import 'package:floating/floating.dart'; +import 'package:flutter/services.dart'; + +/// Android system Picture-in-Picture via the [floating] package. +class VideoCallSystemPipCoordinator { + VideoCallSystemPipCoordinator({Floating? floating}) + : _floating = floating ?? Floating(); + + static const MethodChannel _nativePipChannel = MethodChannel( + 'com.eatvioapp/video_call_pip', + ); + + final Floating _floating; + + /// Portrait ratio close to the in-call mini preview (142×201). + static const Rational callAspectRatio = Rational(2, 3); + + bool get isPlatformSupported => Platform.isAndroid; + + Future get isAvailable async { + if (!isPlatformSupported) return false; + return _floating.isPipAvailable; + } + + Stream get statusStream => _floating.pipStatusStream; + + /// Enter PiP when the user leaves the app (Home / recent apps). + /// + /// Requires Android 12+ (API 31); on older versions this is a no-op. + Future armAutoPipOnLeave() async { + if (!await isAvailable) return; + await _setNativeUserLeaveHintPipEnabled(true); + try { + await _floating.enable( + const OnLeavePiP(aspectRatio: callAspectRatio), + ); + } on PlatformException { + // OnLeavePiP is unavailable below Android 12; native onUserLeaveHint + // still handles Home / recent apps on Android 8+. + } + } + + Future disarm() async { + if (!isPlatformSupported) return; + await _setNativeUserLeaveHintPipEnabled(false); + try { + await _floating.cancelOnLeavePiP(); + } on PlatformException { + // The activity may already be detached while the call is being torn down. + } + } + + Future _setNativeUserLeaveHintPipEnabled(bool enabled) async { + if (!isPlatformSupported) return; + try { + await _nativePipChannel.invokeMethod('setPipEnabled', enabled); + } on PlatformException { + // Native channel may be unavailable during startup/teardown. + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 2f8373da..2813a4d0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -6,7 +6,7 @@ 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/services/video_call_callkit_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.dart'; -import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_app_shell.dart'; import 'dart:async'; import 'package:flutter/material.dart'; @@ -51,9 +51,7 @@ class _MyAppState extends State { debugShowCheckedModeBanner: false, localizationsDelegates: sl().localizationDelegates, supportedLocales: sl().supportedLocales, - builder: (context, child) { - return Stack(clipBehavior: Clip.none, children: [(child ?? const SizedBox.shrink()), const VideoCallGlobalPipLayer()]); - }, + builder: (context, child) => VideoCallAppShell(child: child), ), ), ), diff --git a/pubspec.yaml b/pubspec.yaml index 3f87b816..b6a3d23c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -88,6 +88,7 @@ dependencies: audio_session: ^0.1.25 flutter_foreground_task: ^9.2.1 flutter_callkit_incoming: ^3.1.1 + floating: ^6.0.0 dev_dependencies: build_runner: ^2.10.4 diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index 552f5283..d9c67cd9 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -142,5 +142,32 @@ void main() { expect(cubit.state, const VideoCallSessionState()); }); + + test('tracks whether call screen route is visible', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + expect(cubit.isCallScreenRouteVisible, isTrue); + + cubit.emit( + const VideoCallSessionState( + conversationUuid: 'uuid', + connectPhase: VideoCallConnectPhase.connected, + callStageEntered: true, + uiMode: VideoCallUiMode.pip, + ), + ); + + cubit.onCallScreenDisposed(); + expect(cubit.isCallScreenRouteVisible, isFalse); + }); }); } From 5fa024a7a80fd01789a76574d4ee84fe49336284 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 14:09:54 +0300 Subject: [PATCH 13/56] feat(ci/cd): changed repositories for ci/cd --- .gitea/workflows/test-flight-cd.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 5fea01c4..fbd2e833 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -15,11 +15,12 @@ jobs: uses: https://github.com/actions/checkout@v2 - name: Checkout env repository - uses: https://github.com/actions/checkout@v2 + uses: https://github.com/actions/checkout@v3 with: - repository: mso7nueh/eatvio_app_env + repository: eatvio/eatvio_app_env path: env_repo - token: ${{ vars.MSO7NUEH_TOKEN }} + # Здесь может быть токен любого юзера с доступом в gitea + token: ${{ vars.READ_USER_GITEA_TOKEN }} ref: main - name: Copy .env file @@ -76,7 +77,7 @@ jobs: LC_ALL: en_US.UTF-8 MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }} FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }} - MATCH_GIT_URL: http://gitea.office.local:3000/mso7nueh/fastlane + MATCH_GIT_URL: http://gitea.office.local:3000/eatvio/fastlane FASTLANE_USER: eatvio2023@icloud.com FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }} From 6fb151ef328d633082c44ec39e8123c8a98782d1 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 14:19:21 +0300 Subject: [PATCH 14/56] feat(ci/cd): added pods updating --- .gitea/workflows/test-flight-cd.yaml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index fbd2e833..bd092a3b 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -58,19 +58,16 @@ jobs: - name: Flutter build run: | - flutter --version + flutter upgrade flutter clean flutter pub get + flutter pub upgrade flutter pub run build_runner build --delete-conflicting-outputs # Configure Flutter for release mode - cd ios - pod install --no-repo-update --verbose || { - echo "⚠️ Pod install failed, trying with repo update..." - pod install --repo-update --verbose - } - cd .. + pod repo update flutter build ios --config-only --release cd ios + pod update fastlane beta env: LANG: en_US.UTF-8 From 9dc568ceab921d09ccfc839c8191d9fe659647f5 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 14:35:20 +0300 Subject: [PATCH 15/56] feat(ci/cd): removed flutter updating --- .gitea/workflows/test-flight-cd.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index bd092a3b..828c0d1f 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -58,10 +58,9 @@ jobs: - name: Flutter build run: | - flutter upgrade + flutter --version flutter clean flutter pub get - flutter pub upgrade flutter pub run build_runner build --delete-conflicting-outputs # Configure Flutter for release mode pod repo update From 885c6f66f98aa5f1ba3ac3b450afb118e0e6139f Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 16:39:06 +0300 Subject: [PATCH 16/56] feat(ci/cd): added gitea token for fastlane url --- .gitea/workflows/test-flight-cd.yaml | 2 +- ios/fastlane/Fastfile | 11 +---------- 2 files changed, 2 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 828c0d1f..052fb05b 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -73,7 +73,7 @@ jobs: LC_ALL: en_US.UTF-8 MATCH_PASSWORD: ${{ vars.IOS_RUNNER_CI_PASSWORD }} FASTLANE_PASSWORD: ${{ vars.FASTLANE_PASSWORD }} - MATCH_GIT_URL: http://gitea.office.local:3000/eatvio/fastlane + MATCH_GIT_URL: http://eatvio:${{ vars.READ_USER_GITEA_TOKEN }}@gitea.office.local:3000/eatvio/fastlane.git FASTLANE_USER: eatvio2023@icloud.com FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD: ${{ vars.FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD }} API_APPSTORE_KEY: ${{ vars.API_APPSTORE_KEY }} diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 15a7d506..1e40f5d2 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -67,17 +67,8 @@ platform :ios do end UI.message "Flutter build number: #{flutter_build_number}" - - api_key = app_store_connect_api_key( - key_id: "325JS9JCZH", - issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3", - key_filepath: auth_key_path, - duration: 1200, - in_house: false, - - ) - match(type: "appstore", readonly: false, api_key: api_key) + match(type: "appstore", readonly: true) # Set build number explicitly instead of incrementing increment_build_number( From 3e89bb5ca9dd6947d6e24bc59453c264d6276059 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 17:14:15 +0300 Subject: [PATCH 17/56] feat(ci/cd): added team id to build_app for ios --- ios/fastlane/Fastfile | 1 + 1 file changed, 1 insertion(+) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 1e40f5d2..f40a1eb7 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -81,6 +81,7 @@ platform :ios do scheme: "Runner", export_method: "app-store", codesigning_identity: "Apple Distribution", + team_id: "VL68C36HYS" ) From 4dd32e1a42d1ba003f5a67897edd6f97f7485b6d Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 10 Jun 2026 17:21:40 +0300 Subject: [PATCH 18/56] feat(ci/cd): fixed team id --- ios/fastlane/Fastfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index f40a1eb7..e6209338 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -81,7 +81,7 @@ platform :ios do scheme: "Runner", export_method: "app-store", codesigning_identity: "Apple Distribution", - team_id: "VL68C36HYS" + export_team_id: "VL68C36HYS" ) From f4f884484dcecd6af6296a91dde3ab63b7918e12 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 11 Jun 2026 11:55:43 +0300 Subject: [PATCH 19/56] feat(ci/cd): separeted code sign from build_app --- ios/fastlane/Fastfile | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index e6209338..242ef07d 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -75,12 +75,21 @@ platform :ios do xcodeproj: "Runner.xcodeproj", build_number: flutter_build_number ) + + update_code_signing_settings( + use_automatic_signing: false, + path: "Runner.xcodeproj", + team_id: "VL68C36HYS", + targets: ["Runner"], + code_sign_identity: "Apple Distribution", + profile_name: "match AppStore com.eatvioapp.app 1781098260" + ) build_app( workspace: "Runner.xcworkspace", scheme: "Runner", export_method: "app-store", - codesigning_identity: "Apple Distribution", + # codesigning_identity: "Apple Distribution", export_team_id: "VL68C36HYS" ) From 5daa1870450cc05a92907a623c5a5d0b763db258 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 11 Jun 2026 12:14:42 +0300 Subject: [PATCH 20/56] feat(ci/cd): fixed app delegate for ios --- ios/Runner/AppDelegate.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c439d251..dcfc1df5 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -132,7 +132,7 @@ import flutter_foreground_task handle: conversationUuid.isEmpty ? nameCaller : conversationUuid, type: isVideo ? 1 : 0 ) - data.extra = extra + data.extra = extra as NSDictionary SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( data, @@ -160,4 +160,6 @@ import flutter_foreground_task func didActivateAudioSession(_ audioSession: AVAudioSession) {} func didDeactivateAudioSession(_ audioSession: AVAudioSession) {} + + func providerDidReset() {} } From 1cafb4b05a7305ae308a0864267e62c9b82c58af Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 15 Jun 2026 14:07:56 +0300 Subject: [PATCH 21/56] feat(video_calls): changed chat button layoute --- .../chat/presentation/pages/chat_screen.dart | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index f5f37438..8d02a68a 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -217,19 +217,31 @@ class _ChatScreenState extends State { ), ), const SizedBox(width: 16), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(state.chatRoom.users.receiver.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black)), - AnimatedCrossFade( - firstChild: Text('Печатает...', style: CustomTextStyles.bodySmallMulishPrimary), - firstCurve: Curves.easeIn, - secondCurve: Curves.easeOut, - secondChild: const SizedBox.shrink(), - crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, - duration: const Duration(milliseconds: 150), - ), - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + state.chatRoom.users.receiver.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black), + ), + AnimatedCrossFade( + firstChild: Text( + 'Печатает...', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: CustomTextStyles.bodySmallMulishPrimary, + ), + firstCurve: Curves.easeIn, + secondCurve: Curves.easeOut, + secondChild: const SizedBox.shrink(), + crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, + duration: const Duration(milliseconds: 150), + ), + ], + ), ), ], ), From 4d531036fa03670922bf3b255519d394939a9a00 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 15 Jun 2026 14:12:37 +0300 Subject: [PATCH 22/56] Version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index b6a3d23c..2cf34b05 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.24+105 +version: 1.3.25+106 publish_to: 'none' environment: From b9cb4eeb84a6781b676ff71a18734f429ac4a7a1 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 15 Jun 2026 16:27:24 +0300 Subject: [PATCH 23/56] feat(video_calls): app delegate fix --- ios/Runner/AppDelegate.swift | 21 ++++++++++++++------- pubspec.yaml | 2 +- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index dcfc1df5..a1632593 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -10,6 +10,8 @@ import flutter_foreground_task @main @objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate { + private var voipRegistry: PKPushRegistry? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? @@ -20,9 +22,9 @@ import flutter_foreground_task } let mainQueue = DispatchQueue.main - let voipRegistry = PKPushRegistry(queue: mainQueue) - voipRegistry.delegate = self - voipRegistry.desiredPushTypes = [PKPushType.voIP] + voipRegistry = PKPushRegistry(queue: mainQueue) + voipRegistry?.delegate = self + voipRegistry?.desiredPushTypes = [PKPushType.voIP] if #available(iOS 10.0, *) { UNUserNotificationCenter.current().delegate = self @@ -84,10 +86,6 @@ import flutter_foreground_task } let dict = payload.dictionaryPayload - let id = (dict["id"] as? String) - ?? (dict["call_id"] as? String) - ?? (dict["message_id"] as? String) - ?? UUID().uuidString let nameCaller = (dict["nameCaller"] as? String) ?? (dict["caller_name"] as? String) ?? (dict["callerName"] as? String) @@ -96,6 +94,15 @@ import flutter_foreground_task ?? (dict["conversationUuid"] as? String) ?? (dict["handle"] as? String) ?? "" + let id = [ + dict["id"] as? String, + dict["call_id"] as? String, + conversationUuid, + dict["message_id"] as? String, + ] + .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } + .first { !$0.isEmpty && UUID(uuidString: $0) != nil } + ?? UUID().uuidString let isVideo = (dict["isVideo"] as? Bool) ?? (dict["is_video"] as? Bool) ?? true diff --git a/pubspec.yaml b/pubspec.yaml index 2cf34b05..cfd60cd9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.25+106 +version: 1.3.26+107 publish_to: 'none' environment: From a07565fca9508b752d0cf856118358423852783d Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 15 Jun 2026 16:47:17 +0300 Subject: [PATCH 24/56] feat(ci/cd): removed pods updating --- .gitea/workflows/test-flight-cd.yaml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.gitea/workflows/test-flight-cd.yaml b/.gitea/workflows/test-flight-cd.yaml index 052fb05b..ddf59034 100644 --- a/.gitea/workflows/test-flight-cd.yaml +++ b/.gitea/workflows/test-flight-cd.yaml @@ -62,11 +62,9 @@ jobs: flutter clean flutter pub get flutter pub run build_runner build --delete-conflicting-outputs - # Configure Flutter for release mode - pod repo update flutter build ios --config-only --release cd ios - pod update + pod install fastlane beta env: LANG: en_US.UTF-8 From 03f037518e0141bf6edc4b5fdc86a0ef1d81570e Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 15:01:46 +0300 Subject: [PATCH 25/56] feat(video_calls): fixed ios and HMS pushes for calls --- .../com/eatvioapp/app/HmsPushService.kt | 85 ++++++++++++++ ios/Runner/AppDelegate.swift | 14 +++ .../video_call_callkit_coordinator.dart | 106 +++++++++++++----- .../video_call_notification_navigator.dart | 4 +- pubspec.yaml | 2 +- 5 files changed, 183 insertions(+), 28 deletions(-) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index f18b3992..f8db072b 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -2,11 +2,14 @@ package com.eatvioapp.app import android.app.PendingIntent import android.content.Intent +import android.os.Bundle import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import com.hiennv.flutter_callkit_incoming.CallkitConstants import com.huawei.hms.push.HmsMessageService import com.huawei.hms.push.RemoteMessage +import java.util.HashMap class HmsPushService : HmsMessageService() { override fun onNewToken(token: String?) { @@ -20,6 +23,11 @@ class HmsPushService : HmsMessageService() { if (message == null) return + if (isIncomingVideoCall(message.dataOfMap)) { + showIncomingVideoCallNotification(message.dataOfMap) + return + } + // Берём action_url из data-поля, если оно есть. val actionUrl = message.dataOfMap["action_url"] @@ -57,6 +65,83 @@ class HmsPushService : HmsMessageService() { NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification) } + + private fun isIncomingVideoCall(data: Map): Boolean { + val rawType = data["type"] + ?: data["notification_type"] + ?: data["event"] + ?: data["action"] + ?: data["push_type"] + ?: data["call_type"] + val type = rawType?.lowercase()?.replace("-", "_") + return type?.contains("video_call") == true && + !type.contains("end") && + !type.contains("ended") + } + + private fun showIncomingVideoCallNotification(data: Map) { + val conversationUuid = data["conversationUuid"] + ?: data["conversation_uuid"] + ?: data["conversation_id"] + ?: data["chat_uuid"] + ?: data["uuid"] + ?: return + val callerName = data["counterpartName"] + ?: data["counterpart_name"] + ?: data["caller_name"] + ?: data["callerName"] + ?: data["nameCaller"] + ?: data["caller"] + ?: "Итвио" + val avatarUrl = data["counterpartAvatarUrl"] + ?: data["counterpart_avatar_url"] + ?: data["avatar"] + ?: data["avatarUrl"] + ?: data["avatar_url"] + ?: data["caller_avatar"] + ?: "" + val localParticipantName = data["localParticipantName"] + ?: data["local_participant_name"] + + val extra = HashMap() + extra["conversationUuid"] = conversationUuid + extra["counterpartName"] = callerName + if (avatarUrl.isNotBlank()) { + extra["counterpartAvatarUrl"] = avatarUrl + } + if (!localParticipantName.isNullOrBlank()) { + extra["localParticipantName"] = localParticipantName + } + + val callData = Bundle().apply { + putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) + putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") + putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) + putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) + putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") + putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra) + putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap()) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false) + putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default") + putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, "#2787F5") + putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, "#2787F5") + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff") + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false) + } + + val intent = com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver + .getIntent(this, CallkitConstants.ACTION_CALL_INCOMING, callData) + .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) + sendBroadcast(intent) + } } diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index a1632593..63277f90 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -86,6 +86,20 @@ import flutter_foreground_task } let dict = payload.dictionaryPayload + if let pushType = ( + dict["type"] as? String + ?? dict["notification_type"] as? String + ?? dict["event"] as? String + ?? dict["action"] as? String + )?.lowercased().replacingOccurrences(of: "-", with: "_"), + pushType.contains("video_call"), + pushType.contains("end") || pushType.contains("cancel") + { + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls() + completion() + return + } + let nameCaller = (dict["nameCaller"] as? String) ?? (dict["caller_name"] as? String) ?? (dict["callerName"] as? String) diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 47a310c0..8b4f63cf 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -14,7 +14,6 @@ import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; - class VideoCallCallKitCoordinator { VideoCallCallKitCoordinator({ required FirebaseApi firebaseApi, @@ -29,6 +28,7 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; + AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; @@ -43,13 +43,22 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { await _firebaseApi.registerPushToken(); + _lifecycleListener = AppLifecycleListener( + onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), + ); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_openActiveCallKitCallIfNeeded()); + }); return; } if (Platform.isAndroid) { VideoCallFcmHandler.registerForegroundListener(); FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); - await FlutterCallkitIncoming.requestNotificationPermission({ + await FlutterCallkitIncoming.requestNotificationPermission(< + String, + dynamic + >{ 'title': 'Уведомления', 'rationaleMessagePermission': 'Разрешите уведомления, чтобы получать входящие видеозвонки.', @@ -61,13 +70,14 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); + _lifecycleListener?.dispose(); _eventSubscription = null; + _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } - VideoCallManager get _manager => - _videoCallManager ?? sl(); + VideoCallManager get _manager => _videoCallManager ?? sl(); VideoCallSessionCubit get _cubit => _videoCallSessionCubit ?? sl(); @@ -102,6 +112,49 @@ class VideoCallCallKitCoordinator { CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; + Future _openActiveCallKitCallIfNeeded() async { + if (!Platform.isIOS) return; + if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; + + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + if (activeCalls.isEmpty) return; + + for (final params in activeCalls) { + _cacheCallKitParams(params); + final extra = params.extra ?? {}; + final conversationUuid = _parseConversationUuid( + extra, + handle: params.handle, + ); + if (conversationUuid.isEmpty) continue; + + await _deliverIncomingAccept( + callKitId: params.id, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + params.nameCaller, + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + params.avatar, + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + return; + } + } + Future _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -156,17 +209,21 @@ class VideoCallCallKitCoordinator { await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, - counterpartName: _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? params?.nameCaller, - counterpartAvatarUrl: _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? params?.avatar, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + params?.nameCaller, + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + params?.avatar, localParticipantName: _parseString(extra, const [ 'localParticipantName', 'local_participant_name', @@ -191,12 +248,14 @@ class VideoCallCallKitCoordinator { final binding = WidgetsBinding.instance; final completer = Completer(); binding.addPostFrameCallback((_) { - unawaited(_openConnectingScreenAfterAccept( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - counterpartAvatarUrl: counterpartAvatarUrl, - localParticipantName: localParticipantName, - ).whenComplete(completer.complete)); + unawaited( + _openConnectingScreenAfterAccept( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + ).whenComplete(completer.complete), + ); }); await completer.future; } finally { @@ -284,10 +343,7 @@ class VideoCallCallKitCoordinator { return ''; } - static String? _parseString( - Map map, - List keys, - ) { + static String? _parseString(Map map, List keys) { for (final key in keys) { final value = map[key]; if (value is String && value.trim().isNotEmpty) { diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 09d88086..5ffbd983 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -47,7 +47,7 @@ class VideoCallNotificationNavigator { String? counterpartAvatarUrl, String? localParticipantName, }) async { - for (var attempt = 0; attempt < 40; attempt++) { + for (var attempt = 0; attempt < 100; attempt++) { final ctx = AppRouter.navigatorKey.currentContext; if (ctx != null && ctx.mounted) { final router = GoRouter.of(ctx); @@ -68,7 +68,7 @@ class VideoCallNotificationNavigator { } return; } - await Future.delayed(const Duration(milliseconds: 50)); + await Future.delayed(const Duration(milliseconds: 100)); } } } diff --git a/pubspec.yaml b/pubspec.yaml index cfd60cd9..8f353be8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.26+107 +version: 1.3.27+108 publish_to: 'none' environment: From 705538ac85d11749abcfa6312cea93fde3c8e3f5 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 17:56:59 +0300 Subject: [PATCH 26/56] fix: added NSLocationWhenInUseUsageDescription key description --- ios/Runner/Info.plist | 2 +- pubspec.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index f217c73d..9a4dbd70 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -38,7 +38,7 @@ NSMicrophoneUsageDescription Нужен для звука в видеозвонках с консультантом NSLocationWhenInUseUsageDescription - + Может использоваться для стабильного поддержания связи во время звонка. NSPhotoLibraryUsageDescription Предоставление доступа позволит Вам добавлять фото к записям UIApplicationSupportsIndirectInputEvents diff --git a/pubspec.yaml b/pubspec.yaml index 8f353be8..7a6062eb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+108 +version: 1.3.27+109 publish_to: 'none' environment: From e0d697e8d0055eb194c3f47f33184da13258bc52 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 18:30:16 +0300 Subject: [PATCH 27/56] feat(video_calls): fixed ios push UI --- ios/Runner/AppDelegate.swift | 74 ++++++++++++++++--- .../video_call_callkit_coordinator.dart | 17 ++++- pubspec.yaml | 2 +- 3 files changed, 78 insertions(+), 15 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 63277f90..c800247d 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -60,6 +60,34 @@ import flutter_foreground_task ) } + private func conversationUuid(fromActionUrl actionUrl: String?) -> String? { + guard + let actionUrl = actionUrl, + let components = URLComponents(string: actionUrl) + else { + return nil + } + + let queryItems = components.queryItems ?? [] + for key in ["uuid", "conversationUuid", "conversation_uuid"] { + if let value = queryItems.first(where: { $0.name == key })?.value, + !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + { + return value.trimmingCharacters(in: .whitespacesAndNewlines) + } + } + + let segments = components.path.split(separator: "/").map(String.init) + for index in segments.indices where segments[index] == "video-call" { + let nextIndex = segments.index(after: index) + if segments.indices.contains(nextIndex), !segments[nextIndex].isEmpty { + return segments[nextIndex] + } + } + + return nil + } + func pushRegistry( _ registry: PKPushRegistry, @@ -86,32 +114,42 @@ import flutter_foreground_task } let dict = payload.dictionaryPayload - if let pushType = ( - dict["type"] as? String - ?? dict["notification_type"] as? String - ?? dict["event"] as? String - ?? dict["action"] as? String - )?.lowercased().replacingOccurrences(of: "-", with: "_"), - pushType.contains("video_call"), - pushType.contains("end") || pushType.contains("cancel") - { + let pushMarkers = [ + dict["type"] as? String, + dict["notification_type"] as? String, + dict["event"] as? String, + dict["action"] as? String, + dict["push_type"] as? String, + dict["call_type"] as? String, + ] + .compactMap { $0?.lowercased().replacingOccurrences(of: "-", with: "_") } + + if pushMarkers.contains(where: { $0.contains("video_call") }) + && pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) { SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls() completion() return } let nameCaller = (dict["nameCaller"] as? String) + ?? (dict["counterpartName"] as? String) + ?? (dict["counterpart_name"] as? String) ?? (dict["caller_name"] as? String) ?? (dict["callerName"] as? String) + ?? (dict["caller"] as? String) ?? "" let conversationUuid = (dict["conversation_uuid"] as? String) ?? (dict["conversationUuid"] as? String) + ?? (dict["conversation_id"] as? String) + ?? (dict["chat_uuid"] as? String) + ?? (dict["uuid"] as? String) + ?? conversationUuid(fromActionUrl: dict["action_url"] as? String) ?? (dict["handle"] as? String) ?? "" let id = [ - dict["id"] as? String, - dict["call_id"] as? String, conversationUuid, + dict["call_id"] as? String, + dict["id"] as? String, dict["message_id"] as? String, ] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } @@ -127,6 +165,9 @@ import flutter_foreground_task } if let avatar = dict["caller_avatar"] as? String ?? dict["avatar"] as? String ?? dict["avatarUrl"] as? String + ?? dict["avatar_url"] as? String + ?? dict["counterpartAvatarUrl"] as? String + ?? dict["counterpart_avatar_url"] as? String { extra["counterpartAvatarUrl"] = avatar } @@ -143,6 +184,8 @@ import flutter_foreground_task } if let counterpartName = dict["counterpartName"] as? String { extra["counterpartName"] = counterpartName + } else if let counterpartName = dict["counterpart_name"] as? String { + extra["counterpartName"] = counterpartName } else if !nameCaller.isEmpty { extra["counterpartName"] = nameCaller } @@ -151,8 +194,15 @@ import flutter_foreground_task id: id, nameCaller: nameCaller, handle: conversationUuid.isEmpty ? nameCaller : conversationUuid, - type: isVideo ? 1 : 0 + type: 0 ) + data.appName = "Итвио" + data.duration = 45000 + data.handleType = "generic" + data.supportsVideo = isVideo + data.ringtonePath = "system_ringtone_default" + data.configureAudioSession = false + data.includesCallsInRecents = false data.extra = extra as NSDictionary SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 8b4f63cf..860525b3 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -112,6 +112,18 @@ class VideoCallCallKitCoordinator { CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; + Future _paramsForCallIdOrActiveCall(String id) async { + final cached = _paramsForCallId(id); + if (cached != null || !Platform.isIOS) return cached; + + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + for (final params in activeCalls) { + _cacheCallKitParams(params); + } + + return _paramsForCallId(id); + } + Future _openActiveCallKitCallIfNeeded() async { if (!Platform.isIOS) return; if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; @@ -121,6 +133,7 @@ class VideoCallCallKitCoordinator { for (final params in activeCalls) { _cacheCallKitParams(params); + if (!params.isAccepted) continue; final extra = params.extra ?? {}; final conversationUuid = _parseConversationUuid( extra, @@ -191,12 +204,12 @@ class VideoCallCallKitCoordinator { if (_acceptHandledCallKitIds.contains(callKitId)) return; _activeCallKitId = callKitId; - final params = _paramsForCallId(callKitId); + final params = await _paramsForCallIdOrActiveCall(callKitId); final extra = params?.extra ?? {}; final conversationUuid = _parseConversationUuid( extra, - handle: params?.handle, + handle: params?.handle ?? callKitId, ); if (conversationUuid.isEmpty) { log( diff --git a/pubspec.yaml b/pubspec.yaml index 7a6062eb..cbc66a07 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+109 +version: 1.3.27+110 publish_to: 'none' environment: From b13ff7422099983929125b20aa2439a1fdfec680 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 18:33:21 +0300 Subject: [PATCH 28/56] fix: added NSLocationAlwaysAndWhenInUseUsageDescription key description --- ios/Runner/Info.plist | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9a4dbd70..c35ead46 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -37,6 +37,8 @@ Предоставление доступа позволит Вам делать фотографии и добавлять к записям NSMicrophoneUsageDescription Нужен для звука в видеозвонках с консультантом + NSLocationAlwaysAndWhenInUseUsageDescription + Может использоваться для стабильного поддержания связи во время звонка. NSLocationWhenInUseUsageDescription Может использоваться для стабильного поддержания связи во время звонка. NSPhotoLibraryUsageDescription From e82866f936c565e3b1f143537a7a9180afead8f1 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 19:28:16 +0300 Subject: [PATCH 29/56] feat(video_calls): voip push handling fix --- ios/Runner/AppDelegate.swift | 117 ++++++++++++++-- .../video_call_callkit_coordinator.dart | 127 +++++++++++------- 2 files changed, 183 insertions(+), 61 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index c800247d..4fa2445b 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -11,6 +11,9 @@ import flutter_foreground_task @main @objc class AppDelegate: FlutterAppDelegate, PKPushRegistryDelegate, CallkitIncomingAppDelegate { private var voipRegistry: PKPushRegistry? + private var voipDiagnosticsChannel: FlutterMethodChannel? + private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" + private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" override func application( _ application: UIApplication, @@ -30,7 +33,24 @@ import flutter_foreground_task UNUserNotificationCenter.current().delegate = self } - return super.application(application, didFinishLaunchingWithOptions: launchOptions) + let launched = super.application(application, didFinishLaunchingWithOptions: launchOptions) + + if let controller = window?.rootViewController as? FlutterViewController { + let channel = FlutterMethodChannel( + name: voipDiagnosticsChannelName, + binaryMessenger: controller.binaryMessenger + ) + voipDiagnosticsChannel = channel + channel.setMethodCallHandler { [weak self] call, result in + guard call.method == "drainVoipDiagnostics" else { + result(FlutterMethodNotImplemented) + return + } + result(self?.drainVoipDiagnostics() ?? []) + } + } + + return launched } @available(iOS 8.0, *) @@ -88,6 +108,54 @@ import flutter_foreground_task return nil } + private func sanitizedDiagnosticValue(_ value: Any?) -> Any { + guard let value = value else { + return "" + } + if let string = value as? String { + return string + } + if let number = value as? NSNumber { + return number + } + if let bool = value as? Bool { + return bool + } + if let dictionary = value as? [AnyHashable: Any] { + return dictionary.reduce(into: [String: Any]()) { result, entry in + result[String(describing: entry.key)] = sanitizedDiagnosticValue(entry.value) + } + } + if let array = value as? [Any] { + return array.map { sanitizedDiagnosticValue($0) } + } + return String(describing: value) + } + + private func recordVoipDiagnostic(_ name: String, data: [String: Any]) { + let defaults = UserDefaults.standard + var events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? [] + var event: [String: Any] = [ + "name": name, + "timestamp": ISO8601DateFormatter().string(from: Date()), + "appState": UIApplication.shared.applicationState.rawValue, + ] + event["data"] = sanitizedDiagnosticValue(data) + events.append(event) + if events.count > 20 { + events = Array(events.suffix(20)) + } + defaults.set(events, forKey: voipDiagnosticsStorageKey) + voipDiagnosticsChannel?.invokeMethod("voipDiagnosticsUpdated", arguments: nil) + } + + private func drainVoipDiagnostics() -> [[String: Any]] { + let defaults = UserDefaults.standard + let events = defaults.array(forKey: voipDiagnosticsStorageKey) as? [[String: Any]] ?? [] + defaults.removeObject(forKey: voipDiagnosticsStorageKey) + return events + } + func pushRegistry( _ registry: PKPushRegistry, @@ -126,6 +194,13 @@ import flutter_foreground_task if pushMarkers.contains(where: { $0.contains("video_call") }) && pushMarkers.contains(where: { $0.contains("end") || $0.contains("cancel") }) { + recordVoipDiagnostic( + "voip_end_or_cancel_received", + data: [ + "markers": pushMarkers, + "payload": dict, + ] + ) SwiftFlutterCallkitIncomingPlugin.sharedInstance?.endAllCalls() completion() return @@ -147,10 +222,10 @@ import flutter_foreground_task ?? (dict["handle"] as? String) ?? "" let id = [ - conversationUuid, dict["call_id"] as? String, dict["id"] as? String, dict["message_id"] as? String, + conversationUuid, ] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .first { !$0.isEmpty && UUID(uuidString: $0) != nil } @@ -194,21 +269,43 @@ import flutter_foreground_task id: id, nameCaller: nameCaller, handle: conversationUuid.isEmpty ? nameCaller : conversationUuid, - type: 0 + type: isVideo ? 1 : 0 ) - data.appName = "Итвио" - data.duration = 45000 - data.handleType = "generic" - data.supportsVideo = isVideo - data.ringtonePath = "system_ringtone_default" - data.configureAudioSession = false - data.includesCallsInRecents = false data.extra = extra as NSDictionary + NSLog( + "[EatvioVoIP] report incoming call id=\(id) conversation=\(conversationUuid) state=\(UIApplication.shared.applicationState.rawValue) markers=\(pushMarkers.joined(separator: ",")) payload=\(dict)" + ) + recordVoipDiagnostic( + "voip_incoming_received", + data: [ + "callKitId": id, + "conversationUuid": conversationUuid, + "nameCaller": nameCaller, + "isVideo": isVideo, + "markers": pushMarkers, + "payload": dict, + ] + ) + SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( data, fromPushKit: true ) { + let activeCalls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] + let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") + NSLog( + "[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)" + ) + self.recordVoipDiagnostic( + "callkit_report_completed", + data: [ + "callKitId": id, + "conversationUuid": conversationUuid, + "activeCallIds": activeIds, + "activeCallsCount": activeCalls.count, + ] + ) completion() } } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 860525b3..a6006f7d 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -9,10 +9,12 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; +import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:sentry_flutter/sentry_flutter.dart'; class VideoCallCallKitCoordinator { VideoCallCallKitCoordinator({ @@ -28,12 +30,16 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; - AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; String? _activeCallKitId; bool _initialized = false; + bool _nativeDiagnosticsDrainScheduled = false; + + static const MethodChannel _voipDiagnosticsChannel = MethodChannel( + 'com.eatvioapp/voip_diagnostics', + ); Future initialize() async { if (_initialized) return; @@ -42,12 +48,14 @@ class VideoCallCallKitCoordinator { _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); if (Platform.isIOS) { + _voipDiagnosticsChannel.setMethodCallHandler((call) async { + if (call.method == 'voipDiagnosticsUpdated') { + _scheduleNativeVoipDiagnosticsDrain(); + } + }); await _firebaseApi.registerPushToken(); - _lifecycleListener = AppLifecycleListener( - onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), - ); WidgetsBinding.instance.addPostFrameCallback((_) { - unawaited(_openActiveCallKitCallIfNeeded()); + _scheduleNativeVoipDiagnosticsDrain(); }); return; } @@ -70,9 +78,7 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); - _lifecycleListener?.dispose(); _eventSubscription = null; - _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } @@ -82,6 +88,52 @@ class VideoCallCallKitCoordinator { VideoCallSessionCubit get _cubit => _videoCallSessionCubit ?? sl(); + void _scheduleNativeVoipDiagnosticsDrain() { + if (_nativeDiagnosticsDrainScheduled) return; + _nativeDiagnosticsDrainScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _nativeDiagnosticsDrainScheduled = false; + unawaited(_drainNativeVoipDiagnosticsToSentry()); + }); + } + + Future _drainNativeVoipDiagnosticsToSentry() async { + if (!Platform.isIOS) return; + try { + final events = await _voipDiagnosticsChannel.invokeListMethod( + 'drainVoipDiagnostics', + ); + if (events == null || events.isEmpty) return; + + for (final rawEvent in events) { + final event = _stringKeyedMap(rawEvent); + final name = event['name']?.toString() ?? 'unknown'; + await Sentry.captureMessage( + 'iOS VoIP diagnostic: $name', + level: SentryLevel.info, + withScope: (scope) { + scope.setTag('origin', 'iOS VoIP'); + scope.setTag('voip_diagnostic', 'true'); + scope.setTag('voip_event', name); + scope.setContexts('voip_diagnostic', _stringifyMapValues(event)); + }, + ); + } + } on PlatformException catch (e, st) { + log( + 'VideoCallCallKitCoordinator: failed to drain native VoIP diagnostics: $e', + name: 'VideoCallCallKit', + stackTrace: st, + ); + } catch (e, st) { + log( + 'VideoCallCallKitCoordinator: failed to send native VoIP diagnostics: $e', + name: 'VideoCallCallKit', + stackTrace: st, + ); + } + } + Future _onCallKitEvent(CallEvent? event) async { if (event == null) return; @@ -124,50 +176,6 @@ class VideoCallCallKitCoordinator { return _paramsForCallId(id); } - Future _openActiveCallKitCallIfNeeded() async { - if (!Platform.isIOS) return; - if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; - - final activeCalls = await FlutterCallkitIncoming.activeCalls(); - if (activeCalls.isEmpty) return; - - for (final params in activeCalls) { - _cacheCallKitParams(params); - if (!params.isAccepted) continue; - final extra = params.extra ?? {}; - final conversationUuid = _parseConversationUuid( - extra, - handle: params.handle, - ); - if (conversationUuid.isEmpty) continue; - - await _deliverIncomingAccept( - callKitId: params.id, - conversationUuid: conversationUuid, - counterpartName: - _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? - params.nameCaller, - counterpartAvatarUrl: - _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? - params.avatar, - localParticipantName: _parseString(extra, const [ - 'localParticipantName', - 'local_participant_name', - ]), - ); - return; - } - } - Future _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -365,4 +373,21 @@ class VideoCallCallKitCoordinator { } return null; } + + static Map _stringKeyedMap(Object? value) { + if (value is! Map) return {}; + return value.map((key, value) => MapEntry(key.toString(), value)); + } + + static Map _stringifyMapValues(Map map) { + return map.map((key, value) { + if (value is Map) { + return MapEntry(key, _stringifyMapValues(_stringKeyedMap(value))); + } + if (value is Iterable) { + return MapEntry(key, value.map((item) => item?.toString()).toList()); + } + return MapEntry(key, value?.toString()); + }); + } } From 367753d40971091e09958def3ccd5ea65793423d Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 19:30:01 +0300 Subject: [PATCH 30/56] Version change --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index cbc66a07..c6783736 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+110 +version: 1.3.27+111 publish_to: 'none' environment: From 895443e747e070363fb043bea538ec77f43c8e24 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 21:11:45 +0300 Subject: [PATCH 31/56] feat(video_calls): added more logs for sentry --- ios/Runner/AppDelegate.swift | 53 +++++++++++++++---- .../video_call_callkit_coordinator.dart | 37 +++++++++++++ pubspec.yaml | 2 +- 3 files changed, 81 insertions(+), 11 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 4fa2445b..e2f16042 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -221,15 +221,19 @@ import flutter_foreground_task ?? conversationUuid(fromActionUrl: dict["action_url"] as? String) ?? (dict["handle"] as? String) ?? "" - let id = [ - dict["call_id"] as? String, - dict["id"] as? String, - dict["message_id"] as? String, - conversationUuid, + let callKitIdSource = [ + (key: "call_id", value: dict["call_id"] as? String), + (key: "id", value: dict["id"] as? String), + (key: "message_id", value: dict["message_id"] as? String), ] - .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } - .first { !$0.isEmpty && UUID(uuidString: $0) != nil } - ?? UUID().uuidString + .first { item in + guard let trimmed = item.value?.trimmingCharacters(in: .whitespacesAndNewlines) else { + return false + } + return !trimmed.isEmpty && UUID(uuidString: trimmed) != nil + } + let id = callKitIdSource?.value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? UUID().uuidString + let callKitIdSourceName = callKitIdSource?.key ?? "generated" let isVideo = (dict["isVideo"] as? Bool) ?? (dict["is_video"] as? Bool) ?? true @@ -280,6 +284,7 @@ import flutter_foreground_task "voip_incoming_received", data: [ "callKitId": id, + "callKitIdSource": callKitIdSourceName, "conversationUuid": conversationUuid, "nameCaller": nameCaller, "isVideo": isVideo, @@ -288,11 +293,24 @@ import flutter_foreground_task ] ) - SwiftFlutterCallkitIncomingPlugin.sharedInstance?.showCallkitIncoming( + guard let callkitPlugin = SwiftFlutterCallkitIncomingPlugin.sharedInstance else { + NSLog("[EatvioVoIP] CallKit plugin missing id=\(id)") + recordVoipDiagnostic( + "callkit_plugin_missing", + data: [ + "callKitId": id, + "conversationUuid": conversationUuid, + ] + ) + completion() + return + } + + callkitPlugin.showCallkitIncoming( data, fromPushKit: true ) { - let activeCalls = SwiftFlutterCallkitIncomingPlugin.sharedInstance?.activeCalls() ?? [] + let activeCalls = callkitPlugin.activeCalls() let activeIds = activeCalls.compactMap { $0["id"] as? String }.joined(separator: ",") NSLog( "[EatvioVoIP] report incoming completed id=\(id) activeCallIds=\(activeIds)" @@ -301,11 +319,26 @@ import flutter_foreground_task "callkit_report_completed", data: [ "callKitId": id, + "callKitIdSource": callKitIdSourceName, "conversationUuid": conversationUuid, "activeCallIds": activeIds, "activeCallsCount": activeCalls.count, ] ) + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + let delayedActiveCalls = callkitPlugin.activeCalls() + let delayedActiveIds = delayedActiveCalls.compactMap { $0["id"] as? String }.joined(separator: ",") + self.recordVoipDiagnostic( + "callkit_active_calls_delayed", + data: [ + "callKitId": id, + "callKitIdSource": callKitIdSourceName, + "conversationUuid": conversationUuid, + "activeCallIds": delayedActiveIds, + "activeCallsCount": delayedActiveCalls.count, + ] + ) + } completion() } } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index a6006f7d..02c7c3fd 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -136,6 +136,7 @@ class VideoCallCallKitCoordinator { Future _onCallKitEvent(CallEvent? event) async { if (event == null) return; + unawaited(_captureCallKitEventToSentry(event)); switch (event) { case CallEventActionDidUpdateDevicePushTokenVoip(): @@ -157,6 +158,42 @@ class VideoCallCallKitCoordinator { } } + Future _captureCallKitEventToSentry(CallEvent event) async { + if (!Platform.isIOS) return; + final context = { + 'eventName': event.eventName, + 'eventType': event.runtimeType.toString(), + }; + switch (event) { + case CallEventActionCallIncoming(:final callKitParams): + context.addAll({ + 'id': callKitParams.id, + 'handle': callKitParams.handle, + 'nameCaller': callKitParams.nameCaller, + 'isAccepted': callKitParams.isAccepted, + 'extra': callKitParams.extra?.toString(), + }); + case CallEventActionCallAccept(:final id): + case CallEventActionCallCallback(:final id): + case CallEventActionCallDecline(:final id): + case CallEventActionCallEnded(:final id): + case CallEventActionCallTimeout(:final id): + context['id'] = id; + default: + break; + } + + await Sentry.captureMessage( + 'iOS CallKit event: ${event.runtimeType}', + level: SentryLevel.info, + withScope: (scope) { + scope.setTag('origin', 'iOS CallKit'); + scope.setTag('callkit_event', event.runtimeType.toString()); + scope.setContexts('callkit_event', context); + }, + ); + } + void _cacheCallKitParams(CallKitParams params) { _callKitParamsById[params.id] = params; _activeCallKitId = params.id; diff --git a/pubspec.yaml b/pubspec.yaml index c6783736..209f5b05 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+111 +version: 1.3.27+112 publish_to: 'none' environment: From 95c1d40c791ce024aafa17d62f6fab5561be8e03 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 16 Jun 2026 22:57:17 +0300 Subject: [PATCH 32/56] feat(video_calls): fixed nullable overlay state handling --- .../presentation/widgets/video_call_top_overlay.dart | 10 ++++++---- pubspec.yaml | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart index 1f0bff71..85f2dd0e 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -2,12 +2,11 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; - abstract final class VideoCallTopOverlay { - static const String poorConnectionSvg = 'assets/icons/video_call/poor_connection.svg'; + static const String poorConnectionSvg = + 'assets/icons/video_call/poor_connection.svg'; static const String warningSvg = 'assets/icons/video_call/warning.svg'; - static OverlayEntry insertBanner( BuildContext context, { required String message, @@ -15,7 +14,7 @@ abstract final class VideoCallTopOverlay { required String svgAsset, Duration? autoRemoveAfter, }) { - final overlayState = Overlay.of(context, rootOverlay: true); + final overlayState = Overlay.maybeOf(context, rootOverlay: true); late OverlayEntry entry; entry = OverlayEntry( builder: (ctx) => Positioned( @@ -50,6 +49,9 @@ abstract final class VideoCallTopOverlay { ), ), ); + if (overlayState == null) { + return entry; + } overlayState.insert(entry); if (autoRemoveAfter != null) { Future.delayed(autoRemoveAfter, () { diff --git a/pubspec.yaml b/pubspec.yaml index 209f5b05..96f67cf1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+112 +version: 1.3.27+113 publish_to: 'none' environment: From 65d529b0afb4dc272d60051115773c51f0c145e1 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 17 Jun 2026 11:06:22 +0300 Subject: [PATCH 33/56] feat(video_calls): fixed navigation from voip push --- ios/Runner/AppDelegate.swift | 113 ++++++++++++++++++ ios/Runner/Info.plist | 8 ++ .../video_call_callkit_coordinator.dart | 62 +++++++++- pubspec.yaml | 2 +- .../widgets/video_call_top_overlay_test.dart | 33 ++++- 5 files changed, 210 insertions(+), 8 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e2f16042..ed5fa3dd 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,6 +14,7 @@ import flutter_foreground_task private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" + private let appUrlScheme = "eatvio" override func application( _ application: UIApplication, @@ -74,6 +75,37 @@ import flutter_foreground_task open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { + if url.scheme == appUrlScheme { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryItems = components?.queryItems ?? [] + let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let callKitId = queryItems.first { $0.name == "callKitId" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) + recordVoipDiagnostic( + "accepted_call_url_opened", + data: [ + "callKitId": callKitId ?? "", + "hasCallKitId": callKitId != nil, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + ] + ) + if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { + voipDiagnosticsChannel?.invokeMethod( + "acceptedVoipCall", + arguments: [ + "id": callKitId, + "handle": conversationUuid, + "extra": [ + "conversationUuid": conversationUuid + ], + ] + ) + } + return true + } + return YXLSdk.shared.handleOpen( url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String @@ -108,6 +140,75 @@ import flutter_foreground_task return nil } + private func conversationUuid(fromCall call: Call) -> String? { + let extra = call.data.extra as? [String: Any] ?? [:] + for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] { + if let value = extra[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + + let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines) + if !handle.isEmpty { + return handle + } + + return nil + } + + private func openAppForAcceptedCall(_ call: Call) { + guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else { + recordVoipDiagnostic( + "accepted_call_missing_conversation_uuid", + data: ["callKitId": call.data.uuid] + ) + return + } + + var components = URLComponents() + components.scheme = appUrlScheme + components.host = "video-call" + components.queryItems = [ + URLQueryItem(name: "callKitId", value: call.data.uuid), + URLQueryItem(name: "conversationUuid", value: conversationUuid) + ] + + guard let url = components.url else { + recordVoipDiagnostic( + "accepted_call_url_build_failed", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + ] + ) + return + } + + recordVoipDiagnostic( + "accepted_call_open_app_requested", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + "appState": UIApplication.shared.applicationState.rawValue, + ] + ) + + UIApplication.shared.open(url, options: [:]) { [weak self] success in + self?.recordVoipDiagnostic( + success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + ] + ) + } + } + private func sanitizedDiagnosticValue(_ value: Any?) -> Any { guard let value = value else { return "" @@ -345,6 +446,18 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + recordVoipDiagnostic( + "callkit_accept_native", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid(fromCall: call) ?? "", + "data": call.data.toJSON(), + ] + ) + voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) + DispatchQueue.main.async { [weak self] in + self?.openAppForAcceptedCall(call) + } action.fulfill() } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c35ead46..5bcdccbd 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -91,6 +91,14 @@ yx5399b03636104c6d920b87c1ad329254 + + CFBundleURLName + Eatvio + CFBundleURLSchemes + + eatvio + + diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 02c7c3fd..8c28fbab 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -49,8 +49,11 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { - if (call.method == 'voipDiagnosticsUpdated') { - _scheduleNativeVoipDiagnosticsDrain(); + switch (call.method) { + case 'voipDiagnosticsUpdated': + _scheduleNativeVoipDiagnosticsDrain(); + case 'acceptedVoipCall': + await _handleNativeAcceptedVoipCall(call.arguments); } }); await _firebaseApi.registerPushToken(); @@ -245,6 +248,57 @@ class VideoCallCallKitCoordinator { ); } + Future _handleNativeAcceptedVoipCall(Object? arguments) async { + if (!Platform.isIOS) return; + + final data = _stringKeyedMap(arguments); + final extra = _stringKeyedMap(data['extra']); + final callKitId = _parseString(data, const ['id', 'uuid']); + if (callKitId == null) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without callKitId, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + final conversationUuid = _parseConversationUuid( + extra, + handle: _parseString(data, const ['handle']), + ); + if (conversationUuid.isEmpty) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without conversationUuid, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + await _deliverIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + _parseString(data, const ['nameCaller']), + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + _parseString(data, const ['avatar']), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + Future _handleAccept(String callKitId) async { if (_acceptHandledCallKitIds.contains(callKitId)) return; @@ -384,7 +438,7 @@ class VideoCallCallKitCoordinator { } static String _parseConversationUuid( - Map extra, { + Map extra, { String? handle, }) { final fromExtra = _parseString(extra, const [ @@ -401,7 +455,7 @@ class VideoCallCallKitCoordinator { return ''; } - static String? _parseString(Map map, List keys) { + static String? _parseString(Map map, List keys) { for (final key in keys) { final value = map[key]; if (value is String && value.trim().isNotEmpty) { diff --git a/pubspec.yaml b/pubspec.yaml index 96f67cf1..3cbf6779 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+113 +version: 1.3.27+114 publish_to: 'none' environment: diff --git a/test/features/video_calls/widgets/video_call_top_overlay_test.dart b/test/features/video_calls/widgets/video_call_top_overlay_test.dart index 4b2aea2d..77d7c312 100644 --- a/test/features/video_calls/widgets/video_call_top_overlay_test.dart +++ b/test/features/video_calls/widgets/video_call_top_overlay_test.dart @@ -3,8 +3,12 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - testWidgets('insertBanner shows message and can be removed manually', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + testWidgets('insertBanner shows message and can be removed manually', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); final context = tester.element(find.byType(SizedBox)); final entry = VideoCallTopOverlay.insertBanner( @@ -23,7 +27,9 @@ void main() { }); testWidgets('insertBanner auto removes entry', (tester) async { - await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); final context = tester.element(find.byType(SizedBox)); VideoCallTopOverlay.insertBanner( @@ -40,4 +46,25 @@ void main() { await tester.pump(const Duration(milliseconds: 40)); expect(find.text('Muted mic'), findsNothing); }); + + testWidgets('insertBanner does not throw without an overlay', (tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: SizedBox.shrink(), + ), + ); + + final context = tester.element(find.byType(SizedBox)); + final entry = VideoCallTopOverlay.insertBanner( + context, + message: 'Recording notice', + backgroundColor: Colors.grey, + svgAsset: VideoCallTopOverlay.warningSvg, + ); + + expect(entry.mounted, isFalse); + await tester.pump(); + expect(find.text('Recording notice'), findsNothing); + }); } From 90e5c7e58d147f59a94d9f09262a45ba3f52c32e Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 17 Jun 2026 11:17:37 +0300 Subject: [PATCH 34/56] fix: ci/cd error fixed --- .../video_call_callkit_coordinator.dart | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 8c28fbab..a8aa9101 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -167,23 +167,25 @@ class VideoCallCallKitCoordinator { 'eventName': event.eventName, 'eventType': event.runtimeType.toString(), }; - switch (event) { - case CallEventActionCallIncoming(:final callKitParams): - context.addAll({ - 'id': callKitParams.id, - 'handle': callKitParams.handle, - 'nameCaller': callKitParams.nameCaller, - 'isAccepted': callKitParams.isAccepted, - 'extra': callKitParams.extra?.toString(), - }); - case CallEventActionCallAccept(:final id): - case CallEventActionCallCallback(:final id): - case CallEventActionCallDecline(:final id): - case CallEventActionCallEnded(:final id): - case CallEventActionCallTimeout(:final id): - context['id'] = id; - default: - break; + if (event is CallEventActionCallIncoming) { + final callKitParams = event.callKitParams; + context.addAll({ + 'id': callKitParams.id, + 'handle': callKitParams.handle, + 'nameCaller': callKitParams.nameCaller, + 'isAccepted': callKitParams.isAccepted, + 'extra': callKitParams.extra?.toString(), + }); + } else if (event is CallEventActionCallAccept) { + context['id'] = event.id; + } else if (event is CallEventActionCallCallback) { + context['id'] = event.id; + } else if (event is CallEventActionCallDecline) { + context['id'] = event.id; + } else if (event is CallEventActionCallEnded) { + context['id'] = event.id; + } else if (event is CallEventActionCallTimeout) { + context['id'] = event.id; } await Sentry.captureMessage( From e3dd8162fad23d2c0c6db81e16f89618d8367b6b Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 17 Jun 2026 11:33:17 +0300 Subject: [PATCH 35/56] fix: ci/cd error fixed --- .../video_call_callkit_coordinator.dart | 97 ++++++++++++++++--- 1 file changed, 82 insertions(+), 15 deletions(-) diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index a8aa9101..55f5821e 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -146,21 +146,45 @@ class VideoCallCallKitCoordinator { await _firebaseApi.registerPushToken(); case CallEventActionCallIncoming(:final callKitParams): _cacheCallKitParams(callKitParams); - case CallEventActionCallAccept(:final id): - await _handleAccept(id); - case CallEventActionCallCallback(:final id): - await _handleAccept(id); - case CallEventActionCallDecline(:final id): - await _handleDecline(id); - case CallEventActionCallEnded(:final id): - await _handleDecline(id); - case CallEventActionCallTimeout(:final id): - await _handleDecline(id); + case CallEventActionCallAccept(): + await _handleAcceptEvent(event); + case CallEventActionCallCallback(): + await _handleAcceptEvent(event); + case CallEventActionCallDecline(): + await _handleDeclineEvent(event); + case CallEventActionCallEnded(): + await _handleDeclineEvent(event); + case CallEventActionCallTimeout(): + await _handleDeclineEvent(event); default: break; } } + Future _handleAcceptEvent(CallEvent event) async { + final id = _callKitEventId(event); + if (id == null) { + log( + 'VideoCallCallKitCoordinator: accept event without callKit id, event=$event', + name: 'VideoCallCallKit', + ); + return; + } + await _handleAccept(id); + } + + Future _handleDeclineEvent(CallEvent event) async { + final id = _callKitEventId(event); + if (id == null) { + log( + 'VideoCallCallKitCoordinator: decline/end event without callKit id, event=$event', + name: 'VideoCallCallKit', + ); + return; + } + await _handleDecline(id); + } + Future _captureCallKitEventToSentry(CallEvent event) async { if (!Platform.isIOS) return; final context = { @@ -177,15 +201,15 @@ class VideoCallCallKitCoordinator { 'extra': callKitParams.extra?.toString(), }); } else if (event is CallEventActionCallAccept) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } else if (event is CallEventActionCallCallback) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } else if (event is CallEventActionCallDecline) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } else if (event is CallEventActionCallEnded) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } else if (event is CallEventActionCallTimeout) { - context['id'] = event.id; + _addCallKitEventDetails(context, event); } await Sentry.captureMessage( @@ -199,11 +223,54 @@ class VideoCallCallKitCoordinator { ); } + void _addCallKitEventDetails(Map context, CallEvent event) { + final params = _callKitEventParams(event); + if (params != null) { + context.addAll({ + 'id': params.id, + 'handle': params.handle, + 'nameCaller': params.nameCaller, + 'isAccepted': params.isAccepted, + 'extra': params.extra?.toString(), + }); + return; + } + + final id = _callKitEventId(event); + if (id != null) { + context['id'] = id; + } + } + void _cacheCallKitParams(CallKitParams params) { _callKitParamsById[params.id] = params; _activeCallKitId = params.id; } + CallKitParams? _callKitEventParams(CallEvent event) { + try { + final params = (event as dynamic).callKitParams; + if (params is CallKitParams) return params; + } catch (_) { + return null; + } + return null; + } + + String? _callKitEventId(CallEvent event) { + final params = _callKitEventParams(event); + final paramsId = params?.id.trim(); + if (paramsId != null && paramsId.isNotEmpty) return paramsId; + + try { + final id = (event as dynamic).id; + if (id is String && id.trim().isNotEmpty) return id.trim(); + } catch (_) { + return null; + } + return null; + } + CallKitParams? _paramsForCallId(String id) => _callKitParamsById[id]; Future _paramsForCallIdOrActiveCall(String id) async { From ea5fced456cce5871fabf2021c8de633fc667a27 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 17 Jun 2026 16:47:51 +0300 Subject: [PATCH 36/56] feat(video_calls): added system ui for call and navigation to the app --- ios/Runner/AppDelegate.swift | 113 ----------------- ios/Runner/Info.plist | 8 -- .../video_call_callkit_coordinator.dart | 120 ++++++++++-------- pubspec.yaml | 2 +- 4 files changed, 65 insertions(+), 178 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index ed5fa3dd..e2f16042 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,7 +14,6 @@ import flutter_foreground_task private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" - private let appUrlScheme = "eatvio" override func application( _ application: UIApplication, @@ -75,37 +74,6 @@ import flutter_foreground_task open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { - if url.scheme == appUrlScheme { - let components = URLComponents(url: url, resolvingAgainstBaseURL: false) - let queryItems = components?.queryItems ?? [] - let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value? - .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let callKitId = queryItems.first { $0.name == "callKitId" }?.value? - .trimmingCharacters(in: .whitespacesAndNewlines) - recordVoipDiagnostic( - "accepted_call_url_opened", - data: [ - "callKitId": callKitId ?? "", - "hasCallKitId": callKitId != nil, - "conversationUuid": conversationUuid, - "url": url.absoluteString, - ] - ) - if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { - voipDiagnosticsChannel?.invokeMethod( - "acceptedVoipCall", - arguments: [ - "id": callKitId, - "handle": conversationUuid, - "extra": [ - "conversationUuid": conversationUuid - ], - ] - ) - } - return true - } - return YXLSdk.shared.handleOpen( url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String @@ -140,75 +108,6 @@ import flutter_foreground_task return nil } - private func conversationUuid(fromCall call: Call) -> String? { - let extra = call.data.extra as? [String: Any] ?? [:] - for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] { - if let value = extra[key] as? String { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } - } - } - - let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines) - if !handle.isEmpty { - return handle - } - - return nil - } - - private func openAppForAcceptedCall(_ call: Call) { - guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else { - recordVoipDiagnostic( - "accepted_call_missing_conversation_uuid", - data: ["callKitId": call.data.uuid] - ) - return - } - - var components = URLComponents() - components.scheme = appUrlScheme - components.host = "video-call" - components.queryItems = [ - URLQueryItem(name: "callKitId", value: call.data.uuid), - URLQueryItem(name: "conversationUuid", value: conversationUuid) - ] - - guard let url = components.url else { - recordVoipDiagnostic( - "accepted_call_url_build_failed", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - ] - ) - return - } - - recordVoipDiagnostic( - "accepted_call_open_app_requested", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - "url": url.absoluteString, - "appState": UIApplication.shared.applicationState.rawValue, - ] - ) - - UIApplication.shared.open(url, options: [:]) { [weak self] success in - self?.recordVoipDiagnostic( - success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid, - "url": url.absoluteString, - ] - ) - } - } - private func sanitizedDiagnosticValue(_ value: Any?) -> Any { guard let value = value else { return "" @@ -446,18 +345,6 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { - recordVoipDiagnostic( - "callkit_accept_native", - data: [ - "callKitId": call.data.uuid, - "conversationUuid": conversationUuid(fromCall: call) ?? "", - "data": call.data.toJSON(), - ] - ) - voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) - DispatchQueue.main.async { [weak self] in - self?.openAppForAcceptedCall(call) - } action.fulfill() } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 5bcdccbd..c35ead46 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -91,14 +91,6 @@ yx5399b03636104c6d920b87c1ad329254 - - CFBundleURLName - Eatvio - CFBundleURLSchemes - - eatvio - - diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 55f5821e..0bfff5a5 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -30,6 +30,7 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; + AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; @@ -49,16 +50,17 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { - switch (call.method) { - case 'voipDiagnosticsUpdated': - _scheduleNativeVoipDiagnosticsDrain(); - case 'acceptedVoipCall': - await _handleNativeAcceptedVoipCall(call.arguments); + if (call.method == 'voipDiagnosticsUpdated') { + _scheduleNativeVoipDiagnosticsDrain(); } }); await _firebaseApi.registerPushToken(); + _lifecycleListener = AppLifecycleListener( + onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), + ); WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleNativeVoipDiagnosticsDrain(); + unawaited(_openActiveCallKitCallIfNeeded()); }); return; } @@ -81,7 +83,9 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); + _lifecycleListener?.dispose(); _eventSubscription = null; + _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } @@ -285,6 +289,53 @@ class VideoCallCallKitCoordinator { return _paramsForCallId(id); } + /// When the user opens the app from the system CallKit UI (e.g. taps "Итвио"), + /// navigate to the in-app call screen for an already-accepted active call. + Future _openActiveCallKitCallIfNeeded() async { + if (!Platform.isIOS) return; + if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; + + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + if (activeCalls.isEmpty) return; + + for (final params in activeCalls) { + _cacheCallKitParams(params); + if (!params.isAccepted) continue; + + final extra = params.extra ?? {}; + final conversationUuid = _parseConversationUuid( + extra, + handle: params.handle, + ); + if (conversationUuid.isEmpty) continue; + + await _deliverIncomingAccept( + callKitId: params.id, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + params.nameCaller, + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + params.avatar, + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + return; + } + } + Future _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -317,57 +368,6 @@ class VideoCallCallKitCoordinator { ); } - Future _handleNativeAcceptedVoipCall(Object? arguments) async { - if (!Platform.isIOS) return; - - final data = _stringKeyedMap(arguments); - final extra = _stringKeyedMap(data['extra']); - final callKitId = _parseString(data, const ['id', 'uuid']); - if (callKitId == null) { - log( - 'VideoCallCallKitCoordinator: native acceptedVoipCall without callKitId, data=$data', - name: 'VideoCallCallKit', - ); - return; - } - - final conversationUuid = _parseConversationUuid( - extra, - handle: _parseString(data, const ['handle']), - ); - if (conversationUuid.isEmpty) { - log( - 'VideoCallCallKitCoordinator: native acceptedVoipCall without conversationUuid, data=$data', - name: 'VideoCallCallKit', - ); - return; - } - - await _deliverIncomingAccept( - callKitId: callKitId, - conversationUuid: conversationUuid, - counterpartName: - _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? - _parseString(data, const ['nameCaller']), - counterpartAvatarUrl: - _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? - _parseString(data, const ['avatar']), - localParticipantName: _parseString(extra, const [ - 'localParticipantName', - 'local_participant_name', - ]), - ); - } - Future _handleAccept(String callKitId) async { if (_acceptHandledCallKitIds.contains(callKitId)) return; @@ -387,6 +387,14 @@ class VideoCallCallKitCoordinator { return; } + if (Platform.isIOS) { + log( + 'VideoCallCallKitCoordinator: iOS accept received (keeping CallKit UI), callKitId=$callKitId, conversationUuid=$conversationUuid', + name: 'VideoCallCallKit', + ); + return; + } + await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, diff --git a/pubspec.yaml b/pubspec.yaml index 3cbf6779..7732b34a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+114 +version: 1.3.27+115 publish_to: 'none' environment: From e3c22f8ac8161fcb9343a1cd402866a44eaa8418 Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 18 Jun 2026 14:41:56 +0300 Subject: [PATCH 37/56] Revert "feat(video_calls): added system ui for call and navigation to the app" This reverts commit ea5fced456cce5871fabf2021c8de633fc667a27. --- ios/Runner/AppDelegate.swift | 113 +++++++++++++++++ ios/Runner/Info.plist | 8 ++ .../video_call_callkit_coordinator.dart | 120 ++++++++---------- pubspec.yaml | 2 +- 4 files changed, 178 insertions(+), 65 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e2f16042..ed5fa3dd 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,6 +14,7 @@ import flutter_foreground_task private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" + private let appUrlScheme = "eatvio" override func application( _ application: UIApplication, @@ -74,6 +75,37 @@ import flutter_foreground_task open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { + if url.scheme == appUrlScheme { + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + let queryItems = components?.queryItems ?? [] + let conversationUuid = queryItems.first { $0.name == "conversationUuid" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let callKitId = queryItems.first { $0.name == "callKitId" }?.value? + .trimmingCharacters(in: .whitespacesAndNewlines) + recordVoipDiagnostic( + "accepted_call_url_opened", + data: [ + "callKitId": callKitId ?? "", + "hasCallKitId": callKitId != nil, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + ] + ) + if let callKitId, !callKitId.isEmpty, !conversationUuid.isEmpty { + voipDiagnosticsChannel?.invokeMethod( + "acceptedVoipCall", + arguments: [ + "id": callKitId, + "handle": conversationUuid, + "extra": [ + "conversationUuid": conversationUuid + ], + ] + ) + } + return true + } + return YXLSdk.shared.handleOpen( url, sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String @@ -108,6 +140,75 @@ import flutter_foreground_task return nil } + private func conversationUuid(fromCall call: Call) -> String? { + let extra = call.data.extra as? [String: Any] ?? [:] + for key in ["conversationUuid", "conversation_uuid", "conversation_id", "chat_uuid", "uuid"] { + if let value = extra[key] as? String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + return trimmed + } + } + } + + let handle = call.data.handle.trimmingCharacters(in: .whitespacesAndNewlines) + if !handle.isEmpty { + return handle + } + + return nil + } + + private func openAppForAcceptedCall(_ call: Call) { + guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else { + recordVoipDiagnostic( + "accepted_call_missing_conversation_uuid", + data: ["callKitId": call.data.uuid] + ) + return + } + + var components = URLComponents() + components.scheme = appUrlScheme + components.host = "video-call" + components.queryItems = [ + URLQueryItem(name: "callKitId", value: call.data.uuid), + URLQueryItem(name: "conversationUuid", value: conversationUuid) + ] + + guard let url = components.url else { + recordVoipDiagnostic( + "accepted_call_url_build_failed", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + ] + ) + return + } + + recordVoipDiagnostic( + "accepted_call_open_app_requested", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + "appState": UIApplication.shared.applicationState.rawValue, + ] + ) + + UIApplication.shared.open(url, options: [:]) { [weak self] success in + self?.recordVoipDiagnostic( + success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid, + "url": url.absoluteString, + ] + ) + } + } + private func sanitizedDiagnosticValue(_ value: Any?) -> Any { guard let value = value else { return "" @@ -345,6 +446,18 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + recordVoipDiagnostic( + "callkit_accept_native", + data: [ + "callKitId": call.data.uuid, + "conversationUuid": conversationUuid(fromCall: call) ?? "", + "data": call.data.toJSON(), + ] + ) + voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) + DispatchQueue.main.async { [weak self] in + self?.openAppForAcceptedCall(call) + } action.fulfill() } diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index c35ead46..5bcdccbd 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -91,6 +91,14 @@ yx5399b03636104c6d920b87c1ad329254 + + CFBundleURLName + Eatvio + CFBundleURLSchemes + + eatvio + + diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 0bfff5a5..55f5821e 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -30,7 +30,6 @@ class VideoCallCallKitCoordinator { final VideoCallSessionCubit? _videoCallSessionCubit; StreamSubscription? _eventSubscription; - AppLifecycleListener? _lifecycleListener; final Map _callKitParamsById = {}; final Set _acceptHandledCallKitIds = {}; final Set _suppressedEndCallKitIds = {}; @@ -50,17 +49,16 @@ class VideoCallCallKitCoordinator { if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { - if (call.method == 'voipDiagnosticsUpdated') { - _scheduleNativeVoipDiagnosticsDrain(); + switch (call.method) { + case 'voipDiagnosticsUpdated': + _scheduleNativeVoipDiagnosticsDrain(); + case 'acceptedVoipCall': + await _handleNativeAcceptedVoipCall(call.arguments); } }); await _firebaseApi.registerPushToken(); - _lifecycleListener = AppLifecycleListener( - onResume: () => unawaited(_openActiveCallKitCallIfNeeded()), - ); WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleNativeVoipDiagnosticsDrain(); - unawaited(_openActiveCallKitCallIfNeeded()); }); return; } @@ -83,9 +81,7 @@ class VideoCallCallKitCoordinator { void dispose() { unawaited(_eventSubscription?.cancel()); - _lifecycleListener?.dispose(); _eventSubscription = null; - _lifecycleListener = null; _callKitParamsById.clear(); _initialized = false; } @@ -289,53 +285,6 @@ class VideoCallCallKitCoordinator { return _paramsForCallId(id); } - /// When the user opens the app from the system CallKit UI (e.g. taps "Итвио"), - /// navigate to the in-app call screen for an already-accepted active call. - Future _openActiveCallKitCallIfNeeded() async { - if (!Platform.isIOS) return; - if (_cubit.state.connectPhase != VideoCallConnectPhase.idle) return; - - final activeCalls = await FlutterCallkitIncoming.activeCalls(); - if (activeCalls.isEmpty) return; - - for (final params in activeCalls) { - _cacheCallKitParams(params); - if (!params.isAccepted) continue; - - final extra = params.extra ?? {}; - final conversationUuid = _parseConversationUuid( - extra, - handle: params.handle, - ); - if (conversationUuid.isEmpty) continue; - - await _deliverIncomingAccept( - callKitId: params.id, - conversationUuid: conversationUuid, - counterpartName: - _parseString(extra, const [ - 'counterpartName', - 'caller_name', - 'callerName', - ]) ?? - params.nameCaller, - counterpartAvatarUrl: - _parseString(extra, const [ - 'counterpartAvatarUrl', - 'avatar', - 'avatarUrl', - 'caller_avatar', - ]) ?? - params.avatar, - localParticipantName: _parseString(extra, const [ - 'localParticipantName', - 'local_participant_name', - ]), - ); - return; - } - } - Future _onNativeAcceptCallHandle(Map body) async { final extra = Map.from(body); final conversationUuid = _parseConversationUuid(extra); @@ -368,6 +317,57 @@ class VideoCallCallKitCoordinator { ); } + Future _handleNativeAcceptedVoipCall(Object? arguments) async { + if (!Platform.isIOS) return; + + final data = _stringKeyedMap(arguments); + final extra = _stringKeyedMap(data['extra']); + final callKitId = _parseString(data, const ['id', 'uuid']); + if (callKitId == null) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without callKitId, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + final conversationUuid = _parseConversationUuid( + extra, + handle: _parseString(data, const ['handle']), + ); + if (conversationUuid.isEmpty) { + log( + 'VideoCallCallKitCoordinator: native acceptedVoipCall without conversationUuid, data=$data', + name: 'VideoCallCallKit', + ); + return; + } + + await _deliverIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + _parseString(data, const ['nameCaller']), + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + _parseString(data, const ['avatar']), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + ); + } + Future _handleAccept(String callKitId) async { if (_acceptHandledCallKitIds.contains(callKitId)) return; @@ -387,14 +387,6 @@ class VideoCallCallKitCoordinator { return; } - if (Platform.isIOS) { - log( - 'VideoCallCallKitCoordinator: iOS accept received (keeping CallKit UI), callKitId=$callKitId, conversationUuid=$conversationUuid', - name: 'VideoCallCallKit', - ); - return; - } - await _deliverIncomingAccept( callKitId: callKitId, conversationUuid: conversationUuid, diff --git a/pubspec.yaml b/pubspec.yaml index 7732b34a..3cbf6779 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+115 +version: 1.3.27+114 publish_to: 'none' environment: From 7493870a3d259fd80ccc4faac8cbc810444ff73e Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 18 Jun 2026 15:01:21 +0300 Subject: [PATCH 38/56] feat(video_calls): removed system ui, fixed wrong app initializing --- ios/Runner/AppDelegate.swift | 44 +++++++++++++++++-- .../video_call_callkit_coordinator.dart | 42 ++++++++++++++++-- 2 files changed, 80 insertions(+), 6 deletions(-) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index ed5fa3dd..e1b521eb 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -13,6 +13,7 @@ import flutter_foreground_task private var voipRegistry: PKPushRegistry? private var voipDiagnosticsChannel: FlutterMethodChannel? private let voipDiagnosticsStorageKey = "EatvioVoipDiagnostics" + private let pendingAcceptedVoipCallKey = "EatvioPendingAcceptedVoipCall" private let voipDiagnosticsChannelName = "com.eatvioapp/voip_diagnostics" private let appUrlScheme = "eatvio" @@ -43,11 +44,18 @@ import flutter_foreground_task ) voipDiagnosticsChannel = channel channel.setMethodCallHandler { [weak self] call, result in - guard call.method == "drainVoipDiagnostics" else { - result(FlutterMethodNotImplemented) + guard let self else { + result(nil) return } - result(self?.drainVoipDiagnostics() ?? []) + switch call.method { + case "drainVoipDiagnostics": + result(self.drainVoipDiagnostics()) + case "drainPendingAcceptedVoipCall": + result(self.drainPendingAcceptedVoipCall()) + default: + result(FlutterMethodNotImplemented) + } } } @@ -159,6 +167,32 @@ import flutter_foreground_task return nil } + private func storePendingAcceptedCall(_ call: Call) { + let payload = sanitizedDiagnosticValue(call.data.toJSON()) as? [String: Any] ?? [:] + UserDefaults.standard.set(payload, forKey: pendingAcceptedVoipCallKey) + } + + private func drainPendingAcceptedVoipCall() -> [String: Any]? { + let defaults = UserDefaults.standard + guard let payload = defaults.dictionary(forKey: pendingAcceptedVoipCallKey) else { + return nil + } + defaults.removeObject(forKey: pendingAcceptedVoipCallKey) + return payload + } + + private func bringAppToForeground() { + DispatchQueue.main.async { + for scene in UIApplication.shared.connectedScenes { + guard let windowScene = scene as? UIWindowScene else { continue } + for window in windowScene.windows { + window.makeKeyAndVisible() + } + } + self.window?.makeKeyAndVisible() + } + } + private func openAppForAcceptedCall(_ call: Call) { guard let conversationUuid = conversationUuid(fromCall: call), !conversationUuid.isEmpty else { recordVoipDiagnostic( @@ -197,6 +231,8 @@ import flutter_foreground_task ] ) + bringAppToForeground() + UIApplication.shared.open(url, options: [:]) { [weak self] success in self?.recordVoipDiagnostic( success ? "accepted_call_open_app_succeeded" : "accepted_call_open_app_failed", @@ -446,6 +482,7 @@ import flutter_foreground_task func onAccept(_ call: Call, _ action: CXAnswerCallAction) { + storePendingAcceptedCall(call) recordVoipDiagnostic( "callkit_accept_native", data: [ @@ -456,6 +493,7 @@ import flutter_foreground_task ) voipDiagnosticsChannel?.invokeMethod("acceptedVoipCall", arguments: call.data.toJSON()) DispatchQueue.main.async { [weak self] in + self?.bringAppToForeground() self?.openAppForAcceptedCall(call) } action.fulfill() diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 55f5821e..b25e0468 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -14,6 +14,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_callkit_incoming/entities/call_event.dart'; import 'package:flutter_callkit_incoming/entities/call_kit_params.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; class VideoCallCallKitCoordinator { @@ -57,8 +58,10 @@ class VideoCallCallKitCoordinator { } }); await _firebaseApi.registerPushToken(); + await _drainPendingNativeAcceptedVoipCall(); WidgetsBinding.instance.addPostFrameCallback((_) { _scheduleNativeVoipDiagnosticsDrain(); + unawaited(_drainPendingNativeAcceptedVoipCall()); }); return; } @@ -137,6 +140,29 @@ class VideoCallCallKitCoordinator { } } + Future _drainPendingNativeAcceptedVoipCall() async { + if (!Platform.isIOS) return; + try { + final pending = await _voipDiagnosticsChannel.invokeMethod( + 'drainPendingAcceptedVoipCall', + ); + if (pending == null) return; + await _handleNativeAcceptedVoipCall(pending); + } on PlatformException catch (e, st) { + log( + 'VideoCallCallKitCoordinator: failed to drain pending native accept: $e', + name: 'VideoCallCallKit', + stackTrace: st, + ); + } catch (e, st) { + log( + 'VideoCallCallKitCoordinator: failed to process pending native accept: $e', + name: 'VideoCallCallKit', + stackTrace: st, + ); + } + } + Future _onCallKitEvent(CallEvent? event) async { if (event == null) return; unawaited(_captureCallKitEventToSentry(event)); @@ -424,7 +450,9 @@ class VideoCallCallKitCoordinator { _suppressedEndCallKitIds.add(callKitId); try { - await FlutterCallkitIncoming.endCall(callKitId); + if (!Platform.isIOS) { + await FlutterCallkitIncoming.endCall(callKitId); + } final binding = WidgetsBinding.instance; final completer = Completer(); @@ -439,6 +467,10 @@ class VideoCallCallKitCoordinator { ); }); await completer.future; + + if (Platform.isIOS) { + await FlutterCallkitIncoming.endCall(callKitId); + } } finally { unawaited( Future.delayed(const Duration(seconds: 2), () { @@ -454,8 +486,12 @@ class VideoCallCallKitCoordinator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, - }) { - return VideoCallNotificationNavigator.openIncomingCall( + }) async { + if (Platform.isIOS) { + FlutterNativeSplash.remove(); + } + + await VideoCallNotificationNavigator.openIncomingCall( conversationUuid: conversationUuid, counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, From 8920fc280fa235061d6d5b000d80fbf9ff99644d Mon Sep 17 00:00:00 2001 From: misha Date: Thu, 18 Jun 2026 15:02:53 +0300 Subject: [PATCH 39/56] feat(ci/cd): version changed, anroind action runs only on main branch --- .gitea/workflows/test.yml | 5 ++++- pubspec.yaml | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 0628fd30..a44f6927 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -1,6 +1,9 @@ name: Eatvio App Android Test -on: [pull_request] +on: + pull_request: + branches: + - main env: flutter_version: '3.41.2' diff --git a/pubspec.yaml b/pubspec.yaml index 3cbf6779..29eee75c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+114 +version: 1.3.27+116 publish_to: 'none' environment: From ed72ed9799007f0c53b175c965a4b9273f9db2f9 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 23 Jun 2026 08:42:55 +0300 Subject: [PATCH 40/56] feat(video_calls): changed accept flow, fixed ui bugs for PIP and video render sizes --- android/app/build.gradle | 2 + android/app/src/main/AndroidManifest.xml | 10 + .../app/EatvioFcmMessagingService.kt | 33 ++ .../com/eatvioapp/app/HmsPushService.kt | 92 +--- .../com/eatvioapp/app/VideoCallPushBridge.kt | 163 +++++++ lib/core/di/cubit_registrations.dart | 5 +- .../push_navigation_handler.dart | 2 + lib/core/routing/other_routes.dart | 10 + .../managers/video_call_manager.dart | 430 +++++++----------- .../managers/video_call_manager_snapshot.dart | 10 +- .../video_call_session_cubit.dart | 205 ++++++++- .../video_call_session_state.dart | 15 +- .../presentation/pages/video_call_screen.dart | 60 ++- .../widgets/video_call_app_shell.dart | 26 +- .../widgets/video_call_global_pip_layer.dart | 7 +- .../video_call_livekit_track_utils.dart | 8 +- .../widgets/video_call_pip_video_preview.dart | 63 ++- .../video_call_remote_video_stage.dart | 13 +- .../video_call_room_tracks_listenable.dart | 11 + .../widgets/video_call_stage_layout.dart | 418 ++++++++--------- .../video_call_system_pip_content.dart | 21 +- .../video_call_system_pip_listener.dart | 6 + .../video_call_audio_session_coordinator.dart | 11 +- .../video_call_callkit_coordinator.dart | 1 + .../services/video_call_fcm_handler.dart | 8 + .../video_call_incoming_push_presenter.dart | 1 + .../video_call_notification_navigator.dart | 23 +- ..._call_manager_audio_interruption_test.dart | 173 ------- .../video_call_session_cubit_test.dart | 232 +++++++++- .../video_call_livekit_track_utils_test.dart | 29 ++ .../widgets/video_call_stage_layout_test.dart | 148 +++++- 31 files changed, 1399 insertions(+), 837 deletions(-) create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt create mode 100644 lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart delete mode 100644 test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 5fb4bf83..ff3634f9 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -69,6 +69,8 @@ android { } dependencies { + implementation platform('com.google.firebase:firebase-bom:34.4.0') + implementation 'com.google.firebase:firebase-messaging' implementation 'com.huawei.agconnect:agconnect-core:1.9.5.300' implementation 'com.huawei.hms:push:6.13.0.301' // Huawei Analytics dependency to satisfy com.huawei.hianalytics.* references during R8 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index daa5ec83..4ce279c3 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,16 @@ android:usesCleartextTraffic="true" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + + + + + + diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt new file mode 100644 index 00000000..7e9e7377 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt @@ -0,0 +1,33 @@ +package com.eatvioapp.app + +import android.util.Log +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import io.flutter.plugins.firebase.messaging.FlutterFirebaseTokenLiveData + +class EatvioFcmMessagingService : FirebaseMessagingService() { + override fun onNewToken(token: String) { + super.onNewToken(token) + FlutterFirebaseTokenLiveData.getInstance().postToken(token) + Log.i(TAG, "onNewToken") + } + + override fun onMessageReceived(message: RemoteMessage) { + Log.i(TAG, "onMessageReceived: ${message.data}") + + if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) { + return + } + + if (MainActivity.isInForeground) { + Log.i(TAG, "App in foreground, deferring incoming call to Flutter handler") + return + } + + VideoCallPushBridge.showIncomingCall(this, message.data) + } + + companion object { + private const val TAG = "EatvioFCM" + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index f8db072b..b7b986a6 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -2,14 +2,11 @@ package com.eatvioapp.app import android.app.PendingIntent import android.content.Intent -import android.os.Bundle import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat -import com.hiennv.flutter_callkit_incoming.CallkitConstants import com.huawei.hms.push.HmsMessageService import com.huawei.hms.push.RemoteMessage -import java.util.HashMap class HmsPushService : HmsMessageService() { override fun onNewToken(token: String?) { @@ -23,22 +20,18 @@ class HmsPushService : HmsMessageService() { if (message == null) return - if (isIncomingVideoCall(message.dataOfMap)) { - showIncomingVideoCallNotification(message.dataOfMap) + if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) { + VideoCallPushBridge.showIncomingCall(this, message.dataOfMap) return } - // Берём action_url из data-поля, если оно есть. val actionUrl = message.dataOfMap["action_url"] - - // Строим системное уведомление, по тапу по которому откроется MainActivity с action_url. showNotification(message, actionUrl) } private fun showNotification(message: RemoteMessage, actionUrl: String?) { val context = applicationContext - // Интент для запуска Flutter-активити. val intent = Intent(context, MainActivity::class.java).apply { flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP if (!actionUrl.isNullOrBlank()) { @@ -56,7 +49,7 @@ class HmsPushService : HmsMessageService() { val channelId = "hms_push_channel" val notification = NotificationCompat.Builder(context, channelId) - .setSmallIcon(R.mipmap.ic_launcher) // используем иконку приложения + .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(message.notification?.title ?: context.getString(R.string.app_name)) .setContentText(message.notification?.body ?: "") .setAutoCancel(true) @@ -65,83 +58,4 @@ class HmsPushService : HmsMessageService() { NotificationManagerCompat.from(context).notify(message.messageId.hashCode(), notification) } - - private fun isIncomingVideoCall(data: Map): Boolean { - val rawType = data["type"] - ?: data["notification_type"] - ?: data["event"] - ?: data["action"] - ?: data["push_type"] - ?: data["call_type"] - val type = rawType?.lowercase()?.replace("-", "_") - return type?.contains("video_call") == true && - !type.contains("end") && - !type.contains("ended") - } - - private fun showIncomingVideoCallNotification(data: Map) { - val conversationUuid = data["conversationUuid"] - ?: data["conversation_uuid"] - ?: data["conversation_id"] - ?: data["chat_uuid"] - ?: data["uuid"] - ?: return - val callerName = data["counterpartName"] - ?: data["counterpart_name"] - ?: data["caller_name"] - ?: data["callerName"] - ?: data["nameCaller"] - ?: data["caller"] - ?: "Итвио" - val avatarUrl = data["counterpartAvatarUrl"] - ?: data["counterpart_avatar_url"] - ?: data["avatar"] - ?: data["avatarUrl"] - ?: data["avatar_url"] - ?: data["caller_avatar"] - ?: "" - val localParticipantName = data["localParticipantName"] - ?: data["local_participant_name"] - - val extra = HashMap() - extra["conversationUuid"] = conversationUuid - extra["counterpartName"] = callerName - if (avatarUrl.isNotBlank()) { - extra["counterpartAvatarUrl"] = avatarUrl - } - if (!localParticipantName.isNullOrBlank()) { - extra["localParticipantName"] = localParticipantName - } - - val callData = Bundle().apply { - putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) - putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) - putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") - putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) - putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) - putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) - putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) - putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") - putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") - putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra) - putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap()) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false) - putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default") - putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, "#2787F5") - putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, "#2787F5") - putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff") - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false) - putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false) - } - - val intent = com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver - .getIntent(this, CallkitConstants.ACTION_CALL_INCOMING, callData) - .addFlags(Intent.FLAG_RECEIVER_FOREGROUND) - sendBroadcast(intent) - } } - - diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt new file mode 100644 index 00000000..b4272684 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -0,0 +1,163 @@ +package com.eatvioapp.app + +import android.content.Context +import android.os.Bundle +import android.util.Log +import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager +import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager +import com.hiennv.flutter_callkit_incoming.Data +import com.hiennv.flutter_callkit_incoming.addCall +import java.util.HashMap + +object VideoCallPushBridge { + private const val TAG = "VideoCallPushBridge" + private const val ANDROID_ACCENT_COLOR = "#2787F5" + + fun isIncomingVideoCall(data: Map): Boolean { + val rawType = data["type"] + ?: data["notification_type"] + ?: data["event"] + ?: data["action"] + ?: data["push_type"] + ?: data["call_type"] + + if (rawType != null) { + val normalized = rawType.lowercase().replace("-", "_") + if (normalized.contains("video_call")) { + return !normalized.contains("end") && !normalized.contains("ended") + } + if (normalized == "incoming_call" || + normalized == "video_call_incoming" || + normalized == "incoming_video_call" + ) { + return true + } + } + + if (isTruthy(data["is_video_call"]) || + isTruthy(data["incoming_call"]) || + isTruthy(data["is_incoming_call"]) + ) { + return conversationUuid(data) != null + } + + val actionUrl = data["action_url"] + if (!actionUrl.isNullOrBlank() && + (actionUrl.contains("video-call") || actionUrl.contains("videoCall")) + ) { + return true + } + + return false + } + + fun showIncomingCall(context: Context, data: Map) { + val appContext = context.applicationContext + val callData = buildCallDataBundle(data) + if (callData == null) { + Log.w(TAG, "showIncomingCall: missing conversation uuid, data=$data") + return + } + + Log.i(TAG, "showIncomingCall: conversation=${callData.getString(CallkitConstants.EXTRA_CALLKIT_ID)}") + + val soundManager = CallkitSoundPlayerManager(appContext) + val notificationManager = CallkitNotificationManager(appContext, soundManager) + notificationManager.createNotificationChanel(callData) + notificationManager.showIncomingNotification(callData) + + try { + addCall(appContext, Data.fromBundle(callData)) + } catch (error: Exception) { + Log.w(TAG, "showIncomingCall: failed to persist active call", error) + } + } + + private fun buildCallDataBundle(data: Map): Bundle? { + val conversationUuid = conversationUuid(data) ?: return null + val callerName = data["counterpartName"] + ?: data["counterpart_name"] + ?: data["caller_name"] + ?: data["callerName"] + ?: data["nameCaller"] + ?: data["caller"] + ?: "Итвио" + val avatarUrl = data["counterpartAvatarUrl"] + ?: data["counterpart_avatar_url"] + ?: data["avatar"] + ?: data["avatarUrl"] + ?: data["avatar_url"] + ?: data["caller_avatar"] + ?: "" + val localParticipantName = data["localParticipantName"] + ?: data["local_participant_name"] + + val extra = HashMap() + extra["conversationUuid"] = conversationUuid + extra["counterpartName"] = callerName + if (avatarUrl.isNotBlank()) { + extra["counterpartAvatarUrl"] = avatarUrl + } + if (!localParticipantName.isNullOrBlank()) { + extra["localParticipantName"] = localParticipantName + } + + return Bundle().apply { + putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, callerName) + putString(CallkitConstants.EXTRA_CALLKIT_APP_NAME, "Итвио") + putString(CallkitConstants.EXTRA_CALLKIT_HANDLE, conversationUuid) + putString(CallkitConstants.EXTRA_CALLKIT_AVATAR, avatarUrl) + putInt(CallkitConstants.EXTRA_CALLKIT_TYPE, 0) + putLong(CallkitConstants.EXTRA_CALLKIT_DURATION, 45000L) + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_ACCEPT, "Answer") + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_DECLINE, "Decline") + putSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA, extra) + putSerializable(CallkitConstants.EXTRA_CALLKIT_HEADERS, HashMap()) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_NOTIFICATION, true) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_CUSTOM_SMALL_EX_NOTIFICATION, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_LOGO, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_CALL_ID, false) + putString(CallkitConstants.EXTRA_CALLKIT_RINGTONE_PATH, "system_ringtone_default") + putString(CallkitConstants.EXTRA_CALLKIT_BACKGROUND_COLOR, ANDROID_ACCENT_COLOR) + putString(CallkitConstants.EXTRA_CALLKIT_ACTION_COLOR, ANDROID_ACCENT_COLOR) + putString(CallkitConstants.EXTRA_CALLKIT_TEXT_COLOR, "#ffffff") + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_SHOW_FULL_LOCKED_SCREEN, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_IS_FULL_SCREEN, false) + putBoolean(CallkitConstants.EXTRA_CALLKIT_CALLING_SHOW, false) + } + } + + private fun conversationUuid(data: Map): String? { + val fromData = data["conversationUuid"] + ?: data["conversation_uuid"] + ?: data["conversation_id"] + ?: data["chat_uuid"] + ?: data["uuid"] + if (!fromData.isNullOrBlank()) { + return fromData + } + + val actionUrl = data["action_url"] ?: return null + val uuidFromQuery = Regex("""[?&](?:uuid|conversationUuid|conversation_uuid)=([^&]+)""") + .find(actionUrl) + ?.groupValues + ?.getOrNull(1) + ?.trim() + if (!uuidFromQuery.isNullOrEmpty()) { + return uuidFromQuery + } + + val pathMatch = Regex("""/video-call/([^/?#]+)""").find(actionUrl) + return pathMatch?.groupValues?.getOrNull(1)?.trim()?.takeIf { it.isNotEmpty() } + } + + private fun isTruthy(value: String?): Boolean { + if (value.isNullOrBlank()) return false + return when (value.trim().lowercase()) { + "1", "true", "yes" -> true + else -> false + } + } +} diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 4e6ff7ee..499165e1 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -74,7 +74,10 @@ class CubitRegistrations { sl.registerLazySingleton(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl())); sl.registerLazySingleton(() => InternetConnectionCubit()); sl.registerLazySingleton( - () => VideoCallManager(videoCallRepository: sl()), + () => VideoCallManager( + videoCallRepository: sl(), + profileRepository: sl(), + ), ); sl.registerLazySingleton( () => VideoCallSessionCubit( diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index 1c3b98a8..ad0286a1 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -3,6 +3,7 @@ import 'dart:developer' as developer; import 'dart:async'; import 'package:eatvio_app/core/app_router.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/services/video_call_notification_navigator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -68,6 +69,7 @@ class PushNavigationHandler { counterpartName: videoCallPayload.counterpartName, counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl, localParticipantName: videoCallPayload.localParticipantName, + entryMode: VideoCallEntryMode.incomingAccepted, ), ); return; diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 42e29f10..cd139b77 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -15,6 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart'; @@ -236,6 +237,7 @@ class OtherRoutes { counterpartName: extra['counterpartName'] as String?, counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?, localParticipantName: extra['localParticipantName'] as String?, + entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']), onBackToChat: extra['onBackToChat'] as void Function()?, onHangUp: extra['onHangUp'] as void Function()?, ); @@ -330,3 +332,11 @@ class OtherRoutes { ]; } } + +VideoCallEntryMode _videoCallEntryModeFromRouteExtra(Object? raw) { + if (raw is VideoCallEntryMode) return raw; + if (raw == VideoCallEntryMode.incomingAccepted.name) { + return VideoCallEntryMode.incomingAccepted; + } + return VideoCallEntryMode.outgoing; +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 92fade24..6c967381 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1,10 +1,8 @@ import 'dart:async'; -import 'dart:io'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; -import 'package:audio_session/audio_session.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; @@ -14,6 +12,7 @@ import 'package:eatvio_app/features/video_calls/services/video_call_task_handler 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/features/profile/repositories/profile_repository.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; @@ -25,10 +24,12 @@ typedef VideoCallManagerListener = void Function(VideoCallManagerSnapshot snapsh class VideoCallManager { VideoCallManager({ required VideoCallRepository videoCallRepository, + ProfileRepository? profileRepository, VideoCallForegroundCoordinator? foregroundCoordinator, VideoCallLifecycleBinding? lifecycleBinding, VideoCallAudioSessionCoordinator? audioSessionCoordinator, }) : _videoCallRepository = videoCallRepository, + _profileRepository = profileRepository, _foreground = foregroundCoordinator ?? VideoCallForegroundCoordinator(), _lifecycleBinding = lifecycleBinding ?? VideoCallLifecycleBinding(), _audioSession = @@ -37,6 +38,7 @@ class VideoCallManager { } final VideoCallRepository _videoCallRepository; + final ProfileRepository? _profileRepository; final VideoCallForegroundCoordinator _foreground; final VideoCallLifecycleBinding _lifecycleBinding; final VideoCallAudioSessionCoordinator _audioSession; @@ -47,9 +49,6 @@ class VideoCallManager { static const Duration _mutedMicOverlayMinInterval = Duration( milliseconds: 2600, ); - static const Duration _localMicActivationInterruptionGrace = Duration( - milliseconds: 1500, - ); static const double _mutedMicAudioLevelThreshold = 0.008; static const double _mutedMicEnergyDelta = 1e-6; @@ -67,14 +66,13 @@ class VideoCallManager { bool _callEndedNotified = true; bool _callStageEntered = false; bool _joinInProgress = false; + bool _counterpartJoinedNotified = false; + Future? _roomConnectFuture; String? _preparedWsUrl; String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; - bool? _micEnabledBeforeSystemInterruption; - int _audioInterruptionHoldCount = 0; - DateTime? _ignoreAudioInterruptionUntil; - OverlayEntry? _systemInterruptionEntry; + Future? _myProfileNameFuture; VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial; @@ -128,11 +126,6 @@ class VideoCallManager { @visibleForTesting void setCallStageEnteredForTesting(bool value) => _callStageEntered = value; - @visibleForTesting - void handleAudioInterruptionForTesting(AudioInterruptionEvent event) { - unawaited(_onAudioInterruption(event)); - } - void dispose() { FlutterForegroundTask.removeTaskDataCallback(_onForegroundTaskData); _lifecycleBinding.detach(); @@ -228,11 +221,15 @@ class VideoCallManager { _startMutedMicPollTimer(); _ensureLifecycleAttached(); + final resolvedLocalName = await _resolveLocalParticipantName( + localParticipantName, + ); + _emitSnapshot( VideoCallManagerSnapshot( conversationUuid: conversationUuid, counterpartName: counterpartName, - localParticipantName: localParticipantName, + localParticipantName: resolvedLocalName, micEnabled: micEnabled, videoEnabled: videoEnabled, connectError: null, @@ -269,6 +266,10 @@ class VideoCallManager { localParticipantName ?? _snapshot.localParticipantName, ), ); + if (_room?.connectionState == lk.ConnectionState.connected) { + unawaited(_ensureLocalParticipantNamePublished()); + _syncRemoteParticipantNameFromRoom(); + } } } @@ -279,7 +280,7 @@ class VideoCallManager { _joinInProgress = true; try { - await _joinLiveKitRoom(); + await _ensureRoomConnected(); if (_liveKitDisposed) return false; _callStageEntered = true; @@ -301,6 +302,64 @@ class VideoCallManager { } } + /// Joins the LiveKit room without publishing local media so the outgoing + /// caller can detect when the callee enters. + Future ensureLobbyRoomJoined() async { + if (_callStageEntered || _joinInProgress || _liveKitDisposed) return; + if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; + + final room = _room; + if (room != null && room.connectionState == lk.ConnectionState.connected) { + _notifyCounterpartIfPresent(); + return; + } + + try { + await _ensureRoomConnected(); + if (!_liveKitDisposed) { + _notifyCounterpartIfPresent(); + } + } catch (_) {} + } + + Future _ensureRoomConnected() async { + final room = _room; + if (room != null && room.connectionState == lk.ConnectionState.connected) { + return; + } + + final pending = _roomConnectFuture; + if (pending != null) { + await pending; + return; + } + + final run = _joinLiveKitRoom(); + _roomConnectFuture = run; + try { + await run; + } finally { + if (identical(_roomConnectFuture, run)) { + _roomConnectFuture = null; + } + } + } + + void _notifyCounterpartIfPresent() { + if (_callStageEntered || _counterpartJoinedNotified || _liveKitDisposed) { + return; + } + final room = _room; + if (room == null || + room.connectionState != lk.ConnectionState.connected) { + return; + } + if (room.remoteParticipants.isEmpty) return; + + _counterpartJoinedNotified = true; + _emitSnapshot(_snapshot.copyWith(counterpartPresentInLobby: true)); + } + Future retryAfterConnectionLoss() async { if (_snapshot.conversationUuid.isEmpty) return; _disposeRoomEphemerals(); @@ -344,9 +403,8 @@ class VideoCallManager { _lifecycleBinding.detach(); _callStageEntered = false; _videoPausedForBackground = false; - _micEnabledBeforeSystemInterruption = null; - _audioInterruptionHoldCount = 0; _liveKitDisposed = true; + _myProfileNameFuture = null; _emitSnapshot(VideoCallManagerSnapshot.initial); } @@ -372,26 +430,7 @@ class VideoCallManager { /// [currentUiEnabled] reflects what the user sees on the mic control /// ([VideoCallSessionState.isMicActiveForUi]), not only stored intent. Future toggleMic(bool currentUiEnabled) async { - if (!currentUiEnabled && - _snapshot.systemAudioInterrupted && - _snapshot.micEnabled && - _isLocalMicrophonePublishing()) { - _clearSystemInterruptionState(); - _lastMutedMicTotalEnergy = null; - return; - } final next = !currentUiEnabled; - if (_snapshot.systemAudioInterrupted || _audioInterruptionHoldCount > 0) { - if (next) { - _micEnabledBeforeSystemInterruption = true; - } else { - _micEnabledBeforeSystemInterruption = false; - _emitSnapshot(_snapshot.copyWith(micEnabled: false)); - await _pauseLocalAudioForSystemInterruption(); - } - _lastMutedMicTotalEnergy = null; - return; - } if (!_callStageEntered) { _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; @@ -410,9 +449,6 @@ class VideoCallManager { } _emitSnapshot(_snapshot.copyWith(micEnabled: next)); _lastMutedMicTotalEnergy = null; - if (next) { - _suppressOwnMicActivationInterruptions(); - } await _room!.localParticipant?.setMicrophoneEnabled( next, audioCaptureOptions: _micCaptureOptions, @@ -499,30 +535,81 @@ class VideoCallManager { } String resolvedLocalDisplayName() { - final fromNav = _snapshot.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'; + final fromSnapshot = _snapshot.localParticipantName?.trim(); + if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; + final fromRoom = (_room?.localParticipant?.name ?? '').trim(); + if (fromRoom.isNotEmpty) return fromRoom; + return ''; } - String resolvedRemoteDisplayName(String fallback) { - final c = _snapshot.counterpartName?.trim(); - if (c != null && c.isNotEmpty) return c; + String resolvedRemoteDisplayName([String fallback = '']) { + final fromSnapshot = _snapshot.counterpartName?.trim(); + if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; final room = _room; if (room != null) { - for (final p in room.remoteParticipants.values) { - final n = p.name.trim(); - if (n.isNotEmpty) return n; + for (final participant in room.remoteParticipants.values) { + final name = participant.name.trim(); + if (name.isNotEmpty) return name; } } return fallback; } + Future _resolveLocalParticipantName(String? hint) async { + final fromHint = hint?.trim(); + if (fromHint != null && fromHint.isNotEmpty) return fromHint; + final fromSnapshot = _snapshot.localParticipantName?.trim(); + if (fromSnapshot != null && fromSnapshot.isNotEmpty) return fromSnapshot; + return _fetchMyProfileName(); + } + + Future _fetchMyProfileName() async { + _myProfileNameFuture ??= _loadMyProfileName(); + return _myProfileNameFuture; + } + + Future _loadMyProfileName() async { + final repository = _profileRepository; + if (repository == null) return null; + try { + final name = (await repository.getMyProfile()).name.trim(); + return name.isEmpty ? null : name; + } catch (_) { + return null; + } + } + + Future _ensureLocalParticipantNamePublished() async { + var name = _snapshot.localParticipantName?.trim(); + if (name == null || name.isEmpty) { + name = await _resolveLocalParticipantName(null); + if (name != null && name.isNotEmpty) { + _emitSnapshot(_snapshot.copyWith(localParticipantName: name)); + } + } + _applyLocalParticipantNameToLiveKit(); + } + + void _applyLocalParticipantNameToLiveKit() { + final name = _snapshot.localParticipantName?.trim(); + if (name == null || name.isEmpty) return; + try { + _room?.localParticipant?.setName(name); + } catch (_) {} + } + + void _syncRemoteParticipantNameFromRoom() { + if (_snapshot.counterpartName?.trim().isNotEmpty == true) return; + final room = _room; + if (room == null) return; + for (final participant in room.remoteParticipants.values) { + final name = participant.name.trim(); + if (name.isEmpty) continue; + _emitSnapshot(_snapshot.copyWith(counterpartName: name)); + return; + } + } + void _ensureLifecycleAttached() { _lifecycleBinding.attach(_onAppLifecycleStateChanged); } @@ -622,13 +709,15 @@ class VideoCallManager { _lastMutedMicOverlayAt = null; _lastMutedMicTotalEnergy = null; _recordingOverlayScheduled = false; - _micEnabledBeforeSystemInterruption = null; - _audioInterruptionHoldCount = 0; - _ignoreAudioInterruptionUntil = null; _joinInProgress = false; + _counterpartJoinedNotified = false; + _roomConnectFuture = null; _clearPreparedConnectionCredentials(); - _syncSystemInterruptionOverlay(show: false); - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); + _emitSnapshot( + _snapshot.copyWith( + counterpartPresentInLobby: false, + ), + ); } void _clearPreparedConnectionCredentials() { @@ -657,10 +746,6 @@ class VideoCallManager { if (_liveKitDisposed || _mutedMicStatsInFlight) { return; } - if (_snapshot.systemAudioInterrupted && _snapshot.micEnabled) { - unawaited(_maybeClearStaleSystemInterruptionFromStats()); - return; - } if (_snapshot.micEnabled) { return; } @@ -706,6 +791,10 @@ class VideoCallManager { if (e.speakers.any((p) => p.sid == lp.sid)) { _maybeShowMutedMicOverlay(); } + }) + ..on((_) { + _notifyCounterpartIfPresent(); + _syncRemoteParticipantNameFromRoom(); }); } @@ -847,6 +936,9 @@ class VideoCallManager { await room.connect(wsUrl, token); if (_liveKitDisposed) return; + await _ensureLocalParticipantNamePublished(); + _syncRemoteParticipantNameFromRoom(); + _syncPoorConnectionOverlay( room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown, @@ -995,224 +1087,16 @@ class VideoCallManager { _lastMutedMicTotalEnergy = null; } - bool _isLocalMicrophonePublishing() { - final room = _room; - if (room == null || _liveKitDisposed) return false; - final lp = room.localParticipant; - if (lp == null) return false; - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - if (pub == null || pub.muted) return false; - final track = pub.track; - if (track is lk.LocalAudioTrack) { - return !track.muted; - } - return track != null; - } - - Future _maybeClearStaleSystemInterruptionFromStats() async { - if (!_snapshot.systemAudioInterrupted) return; - if (!_snapshot.micEnabled || !_callStageEntered) return; - if (!_isLocalMicrophonePublishing()) return; - final room = _room; - if (room == null || _liveKitDisposed) 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; - try { - final stats = await track.getSenderStats(); - if (_liveKitDisposed || !_snapshot.systemAudioInterrupted) return; - final src = stats?.audioSourceStats; - if (src == null) return; - final level = src.audioLevel; - if (level != null && level >= _mutedMicAudioLevelThreshold) { - _clearSystemInterruptionState(); - return; - } - final energy = src.totalAudioEnergy; - if (energy == null || _lastMutedMicTotalEnergy == null) { - _lastMutedMicTotalEnergy = energy; - return; - } - if (energy - _lastMutedMicTotalEnergy! >= _mutedMicEnergyDelta) { - _clearSystemInterruptionState(); - } - _lastMutedMicTotalEnergy = energy; - } catch (_) {} - } - - void _clearSystemInterruptionState() { - if (!_snapshot.systemAudioInterrupted && _audioInterruptionHoldCount == 0) { - return; - } - _audioInterruptionHoldCount = 0; - _micEnabledBeforeSystemInterruption = null; - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); - _syncSystemInterruptionOverlay(show: false); - } - Future _startCallAudioSession() async { if (!_canHandleCallAudio) return; try { - await _audioSession.start(onInterruption: _onAudioInterruption); + await _audioSession.start(); } catch (_) {} } Future _stopCallAudioSession() async { - _syncSystemInterruptionOverlay(show: false); try { await _audioSession.stop(); } catch (_) {} } - - Future _onAudioInterruption(AudioInterruptionEvent event) async { - if (!_callStageEntered || - _liveKitDisposed || - _room == null || - _snapshot.connectPhase != VideoCallConnectPhase.connected) { - return; - } - if (_shouldIgnoreAudioInterruption(event)) { - return; - } - if (event.begin) { - _audioInterruptionHoldCount++; - if (_audioInterruptionHoldCount == 1) { - await _handleAudioInterruptionBegin(); - } else if (_snapshot.micEnabled) { - await _pauseLocalAudioForSystemInterruption(); - } - return; - } - if (_audioInterruptionHoldCount <= 0) { - if (_snapshot.micEnabled) { - await _restoreLocalAudioAfterSystemInterruption(); - } - return; - } - _audioInterruptionHoldCount--; - if (_audioInterruptionHoldCount == 0) { - await _handleAudioInterruptionEnd(); - } - } - - Future _handleAudioInterruptionBegin() async { - if (_snapshot.systemAudioInterrupted) return; - _micEnabledBeforeSystemInterruption ??= _snapshot.micEnabled; - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: true)); - _syncSystemInterruptionOverlay(show: true); - await _pauseLocalAudioForSystemInterruption(); - } - - Future _pauseLocalAudioForSystemInterruption() async { - final room = _room; - if (room == null || _liveKitDisposed) return; - try { - await room.localParticipant?.setMicrophoneEnabled( - false, - audioCaptureOptions: _micCaptureOptions, - ); - } catch (_) {} - } - - Future _handleAudioInterruptionEnd() async { - if (!_snapshot.systemAudioInterrupted) return; - await _audioSession.reactivate(); - _emitSnapshot(_snapshot.copyWith(systemAudioInterrupted: false)); - _syncSystemInterruptionOverlay(show: false); - final restoreMic = - _micEnabledBeforeSystemInterruption ?? _snapshot.micEnabled; - _micEnabledBeforeSystemInterruption = null; - if (restoreMic) { - await _restoreLocalAudioAfterSystemInterruption(); - } - } - - Future _restoreLocalAudioAfterSystemInterruption() async { - if (!_canHandleCallAudio || !_snapshot.micEnabled) return; - final lp = _room!.localParticipant; - if (lp == null) return; - - try { - _suppressOwnMicActivationInterruptions(); - await lp.setMicrophoneEnabled( - true, - audioCaptureOptions: _micCaptureOptions, - ); - } catch (_) { - await _restartLocalAudioTrack(); - return; - } - - final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); - final track = pub?.track; - if (track is lk.LocalAudioTrack && track.muted) { - await _restartLocalAudioTrack(); - } - } - - void _suppressOwnMicActivationInterruptions() { - if (!Platform.isAndroid) return; - _ignoreAudioInterruptionUntil = DateTime.now().add( - _localMicActivationInterruptionGrace, - ); - } - - bool _shouldIgnoreAudioInterruption(AudioInterruptionEvent event) { - if (!Platform.isAndroid || !event.begin) return false; - final until = _ignoreAudioInterruptionUntil; - if (until == null) return false; - if (DateTime.now().isBefore(until)) { - return true; - } - _ignoreAudioInterruptionUntil = null; - return false; - } - - Future _restartLocalAudioTrack() async { - if (!_canHandleCallAudio || !_snapshot.micEnabled) 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) { - try { - _suppressOwnMicActivationInterruptions(); - await track.restartTrack(); - return; - } catch (_) {} - } - try { - await lp.setMicrophoneEnabled( - false, - audioCaptureOptions: _micCaptureOptions, - ); - _suppressOwnMicActivationInterruptions(); - await lp.setMicrophoneEnabled( - true, - audioCaptureOptions: _micCaptureOptions, - ); - } catch (_) {} - } - - void _syncSystemInterruptionOverlay({required bool show}) { - final ctx = _navigatorContext; - if (ctx == null) return; - if (show) { - if (_systemInterruptionEntry != null) return; - _systemInterruptionEntry = VideoCallTopOverlay.insertBanner( - ctx, - message: S.of(ctx).video_call_system_interruption, - backgroundColor: appTheme.videoCallNoticeBannerBackground, - svgAsset: VideoCallTopOverlay.warningSvg, - ); - return; - } - final entry = _systemInterruptionEntry; - _systemInterruptionEntry = null; - if (entry != null && entry.mounted) { - entry.remove(); - } - } } diff --git a/lib/features/video_calls/managers/video_call_manager_snapshot.dart b/lib/features/video_calls/managers/video_call_manager_snapshot.dart index c798c4f5..86a16070 100644 --- a/lib/features/video_calls/managers/video_call_manager_snapshot.dart +++ b/lib/features/video_calls/managers/video_call_manager_snapshot.dart @@ -10,7 +10,7 @@ class VideoCallManagerSnapshot { this.connectError, required this.connectPhase, required this.linkIssue, - this.systemAudioInterrupted = false, + this.counterpartPresentInLobby = false, }); final String conversationUuid; @@ -21,7 +21,7 @@ class VideoCallManagerSnapshot { final String? connectError; final VideoCallConnectPhase connectPhase; final VideoCallLinkIssue linkIssue; - final bool systemAudioInterrupted; + final bool counterpartPresentInLobby; static const initial = VideoCallManagerSnapshot( conversationUuid: '', @@ -40,7 +40,7 @@ class VideoCallManagerSnapshot { String? connectError, VideoCallConnectPhase? connectPhase, VideoCallLinkIssue? linkIssue, - bool? systemAudioInterrupted, + bool? counterpartPresentInLobby, bool clearConnectError = false, }) { return VideoCallManagerSnapshot( @@ -52,8 +52,8 @@ class VideoCallManagerSnapshot { connectError: clearConnectError ? null : (connectError ?? this.connectError), connectPhase: connectPhase ?? this.connectPhase, linkIssue: linkIssue ?? this.linkIssue, - systemAudioInterrupted: - systemAudioInterrupted ?? this.systemAudioInterrupted, + counterpartPresentInLobby: + counterpartPresentInLobby ?? this.counterpartPresentInLobby, ); } } 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 index a7c73fc8..332d4fcf 100644 --- 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 @@ -8,6 +8,8 @@ import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_c import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:meta/meta.dart'; +import 'package:permission_handler/permission_handler.dart'; class VideoCallSessionCubit extends BaseCubit { VideoCallSessionCubit({ @@ -24,6 +26,9 @@ class VideoCallSessionCubit extends BaseCubit { final VideoCallManager _videoCallManager; final VideoCallSystemPipCoordinator _systemPipCoordinator; bool _callScreenRouteVisible = false; + bool _autoEnterInFlight = false; + Future? _autoEnterFuture; + Future? _outgoingAutoEnterFuture; lk.Room? get liveKitRoom => _videoCallManager.room; bool get isCallScreenRouteVisible => _callScreenRouteVisible; @@ -52,7 +57,6 @@ class VideoCallSessionCubit extends BaseCubit { connectError: hideStaleConnectError ? null : snapshot.connectError, connectPhase: snapshot.connectPhase, linkIssue: snapshot.linkIssue, - systemAudioInterrupted: snapshot.systemAudioInterrupted, callStageEntered: resetCallStage ? false : state.callStageEntered, uiMode: _isCallSessionInactive(snapshot) @@ -64,16 +68,24 @@ class VideoCallSessionCubit extends BaseCubit { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { if (isClosed) return; if (_isCallSessionInactive(snapshot)) { + if (_callScreenRouteVisible) { + // [VideoCallManager.startCall] briefly emits idle while replacing the room. + return; + } unawaited(_systemPipCoordinator.disarm()); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; } - emit(_sessionStateFromSnapshot(snapshot)); + final next = _sessionStateFromSnapshot(snapshot); + emit(next); + _scheduleAutoEnterIfNeeded(next); + _scheduleOutgoingAutoEnterIfNeeded(next, snapshot); } void _syncFromManager(VideoCallManagerSnapshot snapshot) { if (_isCallSessionInactive(snapshot)) { + if (_callScreenRouteVisible) return; emit(const VideoCallSessionState()); return; } @@ -111,12 +123,49 @@ class VideoCallSessionCubit extends BaseCubit { required String conversationUuid, String? counterpartName, String? localParticipantName, + VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { _callScreenRouteVisible = true; + + final effectiveEntryMode = + state.entryMode == VideoCallEntryMode.incomingAccepted || + entryMode == VideoCallEntryMode.incomingAccepted + ? VideoCallEntryMode.incomingAccepted + : entryMode; + final isIncomingAccepted = + effectiveEntryMode == VideoCallEntryMode.incomingAccepted; final isResumingActiveCall = state.conversationUuid == conversationUuid && state.callStageEntered && _videoCallManager.room != null; + final isSameCallConnecting = + state.conversationUuid == conversationUuid && + !state.callStageEntered && + state.connectPhase != VideoCallConnectPhase.idle; + + if (!isResumingActiveCall && !isSameCallConnecting) { + await _systemPipCoordinator.disarm(); + } + + if (isSameCallConnecting) { + emit( + state.copyWith( + counterpartName: counterpartName ?? state.counterpartName, + localParticipantName: + localParticipantName ?? state.localParticipantName, + entryMode: effectiveEntryMode, + ), + ); + if (isIncomingAccepted) { + await _tryAutoEnterCallStage(); + } + emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); + await _ensureSystemPipArmedIfInCall(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_applyVideoCallSystemUi()); + }); + return; + } if (isResumingActiveCall) { await _videoCallManager.onCallScreenVisible( @@ -137,6 +186,8 @@ class VideoCallSessionCubit extends BaseCubit { callStageEntered: false, connectError: null, linkIssue: VideoCallLinkIssue.none, + connectPhase: VideoCallConnectPhase.connecting, + entryMode: effectiveEntryMode, ), ); await _videoCallManager.startCall( @@ -146,17 +197,137 @@ class VideoCallSessionCubit extends BaseCubit { micEnabled: false, videoEnabled: false, ); + emit(state.copyWith(entryMode: effectiveEntryMode)); + if (isIncomingAccepted) { + await _tryAutoEnterCallStage(); + } } emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); + await _ensureSystemPipArmedIfInCall(); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); } + Future _ensureSystemPipArmedIfInCall() async { + if (!state.callStageEntered) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + if (state.linkIssue == VideoCallLinkIssue.lost) return; + await _systemPipCoordinator.armAutoPipOnLeave(); + } + + void _scheduleAutoEnterIfNeeded(VideoCallSessionState sessionState) { + if (sessionState.entryMode != VideoCallEntryMode.incomingAccepted) return; + if (sessionState.callStageEntered) return; + if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; + unawaited(_tryAutoEnterCallStage()); + } + + void _scheduleOutgoingAutoEnterIfNeeded( + VideoCallSessionState sessionState, + VideoCallManagerSnapshot snapshot, + ) { + if (sessionState.entryMode != VideoCallEntryMode.outgoing) return; + if (sessionState.callStageEntered) return; + if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; + + if (!snapshot.counterpartPresentInLobby) { + unawaited(_videoCallManager.ensureLobbyRoomJoined()); + return; + } + unawaited(_tryAutoEnterWhenCounterpartJoins()); + } + + Future _tryAutoEnterWhenCounterpartJoins() { + final pending = _outgoingAutoEnterFuture; + if (pending != null) return pending; + + final run = _runOutgoingAutoEnterCallStage(); + _outgoingAutoEnterFuture = run; + return run.whenComplete(() { + if (identical(_outgoingAutoEnterFuture, run)) { + _outgoingAutoEnterFuture = null; + } + }); + } + + Future _runOutgoingAutoEnterCallStage() async { + if (isClosed || _autoEnterInFlight) return; + if (state.entryMode != VideoCallEntryMode.outgoing) return; + if (state.callStageEntered) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + + _autoEnterInFlight = true; + try { + await enterCallStage(); + } finally { + _autoEnterInFlight = false; + } + } + + Future _tryAutoEnterCallStage() { + final pending = _autoEnterFuture; + if (pending != null) return pending; + + final run = _runAutoEnterCallStage(); + _autoEnterFuture = run; + return run.whenComplete(() { + if (identical(_autoEnterFuture, run)) { + _autoEnterFuture = null; + } + }); + } + + Future _runAutoEnterCallStage() async { + if (isClosed || _autoEnterInFlight) return; + if (state.entryMode != VideoCallEntryMode.incomingAccepted) return; + if (state.callStageEntered) return; + if (state.connectPhase != VideoCallConnectPhase.connected) return; + + _autoEnterInFlight = true; + try { + await _requestMicBeforeIncomingEnter(); + await enterCallStage(); + } finally { + _autoEnterInFlight = false; + } + } + + /// After the loading/connect phase, request mic before joining the room. + Future _requestMicBeforeIncomingEnter() async { + if (state.entryMode != VideoCallEntryMode.incomingAccepted) return; + + final granted = await _requestMicPermissionForCall(); + if (!granted || isClosed) return; + + await _videoCallManager.toggleMic(state.isMicActiveForUi); + if (isClosed) return; + emit(state.copyWith(micEnabled: true)); + } + + @visibleForTesting + Future requestMicPermissionForCall() => _requestMicPermissionForCall(); + + Future _requestMicPermissionForCall() async { + try { + final status = await Permission.microphone.request(); + return status.isGranted; + } catch (_) { + return false; + } + } + void onCallScreenDisposed() { _callScreenRouteVisible = false; - if (state.uiMode != VideoCallUiMode.fullscreen) return; + if (state.uiMode != VideoCallUiMode.fullscreen) { + if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) { + // [isCallScreenRouteVisible] is not in bloc state; refresh the global + // PiP overlay after the call route is removed. + emit(state.copyWith()); + } + return; + } if (state.connectPhase == VideoCallConnectPhase.connected && state.linkIssue != VideoCallLinkIssue.lost) { if (state.callStageEntered) { @@ -179,15 +350,29 @@ class VideoCallSessionCubit extends BaseCubit { Future _applyPipUiState() async { emit(state.copyWith(uiMode: VideoCallUiMode.pip)); await _restoreSystemUiAfterCall(); + await _ensureSystemPipArmedIfInCall(); } /// Called when Android enters system PiP (e.g. Home gesture). Future syncPipUiStateFromSystem() async { if (!_canMinimizeToPip) return; if (state.uiMode == VideoCallUiMode.pip) return; + // Call screen already renders the in-call mini preview; enabling the global + // overlay here would duplicate PiP after returning from system PiP. + if (_callScreenRouteVisible) return; await _applyPipUiState(); } + /// Re-applies immersive UI after Android system PiP while the call screen + /// route is still on the stack. + Future onSystemPipExitedWithCallScreenVisible() async { + if (!_callScreenRouteVisible) return; + if (state.uiMode != VideoCallUiMode.fullscreen) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_applyVideoCallSystemUi()); + }); + } + Future minimizeToPip() async { if (!_canMinimizeToPip) return; await _applyPipUiState(); @@ -206,6 +391,7 @@ class VideoCallSessionCubit extends BaseCubit { if (state.connectPhase == VideoCallConnectPhase.connected && state.linkIssue != VideoCallLinkIssue.lost) { if (state.callStageEntered) { + _callScreenRouteVisible = false; await minimizeToPip(); } else { await endCall(); @@ -223,7 +409,13 @@ class VideoCallSessionCubit extends BaseCubit { final entered = await _videoCallManager.onEnterCallStage(); if (!entered || isClosed) return; - emit(state.copyWith(callStageEntered: true)); + emit( + state.copyWith( + callStageEntered: true, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + ), + ); unawaited(_systemPipCoordinator.armAutoPipOnLeave()); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; @@ -232,8 +424,9 @@ class VideoCallSessionCubit extends BaseCubit { } Future retryAfterConnectionLoss() async { + final entryMode = state.entryMode; await _videoCallManager.retryAfterConnectionLoss(); - emit(state.copyWith(callStageEntered: false)); + emit(state.copyWith(callStageEntered: false, entryMode: entryMode)); } Future endCall() async { @@ -309,7 +502,7 @@ class VideoCallSessionCubit extends BaseCubit { String resolvedLocalDisplayName() => _videoCallManager.resolvedLocalDisplayName(); - String resolvedRemoteDisplayName(String fallback) => + String resolvedRemoteDisplayName([String fallback = '']) => _videoCallManager.resolvedRemoteDisplayName(fallback); @override 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 index a7c38545..588a01ff 100644 --- 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 @@ -12,6 +12,15 @@ enum VideoCallConnectPhase { idle, connecting, connected } enum VideoCallLinkIssue { none, reconnecting, lost } +/// How the user reached [VideoCallScreen]. +enum VideoCallEntryMode { + /// Outgoing call or push tap — lobby with manual enter. + outgoing, + + /// Accepted incoming CallKit / VoIP / notification — loader then auto-enter. + incomingAccepted, +} + @freezed abstract class VideoCallSessionState with _$VideoCallSessionState { const VideoCallSessionState._(); @@ -22,7 +31,7 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { String? localParticipantName, @Default(false) bool micEnabled, @Default(false) bool videoEnabled, - @Default(true) bool splitParticipantView, + @Default(false) bool splitParticipantView, @Default(false) bool mainStageShowsLocalVideo, @Default(true) bool useFrontCamera, String? connectError, @@ -30,8 +39,8 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { @Default(VideoCallConnectPhase.idle) VideoCallConnectPhase connectPhase, @Default(VideoCallLinkIssue.none) VideoCallLinkIssue linkIssue, @Default(false) bool callStageEntered, - @Default(false) bool systemAudioInterrupted, + @Default(VideoCallEntryMode.outgoing) VideoCallEntryMode entryMode, }) = _VideoCallSessionState; - bool get isMicActiveForUi => micEnabled && !systemAudioInterrupted; + bool get isMicActiveForUi => micEnabled; } 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 e225cf87..b8aefe95 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -8,6 +8,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses 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/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -21,6 +22,7 @@ class VideoCallScreen extends StatefulWidget { this.counterpartName, this.counterpartAvatarUrl, this.localParticipantName, + this.entryMode = VideoCallEntryMode.outgoing, this.onBackToChat, this.onHangUp, }); @@ -32,6 +34,7 @@ class VideoCallScreen extends StatefulWidget { final String? counterpartName; final String? counterpartAvatarUrl; final String? localParticipantName; + final VideoCallEntryMode entryMode; final VoidCallback? onBackToChat; final VoidCallback? onHangUp; @@ -62,6 +65,7 @@ class _VideoCallScreenState extends State { conversationUuid: widget.conversationUuid, counterpartName: widget.counterpartName, localParticipantName: widget.localParticipantName, + entryMode: widget.entryMode, ), ); }); @@ -94,7 +98,6 @@ class _VideoCallScreenState extends State { final topInset = viewPadding.top > 0 ? viewPadding.top : 16.0; final bottomInset = viewPadding.bottom; - final counterpartFallback = s.video_call_counterpart_default; final controlLabelStyle = TextStyles.textSMedium.copyWith( color: appTheme.whiteA700, height: 20 / 14, @@ -115,13 +118,14 @@ class _VideoCallScreenState extends State { a.connectError != b.connectError || a.linkIssue != b.linkIssue || a.counterpartName != b.counterpartName || + a.localParticipantName != b.localParticipantName || a.callStageEntered != b.callStageEntered || a.micEnabled != b.micEnabled || - a.systemAudioInterrupted != b.systemAudioInterrupted || a.videoEnabled != b.videoEnabled || a.splitParticipantView != b.splitParticipantView || a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo || - a.useFrontCamera != b.useFrontCamera, + a.useFrontCamera != b.useFrontCamera || + a.entryMode != b.entryMode, builder: (context, sessionState) { final room = _cubit.liveKitRoom; @@ -159,14 +163,31 @@ class _VideoCallScreenState extends State { } final showEntranceLobby = + sessionState.entryMode == VideoCallEntryMode.outgoing && !sessionState.callStageEntered && sessionState.linkIssue == VideoCallLinkIssue.none && sessionState.connectPhase != VideoCallConnectPhase.idle; - if (showEntranceLobby) { - final remoteName = _cubit.resolvedRemoteDisplayName( - counterpartFallback, + final showAutoEnterLoading = + sessionState.entryMode == VideoCallEntryMode.incomingAccepted && + !sessionState.callStageEntered && + sessionState.linkIssue == VideoCallLinkIssue.none && + sessionState.connectPhase != VideoCallConnectPhase.idle; + + if (showAutoEnterLoading) { + return AnnotatedRegion( + value: _videoCallOverlayStyle, + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: VideoCallLoadingScreen( + onClose: () => unawaited(_handleHangUp()), + ), + ), ); + } + + if (showEntranceLobby) { + final remoteName = _resolveRemoteDisplayName(sessionState); final canEnterCall = sessionState.connectPhase == VideoCallConnectPhase.connected; return AnnotatedRegion( @@ -228,18 +249,15 @@ class _VideoCallScreenState extends State { child: Scaffold( backgroundColor: appTheme.whiteA700, body: ListenableBuilder( - listenable: activeRoom, + listenable: videoCallRoomTracksListenable(activeRoom), builder: (context, _) { return VideoCallStageLayout( room: activeRoom, connectError: sessionState.connectError, - localDisplayName: _cubit.resolvedLocalDisplayName(), - remoteDisplayName: _cubit.resolvedRemoteDisplayName( - counterpartFallback, - ), - micEnabled: sessionState.isMicActiveForUi, + localDisplayName: _resolveLocalDisplayName(sessionState), + remoteDisplayName: _resolveRemoteDisplayName(sessionState), + micEnabled: sessionState.micEnabled, videoEnabled: sessionState.videoEnabled, - systemAudioInterrupted: sessionState.systemAudioInterrupted, splitParticipantView: sessionState.splitParticipantView, mainStageShowsLocalVideo: sessionState.mainStageShowsLocalVideo, @@ -272,4 +290,20 @@ class _VideoCallScreenState extends State { ), ); } + + String _resolveLocalDisplayName(VideoCallSessionState sessionState) { + final resolved = _cubit.resolvedLocalDisplayName().trim(); + if (resolved.isNotEmpty) return resolved; + return sessionState.localParticipantName?.trim() ?? + widget.localParticipantName?.trim() ?? + ''; + } + + String _resolveRemoteDisplayName(VideoCallSessionState sessionState) { + final resolved = _cubit.resolvedRemoteDisplayName().trim(); + if (resolved.isNotEmpty) return resolved; + return sessionState.counterpartName?.trim() ?? + widget.counterpartName?.trim() ?? + ''; + } } diff --git a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart index 5a972711..e7fdd6fd 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart @@ -1,10 +1,14 @@ import 'dart:io'; +import 'package:eatvio_app/core/locator_service.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_global_pip_layer.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart'; import 'package:floating/floating.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; /// Root overlay shell: system PiP on Android, in-app PiP fallback on iOS. class VideoCallAppShell extends StatelessWidget { @@ -25,9 +29,25 @@ class VideoCallAppShell extends StatelessWidget { if (!Platform.isAndroid) return shell; - return PiPSwitcher( - childWhenDisabled: shell, - childWhenEnabled: const VideoCallSystemPipContent(), + return BlocBuilder( + bloc: sl(), + buildWhen: (previous, current) => + previous.callStageEntered != current.callStageEntered || + previous.connectPhase != current.connectPhase || + previous.linkIssue != current.linkIssue, + builder: (context, state) { + final allowSystemPipShell = + state.callStageEntered && + state.connectPhase == VideoCallConnectPhase.connected && + state.linkIssue != VideoCallLinkIssue.lost; + + if (!allowSystemPipShell) return shell; + + return PiPSwitcher( + childWhenDisabled: shell, + childWhenEnabled: const VideoCallSystemPipContent(), + ); + }, ); } } 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 index e9c9b281..9a1e4e55 100644 --- 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 @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'dart:math' as math; import 'package:eatvio_app/core/app_router.dart'; @@ -7,6 +8,7 @@ import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_ses 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_pip_video_preview.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -68,6 +70,9 @@ class _VideoCallGlobalPipLayerState extends State { return const SizedBox.shrink(); } final cubit = context.read(); + if (Platform.isAndroid && cubit.isCallScreenRouteVisible) { + return const SizedBox.shrink(); + } final room = cubit.liveKitRoom; if (room == null) return const SizedBox.shrink(); @@ -94,7 +99,7 @@ class _VideoCallGlobalPipLayerState extends State { return Material( type: MaterialType.transparency, child: ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { return Stack( clipBehavior: Clip.none, diff --git a/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart index 547a9320..18b1acba 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart @@ -8,10 +8,8 @@ abstract final class VideoCallLiveKitTrackUtils { static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) { if (pub == null || pub.muted) return null; - if (pub is lk.RemoteTrackPublication) { - if (!pub.subscribed || pub.streamState != lk.StreamState.active) { - return null; - } + if (pub is lk.RemoteTrackPublication && !pub.subscribed) { + return null; } final v = asVideoTrack(pub.track); if (v == null || v.muted) return null; @@ -42,4 +40,6 @@ abstract final class VideoCallLiveKitTrackUtils { room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera), ); } + + static bool isSoloInRoom(lk.Room room) => room.remoteParticipants.isEmpty; } diff --git a/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart index 0653f7e2..c3baad4c 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart @@ -1,6 +1,8 @@ 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_livekit_track_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -15,26 +17,55 @@ class VideoCallPipVideoPreview extends StatelessWidget { final VideoCallSessionState sessionState; final lk.Room room; + static const double _pipNoVideoLogoSize = 56; + @override Widget build(BuildContext context) { - final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); - if (remote != null) { - return IgnorePointer( - child: lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover), - ); + return ListenableBuilder( + listenable: videoCallRoomTracksListenable(room), + builder: (context, _) { + if (VideoCallLiveKitTrackUtils.isSoloInRoom(room)) { + return _VideoCallPipLocalPreview( + room: room, + videoEnabled: sessionState.videoEnabled, + ); + } + + return VideoCallRemoteVideoStage( + room: room, + connectError: null, + noVideoLogoSize: _pipNoVideoLogoSize, + ); + }, + ); + } +} + +class _VideoCallPipLocalPreview extends StatelessWidget { + const _VideoCallPipLocalPreview({ + required this.room, + required this.videoEnabled, + }); + + final lk.Room room; + final bool videoEnabled; + + @override + Widget build(BuildContext context) { + if (!videoEnabled) { + return const VideoCallNoVideoBackground(logoSize: 56); } - if (sessionState.videoEnabled) { - final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); - if (local != null) { - return IgnorePointer( - child: lk.VideoTrackRenderer( - local, - fit: lk.VideoViewFit.cover, - mirrorMode: lk.VideoViewMirrorMode.auto, - ), - ); - } + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + if (local != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), + ); } return const VideoCallNoVideoBackground(logoSize: 56); diff --git a/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart index 86c6333f..3f35cd61 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart @@ -2,6 +2,7 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -10,19 +11,25 @@ class VideoCallRemoteVideoStage extends StatelessWidget { super.key, required this.room, required this.connectError, + this.noVideoLogoSize = 96, }); final lk.Room room; final String? connectError; + final double noVideoLogoSize; @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); if (remote != null) { - return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover); + return lk.VideoTrackRenderer( + remote, + key: ValueKey(remote.sid), + fit: lk.VideoViewFit.cover, + ); } final showConnectError = connectError != null && @@ -30,7 +37,7 @@ class VideoCallRemoteVideoStage extends StatelessWidget { return Stack( fit: StackFit.expand, children: [ - const VideoCallNoVideoBackground(), + VideoCallNoVideoBackground(logoSize: noVideoLogoSize), if (showConnectError) Center( child: Padding( diff --git a/lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart b/lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart new file mode 100644 index 00000000..c36801d7 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart @@ -0,0 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + + +Listenable videoCallRoomTracksListenable(lk.Room room) { + return Listenable.merge([ + room, + ...room.remoteParticipants.values, + if (room.localParticipant != null) room.localParticipant!, + ]); +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart index c70f3407..feac9376 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart @@ -1,13 +1,11 @@ import 'dart:math' as math; -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/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; -import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:livekit_client/livekit_client.dart' as lk; @@ -28,7 +26,6 @@ class VideoCallStageLayout extends StatefulWidget { required this.localDisplayName, required this.remoteDisplayName, required this.micEnabled, - this.systemAudioInterrupted = false, required this.videoEnabled, required this.splitParticipantView, required this.mainStageShowsLocalVideo, @@ -53,7 +50,6 @@ class VideoCallStageLayout extends StatefulWidget { final String localDisplayName; final String remoteDisplayName; final bool micEnabled; - final bool systemAudioInterrupted; final bool videoEnabled; final bool splitParticipantView; final bool mainStageShowsLocalVideo; @@ -88,6 +84,8 @@ class _VideoCallStageLayoutState extends State { static const double _pipInitialExtraDown = 16; static const double _bottomGradientHeight = 140; static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop; + static const int _splitRemoteFlex = 7; + static const int _splitLocalFlex = 3; double? _pipLeft; double? _pipTop; @@ -95,13 +93,6 @@ class _VideoCallStageLayoutState extends State { double? _pipPanOriginLeft; double? _pipPanOriginTop; - bool _pipHasVisibleVideo() { - if (widget.mainStageShowsLocalVideo) { - return VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(widget.room) != null; - } - return widget.videoEnabled && VideoCallLiveKitTrackUtils.localCameraVideoTrack(widget.room) != null; - } - @override Widget build(BuildContext context) { return LayoutBuilder( @@ -115,15 +106,17 @@ class _VideoCallStageLayoutState extends State { } Widget _buildSplitLayout(BoxConstraints constraints) { - return Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: Padding( - padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), - child: Column( - children: [ - Expanded( + return ListenableBuilder( + listenable: videoCallRoomTracksListenable(widget.room), + builder: (context, _) { + final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room); + if (soloInRoom) { + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), child: _SplitParticipantPane( room: widget.room, displayName: widget.localDisplayName, @@ -133,54 +126,73 @@ class _VideoCallStageLayoutState extends State { useFrontCamera: widget.useFrontCamera, onExpand: widget.onExpandLocal, onSwitchCamera: widget.onSwitchCamera, - borderRadius: const BorderRadius.only( - topLeft: Radius.circular(_mainStageCornerRadius), - topRight: Radius.circular(_mainStageCornerRadius), - bottomLeft: Radius.zero, - bottomRight: Radius.zero, - ), + borderRadius: BorderRadius.circular(_mainStageCornerRadius), ), ), - const SizedBox(height: _edgeInset), - Expanded( - child: _SplitParticipantPane( - room: widget.room, - displayName: widget.remoteDisplayName, - isLocal: false, - connectError: widget.connectError, - videoEnabled: widget.videoEnabled, - useFrontCamera: widget.useFrontCamera, - onExpand: widget.onExpandRemote, - onSwitchCamera: widget.onSwitchCamera, - borderRadius: const BorderRadius.only( - topLeft: Radius.zero, - topRight: Radius.zero, - bottomLeft: Radius.circular(_mainStageCornerRadius), - bottomRight: Radius.circular(_mainStageCornerRadius), - ), - ), - ), - ], - ), - ), - ), - ..._bottomBarGradient(), - ..._bottomControlsRow(), - ..._systemInterruptionBanner(), - ], - ); - } + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + ], + ); + } - List _systemInterruptionBanner() { - if (!widget.systemAudioInterrupted) return const []; - return [ - Positioned( - left: 16, - right: 16, - top: widget.topInset + 8, - child: const _VideoCallSystemInterruptionBanner(), - ), - ]; + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Column( + children: [ + Expanded( + flex: _splitRemoteFlex, + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.remoteDisplayName, + isLocal: false, + connectError: widget.connectError, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandRemote, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(_mainStageCornerRadius), + topRight: Radius.circular(_mainStageCornerRadius), + bottomLeft: Radius.zero, + bottomRight: Radius.zero, + ), + ), + ), + const SizedBox(height: _edgeInset), + Expanded( + flex: _splitLocalFlex, + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.localDisplayName, + isLocal: true, + connectError: null, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandLocal, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.zero, + topRight: Radius.zero, + bottomLeft: Radius.circular(_mainStageCornerRadius), + bottomRight: Radius.circular(_mainStageCornerRadius), + ), + ), + ), + ], + ), + ), + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + ], + ); + }, + ); } Widget _buildFullscreenLayout(BoxConstraints constraints) { @@ -194,100 +206,105 @@ class _VideoCallStageLayoutState extends State { final pipLeft = ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num).toDouble(); final pipTop = ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num).toDouble(); - return Stack( - fit: StackFit.expand, - children: [ - Positioned.fill( - child: Padding( - padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), - child: Material( - type: MaterialType.transparency, - clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)), - child: Stack( - fit: StackFit.expand, - children: [ - _VideoCallMainStage( - room: widget.room, - connectError: widget.connectError, - mainShowsLocal: widget.mainStageShowsLocalVideo, - videoEnabled: widget.videoEnabled, - ), - VideoCallParticipantNameLabel(name: widget.mainStageShowsLocalVideo ? widget.localDisplayName : widget.remoteDisplayName), - ], - ), - ), - ), - ), - ..._bottomBarGradient(), - ..._bottomControlsRow(), - ..._systemInterruptionBanner(), - Positioned( - top: widget.topInset + _edgeInset + _shrinkExpandExtraTop, - left: _controlsHorizontalInset, - child: Padding( - padding: _kExpandShrinkOuterPadding, - child: VideoCallPipStyleIconButton( - onPressed: widget.onShrinkFromFullscreen, - child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain), - ), - ), - ), - ListenableBuilder( - listenable: widget.room, - builder: (context, _) { - if (!_pipHasVisibleVideo()) { - return const SizedBox.shrink(); - } - return Positioned( - left: pipLeft, - top: pipTop, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: widget.onToggleStageSwap, - onPanStart: (details) { - _pipPanPointerGlobal = details.globalPosition; - _pipPanOriginLeft = pipLeft; - _pipPanOriginTop = pipTop; - }, - onPanUpdate: (details) { - if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) { - return; - } - final delta = details.globalPosition - _pipPanPointerGlobal!; - setState(() { - final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft); - final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop); - _pipLeft = (nx as num).toDouble(); - _pipTop = (ny as num).toDouble(); - }); - }, - onPanEnd: (_) { - _resetPipPanState(); - }, - onPanCancel: _resetPipPanState, - child: AnimatedContainer( - duration: const Duration(milliseconds: 220), - curve: Curves.easeOutCubic, - width: _pipW, - height: _pipH, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: widget.mainStageShowsLocalVideo - ? _VideoCallPipRemoteContent(room: widget.room) - : _VideoCallPipLocalContent( - room: widget.room, - videoEnabled: widget.videoEnabled, - useFrontCamera: widget.useFrontCamera, - onSwitchCamera: widget.onSwitchCamera, - ), + return ListenableBuilder( + listenable: videoCallRoomTracksListenable(widget.room), + builder: (context, _) { + final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room); + final mainShowsLocal = + soloInRoom || widget.mainStageShowsLocalVideo; + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)), + child: Stack( + fit: StackFit.expand, + children: [ + _VideoCallMainStage( + room: widget.room, + connectError: widget.connectError, + mainShowsLocal: mainShowsLocal, + videoEnabled: widget.videoEnabled, + ), + VideoCallParticipantNameLabel( + name: mainShowsLocal + ? widget.localDisplayName + : widget.remoteDisplayName, + ), + ], ), ), ), - ); - }, - ), - ], + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + Positioned( + top: widget.topInset + _edgeInset + _shrinkExpandExtraTop, + left: _controlsHorizontalInset, + child: Padding( + padding: _kExpandShrinkOuterPadding, + child: VideoCallPipStyleIconButton( + onPressed: widget.onShrinkFromFullscreen, + child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain), + ), + ), + ), + if (!soloInRoom) + Positioned( + left: pipLeft, + top: pipTop, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onToggleStageSwap, + onPanStart: (details) { + _pipPanPointerGlobal = details.globalPosition; + _pipPanOriginLeft = pipLeft; + _pipPanOriginTop = pipTop; + }, + onPanUpdate: (details) { + if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) { + return; + } + final delta = details.globalPosition - _pipPanPointerGlobal!; + setState(() { + final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft); + final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop); + _pipLeft = (nx as num).toDouble(); + _pipTop = (ny as num).toDouble(); + }); + }, + onPanEnd: (_) { + _resetPipPanState(); + }, + onPanCancel: _resetPipPanState, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: _pipW, + height: _pipH, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: mainShowsLocal + ? _VideoCallPipRemoteContent(room: widget.room) + : _VideoCallPipLocalContent( + room: widget.room, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onSwitchCamera: widget.onSwitchCamera, + ), + ), + ), + ), + ), + ], + ); + }, ); } @@ -381,7 +398,7 @@ class _SplitParticipantPane extends StatelessWidget { children: [ Positioned.fill( child: ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { if (isLocal) { if (!videoEnabled) { @@ -390,7 +407,12 @@ class _SplitParticipantPane extends StatelessWidget { final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); if (local != null) { return IgnorePointer( - child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), ); } return const VideoCallNoVideoBackground(); @@ -440,7 +462,7 @@ class _VideoCallMainStage extends StatelessWidget { @override Widget build(BuildContext context) { return ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) { if (mainShowsLocal) { if (!videoEnabled) { @@ -449,7 +471,12 @@ class _VideoCallMainStage extends StatelessWidget { final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); if (local != null) { return IgnorePointer( - child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), ); } return const VideoCallNoVideoBackground(); @@ -467,7 +494,13 @@ class _VideoCallPipRemoteContent extends StatelessWidget { @override Widget build(BuildContext context) { - return IgnorePointer(child: VideoCallRemoteVideoStage(room: room, connectError: null)); + return IgnorePointer( + child: VideoCallRemoteVideoStage( + room: room, + connectError: null, + noVideoLogoSize: 56, + ), + ); } } @@ -489,7 +522,12 @@ class _VideoCallPipLocalContent extends StatelessWidget { const VideoCallNoVideoBackground(logoSize: 56) else if (local != null) IgnorePointer( - child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + child: lk.VideoTrackRenderer( + local, + key: ValueKey(local.sid), + fit: lk.VideoViewFit.cover, + mirrorMode: lk.VideoViewMirrorMode.auto, + ), ) else const VideoCallNoVideoBackground(logoSize: 56), @@ -506,57 +544,3 @@ class _VideoCallPipLocalContent extends StatelessWidget { ); } } - -class _VideoCallSystemInterruptionBanner extends StatelessWidget { - const _VideoCallSystemInterruptionBanner(); - - @override - Widget build(BuildContext context) { - final s = S.of(context); - return Material( - color: appTheme.videoCallNoticeBannerBackground, - borderRadius: BorderRadius.circular(12), - elevation: 6, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SvgPicture.asset( - VideoCallTopOverlay.warningSvg, - width: 22, - height: 22, - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - s.video_call_mic_muted_phone_call, - style: const TextStyle( - color: Colors.white, - fontSize: 14, - height: 1.25, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 4), - Text( - s.video_call_system_interruption, - style: const TextStyle( - color: Colors.white, - fontSize: 13, - height: 1.25, - fontWeight: FontWeight.w400, - ), - ), - ], - ), - ), - ], - ), - ), - ); - } -} diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart index da20656f..fbcc16fd 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart @@ -1,7 +1,9 @@ import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/theme_helper.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_pip_video_preview.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -22,6 +24,23 @@ class VideoCallSystemPipContent extends StatelessWidget { if (state.connectPhase != VideoCallConnectPhase.connected || state.linkIssue == VideoCallLinkIssue.lost || !state.callStageEntered) { + if (state.conversationUuid.isNotEmpty && + state.connectPhase != VideoCallConnectPhase.idle && + state.linkIssue != VideoCallLinkIssue.lost) { + return ColoredBox( + color: appTheme.gray100, + child: Center( + child: SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + strokeWidth: 3, + color: appTheme.blueA400, + ), + ), + ), + ); + } return const ColoredBox(color: Colors.black); } @@ -34,7 +53,7 @@ class VideoCallSystemPipContent extends StatelessWidget { return Material( color: Colors.black, child: ListenableBuilder( - listenable: room, + listenable: videoCallRoomTracksListenable(room), builder: (context, _) => VideoCallPipVideoPreview( sessionState: state, room: room, diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart index 7760e27f..4731d98f 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart @@ -55,6 +55,12 @@ class _VideoCallSystemPipListenerState extends State } if (!wasEnabled || status != PiPStatus.disabled) return; + + if (cubit.isCallScreenRouteVisible) { + await cubit.onSystemPipExitedWithCallScreenVisible(); + return; + } + if (cubit.state.uiMode != VideoCallUiMode.pip) return; await cubit.restoreFullscreenFromPip(); diff --git a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart index fd976f08..55c2477d 100644 --- a/lib/features/video_calls/services/video_call_audio_session_coordinator.dart +++ b/lib/features/video_calls/services/video_call_audio_session_coordinator.dart @@ -32,15 +32,12 @@ class VideoCallAudioSessionCoordinator { androidWillPauseWhenDucked: true, ); - StreamSubscription? _interruptionSubscription; AudioSession? _session; bool _configured = false; bool get isActive => _session != null; - Future start({ - required void Function(AudioInterruptionEvent event) onInterruption, - }) async { + Future start() async { final session = await _audioSessionFactory(); _session = session; if (!_configured) { @@ -48,10 +45,6 @@ class VideoCallAudioSessionCoordinator { _configured = true; } await session.setActive(true); - await _interruptionSubscription?.cancel(); - _interruptionSubscription = session.interruptionEventStream.listen( - onInterruption, - ); } Future reactivate() async { @@ -65,8 +58,6 @@ class VideoCallAudioSessionCoordinator { } Future stop() async { - await _interruptionSubscription?.cancel(); - _interruptionSubscription = null; final session = _session; _session = null; if (session == null) return; diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index b25e0468..036103ba 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -496,6 +496,7 @@ class VideoCallCallKitCoordinator { counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + entryMode: VideoCallEntryMode.incomingAccepted, ); } diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart index 6f058c5b..9d582d66 100644 --- a/lib/features/video_calls/services/video_call_fcm_handler.dart +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -57,6 +57,14 @@ abstract final class VideoCallFcmHandler { return; } + if (source == 'background') { + log( + 'VideoCallFcmHandler: skip background video call, handled by native FCM service, conversation=${payload.conversationUuid}', + name: 'VideoCallFcm', + ); + return; + } + log( 'VideoCallFcmHandler: show incoming call UI, source=$source, conversation=${payload.conversationUuid}', name: 'VideoCallFcm', diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index c3325be9..4fb6bf2e 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -28,6 +28,7 @@ abstract final class VideoCallIncomingPushPresenter { if (payload.localParticipantName != null) 'localParticipantName': payload.localParticipantName, }, + callingNotification: const NotificationParams(showNotification: false), android: const AndroidParams( isCustomNotification: true, isShowLogo: false, diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 5ffbd983..612e032f 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -3,6 +3,8 @@ import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.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:go_router/go_router.dart'; @@ -30,22 +32,34 @@ class VideoCallNotificationNavigator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { if (conversationUuid.isEmpty) return; - await _pushCallScreen( + final pushed = await _pushCallScreen( conversationUuid: conversationUuid, counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + entryMode: entryMode, ); + + if (!pushed) { + await sl().onCallScreenVisible( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + localParticipantName: localParticipantName, + entryMode: entryMode, + ); + } } - static Future _pushCallScreen({ + static Future _pushCallScreen({ required String conversationUuid, String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { for (var attempt = 0; attempt < 100; attempt++) { final ctx = AppRouter.navigatorKey.currentContext; @@ -63,12 +77,15 @@ class VideoCallNotificationNavigator { 'counterpartName': counterpartName, 'counterpartAvatarUrl': counterpartAvatarUrl, 'localParticipantName': localParticipantName, + 'entryMode': entryMode.name, }, ); + return true; } - return; + return false; } await Future.delayed(const Duration(milliseconds: 100)); } + return false; } } diff --git a/test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart b/test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart deleted file mode 100644 index 73ef657d..00000000 --- a/test/features/video_calls/presentation/video_call_manager_audio_interruption_test.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:audio_session/audio_session.dart'; -import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; -import 'package:eatvio_app/features/video_calls/services/video_call_audio_session_coordinator.dart'; -import 'package:eatvio_app/features/video_calls/services/video_call_foreground_coordinator.dart'; -import 'package:eatvio_app/features/video_calls/services/video_call_lifecycle_binding.dart'; -import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:livekit_client/livekit_client.dart' as lk; -import 'package:mockito/annotations.dart'; -import 'package:mockito/mockito.dart'; - -import 'video_call_manager_audio_interruption_test.mocks.dart'; - -class _FakeAudioSessionCoordinator extends VideoCallAudioSessionCoordinator { - _FakeAudioSessionCoordinator(this.onStart); - - final void Function(void Function(AudioInterruptionEvent event) onInterruption) onStart; - bool reactivateCalled = false; - - @override - Future start({required void Function(AudioInterruptionEvent event) onInterruption}) async { - onStart(onInterruption); - } - - @override - Future reactivate() async { - reactivateCalled = true; - return true; - } - - @override - Future stop() async {} -} - -@GenerateNiceMocks([MockSpec(), MockSpec(), MockSpec()]) -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); - - group('VideoCallManager audio interruption', () { - late MockVideoCallRepository mockRepository; - late MockRoom mockRoom; - late MockLocalParticipant mockLocalParticipant; - late _FakeAudioSessionCoordinator audioCoordinator; - late VideoCallManager manager; - - setUp(() { - mockRepository = MockVideoCallRepository(); - mockRoom = MockRoom(); - mockLocalParticipant = MockLocalParticipant(); - audioCoordinator = _FakeAudioSessionCoordinator((_) {}); - manager = VideoCallManager( - videoCallRepository: mockRepository, - foregroundCoordinator: VideoCallForegroundCoordinator(), - lifecycleBinding: VideoCallLifecycleBinding(), - audioSessionCoordinator: audioCoordinator, - ); - when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected); - when(mockRoom.localParticipant).thenReturn(mockLocalParticipant); - when(mockLocalParticipant.setMicrophoneEnabled(any, audioCaptureOptions: anyNamed('audioCaptureOptions'))).thenAnswer((_) async { - return null; - }); - when(mockLocalParticipant.getTrackPublicationBySource(lk.TrackSource.microphone)).thenReturn(null); - }); - - tearDown(() { - manager.dispose(); - }); - - test('duck interruption mutes mic and sets interrupted UI state', () async { - manager.markActiveCallForTesting(mockRoom); - - manager.handleAudioInterruptionForTesting( - AudioInterruptionEvent(true, AudioInterruptionType.duck), - ); - await Future.delayed(Duration.zero); - - expect(manager.snapshot.systemAudioInterrupted, isTrue); - verify( - mockLocalParticipant.setMicrophoneEnabled( - false, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ).called(1); - }); - - test('interruption begin mutes mic and sets interrupted state', () async { - manager.markActiveCallForTesting(mockRoom); - - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - - expect(manager.snapshot.systemAudioInterrupted, isTrue); - expect(manager.snapshot.micEnabled, isTrue); - verify(mockLocalParticipant.setMicrophoneEnabled(false, audioCaptureOptions: anyNamed('audioCaptureOptions'))).called(1); - verifyNever(mockRepository.notifyCallEnded(any)); - }); - - test('interruption end reactivates session and restores mic', () async { - manager.markActiveCallForTesting(mockRoom); - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(false, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - - expect(manager.snapshot.systemAudioInterrupted, isFalse); - expect(audioCoordinator.reactivateCalled, isTrue); - verify(mockLocalParticipant.setMicrophoneEnabled(true, audioCaptureOptions: anyNamed('audioCaptureOptions'))).called(1); - }); - - test('toggleMic before call stage updates intent without livekit', () async { - manager.markActiveCallForTesting(mockRoom); - manager.setCallStageEnteredForTesting(false); - - await manager.toggleMic(false); - - expect(manager.snapshot.micEnabled, isTrue); - verifyNever( - mockLocalParticipant.setMicrophoneEnabled( - any, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ); - }); - - test( - 'toggleMic from UI off while interrupted keeps interrupted and does not unmute', - () async { - manager.markActiveCallForTesting(mockRoom); - manager.handleAudioInterruptionForTesting( - AudioInterruptionEvent(true, AudioInterruptionType.pause), - ); - await Future.delayed(Duration.zero); - clearInteractions(mockLocalParticipant); - - await manager.toggleMic(false); - - expect(manager.snapshot.systemAudioInterrupted, isTrue); - expect(manager.snapshot.micEnabled, isTrue); - verifyNever( - mockLocalParticipant.setMicrophoneEnabled( - true, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ); - }, - ); - - test('toggleMic during interruption disables intent and keeps mic paused', () async { - manager.markActiveCallForTesting(mockRoom); - manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(true, AudioInterruptionType.pause)); - await Future.delayed(Duration.zero); - clearInteractions(mockLocalParticipant); - - await manager.toggleMic(true); - - expect(manager.snapshot.micEnabled, isFalse); - expect(manager.snapshot.systemAudioInterrupted, isTrue); - verify( - mockLocalParticipant.setMicrophoneEnabled( - false, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ).called(1); - verifyNever( - mockLocalParticipant.setMicrophoneEnabled( - true, - audioCaptureOptions: anyNamed('audioCaptureOptions'), - ), - ); - }); - }); -} diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index d9c67cd9..b18404ff 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -3,18 +3,19 @@ import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snap 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:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'video_call_session_cubit_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec()]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); group('VideoCallSessionCubit', () { late MockVideoCallManager mockManager; - late VideoCallSessionCubit cubit; + late _TestVideoCallSessionCubit cubit; late VideoCallManagerListener managerListener; @@ -26,7 +27,7 @@ void main() { managerListener = invocation.positionalArguments[0] as VideoCallManagerListener; }); - cubit = VideoCallSessionCubit(videoCallManager: mockManager); + cubit = _TestVideoCallSessionCubit(videoCallManager: mockManager); }); tearDown(() async { @@ -120,12 +121,9 @@ void main() { }, ); - test('isMicActiveForUi is false when system audio interrupted', () { - const state = VideoCallSessionState( - micEnabled: true, - systemAudioInterrupted: true, - ); - expect(state.isMicActiveForUi, isFalse); + test('isMicActiveForUi mirrors micEnabled', () { + const state = VideoCallSessionState(micEnabled: true); + expect(state.isMicActiveForUi, isTrue); }); test('onCallEndedExternally resets session state', () async { @@ -143,6 +141,213 @@ void main() { expect(cubit.state, const VideoCallSessionState()); }); + test('syncPipUiStateFromSystem skips overlay when call screen is visible', () async { + cubit.emit( + const VideoCallSessionState( + conversationUuid: 'uuid', + connectPhase: VideoCallConnectPhase.connected, + callStageEntered: true, + uiMode: VideoCallUiMode.fullscreen, + ), + ); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + await cubit.syncPipUiStateFromSystem(); + + expect(cubit.state.uiMode, VideoCallUiMode.fullscreen); + }); + + test('manager idle snapshot ignored while call screen is visible', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.incomingAccepted, + ); + + managerListener(VideoCallManagerSnapshot.initial); + + expect(cubit.state.entryMode, VideoCallEntryMode.incomingAccepted); + expect(cubit.state.conversationUuid, 'uuid'); + }); + + test('incoming accept auto-enters call stage after connect', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async { + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: false, + videoEnabled: false, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + ), + ); + }); + when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); + + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.incomingAccepted, + ); + + verify( + mockManager.startCall( + conversationUuid: 'uuid', + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: false, + videoEnabled: false, + ), + ).called(1); + verify(mockManager.onEnterCallStage()).called(1); + expect(cubit.state.entryMode, VideoCallEntryMode.incomingAccepted); + expect(cubit.state.callStageEntered, isTrue); + }); + + test('outgoing call keeps lobby until callee joins', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {}); + when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + + verifyNever(mockManager.onEnterCallStage()); + expect(cubit.state.entryMode, VideoCallEntryMode.outgoing); + expect(cubit.state.callStageEntered, isFalse); + expect(cubit.state.micEnabled, isFalse); + + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: false, + videoEnabled: false, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + ), + ); + await Future.delayed(Duration.zero); + + verify(mockManager.ensureLobbyRoomJoined()).called(1); + verifyNever(mockManager.onEnterCallStage()); + }); + + test('outgoing call auto-enters when callee joins lobby room', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + when(mockManager.ensureLobbyRoomJoined()).thenAnswer((_) async {}); + when(mockManager.onEnterCallStage()).thenAnswer((_) async => true); + + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + + managerListener( + const VideoCallManagerSnapshot( + conversationUuid: 'uuid', + micEnabled: false, + videoEnabled: false, + connectPhase: VideoCallConnectPhase.connected, + linkIssue: VideoCallLinkIssue.none, + counterpartPresentInLobby: true, + ), + ); + await Future.delayed(Duration.zero); + + verify(mockManager.onEnterCallStage()).called(1); + expect(cubit.state.callStageEntered, isTrue); + }); + + test('duplicate onCallScreenVisible does not restart connecting call', () async { + when( + mockManager.startCall( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.incomingAccepted, + ); + await cubit.onCallScreenVisible( + conversationUuid: 'uuid', + entryMode: VideoCallEntryMode.outgoing, + ); + + verify( + mockManager.startCall( + conversationUuid: 'uuid', + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: false, + videoEnabled: false, + ), + ).called(1); + expect(cubit.state.entryMode, VideoCallEntryMode.incomingAccepted); + }); + + test('leaveCallRoute minimizes to pip and marks call screen not visible', () async { + final mockRoom = MockRoom(); + when(mockManager.room).thenReturn(mockRoom); + when( + mockManager.onCallScreenVisible( + conversationUuid: anyNamed('conversationUuid'), + counterpartName: anyNamed('counterpartName'), + localParticipantName: anyNamed('localParticipantName'), + micEnabled: anyNamed('micEnabled'), + videoEnabled: anyNamed('videoEnabled'), + ), + ).thenAnswer((_) async {}); + + cubit.emit( + const VideoCallSessionState( + conversationUuid: 'uuid', + connectPhase: VideoCallConnectPhase.connected, + callStageEntered: true, + uiMode: VideoCallUiMode.fullscreen, + ), + ); + await cubit.onCallScreenVisible(conversationUuid: 'uuid'); + expect(cubit.isCallScreenRouteVisible, isTrue); + + await cubit.leaveCallRoute(); + + expect(cubit.state.uiMode, VideoCallUiMode.pip); + expect(cubit.isCallScreenRouteVisible, isFalse); + }); + test('tracks whether call screen route is visible', () async { when( mockManager.startCall( @@ -171,3 +376,12 @@ void main() { }); }); } + +class _TestVideoCallSessionCubit extends VideoCallSessionCubit { + _TestVideoCallSessionCubit({required super.videoCallManager}); + + bool grantMicOnRequest = false; + + @override + Future requestMicPermissionForCall() async => grantMicOnRequest; +} diff --git a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart index 2749415a..d8aff74a 100644 --- a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart +++ b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart @@ -12,6 +12,8 @@ import 'video_call_livekit_track_utils_test.mocks.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), + MockSpec(), MockSpec(), MockSpec(), ]) @@ -67,6 +69,33 @@ void main() { expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); }); + test('visibleVideoFromPublication возвращает remote track при paused stream', () { + final pub = MockRemoteTrackPublication(); + final track = MockRemoteVideoTrack(); + when(pub.muted).thenReturn(false); + when(pub.subscribed).thenReturn(true); + when(pub.streamState).thenReturn(lk.StreamState.paused); + when(pub.track).thenReturn(track); + when(track.muted).thenReturn(false); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), equals(track)); + }); + + test('visibleVideoFromPublication возвращает null для unsubscribed remote', () { + final pub = MockRemoteTrackPublication(); + when(pub.muted).thenReturn(false); + when(pub.subscribed).thenReturn(false); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('isSoloInRoom is true when remote participants are empty', () { + final room = MockRoom(); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + + expect(VideoCallLiveKitTrackUtils.isSoloInRoom(room), isTrue); + }); + test('localCameraVideoTrack возвращает null если localParticipant отсутствует', () { final room = MockRoom(); when(room.localParticipant).thenReturn(null); diff --git a/test/features/video_calls/widgets/video_call_stage_layout_test.dart b/test/features/video_calls/widgets/video_call_stage_layout_test.dart index 078f43c6..ba3afd89 100644 --- a/test/features/video_calls/widgets/video_call_stage_layout_test.dart +++ b/test/features/video_calls/widgets/video_call_stage_layout_test.dart @@ -9,7 +9,7 @@ import 'package:mockito/mockito.dart'; import 'video_call_stage_layout_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([MockSpec(), MockSpec()]) void main() { Widget wrap(Widget child) { return MaterialApp( @@ -27,8 +27,19 @@ void main() { return room; } + MockRoom buildRoomWithRemote() { + final room = MockRoom(); + final remote = MockRemoteParticipant(); + when(room.remoteParticipants).thenReturn( + UnmodifiableMapView({'remote': remote}), + ); + when(room.localParticipant).thenReturn(null); + when(room.connectionState).thenReturn(lk.ConnectionState.connected); + return room; + } + testWidgets('renders split mode participant names and controls', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); var localExpanded = 0; var remoteExpanded = 0; @@ -114,7 +125,7 @@ void main() { }); testWidgets('split layout hides connectError on remote pane when connected', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); await tester.pumpWidget( wrap( @@ -150,7 +161,7 @@ void main() { }); testWidgets('fullscreen layout shows remote name when main stage is remote', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); await tester.pumpWidget( wrap( @@ -186,7 +197,7 @@ void main() { }); testWidgets('split layout with video on shows camera switch on local pane', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); await tester.pumpWidget( wrap( @@ -222,7 +233,7 @@ void main() { }); testWidgets('split layout with rear camera and remote main stage variant', (tester) async { - final room = buildRoom(); + final room = buildRoomWithRemote(); var stageSwapped = 0; await tester.pumpWidget( @@ -299,8 +310,131 @@ void main() { expect(find.text('Leave'), findsOneWidget); }); - testWidgets('fullscreen layout with connect error shows it on stage', (tester) async { + testWidgets('fullscreen layout shows local PIP when camera is off', (tester) async { + final room = buildRoomWithRemote(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + await tester.pump(); + expect(find.byType(AnimatedContainer), findsOneWidget); + }); + + testWidgets('fullscreen layout shows only local participant when solo in room', (tester) async { final room = buildRoom(); + var stageSwapped = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () => stageSwapped++, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 8, + bottomInset: 32, + ), + ), + ); + + await tester.pump(); + expect(find.text('You'), findsOneWidget); + expect(find.text('Doctor'), findsNothing); + expect(find.byType(AnimatedContainer), findsNothing); + expect(stageSwapped, 0); + }); + + testWidgets('split layout places remote above local with 70/30 height ratio', (tester) async { + final room = buildRoomWithRemote(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: true, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + await tester.pump(); + + final remoteTop = tester.getTopLeft(find.text('Doctor')).dy; + final localTop = tester.getTopLeft(find.text('You')).dy; + expect(remoteTop, lessThan(localTop)); + + final remotePane = tester.getSize( + find.ancestor(of: find.text('Doctor'), matching: find.byType(Material)).first, + ); + final localPane = tester.getSize( + find.ancestor(of: find.text('You'), matching: find.byType(Material)).first, + ); + expect(remotePane.height / localPane.height, closeTo(7 / 3, 0.05)); + }); + + testWidgets('split layout with connect error shows it on stage', (tester) async { + final room = buildRoomWithRemote(); when(room.connectionState).thenReturn(lk.ConnectionState.disconnected); await tester.pumpWidget( From 1e29cda65ccff82834c7277e9b58a366447c5ec3 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 23 Jun 2026 08:43:40 +0300 Subject: [PATCH 41/56] Version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index 29eee75c..56444d8a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.27+116 +version: 1.3.28+117 publish_to: 'none' environment: From 8ff826fa3e8de636a62d5723f2065b5583c2d9f2 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 23 Jun 2026 09:08:04 +0300 Subject: [PATCH 42/56] fix: analyzer fix --- .../bloc/video_call_session/video_call_session_cubit.dart | 1 - 1 file changed, 1 deletion(-) 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 index 332d4fcf..d4888673 100644 --- 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 @@ -8,7 +8,6 @@ import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_c import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:livekit_client/livekit_client.dart' as lk; -import 'package:meta/meta.dart'; import 'package:permission_handler/permission_handler.dart'; class VideoCallSessionCubit extends BaseCubit { From c5bf5acfe7cb76bb28e27cc8f6b3f741a6e4fa51 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 24 Jun 2026 08:26:16 +0300 Subject: [PATCH 43/56] feat(video_calls): fixed online indicator and in app notifications for video call --- lib/core/const.dart | 1 + lib/core/routing/other_routes.dart | 3 + lib/core/websockets/ws_manager.dart | 132 ++++++++++++++++++ .../models/chat_room_pagination_model.dart | 2 + lib/features/chat/models/chat_user_model.dart | 2 + .../bloc/chat_room_cubit/chat_room_cubit.dart | 123 ++++++++++++++++ .../chat/presentation/pages/chat_screen.dart | 21 ++- .../widgets/chat_user_online_status_line.dart | 70 ++++++++++ .../chat/utils/chat_counterpart_utils.dart | 15 ++ .../managers/video_call_manager.dart | 17 ++- .../video_call_session_cubit.dart | 20 ++- .../video_call_session_state.dart | 2 +- .../presentation/pages/video_call_screen.dart | 91 ++++++++++++ .../widgets/video_call_app_shell.dart | 11 +- .../widgets/video_call_banner_overlay.dart | 10 ++ ...video_call_connecting_entrance_screen.dart | 58 ++++---- .../video_call_system_pip_listener.dart | 20 ++- .../widgets/video_call_top_overlay.dart | 11 +- pubspec.yaml | 2 +- test/core/const_test.dart | 1 + .../chat/models/chat_user_model_test.dart | 6 +- .../video_call_session_cubit_test.dart | 2 +- 22 files changed, 569 insertions(+), 51 deletions(-) create mode 100644 lib/features/chat/presentation/widgets/chat_user_online_status_line.dart create mode 100644 lib/features/chat/utils/chat_counterpart_utils.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart diff --git a/lib/core/const.dart b/lib/core/const.dart index 5aab4fd4..6811b710 100644 --- a/lib/core/const.dart +++ b/lib/core/const.dart @@ -9,4 +9,5 @@ const List planTypeValues = ['fit', 'slim', 'wellness', 'behavior']; const newMessagesChannelName = 'private-new-chat-messages'; const newNotificationsChannelName = 'private-message-events'; const directChatChannelName = 'private-direct-chat'; +const presenceDirectChatChannelName = 'presence-direct-chat'; const thresholdForPagination = 150; diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index cd139b77..d650445d 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -236,6 +236,9 @@ class OtherRoutes { conversationUuid: extra['conversationUuid'] as String, counterpartName: extra['counterpartName'] as String?, counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?, + counterpartUserId: extra['counterpartUserId'] as int?, + counterpartIsOnline: extra['counterpartIsOnline'] as bool?, + counterpartLastSeenText: extra['counterpartLastSeenText'] as String?, localParticipantName: extra['localParticipantName'] as String?, entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']), onBackToChat: extra['onBackToChat'] as void Function()?, diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index cb90f4f8..3631e0ac 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -26,6 +26,13 @@ class WsManager { PrivateChannel? _privateNotificationChannel; PrivateChannel? _privateMessagesChannel; PrivateChannel? _privateDirectChatChannel; + PresenceChannel? _presenceDirectChatChannel; + + StreamSubscription? _presenceMemberAddedSubscription; + StreamSubscription? _presenceMemberRemovedSubscription; + StreamSubscription? _presenceSubscriptionSucceededSubscription; + final List<_PresenceDirectChatListener> _presenceDirectChatListeners = []; + bool _presenceChannelEventsBound = false; StreamSubscription? _lifecycleSubscription; StreamSubscription? _eventSubscription; @@ -175,6 +182,107 @@ class WsManager { ); } + Future initPresenceDirectChatChannel(String chatId) async { + final token = await TokenStorage.getTokenFromStorage(); + _presenceDirectChatChannel = client?.presenceChannel( + '$presenceDirectChatChannelName.$chatId', + authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel( + authorizationEndpoint: Uri.parse('${sl().baseUrl}/broadcasting/auth'), + headers: { + 'Authorization': 'Bearer $token', + }, + ), + ); + } + + void addPresenceDirectChatListener({ + required int counterpartUserId, + required void Function(bool isOnline) onCounterpartOnlineChanged, + }) { + final channel = _presenceDirectChatChannel; + if (channel == null) return; + + _presenceDirectChatListeners.add( + _PresenceDirectChatListener( + counterpartUserId: counterpartUserId, + onCounterpartOnlineChanged: onCounterpartOnlineChanged, + ), + ); + + _bindPresenceChannelEventsIfNeeded(); + channel.subscribe(); + _emitCounterpartPresence(counterpartUserId); + } + + void removePresenceDirectChatListener( + void Function(bool isOnline) onCounterpartOnlineChanged, + ) { + _presenceDirectChatListeners.removeWhere( + (listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged, + ); + if (_presenceDirectChatListeners.isEmpty) { + unsubscribePresenceDirectChatChannel(); + } + } + + void _bindPresenceChannelEventsIfNeeded() { + if (_presenceChannelEventsBound) return; + final channel = _presenceDirectChatChannel; + if (channel == null) return; + + _presenceChannelEventsBound = true; + + _presenceSubscriptionSucceededSubscription = + channel.whenSubscriptionSucceeded().listen((_) { + final counterpartIds = _presenceDirectChatListeners + .map((listener) => listener.counterpartUserId) + .toSet(); + for (final counterpartUserId in counterpartIds) { + _emitCounterpartPresence(counterpartUserId); + } + }); + _presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) { + final memberUserId = _parsePresenceMemberUserId(event.data); + if (memberUserId == null) return; + _notifyPresenceListeners(memberUserId, isOnline: true); + }); + _presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) { + final memberUserId = _parsePresenceMemberUserId(event.data); + if (memberUserId == null) return; + _notifyPresenceListeners(memberUserId, isOnline: false); + }); + } + + void _emitCounterpartPresence(int counterpartUserId) { + final channel = _presenceDirectChatChannel; + if (channel == null) return; + final members = channel.state?.members; + if (members == null) return; + final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null; + _notifyPresenceListeners(counterpartUserId, isOnline: isOnline); + } + + void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) { + for (final listener in _presenceDirectChatListeners) { + if (listener.counterpartUserId == counterpartUserId) { + listener.onCounterpartOnlineChanged(isOnline); + } + } + } + + int? _parsePresenceMemberUserId(String? data) { + if (data == null || data.isEmpty) return null; + try { + final decoded = jsonDecode(data); + if (decoded is! Map) return null; + final rawId = decoded['user_id'] ?? decoded['id']; + if (rawId == null) return null; + return int.tryParse(rawId.toString()); + } catch (_) { + return null; + } + } + void _subscribe() { _privateMessagesChannel?.subscribe(); _privateNotificationChannel?.subscribe(); @@ -193,6 +301,19 @@ class WsManager { _privateDirectChatChannel?.unsubscribe(); } + void unsubscribePresenceDirectChatChannel() { + _presenceSubscriptionSucceededSubscription?.cancel(); + _presenceMemberAddedSubscription?.cancel(); + _presenceMemberRemovedSubscription?.cancel(); + _presenceSubscriptionSucceededSubscription = null; + _presenceMemberAddedSubscription = null; + _presenceMemberRemovedSubscription = null; + _presenceDirectChatListeners.clear(); + _presenceChannelEventsBound = false; + _presenceDirectChatChannel?.unsubscribe(); + _presenceDirectChatChannel = null; + } + Future clear() async { await client?.disconnect(); await _lifecycleSubscription?.cancel(); @@ -204,6 +325,7 @@ class WsManager { client = null; userId = null; _privateDirectChatChannel?.unsubscribe(); + unsubscribePresenceDirectChatChannel(); _privateMessagesChannel?.unsubscribe(); _privateNotificationChannel?.unsubscribe(); _privateDirectChatChannel = null; @@ -211,3 +333,13 @@ class WsManager { _privateNotificationChannel = null; } } + +class _PresenceDirectChatListener { + const _PresenceDirectChatListener({ + required this.counterpartUserId, + required this.onCounterpartOnlineChanged, + }); + + final int counterpartUserId; + final void Function(bool isOnline) onCounterpartOnlineChanged; +} diff --git a/lib/features/chat/models/chat_room_pagination_model.dart b/lib/features/chat/models/chat_room_pagination_model.dart index 52cb9f7c..fa62adc3 100644 --- a/lib/features/chat/models/chat_room_pagination_model.dart +++ b/lib/features/chat/models/chat_room_pagination_model.dart @@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser { @JsonKey(name: "name") required String name, @JsonKey(name: "profile_photo_path") required String? profilePhotoPath, @JsonKey(name: "profile_photo_url") required String profilePhotoUrl, + @Default(false) @JsonKey(name: 'is_online') bool isOnline, + @Default('') @JsonKey(name: 'last_seen_text') String lastSeenText, }) = _ChatUser; factory ChatUser.fromJson(Map json) => _$ChatUserFromJson(json); diff --git a/lib/features/chat/models/chat_user_model.dart b/lib/features/chat/models/chat_user_model.dart index ad876727..184c7889 100644 --- a/lib/features/chat/models/chat_user_model.dart +++ b/lib/features/chat/models/chat_user_model.dart @@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel { required String name, required String slug, @JsonKey(name: 'profile_photo_url') String? profilePhotoUrl, + @Default(false) @JsonKey(name: 'is_online') bool isOnline, + @Default('') @JsonKey(name: 'last_seen_text') String lastSeenText, }) = _ChatUserModel; factory ChatUserModel.fromJson(Map json) => _$ChatUserModelFromJson(json); diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index dc56a0c1..dac801f7 100644 --- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart +++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart @@ -16,6 +16,7 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; import 'package:eatvio_app/features/my_finance/models/card_model.dart'; import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; @@ -34,6 +35,8 @@ class ChatRoomCubit extends Cubit { late int lastPage; bool isLoading = false; Timer? _timer; + String? _activeChatUuid; + void Function(bool isOnline)? _presenceOnlineListener; var uuid = const Uuid(); ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading()); @@ -170,8 +173,10 @@ class ChatRoomCubit extends Cubit { Future _subscribeToChatChannel({required String uuid, required String chatId}) async { final client = wsManager.client; if (client == null) return; + _activeChatUuid = uuid; await wsManager.initPrivateDirectChatChannel(chatId); wsManager.subscribeDirectChatChannel(); + await _subscribeToPresenceChannel(chatId: chatId); messagesSubscription = wsManager.directChatStream?.listen((event) { AudioPlayer().play((AssetSource('audio/recive_message.wav'))); final message = ChatMessageModel.fromJson(jsonDecode(event.data)); @@ -197,6 +202,118 @@ class ChatRoomCubit extends Cubit { }); } + Future _subscribeToPresenceChannel({required String chatId}) async { + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final counterpart = resolveChatCounterpart( + users: currentState.chatRoom.users, + currentUserId: wsManager.userId, + ); + + await wsManager.initPresenceDirectChatChannel(chatId); + _presenceOnlineListener = (isOnline) { + unawaited(_handleCounterpartOnlineChanged( + counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id + ? _ChatCounterpartField.receiver + : _ChatCounterpartField.sender, + isOnline: isOnline, + )); + }; + wsManager.addPresenceDirectChatListener( + counterpartUserId: counterpart.id, + onCounterpartOnlineChanged: _presenceOnlineListener!, + ); + } + + Future _handleCounterpartOnlineChanged({ + required _ChatCounterpartField counterpartField, + required bool isOnline, + }) async { + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final counterpart = counterpartField == _ChatCounterpartField.receiver + ? currentState.chatRoom.users.receiver + : currentState.chatRoom.users.sender; + + if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) { + return; + } + + if (!isOnline) { + await _refreshCounterpartOnlineStatus(counterpartField: counterpartField); + return; + } + + _emitCounterpartOnlineStatus( + counterpartField: counterpartField, + isOnline: true, + lastSeenText: '', + ); + } + + Future _refreshCounterpartOnlineStatus({ + required _ChatCounterpartField counterpartField, + }) async { + final chatUuid = _activeChatUuid; + if (chatUuid == null) return; + + try { + final chatRoom = await chatRepository.getChatRoom(chatUuid); + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final counterpart = resolveChatCounterpart( + users: chatRoom.users, + currentUserId: wsManager.userId, + ); + final field = counterpart.id == currentState.chatRoom.users.receiver.id + ? _ChatCounterpartField.receiver + : _ChatCounterpartField.sender; + + if (field != counterpartField) return; + + _emitCounterpartOnlineStatus( + counterpartField: counterpartField, + isOnline: counterpart.isOnline, + lastSeenText: counterpart.lastSeenText, + ); + } catch (e, st) { + await reportFrom(e, st, this); + } + } + + void _emitCounterpartOnlineStatus({ + required _ChatCounterpartField counterpartField, + required bool isOnline, + required String lastSeenText, + }) { + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final users = currentState.chatRoom.users; + final updatedUsers = counterpartField == _ChatCounterpartField.receiver + ? users.copyWith( + receiver: users.receiver.copyWith( + isOnline: isOnline, + lastSeenText: lastSeenText, + ), + ) + : users.copyWith( + sender: users.sender.copyWith( + isOnline: isOnline, + lastSeenText: lastSeenText, + ), + ); + + emit( + currentState.copyWith( + chatRoom: currentState.chatRoom.copyWith(users: updatedUsers), + ), + ); + } + Future sendMessage({String? data, List? files, required String userSlug}) async { try { final messageUuid = uuid.v4(); @@ -337,6 +454,10 @@ class ChatRoomCubit extends Cubit { @override Future close() async { + if (_presenceOnlineListener != null) { + wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); + _presenceOnlineListener = null; + } wsManager.unsubscribeDirectChatChannel(); _timer?.cancel(); messagesSubscription?.cancel(); @@ -435,3 +556,5 @@ class ChatRoomCubit extends Cubit { } } } + +enum _ChatCounterpartField { sender, receiver } diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 8d02a68a..5746beae 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -15,11 +15,14 @@ import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart'; +import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; 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'; @@ -141,6 +144,10 @@ class _ChatScreenState extends State { } Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) { + final chatCounterpart = resolveChatCounterpart( + users: state.chatRoom.users, + currentUserId: sl().userId, + ); final chosenPlan = state.chatRoom.chosenPlan; final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id; final showUserDetailsButton = [ @@ -236,7 +243,12 @@ class _ChatScreenState extends State { ), firstCurve: Curves.easeIn, secondCurve: Curves.easeOut, - secondChild: const SizedBox.shrink(), + secondChild: ChatUserOnlineStatusLine( + isOnline: chatCounterpart.isOnline, + lastSeenText: chatCounterpart.lastSeenText, + style: CustomTextStyles.bodySmallMulishPrimary, + showOnlineDot: false, + ), crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 150), ), @@ -255,8 +267,11 @@ class _ChatScreenState extends State { VideoCallScreen.name, extra: { 'conversationUuid': widget.uuid, - 'counterpartName': state.chatRoom.users.receiver.name, - 'counterpartAvatarUrl': state.chatRoom.users.receiver.profilePhotoUrl, + 'counterpartName': chatCounterpart.name, + 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl, + 'counterpartUserId': chatCounterpart.id, + 'counterpartIsOnline': chatCounterpart.isOnline, + 'counterpartLastSeenText': chatCounterpart.lastSeenText, 'localParticipantName': state.chatRoom.users.sender.name, }, ); diff --git a/lib/features/chat/presentation/widgets/chat_user_online_status_line.dart b/lib/features/chat/presentation/widgets/chat_user_online_status_line.dart new file mode 100644 index 00000000..8f851ab2 --- /dev/null +++ b/lib/features/chat/presentation/widgets/chat_user_online_status_line.dart @@ -0,0 +1,70 @@ +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'; + +class ChatUserOnlineStatusLine extends StatelessWidget { + const ChatUserOnlineStatusLine({ + super.key, + required this.isOnline, + required this.lastSeenText, + this.style, + this.showOnlineDot = true, + }); + + final bool isOnline; + final String lastSeenText; + final TextStyle? style; + final bool showOnlineDot; + + @override + Widget build(BuildContext context) { + final textStyle = style ?? + CustomTextStyles.bodySmallMulishBlack900.copyWith(height: 14 / 12); + + if (isOnline) { + if (!showOnlineDot) { + return Text( + S.of(context).video_call_status_online, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: appTheme.greenA700, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + S.of(context).video_call_status_online, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), + ), + ], + ); + } + + final trimmedLastSeen = lastSeenText.trim(); + if (trimmedLastSeen.isEmpty) { + return const SizedBox.shrink(); + } + + return Text( + trimmedLastSeen, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + } +} diff --git a/lib/features/chat/utils/chat_counterpart_utils.dart b/lib/features/chat/utils/chat_counterpart_utils.dart new file mode 100644 index 00000000..1fe562c5 --- /dev/null +++ b/lib/features/chat/utils/chat_counterpart_utils.dart @@ -0,0 +1,15 @@ +import 'package:eatvio_app/features/chat/models/chat_room_model.dart'; +import 'package:eatvio_app/features/chat/models/chat_user_model.dart'; + +ChatUserModel resolveChatCounterpart({ + required Users users, + required int? currentUserId, +}) { + if (currentUserId != null && users.sender.id == currentUserId) { + return users.receiver; + } + if (currentUserId != null && users.receiver.id == currentUserId) { + return users.sender; + } + return users.receiver; +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 6c967381..64834455 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -524,14 +524,16 @@ class VideoCallManager { if (_recordingOverlayScheduled) return; final ctx = _navigatorContext; if (ctx == null) return; - _recordingOverlayScheduled = true; - VideoCallTopOverlay.insertBanner( + final shown = VideoCallTopOverlay.insertBanner( ctx, message: S.of(ctx).video_call_recording_notice, backgroundColor: appTheme.videoCallNoticeBannerBackground, svgAsset: VideoCallTopOverlay.warningSvg, autoRemoveAfter: const Duration(seconds: 3), - ); + ).mounted; + if (shown) { + _recordingOverlayScheduled = true; + } } String resolvedLocalDisplayName() { @@ -804,13 +806,18 @@ class VideoCallManager { final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; if (bad) { - if (_poorConnectionEntry != null) return; - _poorConnectionEntry = VideoCallTopOverlay.insertBanner( + final existing = _poorConnectionEntry; + if (existing != null && existing.mounted) return; + _poorConnectionEntry = null; + final entry = VideoCallTopOverlay.insertBanner( ctx, message: S.of(ctx).video_call_poor_connection, backgroundColor: appTheme.strongRed, svgAsset: VideoCallTopOverlay.poorConnectionSvg, ); + if (entry.mounted) { + _poorConnectionEntry = entry; + } } else { final e = _poorConnectionEntry; if (e != null && e.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 index d4888673..e1a37e71 100644 --- 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 @@ -67,11 +67,11 @@ class VideoCallSessionCubit extends BaseCubit { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { if (isClosed) return; if (_isCallSessionInactive(snapshot)) { + unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) { // [VideoCallManager.startCall] briefly emits idle while replacing the room. return; } - unawaited(_systemPipCoordinator.disarm()); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; @@ -84,6 +84,7 @@ class VideoCallSessionCubit extends BaseCubit { void _syncFromManager(VideoCallManagerSnapshot snapshot) { if (_isCallSessionInactive(snapshot)) { + unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) return; emit(const VideoCallSessionState()); return; @@ -210,9 +211,10 @@ class VideoCallSessionCubit extends BaseCubit { } Future _ensureSystemPipArmedIfInCall() async { - if (!state.callStageEntered) return; - if (state.connectPhase != VideoCallConnectPhase.connected) return; - if (state.linkIssue == VideoCallLinkIssue.lost) return; + if (!_videoCallManager.isActiveCall) { + await _systemPipCoordinator.disarm(); + return; + } await _systemPipCoordinator.armAutoPipOnLeave(); } @@ -227,6 +229,7 @@ class VideoCallSessionCubit extends BaseCubit { VideoCallSessionState sessionState, VideoCallManagerSnapshot snapshot, ) { + if (!_callScreenRouteVisible) return; if (sessionState.entryMode != VideoCallEntryMode.outgoing) return; if (sessionState.callStageEntered) return; if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; @@ -253,6 +256,7 @@ class VideoCallSessionCubit extends BaseCubit { Future _runOutgoingAutoEnterCallStage() async { if (isClosed || _autoEnterInFlight) return; + if (!_callScreenRouteVisible) return; if (state.entryMode != VideoCallEntryMode.outgoing) return; if (state.callStageEntered) return; if (state.connectPhase != VideoCallConnectPhase.connected) return; @@ -380,7 +384,7 @@ class VideoCallSessionCubit extends BaseCubit { Future restoreFullscreenFromPip() async { if (state.uiMode != VideoCallUiMode.pip) return; emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); - unawaited(_systemPipCoordinator.armAutoPipOnLeave()); + unawaited(_ensureSystemPipArmedIfInCall()); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); @@ -415,10 +419,14 @@ class VideoCallSessionCubit extends BaseCubit { mainStageShowsLocalVideo: false, ), ); - unawaited(_systemPipCoordinator.armAutoPipOnLeave()); + await _ensureSystemPipArmedIfInCall(); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; _videoCallManager.scheduleRecordingNoticeOnce(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isClosed) return; + _videoCallManager.scheduleRecordingNoticeOnce(); + }); }); } 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 index 588a01ff..cc204991 100644 --- 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 @@ -14,7 +14,7 @@ enum VideoCallLinkIssue { none, reconnecting, lost } /// How the user reached [VideoCallScreen]. enum VideoCallEntryMode { - /// Outgoing call or push tap — lobby with manual enter. + /// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins. outgoing, /// Accepted incoming CallKit / VoIP / notification — loader then auto-enter. 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 b8aefe95..459b85c2 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -3,6 +3,9 @@ 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/theme/theme_helper.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.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'; @@ -21,6 +24,9 @@ class VideoCallScreen extends StatefulWidget { required this.conversationUuid, this.counterpartName, this.counterpartAvatarUrl, + this.counterpartUserId, + this.counterpartIsOnline, + this.counterpartLastSeenText, this.localParticipantName, this.entryMode = VideoCallEntryMode.outgoing, this.onBackToChat, @@ -33,6 +39,9 @@ class VideoCallScreen extends StatefulWidget { final String conversationUuid; final String? counterpartName; final String? counterpartAvatarUrl; + final int? counterpartUserId; + final bool? counterpartIsOnline; + final String? counterpartLastSeenText; final String? localParticipantName; final VideoCallEntryMode entryMode; final VoidCallback? onBackToChat; @@ -54,12 +63,20 @@ class _VideoCallScreenState extends State { ); VideoCallSessionCubit get _cubit => sl(); + WsManager get _wsManager => sl(); + + bool _counterpartIsOnline = false; + String _counterpartLastSeenText = ''; + void Function(bool isOnline)? _presenceOnlineListener; @override void initState() { super.initState(); + _counterpartIsOnline = widget.counterpartIsOnline ?? false; + _counterpartLastSeenText = widget.counterpartLastSeenText ?? ''; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + unawaited(_initCounterpartOnlineStatus()); unawaited( _cubit.onCallScreenVisible( conversationUuid: widget.conversationUuid, @@ -71,8 +88,80 @@ class _VideoCallScreenState extends State { }); } + Future _initCounterpartOnlineStatus() async { + var isOnline = widget.counterpartIsOnline ?? false; + var lastSeenText = widget.counterpartLastSeenText ?? ''; + var counterpartUserId = widget.counterpartUserId; + + final shouldFetchFromApi = + counterpartUserId == null || + widget.counterpartIsOnline == null || + widget.counterpartLastSeenText == null; + + if (shouldFetchFromApi) { + try { + final chatRoom = await sl().getChatRoom( + widget.conversationUuid, + ); + final counterpart = resolveChatCounterpart( + users: chatRoom.users, + currentUserId: _wsManager.userId, + ); + isOnline = counterpart.isOnline; + lastSeenText = counterpart.lastSeenText; + counterpartUserId = counterpart.id; + } catch (_) {} + } + + if (!mounted) return; + setState(() { + _counterpartIsOnline = isOnline; + _counterpartLastSeenText = lastSeenText; + }); + + if (counterpartUserId == null) return; + + _presenceOnlineListener = (isOnline) { + if (!mounted) return; + if (isOnline) { + setState(() { + _counterpartIsOnline = true; + _counterpartLastSeenText = ''; + }); + return; + } + unawaited(_refreshCounterpartOnlineStatus()); + }; + await _wsManager.initPresenceDirectChatChannel(widget.conversationUuid); + _wsManager.addPresenceDirectChatListener( + counterpartUserId: counterpartUserId, + onCounterpartOnlineChanged: _presenceOnlineListener!, + ); + } + + Future _refreshCounterpartOnlineStatus() async { + try { + final chatRoom = await sl().getChatRoom( + widget.conversationUuid, + ); + final counterpart = resolveChatCounterpart( + users: chatRoom.users, + currentUserId: _wsManager.userId, + ); + if (!mounted) return; + setState(() { + _counterpartIsOnline = counterpart.isOnline; + _counterpartLastSeenText = counterpart.lastSeenText; + }); + } catch (_) {} + } + @override void dispose() { + if (_presenceOnlineListener != null) { + _wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); + _presenceOnlineListener = null; + } _cubit.onCallScreenDisposed(); super.dispose(); } @@ -197,6 +286,8 @@ class _VideoCallScreenState extends State { body: VideoCallConnectingEntranceScreen( counterpartDisplayName: remoteName, counterpartAvatarUrl: widget.counterpartAvatarUrl, + counterpartIsOnline: _counterpartIsOnline, + counterpartLastSeenText: _counterpartLastSeenText, micEnabled: sessionState.isMicActiveForUi, videoEnabled: sessionState.videoEnabled, canEnterCall: canEnterCall, diff --git a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart index e7fdd6fd..bf9d774a 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart @@ -1,8 +1,10 @@ import 'dart:io'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/managers/video_call_manager.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_banner_overlay.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart'; @@ -22,6 +24,10 @@ class VideoCallAppShell extends StatelessWidget { clipBehavior: Clip.none, children: [ child ?? const SizedBox.shrink(), + Overlay( + key: VideoCallBannerOverlay.overlayKey, + initialEntries: const [], + ), const VideoCallGlobalPipLayer(), const VideoCallSystemPipListener(), ], @@ -36,10 +42,7 @@ class VideoCallAppShell extends StatelessWidget { previous.connectPhase != current.connectPhase || previous.linkIssue != current.linkIssue, builder: (context, state) { - final allowSystemPipShell = - state.callStageEntered && - state.connectPhase == VideoCallConnectPhase.connected && - state.linkIssue != VideoCallLinkIssue.lost; + final allowSystemPipShell = sl().isActiveCall; if (!allowSystemPipShell) return shell; diff --git a/lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart new file mode 100644 index 00000000..9bdb2f40 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +/// Dedicated overlay layer above the app navigator for in-call banners. +abstract final class VideoCallBannerOverlay { + static final GlobalKey overlayKey = GlobalKey( + debugLabel: 'videoCallBannerOverlay', + ); + + static OverlayState? get overlayState => overlayKey.currentState; +} 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 index ff37c96a..7870eac8 100644 --- 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 @@ -1,5 +1,6 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.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'; @@ -10,6 +11,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { super.key, required this.counterpartDisplayName, this.counterpartAvatarUrl, + required this.counterpartIsOnline, + required this.counterpartLastSeenText, required this.micEnabled, required this.videoEnabled, required this.canEnterCall, @@ -24,6 +27,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { final String counterpartDisplayName; final String? counterpartAvatarUrl; + final bool counterpartIsOnline; + final String counterpartLastSeenText; final bool micEnabled; final bool videoEnabled; final bool canEnterCall; @@ -140,33 +145,7 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { const SizedBox(height: _topContentSpacing), _avatar(context, counterpartDisplayName, counterpartAvatarUrl), const SizedBox(height: 16), - if (canEnterCall) - 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), - ), - ], - ) - else - Text( - s.video_call_screen_connecting_subtitle, - textAlign: TextAlign.center, - style: CustomTextStyles.bodySmallMulishBlack900 - .copyWith(height: 14 / 12), - ), + _buildStatusLine(context, s), SizedBox(height: canEnterCall ? 20 : 16), Text( s.video_call_screen_connecting_title, @@ -345,4 +324,29 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { ), ); } + + Widget _buildStatusLine(BuildContext context, S s) { + final statusStyle = CustomTextStyles.bodySmallMulishBlack900.copyWith( + height: 14 / 12, + ); + final onlineStatus = ChatUserOnlineStatusLine( + isOnline: counterpartIsOnline, + lastSeenText: counterpartLastSeenText, + style: statusStyle, + ); + + if (counterpartIsOnline || counterpartLastSeenText.trim().isNotEmpty) { + return Center(child: onlineStatus); + } + + if (!canEnterCall) { + return Text( + s.video_call_screen_connecting_subtitle, + textAlign: TextAlign.center, + style: statusStyle, + ); + } + + return const SizedBox.shrink(); + } } diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart index 4731d98f..7a0f8957 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/managers/video_call_manager.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'; @@ -21,7 +22,8 @@ class VideoCallSystemPipListener extends StatefulWidget { _VideoCallSystemPipListenerState(); } -class _VideoCallSystemPipListenerState extends State { +class _VideoCallSystemPipListenerState extends State + with WidgetsBindingObserver { static const MethodChannel _userLeaveHintChannel = MethodChannel( 'com.eatvioapp/video_call_pip', ); @@ -34,9 +36,24 @@ class _VideoCallSystemPipListenerState extends State super.initState(); if (!Platform.isAndroid) return; + WidgetsBinding.instance.addObserver(this); _userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall); final coordinator = sl(); _subscription = coordinator.statusStream.listen(_onPipStatus); + unawaited(_disarmSystemPipIfNoActiveCall()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_disarmSystemPipIfNoActiveCall()); + } + } + + Future _disarmSystemPipIfNoActiveCall() async { + if (!sl().isActiveCall) { + await sl().disarm(); + } } Future _onNativePipMethodCall(MethodCall call) async { @@ -88,6 +105,7 @@ class _VideoCallSystemPipListenerState extends State @override void dispose() { if (Platform.isAndroid) { + WidgetsBinding.instance.removeObserver(this); _userLeaveHintChannel.setMethodCallHandler(null); } unawaited(_subscription?.cancel()); diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart index 85f2dd0e..8732f24c 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -1,4 +1,6 @@ +import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -7,6 +9,13 @@ abstract final class VideoCallTopOverlay { 'assets/icons/video_call/poor_connection.svg'; static const String warningSvg = 'assets/icons/video_call/warning.svg'; + static OverlayState? _resolveOverlayState(BuildContext context) { + return VideoCallBannerOverlay.overlayState ?? + AppRouter.navigatorKey.currentState?.overlay ?? + Overlay.maybeOf(context, rootOverlay: true); + } + + static OverlayEntry insertBanner( BuildContext context, { required String message, @@ -14,7 +23,7 @@ abstract final class VideoCallTopOverlay { required String svgAsset, Duration? autoRemoveAfter, }) { - final overlayState = Overlay.maybeOf(context, rootOverlay: true); + final overlayState = _resolveOverlayState(context); late OverlayEntry entry; entry = OverlayEntry( builder: (ctx) => Positioned( diff --git a/pubspec.yaml b/pubspec.yaml index 56444d8a..e8cecc5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.28+117 +version: 1.3.29+118 publish_to: 'none' environment: diff --git a/test/core/const_test.dart b/test/core/const_test.dart index 1adf6b1b..415dfb37 100644 --- a/test/core/const_test.dart +++ b/test/core/const_test.dart @@ -33,6 +33,7 @@ void main() { expect(newMessagesChannelName, 'private-new-chat-messages'); expect(newNotificationsChannelName, 'private-message-events'); expect(directChatChannelName, 'private-direct-chat'); + expect(presenceDirectChatChannelName, 'presence-direct-chat'); }); test('thresholdForPagination has correct value', () { diff --git a/test/features/chat/models/chat_user_model_test.dart b/test/features/chat/models/chat_user_model_test.dart index fb825d10..bc913e9b 100644 --- a/test/features/chat/models/chat_user_model_test.dart +++ b/test/features/chat/models/chat_user_model_test.dart @@ -28,12 +28,14 @@ void main() { expect(model.profilePhotoUrl, isNull); }); - test('fromJson создает ChatUserModel из JSON', () { + test('fromJson creates ChatUserModel from JSON', () { final json = { 'id': 1, 'name': 'User Name', 'slug': 'user-slug', 'profile_photo_url': 'https://example.com/photo.jpg', + 'is_online': true, + 'last_seen_text': 'был(а) недавно', }; final model = ChatUserModel.fromJson(json); @@ -42,6 +44,8 @@ void main() { expect(model.name, 'User Name'); expect(model.slug, 'user-slug'); expect(model.profilePhotoUrl, 'https://example.com/photo.jpg'); + expect(model.isOnline, isTrue); + expect(model.lastSeenText, 'был(а) недавно'); }); test('fromJson создает ChatUserModel из JSON без фото', () { diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index b18404ff..d6df22d4 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -220,7 +220,7 @@ void main() { expect(cubit.state.callStageEntered, isTrue); }); - test('outgoing call keeps lobby until callee joins', () async { + test('outgoing call stays on connecting lobby until callee joins', () async { when( mockManager.startCall( conversationUuid: anyNamed('conversationUuid'), From 59f42c9b1e2e6ccda07bef26c8d92f1a52f6af04 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 24 Jun 2026 09:35:04 +0300 Subject: [PATCH 44/56] fix: test fixed --- .../presentation/pages/chat_screen_loaded_smoke_test.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart index c36e6893..e4bbd786 100644 --- a/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart +++ b/test/features/chat/presentation/pages/chat_screen_loaded_smoke_test.dart @@ -2,6 +2,7 @@ import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_model.dart'; import 'package:eatvio_app/features/chat/models/chat_room_settings_model.dart'; @@ -37,6 +38,7 @@ void main() { setUp(() async { await sl.reset(); sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton(() => _FakeWsManager()); }); const fakeSvg = ''' @@ -174,6 +176,11 @@ void main() { }); } +class _FakeWsManager extends Fake implements WsManager { + @override + int? userId = 1; +} + class _FakeAssetBundle extends CachingAssetBundle { _FakeAssetBundle(this.assets); final Map assets; From bde4eea7d125901f3810f61fb9ceb4754c3779cd Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 26 Jun 2026 08:56:57 +0300 Subject: [PATCH 45/56] feat(video_calls): fixed recinnecting logic, added consultant bage --- .../com/eatvioapp/app/VideoCallPushBridge.kt | 9 +++ ios/Runner/AppDelegate.swift | 2 +- .../push_navigation_handler.dart | 1 + lib/core/routing/other_routes.dart | 1 + .../managers/video_call_manager.dart | 31 +++++++-- .../video_call_session_cubit.dart | 29 ++++++-- .../video_call_session_state.dart | 7 ++ .../presentation/pages/video_call_screen.dart | 48 +++++++------ .../widgets/video_call_name_chips.dart | 68 ++++++++++++++----- .../widgets/video_call_stage_layout.dart | 11 ++- .../video_call_system_pip_content.dart | 4 +- .../video_call_callkit_coordinator.dart | 41 +++++++++++ .../video_call_incoming_push_presenter.dart | 2 + .../video_call_notification_navigator.dart | 6 ++ .../services/video_call_push_payload.dart | 17 +++++ lib/generated/intl/messages_en.dart | 2 +- lib/generated/intl/messages_ru.dart | 2 +- lib/generated/l10n.dart | 4 +- lib/l10n/intl_en.arb | 2 +- lib/l10n/intl_ru.arb | 2 +- .../video_call_push_payload_test.dart | 12 ++++ .../widgets/video_call_name_label_test.dart | 28 ++++++++ 22 files changed, 272 insertions(+), 57 deletions(-) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index b4272684..358f0540 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -92,6 +92,12 @@ object VideoCallPushBridge { ?: "" val localParticipantName = data["localParticipantName"] ?: data["local_participant_name"] + val counterpartIsConsultant = isTruthy( + data["counterpartIsConsultant"] + ?: data["counterpart_is_consultant"] + ?: data["is_consultant"] + ?: data["isConsultant"] + ) val extra = HashMap() extra["conversationUuid"] = conversationUuid @@ -102,6 +108,9 @@ object VideoCallPushBridge { if (!localParticipantName.isNullOrBlank()) { extra["localParticipantName"] = localParticipantName } + if (counterpartIsConsultant) { + extra["counterpartIsConsultant"] = true + } return Bundle().apply { putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e1b521eb..15dfb671 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -129,7 +129,7 @@ import flutter_foreground_task } let queryItems = components.queryItems ?? [] - for key in ["uuid", "conversationUuid", "conversation_uuid"] { + for key: String in ["uuid", "conversationUuid", "conversation_uuid"] { if let value = queryItems.first(where: { $0.name == key })?.value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index ad0286a1..e44c32ab 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -69,6 +69,7 @@ class PushNavigationHandler { counterpartName: videoCallPayload.counterpartName, counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl, localParticipantName: videoCallPayload.localParticipantName, + counterpartIsConsultant: videoCallPayload.counterpartIsConsultant, entryMode: VideoCallEntryMode.incomingAccepted, ), ); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index d650445d..db8db78b 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -239,6 +239,7 @@ class OtherRoutes { counterpartUserId: extra['counterpartUserId'] as int?, counterpartIsOnline: extra['counterpartIsOnline'] as bool?, counterpartLastSeenText: extra['counterpartLastSeenText'] as String?, + counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?, localParticipantName: extra['localParticipantName'] as String?, entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']), onBackToChat: extra['onBackToChat'] as void Function()?, diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 64834455..6bd2294b 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -72,6 +72,7 @@ class VideoCallManager { String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; + bool _abortingLiveKitAutoReconnect = false; Future? _myProfileNameFuture; VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial; @@ -86,7 +87,7 @@ class VideoCallManager { bool get isActiveCall => _snapshot.connectPhase == VideoCallConnectPhase.connected && _callStageEntered && - _snapshot.linkIssue != VideoCallLinkIssue.lost && + !_snapshot.linkIssue.needsManualReconnect && !_liveKitDisposed && _room != null; @@ -764,17 +765,37 @@ class VideoCallManager { }); } + /// Stops LiveKit's built-in auto-reconnect and surfaces [VideoCallLinkIssue.lost] + /// so the user can retry manually from [VideoCallConnectionLostScreen]. + Future _abortLiveKitAutoReconnect() async { + if (_liveKitDisposed || _abortingLiveKitAutoReconnect) return; + if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; + if (_snapshot.linkIssue == VideoCallLinkIssue.lost) return; + + _abortingLiveKitAutoReconnect = true; + try { + _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost)); + final room = _room; + if (room != null && + room.connectionState != lk.ConnectionState.disconnected) { + try { + await room.disconnect(); + } catch (_) {} + } + } finally { + _abortingLiveKitAutoReconnect = false; + } + } + void _attachRoomEventListeners() { final room = _room; if (room == null) return; _roomEventsListener = room.createListener() ..on((_) { - if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; - _emitSnapshot( - _snapshot.copyWith(linkIssue: VideoCallLinkIssue.reconnecting), - ); + unawaited(_abortLiveKitAutoReconnect()); }) ..on((_) { + if (_snapshot.linkIssue.needsManualReconnect) return; _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none)); }) ..on((_) { 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 index e1a37e71..2ad362d1 100644 --- 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 @@ -42,7 +42,7 @@ class VideoCallSessionCubit extends BaseCubit { ) { final hideStaleConnectError = snapshot.connectPhase == VideoCallConnectPhase.connected && - snapshot.linkIssue != VideoCallLinkIssue.lost; + !snapshot.linkIssue.needsManualReconnect; final resetCallStage = _isCallSessionInactive(snapshot) || snapshot.connectPhase == VideoCallConnectPhase.connecting; @@ -123,6 +123,7 @@ class VideoCallSessionCubit extends BaseCubit { required String conversationUuid, String? counterpartName, String? localParticipantName, + bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { _callScreenRouteVisible = true; @@ -153,6 +154,8 @@ class VideoCallSessionCubit extends BaseCubit { counterpartName: counterpartName ?? state.counterpartName, localParticipantName: localParticipantName ?? state.localParticipantName, + counterpartIsConsultant: + counterpartIsConsultant ?? state.counterpartIsConsultant, entryMode: effectiveEntryMode, ), ); @@ -175,12 +178,16 @@ class VideoCallSessionCubit extends BaseCubit { micEnabled: state.micEnabled, videoEnabled: state.videoEnabled, ); + if (counterpartIsConsultant != null) { + emit(state.copyWith(counterpartIsConsultant: counterpartIsConsultant)); + } } else { emit( state.copyWith( conversationUuid: conversationUuid, counterpartName: counterpartName, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant ?? false, micEnabled: false, videoEnabled: false, callStageEntered: false, @@ -332,7 +339,7 @@ class VideoCallSessionCubit extends BaseCubit { return; } if (state.connectPhase == VideoCallConnectPhase.connected && - state.linkIssue != VideoCallLinkIssue.lost) { + !state.linkIssue.needsManualReconnect) { if (state.callStageEntered) { unawaited(minimizeToPip()); } else { @@ -340,14 +347,14 @@ class VideoCallSessionCubit extends BaseCubit { } } else if (state.connectPhase == VideoCallConnectPhase.connecting) { unawaited(endCall()); - } else if (state.linkIssue == VideoCallLinkIssue.lost) { + } else if (state.linkIssue.needsManualReconnect) { unawaited(endCall()); } } bool get _canMinimizeToPip => state.connectPhase == VideoCallConnectPhase.connected && - state.linkIssue != VideoCallLinkIssue.lost && + !state.linkIssue.needsManualReconnect && state.callStageEntered; Future _applyPipUiState() async { @@ -392,7 +399,7 @@ class VideoCallSessionCubit extends BaseCubit { Future leaveCallRoute() async { if (state.connectPhase == VideoCallConnectPhase.connected && - state.linkIssue != VideoCallLinkIssue.lost) { + !state.linkIssue.needsManualReconnect) { if (state.callStageEntered) { _callScreenRouteVisible = false; await minimizeToPip(); @@ -404,6 +411,11 @@ class VideoCallSessionCubit extends BaseCubit { } } + void updateCounterpartIsConsultant(bool isConsultant) { + if (isClosed || state.counterpartIsConsultant == isConsultant) return; + emit(state.copyWith(counterpartIsConsultant: isConsultant)); + } + Future enterCallStage() async { if (isClosed) return; if (state.connectPhase != VideoCallConnectPhase.connected) return; @@ -432,8 +444,15 @@ class VideoCallSessionCubit extends BaseCubit { Future retryAfterConnectionLoss() async { final entryMode = state.entryMode; + final wasInCallStage = state.callStageEntered; await _videoCallManager.retryAfterConnectionLoss(); + if (isClosed) return; emit(state.copyWith(callStageEntered: false, entryMode: entryMode)); + if (wasInCallStage && + state.connectPhase == VideoCallConnectPhase.connected && + !state.linkIssue.needsManualReconnect) { + await enterCallStage(); + } } Future endCall() async { 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 index cc204991..28b7fabc 100644 --- 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 @@ -12,6 +12,12 @@ enum VideoCallConnectPhase { idle, connecting, connected } enum VideoCallLinkIssue { none, reconnecting, lost } +extension VideoCallLinkIssueX on VideoCallLinkIssue { + /// True when the user must manually retry (lost link or LiveKit auto-reconnect). + bool get needsManualReconnect => + this == VideoCallLinkIssue.lost || this == VideoCallLinkIssue.reconnecting; +} + /// How the user reached [VideoCallScreen]. enum VideoCallEntryMode { /// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins. @@ -28,6 +34,7 @@ abstract class VideoCallSessionState with _$VideoCallSessionState { const factory VideoCallSessionState({ @Default('') String conversationUuid, String? counterpartName, + @Default(false) bool counterpartIsConsultant, String? localParticipantName, @Default(false) bool micEnabled, @Default(false) bool videoEnabled, 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 459b85c2..76b3934d 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; +import 'package:eatvio_app/features/profile/repositories/profile_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'; @@ -27,6 +28,7 @@ class VideoCallScreen extends StatefulWidget { this.counterpartUserId, this.counterpartIsOnline, this.counterpartLastSeenText, + this.counterpartIsConsultant, this.localParticipantName, this.entryMode = VideoCallEntryMode.outgoing, this.onBackToChat, @@ -42,6 +44,7 @@ class VideoCallScreen extends StatefulWidget { final int? counterpartUserId; final bool? counterpartIsOnline; final String? counterpartLastSeenText; + final bool? counterpartIsConsultant; final String? localParticipantName; final VideoCallEntryMode entryMode; final VoidCallback? onBackToChat; @@ -76,29 +79,32 @@ class _VideoCallScreenState extends State { _counterpartLastSeenText = widget.counterpartLastSeenText ?? ''; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - unawaited(_initCounterpartOnlineStatus()); + unawaited(_initCounterpartMetadata()); unawaited( _cubit.onCallScreenVisible( conversationUuid: widget.conversationUuid, counterpartName: widget.counterpartName, localParticipantName: widget.localParticipantName, + counterpartIsConsultant: widget.counterpartIsConsultant, entryMode: widget.entryMode, ), ); }); } - Future _initCounterpartOnlineStatus() async { + Future _initCounterpartMetadata() async { var isOnline = widget.counterpartIsOnline ?? false; var lastSeenText = widget.counterpartLastSeenText ?? ''; var counterpartUserId = widget.counterpartUserId; + var counterpartIsConsultant = widget.counterpartIsConsultant; - final shouldFetchFromApi = + final shouldFetchChatRoom = counterpartUserId == null || widget.counterpartIsOnline == null || - widget.counterpartLastSeenText == null; + widget.counterpartLastSeenText == null || + counterpartIsConsultant == null; - if (shouldFetchFromApi) { + if (shouldFetchChatRoom) { try { final chatRoom = await sl().getChatRoom( widget.conversationUuid, @@ -110,6 +116,17 @@ class _VideoCallScreenState extends State { isOnline = counterpart.isOnline; lastSeenText = counterpart.lastSeenText; counterpartUserId = counterpart.id; + + if (counterpartIsConsultant == null) { + try { + final profile = await sl().getProfileBySlug( + counterpart.slug, + ); + counterpartIsConsultant = profile.roles.contains('consultant'); + } catch (_) { + counterpartIsConsultant = false; + } + } } catch (_) {} } @@ -118,6 +135,9 @@ class _VideoCallScreenState extends State { _counterpartIsOnline = isOnline; _counterpartLastSeenText = lastSeenText; }); + if (counterpartIsConsultant != null) { + _cubit.updateCounterpartIsConsultant(counterpartIsConsultant); + } if (counterpartUserId == null) return; @@ -207,6 +227,7 @@ class _VideoCallScreenState extends State { a.connectError != b.connectError || a.linkIssue != b.linkIssue || a.counterpartName != b.counterpartName || + a.counterpartIsConsultant != b.counterpartIsConsultant || a.localParticipantName != b.localParticipantName || a.callStageEntered != b.callStageEntered || a.micEnabled != b.micEnabled || @@ -219,7 +240,7 @@ class _VideoCallScreenState extends State { final room = _cubit.liveKitRoom; final showLost = - sessionState.linkIssue == VideoCallLinkIssue.lost || + sessionState.linkIssue.needsManualReconnect || (sessionState.connectPhase == VideoCallConnectPhase.idle && sessionState.connectError != null); @@ -237,20 +258,6 @@ class _VideoCallScreenState extends State { ); } - 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.entryMode == VideoCallEntryMode.outgoing && !sessionState.callStageEntered && @@ -347,6 +354,7 @@ class _VideoCallScreenState extends State { connectError: sessionState.connectError, localDisplayName: _resolveLocalDisplayName(sessionState), remoteDisplayName: _resolveRemoteDisplayName(sessionState), + remoteIsConsultant: sessionState.counterpartIsConsultant, micEnabled: sessionState.micEnabled, videoEnabled: sessionState.videoEnabled, splitParticipantView: sessionState.splitParticipantView, diff --git a/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart b/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart index ff4829eb..389af26e 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart @@ -2,40 +2,74 @@ import 'dart:ui'; 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'; class VideoCallParticipantNameLabel extends StatelessWidget { - const VideoCallParticipantNameLabel({super.key, required this.name}); + const VideoCallParticipantNameLabel({ + super.key, + required this.name, + this.isConsultant = false, + }); final String name; + final bool isConsultant; @override Widget build(BuildContext context) { + final roleLabel = isConsultant ? S.of(context).video_call_role_consultant : null; + return Positioned( left: 12, bottom: 12, right: 12, child: Align( alignment: Alignment.bottomLeft, - child: ClipRRect( - borderRadius: BorderRadius.circular(60), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5), - child: Container( - constraints: const BoxConstraints(maxWidth: 240), - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), - decoration: BoxDecoration( - color: appTheme.transparentBlack, - borderRadius: BorderRadius.circular(60), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (roleLabel != null) ...[ + Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: appTheme.whiteA700, + borderRadius: BorderRadius.circular(60), + ), + child: Text( + roleLabel, + style: TextStyles.textXSSemibold.copyWith( + color: appTheme.blueA400, + height: 14 / 12, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - child: Text( - name, - style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700), - maxLines: 1, - overflow: TextOverflow.ellipsis, + const SizedBox(height: 16), + ], + ClipRRect( + borderRadius: BorderRadius.circular(60), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5), + child: Container( + constraints: const BoxConstraints(maxWidth: 240), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: appTheme.transparentBlack, + borderRadius: BorderRadius.circular(60), + ), + child: Text( + name, + style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), ), ), - ), + ], ), ), ); diff --git a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart index feac9376..131c7a55 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart @@ -25,6 +25,7 @@ class VideoCallStageLayout extends StatefulWidget { required this.connectError, required this.localDisplayName, required this.remoteDisplayName, + this.remoteIsConsultant = false, required this.micEnabled, required this.videoEnabled, required this.splitParticipantView, @@ -49,6 +50,7 @@ class VideoCallStageLayout extends StatefulWidget { final String? connectError; final String localDisplayName; final String remoteDisplayName; + final bool remoteIsConsultant; final bool micEnabled; final bool videoEnabled; final bool splitParticipantView; @@ -149,6 +151,7 @@ class _VideoCallStageLayoutState extends State { child: _SplitParticipantPane( room: widget.room, displayName: widget.remoteDisplayName, + isConsultant: widget.remoteIsConsultant, isLocal: false, connectError: widget.connectError, videoEnabled: widget.videoEnabled, @@ -236,6 +239,7 @@ class _VideoCallStageLayoutState extends State { name: mainShowsLocal ? widget.localDisplayName : widget.remoteDisplayName, + isConsultant: !mainShowsLocal && widget.remoteIsConsultant, ), ], ), @@ -368,6 +372,7 @@ class _SplitParticipantPane extends StatelessWidget { const _SplitParticipantPane({ required this.room, required this.displayName, + this.isConsultant = false, required this.isLocal, required this.connectError, required this.videoEnabled, @@ -379,6 +384,7 @@ class _SplitParticipantPane extends StatelessWidget { final lk.Room room; final String displayName; + final bool isConsultant; final bool isLocal; final String? connectError; final bool videoEnabled; @@ -421,7 +427,10 @@ class _SplitParticipantPane extends StatelessWidget { }, ), ), - VideoCallParticipantNameLabel(name: displayName), + VideoCallParticipantNameLabel( + name: displayName, + isConsultant: !isLocal && isConsultant, + ), Positioned( left: 0, top: _VideoCallStageLayoutState._splitExpandButtonTop, diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart index fbcc16fd..3abcb415 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart @@ -22,11 +22,11 @@ class VideoCallSystemPipContent extends StatelessWidget { a.videoEnabled != b.videoEnabled, builder: (context, state) { if (state.connectPhase != VideoCallConnectPhase.connected || - state.linkIssue == VideoCallLinkIssue.lost || + state.linkIssue.needsManualReconnect || !state.callStageEntered) { if (state.conversationUuid.isNotEmpty && state.connectPhase != VideoCallConnectPhase.idle && - state.linkIssue != VideoCallLinkIssue.lost) { + !state.linkIssue.needsManualReconnect) { return ColoredBox( color: appTheme.gray100, child: Center( diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 036103ba..3c5b1ad0 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -340,6 +340,12 @@ class VideoCallCallKitCoordinator { 'localParticipantName', 'local_participant_name', ]), + counterpartIsConsultant: _parseBool(extra, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), ); } @@ -391,6 +397,12 @@ class VideoCallCallKitCoordinator { 'localParticipantName', 'local_participant_name', ]), + counterpartIsConsultant: _parseBool(extra, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), ); } @@ -435,6 +447,12 @@ class VideoCallCallKitCoordinator { 'localParticipantName', 'local_participant_name', ]), + counterpartIsConsultant: _parseBool(extra, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), ); } @@ -444,6 +462,7 @@ class VideoCallCallKitCoordinator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + bool? counterpartIsConsultant, }) async { if (_acceptHandledCallKitIds.contains(callKitId)) return; _acceptHandledCallKitIds.add(callKitId); @@ -463,6 +482,7 @@ class VideoCallCallKitCoordinator { counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, ).whenComplete(completer.complete), ); }); @@ -486,6 +506,7 @@ class VideoCallCallKitCoordinator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + bool? counterpartIsConsultant, }) async { if (Platform.isIOS) { FlutterNativeSplash.remove(); @@ -496,6 +517,7 @@ class VideoCallCallKitCoordinator { counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, entryMode: VideoCallEntryMode.incomingAccepted, ); } @@ -571,6 +593,25 @@ class VideoCallCallKitCoordinator { return null; } + static bool? _parseBool(Map map, List keys) { + for (final key in keys) { + if (!map.containsKey(key)) continue; + final value = map[key]; + if (value is bool) return value; + if (value is num) return value != 0; + if (value is String) { + final normalized = value.trim().toLowerCase(); + if (normalized == '1' || normalized == 'true' || normalized == 'yes') { + return true; + } + if (normalized == '0' || normalized == 'false' || normalized == 'no') { + return false; + } + } + } + return null; + } + static Map _stringKeyedMap(Object? value) { if (value is! Map) return {}; return value.map((key, value) => MapEntry(key.toString(), value)); diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index 4fb6bf2e..9136a26d 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -27,6 +27,8 @@ abstract final class VideoCallIncomingPushPresenter { 'counterpartAvatarUrl': payload.counterpartAvatarUrl, if (payload.localParticipantName != null) 'localParticipantName': payload.localParticipantName, + if (payload.counterpartIsConsultant != null) + 'counterpartIsConsultant': payload.counterpartIsConsultant, }, callingNotification: const NotificationParams(showNotification: false), android: const AndroidParams( diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 612e032f..a9efde95 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -32,6 +32,7 @@ class VideoCallNotificationNavigator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { if (conversationUuid.isEmpty) return; @@ -41,6 +42,7 @@ class VideoCallNotificationNavigator { counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, entryMode: entryMode, ); @@ -49,6 +51,7 @@ class VideoCallNotificationNavigator { conversationUuid: conversationUuid, counterpartName: counterpartName, localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, entryMode: entryMode, ); } @@ -59,6 +62,7 @@ class VideoCallNotificationNavigator { String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, + bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { for (var attempt = 0; attempt < 100; attempt++) { @@ -77,6 +81,8 @@ class VideoCallNotificationNavigator { 'counterpartName': counterpartName, 'counterpartAvatarUrl': counterpartAvatarUrl, 'localParticipantName': localParticipantName, + if (counterpartIsConsultant != null) + 'counterpartIsConsultant': counterpartIsConsultant, 'entryMode': entryMode.name, }, ); diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart index 3b0decac..ceb0767a 100644 --- a/lib/features/video_calls/services/video_call_push_payload.dart +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -4,12 +4,14 @@ class VideoCallPushPayload { this.counterpartName, this.counterpartAvatarUrl, this.localParticipantName, + this.counterpartIsConsultant, }); final String conversationUuid; final String? counterpartName; final String? counterpartAvatarUrl; final String? localParticipantName; + final bool? counterpartIsConsultant; static VideoCallPushPayload? tryParse(Map data) { if (!isIncomingVideoCall(data)) return null; @@ -41,6 +43,12 @@ class VideoCallPushPayload { 'localParticipantName', 'local_participant_name', ]), + counterpartIsConsultant: _bool(data, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), ); } @@ -126,9 +134,18 @@ class VideoCallPushPayload { return null; } + static bool? _bool(Map map, List keys) { + for (final key in keys) { + if (!map.containsKey(key)) continue; + return _isTruthy(map[key]); + } + return null; + } + static bool _isTruthy(Object? value) { if (value == null) return false; if (value is bool) return value; + if (value is num) return value != 0; final normalized = value.toString().trim().toLowerCase(); return normalized == '1' || normalized == 'true' || diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index aca4b29f..37ffb25c 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -877,7 +877,7 @@ class MessageLookup extends MessageLookupByLibrary { "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...", + "It looks like there is an internet issue. Tap Retry to reconnect.", ), "video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage( "Connection lost", diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index a0e9f849..0f59a0dc 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -357,7 +357,7 @@ class MessageLookup extends MessageLookupByLibrary { "video_call_chat": MessageLookupByLibrary.simpleMessage("Чат"), "video_call_close": MessageLookupByLibrary.simpleMessage("Закрыть"), "video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage( - "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...", + "Похоже, возникли проблемы с интернетом. Нажмите «Повторить», чтобы переподключиться.", ), "video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage( "Связь прервалась", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index acaa66ec..5fdc1ea4 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4239,10 +4239,10 @@ class S { ); } - /// `It looks like there is an internet issue. We're trying to restore the connection...` + /// `It looks like there is an internet issue. Tap Retry to reconnect.` 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...', + 'It looks like there is an internet issue. Tap Retry to reconnect.', name: 'video_call_connection_lost_message', desc: '', args: [], diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 276785c5..8dba037c 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -498,7 +498,7 @@ "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_connection_lost_message": "It looks like there is an internet issue. Tap Retry to reconnect.", "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 f854b851..9b4fefd4 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -243,7 +243,7 @@ "video_call_screen_reconnecting_title": "Восстанавливаем соединение", "video_call_screen_reconnecting_subtitle": "Проверьте интернет и подождите", "video_call_connection_lost_title": "Связь прервалась", - "video_call_connection_lost_message": "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...", + "video_call_connection_lost_message": "Похоже, возникли проблемы с интернетом. Нажмите «Повторить», чтобы переподключиться.", "video_call_try_again": "Повторить", "video_call_enter": "Войти", "video_call_close": "Закрыть", diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart index 9ddae36e..91b3e8d6 100644 --- a/test/features/video_calls/services/video_call_push_payload_test.dart +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -17,6 +17,18 @@ void main() { expect(payload.counterpartAvatarUrl, 'https://cdn.example/avatar.jpg'); }); + test('parses is_consultant flag from push payload', () { + final payload = VideoCallPushPayload.tryParse({ + 'type': 'video_call_incoming', + 'conversation_uuid': 'abc-123', + 'caller_name': 'Anna', + 'is_consultant': 'true', + }); + + expect(payload, isNotNull); + expect(payload!.counterpartIsConsultant, isTrue); + }); + test('parses action_url with video-call path', () { final payload = VideoCallPushPayload.tryParse({ 'action_url': 'https://eatvio.ru/video-call-screen?uuid=room-42', diff --git a/test/features/video_calls/widgets/video_call_name_label_test.dart b/test/features/video_calls/widgets/video_call_name_label_test.dart index bf759ad6..6332785e 100644 --- a/test/features/video_calls/widgets/video_call_name_label_test.dart +++ b/test/features/video_calls/widgets/video_call_name_label_test.dart @@ -1,5 +1,7 @@ import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; +import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -18,4 +20,30 @@ void main() { expect(find.text('Alex'), findsOneWidget); }); + + testWidgets('VideoCallParticipantNameLabel renders consultant role above name', (tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: const [ + S.delegate, + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: S.delegate.supportedLocales, + home: const Scaffold( + body: Stack( + children: [ + VideoCallParticipantNameLabel( + name: 'Alex', + isConsultant: true, + ), + ], + ), + ), + ), + ); + + expect(find.text('Alex'), findsOneWidget); + expect(find.text('Eatvio consultant'), findsOneWidget); + }); } From 10b36117a6e0414e4ae9f645461ad7131931138b Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 26 Jun 2026 08:57:31 +0300 Subject: [PATCH 46/56] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index e8cecc5e..ab312c89 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.29+118 +version: 1.3.30+119 publish_to: 'none' environment: From 2100e5ca028cb82431712f0487d6dc77325b4ddb Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 26 Jun 2026 11:30:49 +0300 Subject: [PATCH 47/56] fix: analyzer fix --- .../services/video_call_notification_navigator.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index a9efde95..5ed4bc16 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -81,8 +81,7 @@ class VideoCallNotificationNavigator { 'counterpartName': counterpartName, 'counterpartAvatarUrl': counterpartAvatarUrl, 'localParticipantName': localParticipantName, - if (counterpartIsConsultant != null) - 'counterpartIsConsultant': counterpartIsConsultant, + 'counterpartIsConsultant': ?counterpartIsConsultant, 'entryMode': entryMode.name, }, ); From f913ae60fa403072151b730220cb84dd2f01eb83 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 29 Jun 2026 12:12:53 +0300 Subject: [PATCH 48/56] feat(vide_calls): added tokens clearing --- lib/core/firebase_api.dart | 57 +++++++++++++++++++ .../menu/presentation/pages/menu_screen.dart | 7 +++ 2 files changed, 64 insertions(+) diff --git a/lib/core/firebase_api.dart b/lib/core/firebase_api.dart index 089ba23f..a371faa3 100644 --- a/lib/core/firebase_api.dart +++ b/lib/core/firebase_api.dart @@ -99,6 +99,43 @@ class FirebaseApi { } } + + Future unregisterPushTokens() async { + if (!await TokenStorage.haveTokenInStorage()) return; + try { + await unregisterPushToken(); + } catch (_) {} + if (Platform.isIOS) { + try { + await _unregisterIosVoipPushToken(); + } catch (_) {} + } + } + + Future unregisterPushToken() async { + if (!await TokenStorage.haveTokenInStorage()) return; + try { + final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false; + final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android'); + final token = (await getToken()) ?? ''; + await sl().post( + '/api/profile/notification/token/unregister', + data: { + 'platform': platform, + 'token': token, + }, + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException('unregisterPushToken timeout'); + }, + ); + } catch (e, st) { + log(e.toString(), stackTrace: st); + await _errorReporter.report(e, st, origin: 'FirebaseApi'); + } + } + Future _registerIosVoipPushToken() async { try { final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim(); @@ -124,6 +161,26 @@ class FirebaseApi { } } + Future _unregisterIosVoipPushToken() async { + try { + final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim() ?? ''; + await sl().post( + '/api/profile/notification/token/voip/unregister', + data: { + 'token': token, + }, + ).timeout( + const Duration(seconds: 10), + onTimeout: () { + throw TimeoutException('unregisterPushToken voip timeout'); + }, + ); + } catch (e, st) { + log(e.toString(), stackTrace: st); + await _errorReporter.report(e, st, origin: 'FirebaseApi'); + } + } + Future _isHuaweiDevice() async { if (!Platform.isAndroid) return false; try { diff --git a/lib/features/menu/presentation/pages/menu_screen.dart b/lib/features/menu/presentation/pages/menu_screen.dart index 41f405fd..67017dd0 100644 --- a/lib/features/menu/presentation/pages/menu_screen.dart +++ b/lib/features/menu/presentation/pages/menu_screen.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; import 'package:eatvio_app/core/const.dart'; +import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/utils/image_constant.dart'; @@ -166,6 +167,12 @@ class MenuScreen extends StatelessWidget { } catch (e, s) { await sl().report(e, s, origin: 'MenuScreen'); } finally { + try { + // Needs Authorization header → must run before removing token. + await sl().unregisterPushTokens(); + } catch (e, s) { + await sl().report(e, s, origin: 'MenuScreen'); + } await sl().endCall(); await sl().clear(); await TokenStorage.removeTokenInStorage(); From c0c7e2e65812bdf59f192fe1aa5ff9ca59f81e39 Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 29 Jun 2026 12:15:34 +0300 Subject: [PATCH 49/56] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index ab312c89..06d61909 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.30+119 +version: 1.3.31+120 publish_to: 'none' environment: From f493e07f9fdef040bfebef8ebc79b8733952f251 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 30 Jun 2026 16:11:18 +0300 Subject: [PATCH 50/56] feat(video_calls): call ended push, recall button for message --- .../app/EatvioFcmMessagingService.kt | 5 + .../com/eatvioapp/app/HmsPushService.kt | 5 + .../com/eatvioapp/app/VideoCallPushBridge.kt | 55 +++++++++ lib/core/device/device_hw_id_provider.dart | 16 +++ .../push_navigation_handler.dart | 12 ++ .../chat/models/chat_message_model.dart | 21 +++- .../chat/presentation/pages/chat_screen.dart | 106 +++++++++++------- .../widgets/chat_bubble_widget.dart | 4 +- .../video_call_chat_message_widget.dart | 103 +++++++++++++++++ .../managers/video_call_manager.dart | 55 +++++---- .../video_calls/models/video_call_models.dart | 11 ++ .../video_call_session_cubit.dart | 51 +++++++++ .../widgets/video_call_top_overlay.dart | 55 +++++++++ .../repositories/video_call_repository.dart | 10 ++ .../video_call_callkit_coordinator.dart | 38 ++++++- .../video_call_ended_push_handler.dart | 19 ++++ .../video_call_ended_push_payload.dart | 11 ++ .../services/video_call_fcm_handler.dart | 32 +++++- .../services/video_call_push_payload.dart | 25 +++++ .../services/video_call_service.dart | 6 + pubspec.yaml | 2 +- .../chat/models/chat_message_model_test.dart | 2 +- .../chat/models/chat_messages_model_test.dart | 2 +- .../presentation/bloc/chats_cubit_test.dart | 4 +- .../models/video_call_models_test.dart | 20 ++++ .../video_call_session_cubit_test.dart | 3 + .../video_call_push_payload_test.dart | 24 ++-- .../services/video_call_service_test.dart | 20 ++++ .../widgets/video_call_top_overlay_test.dart | 33 ++++++ .../video_call_accept_response.json | 4 + 30 files changed, 671 insertions(+), 83 deletions(-) create mode 100644 lib/core/device/device_hw_id_provider.dart create mode 100644 lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart create mode 100644 lib/features/video_calls/services/video_call_ended_push_handler.dart create mode 100644 lib/features/video_calls/services/video_call_ended_push_payload.dart create mode 100644 test/fixtures/video_calls/video_call_accept_response.json diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt index 7e9e7377..4a370800 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt @@ -15,6 +15,11 @@ class EatvioFcmMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { Log.i(TAG, "onMessageReceived: ${message.data}") + if (VideoCallPushBridge.isEndedVideoCall(message.data)) { + VideoCallPushBridge.dismissAllIncomingCalls(this) + return + } + if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) { return } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index b7b986a6..5c1c0d3e 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -20,6 +20,11 @@ class HmsPushService : HmsMessageService() { if (message == null) return + if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) { + VideoCallPushBridge.dismissAllIncomingCalls(this) + return + } + if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) { VideoCallPushBridge.showIncomingCall(this, message.dataOfMap) return diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index 358f0540..a52976e9 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -4,10 +4,13 @@ import android.content.Context import android.os.Bundle import android.util.Log import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager import com.hiennv.flutter_callkit_incoming.Data import com.hiennv.flutter_callkit_incoming.addCall +import com.hiennv.flutter_callkit_incoming.getDataActiveCalls +import com.hiennv.flutter_callkit_incoming.removeAllCalls import java.util.HashMap object VideoCallPushBridge { @@ -52,6 +55,29 @@ object VideoCallPushBridge { return false } + fun isEndedVideoCall(data: Map): Boolean { + val rawType = data["type"] + ?: data["notification_type"] + ?: data["event"] + ?: data["action"] + ?: data["push_type"] + ?: data["call_type"] + + if (rawType != null) { + val normalized = rawType.lowercase().replace("-", "_") + if (normalized == "video_call_ended" || normalized == "video_call_end") { + return true + } + if (normalized.contains("video_call") && + (normalized.contains("ended") || normalized.endsWith("_end")) + ) { + return true + } + } + + return false + } + fun showIncomingCall(context: Context, data: Map) { val appContext = context.applicationContext val callData = buildCallDataBundle(data) @@ -74,6 +100,35 @@ object VideoCallPushBridge { } } + fun dismissAllIncomingCalls(context: Context) { + val appContext = context.applicationContext + val calls = getDataActiveCalls(appContext) + Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") + + val soundManager = CallkitSoundPlayerManager(appContext) + val notificationManager = CallkitNotificationManager(appContext, soundManager) + + for (call in calls) { + val bundle = call.toBundle() + try { + notificationManager.clearIncomingNotification(bundle, isAccepted = false) + appContext.sendBroadcast( + CallkitIncomingBroadcastReceiver.getIntentEnded(appContext, bundle), + ) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed for call ${call.id}", error) + } + } + + try { + removeAllCalls(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error) + } + + soundManager.stop() + } + private fun buildCallDataBundle(data: Map): Bundle? { val conversationUuid = conversationUuid(data) ?: return null val callerName = data["counterpartName"] diff --git a/lib/core/device/device_hw_id_provider.dart b/lib/core/device/device_hw_id_provider.dart new file mode 100644 index 00000000..383aac2b --- /dev/null +++ b/lib/core/device/device_hw_id_provider.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; + +abstract final class DeviceHwIdProvider { + static Future current() async { + final deviceInfo = DeviceInfoPlugin(); + if (Platform.isIOS) { + return (await deviceInfo.iosInfo).identifierForVendor; + } + if (Platform.isAndroid) { + return (await deviceInfo.androidInfo).id; + } + return null; + } +} diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index e44c32ab..22191b85 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -4,6 +4,8 @@ import 'dart:async'; import 'package:eatvio_app/core/app_router.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/services/video_call_ended_push_handler.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -57,6 +59,16 @@ class PushNavigationHandler { static void _handleMessageInternal(RemoteMessage message, {required String source}) { final data = Map.from(message.data); + final endedPayload = VideoCallEndedPushPayload.tryParse(data); + if (endedPayload != null) { + developer.log( + 'PushNavigationHandler: dismiss incoming call UI, source=$source', + name: 'PushNavigationHandler', + ); + unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi()); + return; + } + final videoCallPayload = VideoCallPushPayload.tryParse(data); if (videoCallPayload != null) { developer.log( diff --git a/lib/features/chat/models/chat_message_model.dart b/lib/features/chat/models/chat_message_model.dart index c50a68f8..4bffd8b7 100644 --- a/lib/features/chat/models/chat_message_model.dart +++ b/lib/features/chat/models/chat_message_model.dart @@ -33,7 +33,7 @@ abstract class ChatMessageModel with _$ChatMessageModel { @JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead, @JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor, @JsonKey(name: 'room_id') required String? roomId, - @JsonKey(name: 'type') String? type, + @JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type, @Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState, @Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid, }) = _ChatMessageModel; @@ -42,3 +42,22 @@ abstract class ChatMessageModel with _$ChatMessageModel { } enum MessageState { loading, sent, error } + +enum ChatMessageType { + @JsonValue('text') + text, + @JsonValue('system') + system, + @JsonValue('attachment') + attachment, + @JsonValue('image') + image, + @JsonValue('plan_order') + planOrder, + @JsonValue('video_call_started') + videoCallStarted, + @JsonValue('video_call_ended') + videoCallEnded, + @JsonValue('unknown') + unknown; +} diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 5746beae..e712955b 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -13,7 +13,10 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cub import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; +import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; +import 'package:eatvio_app/features/chat/models/chat_user_model.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/video_call_chat_message_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; @@ -262,20 +265,7 @@ class _ChatScreenState extends State { Padding( padding: const EdgeInsetsDirectional.only(end: 8), child: _ChatHeaderVideoCallButton( - onPressed: () { - GoRouter.of(context).pushNamed( - VideoCallScreen.name, - extra: { - 'conversationUuid': widget.uuid, - 'counterpartName': chatCounterpart.name, - 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl, - 'counterpartUserId': chatCounterpart.id, - 'counterpartIsOnline': chatCounterpart.isOnline, - 'counterpartLastSeenText': chatCounterpart.lastSeenText, - 'localParticipantName': state.chatRoom.users.sender.name, - }, - ); - }, + onPressed: () => _openVideoCall(context, state, chatCounterpart), ), ), ], @@ -311,7 +301,7 @@ class _ChatScreenState extends State { index + 1 == state.chatMessages.length) { final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; final message = state.chatMessages[index]; - final isPlanOrderMessage = message.type == 'plan_order'; + final isPlanOrderMessage = message.type == ChatMessageType.planOrder; PacketOptions? messageOption; if (isPlanOrderMessage && chosenPlan != null) { @@ -352,18 +342,13 @@ class _ChatScreenState extends State { }); } }, - child: ChatBubbleWidget( - isRead: message.isRead, - sendTime: message.updatedAt, - text: message.data.trim(), - isCurrentUser: isCurrentUserAuthor, - mediaData: message.mediaData ?? [], - messageState: message.messageState, - messageUuid: message.uuid ?? '', - userSlug: state.chatRoom.users.receiver.slug, - messageType: message.type, - isConsultant: amIConsultant, - urlsMediaInfo: message.urlsMediaInfo, + child: _buildMessageBubble( + context: context, + state: state, + chatCounterpart: chatCounterpart, + isCurrentUserAuthor: isCurrentUserAuthor, + message: message, + amIConsultant: amIConsultant, ), ), ], @@ -371,7 +356,7 @@ class _ChatScreenState extends State { } final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; final message = state.chatMessages[index]; - final isPlanOrderMessage = message.type == 'plan_order'; + final isPlanOrderMessage = message.type == ChatMessageType.planOrder; PacketOptions? messageOption; if (isPlanOrderMessage && chosenPlan != null) { @@ -408,18 +393,13 @@ class _ChatScreenState extends State { }); } }, - child: ChatBubbleWidget( - isRead: message.isRead, - sendTime: message.updatedAt, - text: message.data.trim(), - isCurrentUser: isCurrentUserAuthor, - mediaData: message.mediaData ?? [], - messageState: message.messageState, - messageUuid: message.uuid ?? '', - userSlug: state.chatRoom.users.receiver.slug, - messageType: message.type, - isConsultant: amIConsultant, - urlsMediaInfo: message.urlsMediaInfo, + child: _buildMessageBubble( + context: context, + state: state, + chatCounterpart: chatCounterpart, + isCurrentUserAuthor: isCurrentUserAuthor, + message: message, + amIConsultant: amIConsultant, ), ), ], @@ -786,6 +766,52 @@ class _ChatScreenState extends State { ), ); } + + void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) { + GoRouter.of(context).pushNamed( + VideoCallScreen.name, + extra: { + 'conversationUuid': widget.uuid, + 'counterpartName': chatCounterpart.name, + 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl, + 'counterpartUserId': chatCounterpart.id, + 'counterpartIsOnline': chatCounterpart.isOnline, + 'counterpartLastSeenText': chatCounterpart.lastSeenText, + 'localParticipantName': state.chatRoom.users.sender.name, + }, + ); + } + + Widget _buildMessageBubble({ + required BuildContext context, + required ChatRoomStateLoaded state, + required ChatUserModel chatCounterpart, + required bool isCurrentUserAuthor, + required ChatMessageModel message, + required bool? amIConsultant, + }) { + if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) { + return VideoCallChatMessageWidget( + text: message.data.trim(), + sendTime: message.updatedAt, + onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart), + ); + } + + return ChatBubbleWidget( + isRead: message.isRead, + sendTime: message.updatedAt, + text: message.data.trim(), + isCurrentUser: isCurrentUserAuthor, + mediaData: message.mediaData ?? [], + messageState: message.messageState, + messageUuid: message.uuid ?? '', + userSlug: state.chatRoom.users.receiver.slug, + messageType: message.type, + isConsultant: amIConsultant, + urlsMediaInfo: message.urlsMediaInfo, + ); + } } class _ChatHeaderVideoCallButton extends StatelessWidget { diff --git a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart index 00d4e729..8d83e961 100644 --- a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart +++ b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart @@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget { final MessageState messageState; final String messageUuid; final String userSlug; - final String? messageType; + final ChatMessageType? messageType; final bool? isConsultant; final List>? urlsMediaInfo; @override @@ -258,7 +258,7 @@ class ChatBubbleWidget extends StatelessWidget { } String _getDisplayText() { - if (messageType == 'plan_order') { + if (messageType == ChatMessageType.planOrder) { if (isConsultant == true) { // Текст для консультанта return '1. Уточните у подопечного его цели и пожелания\n' diff --git a/lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart b/lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart new file mode 100644 index 00000000..90930e1d --- /dev/null +++ b/lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart @@ -0,0 +1,103 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/utils/date_time_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class VideoCallChatMessageWidget extends StatelessWidget { + const VideoCallChatMessageWidget({ + super.key, + required this.text, + required this.sendTime, + required this.onVideoCallPressed, + }); + + final String text; + final DateTime sendTime; + final VoidCallback onVideoCallPressed; + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16); + + return SizedBox( + width: size.width, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 64, 4), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + constraints: BoxConstraints(maxWidth: size.width * 0.7), + decoration: BoxDecoration( + color: appTheme.gray60004, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(16), + right: Radius.circular(24), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (text.isNotEmpty) + Flexible( + child: Text(text, style: textStyle), + ), + if (text.isNotEmpty) const SizedBox(width: 8), + _VideoCallBubbleActionButton(onPressed: onVideoCallPressed), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 4), + child: Text( + sendTime.format('HH:mm', 'ru'), + style: const TextStyle( + color: Color(0xFF7B8794), + height: 1, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _VideoCallBubbleActionButton extends StatelessWidget { + const _VideoCallBubbleActionButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: appTheme.greenA700, + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + child: SizedBox( + width: 36, + height: 36, + child: Center( + child: SvgPicture.asset( + 'assets/icons/video_call/vc_control_enter.svg', + width: 20, + height: 20, + fit: BoxFit.contain, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 6bd2294b..2501a772 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/device/device_hw_id_provider.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/managers/video_call_manager_snapshot.dart'; @@ -56,7 +57,6 @@ class VideoCallManager { lk.Room? _room; lk.EventsListener? _roomEventsListener; - OverlayEntry? _poorConnectionEntry; Timer? _mutedMicPollTimer; DateTime? _lastMutedMicOverlayAt; bool _recordingOverlayScheduled = false; @@ -64,6 +64,7 @@ class VideoCallManager { num? _lastMutedMicTotalEnergy; bool _liveKitDisposed = true; bool _callEndedNotified = true; + final Set _callAcceptNotifiedConversationUuids = {}; bool _callStageEntered = false; bool _joinInProgress = false; bool _counterpartJoinedNotified = false; @@ -409,6 +410,27 @@ class VideoCallManager { _emitSnapshot(VideoCallManagerSnapshot.initial); } + /// Notifies backend that this device accepted an incoming call so ringing + /// stops on the user's other devices. + Future notifyIncomingCallAccepted(String conversationUuid) async { + final uuid = conversationUuid.trim(); + if (uuid.isEmpty) return; + if (_callAcceptNotifiedConversationUuids.contains(uuid)) return; + + final hwId = (await DeviceHwIdProvider.current())?.trim(); + if (hwId == null || hwId.length < 8) return; + + _callAcceptNotifiedConversationUuids.add(uuid); + try { + await _videoCallRepository.notifyCallAccepted( + conversationUuid: uuid, + hwId: hwId, + ); + } catch (_) { + _callAcceptNotifiedConversationUuids.remove(uuid); + } + } + /// Decline from incoming UI before joining the LiveKit room. Future declineIncomingCall(String conversationUuid) async { final uuid = conversationUuid.trim(); @@ -716,6 +738,7 @@ class VideoCallManager { _counterpartJoinedNotified = false; _roomConnectFuture = null; _clearPreparedConnectionCredentials(); + _callAcceptNotifiedConversationUuids.clear(); _emitSnapshot( _snapshot.copyWith( counterpartPresentInLobby: false, @@ -736,11 +759,7 @@ class VideoCallManager { if (listener != null) { unawaited(listener.dispose()); } - final poor = _poorConnectionEntry; - _poorConnectionEntry = null; - if (poor != null && poor.mounted) { - poor.remove(); - } + VideoCallTopOverlay.hidePoorConnectionIcon(); } void _startMutedMicPollTimer() { @@ -775,6 +794,7 @@ class VideoCallManager { _abortingLiveKitAutoReconnect = true; try { _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost)); + VideoCallTopOverlay.hidePoorConnectionIcon(); final room = _room; if (room != null && room.connectionState != lk.ConnectionState.disconnected) { @@ -822,29 +842,18 @@ class VideoCallManager { } void _syncPoorConnectionOverlay(lk.ConnectionQuality q) { + if (_liveKitDisposed || !_callStageEntered) { + VideoCallTopOverlay.hidePoorConnectionIcon(); + return; + } final ctx = _navigatorContext; if (ctx == null) return; final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; if (bad) { - final existing = _poorConnectionEntry; - if (existing != null && existing.mounted) return; - _poorConnectionEntry = null; - final entry = VideoCallTopOverlay.insertBanner( - ctx, - message: S.of(ctx).video_call_poor_connection, - backgroundColor: appTheme.strongRed, - svgAsset: VideoCallTopOverlay.poorConnectionSvg, - ); - if (entry.mounted) { - _poorConnectionEntry = entry; - } + VideoCallTopOverlay.showPoorConnectionIcon(ctx); } else { - final e = _poorConnectionEntry; - if (e != null && e.mounted) { - e.remove(); - } - _poorConnectionEntry = null; + VideoCallTopOverlay.hidePoorConnectionIcon(); } } diff --git a/lib/features/video_calls/models/video_call_models.dart b/lib/features/video_calls/models/video_call_models.dart index 7ab0a747..f344822d 100644 --- a/lib/features/video_calls/models/video_call_models.dart +++ b/lib/features/video_calls/models/video_call_models.dart @@ -35,3 +35,14 @@ abstract class VideoCallEndedResponse with _$VideoCallEndedResponse { factory VideoCallEndedResponse.fromJson(Map json) => _$VideoCallEndedResponseFromJson(json); } + +@freezed +abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse { + const factory VideoCallAcceptResponse({ + required bool sent, + @JsonKey(name: 'devices_notified') required int devicesNotified, + }) = _VideoCallAcceptResponse; + + factory VideoCallAcceptResponse.fromJson(Map json) => + _$VideoCallAcceptResponseFromJson(json); +} 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 index 2ad362d1..220ec4fd 100644 --- 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 @@ -26,6 +26,10 @@ class VideoCallSessionCubit extends BaseCubit { final VideoCallSystemPipCoordinator _systemPipCoordinator; bool _callScreenRouteVisible = false; bool _autoEnterInFlight = false; + bool _counterpartPresentWhenLobbyReady = false; + bool _sawEmptyLobbyWhenConnected = false; + bool _lobbyReadyTracked = false; + String? _lobbyReadyTrackedConversationUuid; Future? _autoEnterFuture; Future? _outgoingAutoEnterFuture; @@ -72,10 +76,12 @@ class VideoCallSessionCubit extends BaseCubit { // [VideoCallManager.startCall] briefly emits idle while replacing the room. return; } + _resetLobbyReadyTracking(); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; } + _trackLobbyReadyCounterpartPresence(snapshot); final next = _sessionStateFromSnapshot(snapshot); emit(next); _scheduleAutoEnterIfNeeded(next); @@ -86,12 +92,47 @@ class VideoCallSessionCubit extends BaseCubit { if (_isCallSessionInactive(snapshot)) { unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) return; + _resetLobbyReadyTracking(); emit(const VideoCallSessionState()); return; } + _trackLobbyReadyCounterpartPresence(snapshot); emit(_sessionStateFromSnapshot(snapshot)); } + void _resetLobbyReadyTracking() { + _lobbyReadyTrackedConversationUuid = null; + _counterpartPresentWhenLobbyReady = false; + _sawEmptyLobbyWhenConnected = false; + _lobbyReadyTracked = false; + } + + void _trackLobbyReadyCounterpartPresence(VideoCallManagerSnapshot snapshot) { + final uuid = snapshot.conversationUuid; + if (uuid.isEmpty) { + _resetLobbyReadyTracking(); + return; + } + if (snapshot.connectPhase != VideoCallConnectPhase.connected) return; + + if (!_lobbyReadyTracked || _lobbyReadyTrackedConversationUuid != uuid) { + _lobbyReadyTracked = true; + _lobbyReadyTrackedConversationUuid = uuid; + _counterpartPresentWhenLobbyReady = snapshot.counterpartPresentInLobby; + _sawEmptyLobbyWhenConnected = !snapshot.counterpartPresentInLobby; + return; + } + + if (_sawEmptyLobbyWhenConnected && snapshot.counterpartPresentInLobby) { + _counterpartPresentWhenLobbyReady = false; + _sawEmptyLobbyWhenConnected = false; + } + } + + Future _notifyIncomingCallAcceptedIfNeeded(String conversationUuid) { + return _videoCallManager.notifyIncomingCallAccepted(conversationUuid); + } + /// Синхронизирует UI-состояние после [VideoCallManager.endCall] из notification / /// foreground task (без повторного disconnect). Future onCallEndedExternally() async { @@ -144,7 +185,12 @@ class VideoCallSessionCubit extends BaseCubit { !state.callStageEntered && state.connectPhase != VideoCallConnectPhase.idle; + if (isIncomingAccepted && !isResumingActiveCall) { + unawaited(_notifyIncomingCallAcceptedIfNeeded(conversationUuid)); + } + if (!isResumingActiveCall && !isSameCallConnecting) { + _resetLobbyReadyTracking(); await _systemPipCoordinator.disarm(); } @@ -421,6 +467,11 @@ class VideoCallSessionCubit extends BaseCubit { if (state.connectPhase != VideoCallConnectPhase.connected) return; if (state.callStageEntered) return; + if (state.entryMode == VideoCallEntryMode.outgoing && + _counterpartPresentWhenLobbyReady) { + unawaited(_notifyIncomingCallAcceptedIfNeeded(state.conversationUuid)); + } + final entered = await _videoCallManager.onEnterCallStage(); if (!entered || isClosed) return; diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart index 8732f24c..8ea2ddb4 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -9,12 +9,67 @@ abstract final class VideoCallTopOverlay { 'assets/icons/video_call/poor_connection.svg'; static const String warningSvg = 'assets/icons/video_call/warning.svg'; + static const double _poorConnectionIconSize = 44; + static const double _poorConnectionGlyphSize = 22; + + static OverlayEntry? _poorConnectionEntry; + + static bool get isPoorConnectionIconVisible => + _poorConnectionEntry?.mounted ?? false; + static OverlayState? _resolveOverlayState(BuildContext context) { return VideoCallBannerOverlay.overlayState ?? AppRouter.navigatorKey.currentState?.overlay ?? Overlay.maybeOf(context, rootOverlay: true); } + /// Compact poor-connection indicator: red square with icon only. + static void showPoorConnectionIcon(BuildContext context) { + if (isPoorConnectionIconVisible) return; + + hidePoorConnectionIcon(); + + final overlayState = _resolveOverlayState(context); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) { + final topInset = MediaQuery.viewPaddingOf(ctx).top; + return Positioned( + left: 16, + top: topInset + 8, + width: _poorConnectionIconSize, + height: _poorConnectionIconSize, + child: Material( + color: appTheme.strongRed, + borderRadius: BorderRadius.circular(10), + elevation: 6, + child: Center( + child: SvgPicture.asset( + poorConnectionSvg, + width: _poorConnectionGlyphSize, + height: _poorConnectionGlyphSize, + ), + ), + ), + ); + }, + ); + + _poorConnectionEntry = entry; + if (overlayState == null) { + _poorConnectionEntry = null; + return; + } + overlayState.insert(entry); + } + + static void hidePoorConnectionIcon() { + final entry = _poorConnectionEntry; + _poorConnectionEntry = null; + if (entry != null && entry.mounted) { + entry.remove(); + } + } static OverlayEntry insertBanner( BuildContext context, { diff --git a/lib/features/video_calls/repositories/video_call_repository.dart b/lib/features/video_calls/repositories/video_call_repository.dart index 1b9835b5..15cbb4f1 100644 --- a/lib/features/video_calls/repositories/video_call_repository.dart +++ b/lib/features/video_calls/repositories/video_call_repository.dart @@ -24,4 +24,14 @@ class VideoCallRepository { body: _emptyJsonBody, ); } + + Future notifyCallAccepted({ + required String conversationUuid, + required String hwId, + }) { + return videoCallService.notifyCallAccepted( + conversationUuid: conversationUuid, + body: {'hw_id': hwId}, + ); + } } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 3c5b1ad0..57c718fa 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -48,6 +48,8 @@ class VideoCallCallKitCoordinator { _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); + VideoCallFcmHandler.registerForegroundListener(); + if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { switch (call.method) { @@ -67,7 +69,6 @@ class VideoCallCallKitCoordinator { } if (Platform.isAndroid) { - VideoCallFcmHandler.registerForegroundListener(); FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); await FlutterCallkitIncoming.requestNotificationPermission(< String, @@ -179,7 +180,7 @@ class VideoCallCallKitCoordinator { case CallEventActionCallDecline(): await _handleDeclineEvent(event); case CallEventActionCallEnded(): - await _handleDeclineEvent(event); + await _handleEndedEvent(event); case CallEventActionCallTimeout(): await _handleDeclineEvent(event); default: @@ -211,6 +212,18 @@ class VideoCallCallKitCoordinator { await _handleDecline(id); } + Future _handleEndedEvent(CallEvent event) async { + final id = _callKitEventId(event); + if (id == null) { + log( + 'VideoCallCallKitCoordinator: ended event without callKit id, event=$event', + name: 'VideoCallCallKit', + ); + return; + } + await _handleEnded(id); + } + Future _captureCallKitEventToSentry(CallEvent event) async { if (!Platform.isIOS) return; final context = { @@ -468,6 +481,8 @@ class VideoCallCallKitCoordinator { _acceptHandledCallKitIds.add(callKitId); _suppressedEndCallKitIds.add(callKitId); + unawaited(_manager.notifyIncomingCallAccepted(conversationUuid)); + try { if (!Platform.isIOS) { await FlutterCallkitIncoming.endCall(callKitId); @@ -565,6 +580,25 @@ class VideoCallCallKitCoordinator { } } + Future _handleEnded(String callKitId) async { + if (_suppressedEndCallKitIds.contains(callKitId)) { + log( + 'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); + return; + } + + _callKitParamsById.remove(callKitId); + if (_activeCallKitId == callKitId) { + _activeCallKitId = null; + } + + try { + await FlutterCallkitIncoming.endCall(callKitId); + } catch (_) {} + } + static String _parseConversationUuid( Map extra, { String? handle, diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart new file mode 100644 index 00000000..1b27f32f --- /dev/null +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -0,0 +1,19 @@ +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; + +/// Dismisses incoming call UI after a remote `video_call_ended` push. +abstract final class VideoCallEndedPushHandler { + static Future dismissIncomingCallUi() async { + try { + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + for (final call in activeCalls) { + try { + await FlutterCallkitIncoming.endCall(call.id); + } catch (_) {} + } + } catch (_) {} + + try { + await FlutterCallkitIncoming.endAllCalls(); + } catch (_) {} + } +} diff --git a/lib/features/video_calls/services/video_call_ended_push_payload.dart b/lib/features/video_calls/services/video_call_ended_push_payload.dart new file mode 100644 index 00000000..4defe8fe --- /dev/null +++ b/lib/features/video_calls/services/video_call_ended_push_payload.dart @@ -0,0 +1,11 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; + +/// Marker for a data-only `video_call_ended` push (no conversation uuid required). +class VideoCallEndedPushPayload { + const VideoCallEndedPushPayload._(); + + static VideoCallEndedPushPayload? tryParse(Map data) { + if (!VideoCallPushPayload.isEndedVideoCall(data)) return null; + return const VideoCallEndedPushPayload._(); + } +} diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart index 9d582d66..941da2f9 100644 --- a/lib/features/video_calls/services/video_call_fcm_handler.dart +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -1,6 +1,8 @@ import 'dart:developer'; import 'dart:io'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:eatvio_app/firebase_options.dart'; @@ -43,9 +45,35 @@ abstract final class VideoCallFcmHandler { RemoteMessage message, { required String source, }) async { - if (!Platform.isAndroid) return; - final data = Map.from(message.data); + + final endedPayload = VideoCallEndedPushPayload.tryParse(data); + if (endedPayload != null) { + if (Platform.isAndroid && source == 'background') { + log( + 'VideoCallFcmHandler: skip background ended call, handled by native FCM service', + name: 'VideoCallFcm', + ); + return; + } + + log( + 'VideoCallFcmHandler: dismiss incoming call UI, source=$source', + name: 'VideoCallFcm', + ); + try { + await VideoCallEndedPushHandler.dismissIncomingCallUi(); + } catch (e, st) { + log( + 'VideoCallFcmHandler: failed to dismiss incoming call UI: $e', + name: 'VideoCallFcm', + stackTrace: st, + ); + } + return; + } + + if (!Platform.isAndroid) return; if (data.isEmpty) return; final payload = VideoCallPushPayload.tryParse(data); diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart index ceb0767a..4b1f578d 100644 --- a/lib/features/video_calls/services/video_call_push_payload.dart +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -52,6 +52,28 @@ class VideoCallPushPayload { ); } + static bool isEndedVideoCall(Map data) { + final type = _string(data, const [ + 'type', + 'notification_type', + 'event', + 'action', + 'push_type', + 'call_type', + ]); + if (type != null) { + final normalized = type.toLowerCase().replaceAll('-', '_'); + if (normalized == 'video_call_ended' || normalized == 'video_call_end') { + return true; + } + if (normalized.contains('video_call') && + (normalized.contains('ended') || normalized.endsWith('_end'))) { + return true; + } + } + return false; + } + static bool isIncomingVideoCall(Map data) { final type = _string(data, const [ 'type', @@ -91,6 +113,9 @@ class VideoCallPushPayload { return false; } + static String? conversationUuidFromData(Map data) => + _conversationUuid(data); + static String? _conversationUuid(Map data) { final fromData = _string(data, const [ 'conversationUuid', diff --git a/lib/features/video_calls/services/video_call_service.dart b/lib/features/video_calls/services/video_call_service.dart index e46b0194..eff4e99b 100644 --- a/lib/features/video_calls/services/video_call_service.dart +++ b/lib/features/video_calls/services/video_call_service.dart @@ -23,4 +23,10 @@ abstract class VideoCallService { @Path('conversation') required String conversationUuid, @Body() required Map body, }); + + @POST('/api/v2/chat/{conversation}/video-call/accept') + Future notifyCallAccepted({ + @Path('conversation') required String conversationUuid, + @Body() required Map body, + }); } diff --git a/pubspec.yaml b/pubspec.yaml index 06d61909..a9be1b5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.31+120 +version: 1.3.32+121 publish_to: 'none' environment: diff --git a/test/features/chat/models/chat_message_model_test.dart b/test/features/chat/models/chat_message_model_test.dart index fc23b1b4..b731b951 100644 --- a/test/features/chat/models/chat_message_model_test.dart +++ b/test/features/chat/models/chat_message_model_test.dart @@ -54,7 +54,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: 'room-1', - type: 'text', + type: ChatMessageType.text, ); final json = model.toJson(); diff --git a/test/features/chat/models/chat_messages_model_test.dart b/test/features/chat/models/chat_messages_model_test.dart index 0eab83cf..630bcb36 100644 --- a/test/features/chat/models/chat_messages_model_test.dart +++ b/test/features/chat/models/chat_messages_model_test.dart @@ -48,7 +48,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: 'room-1', - type: 'text', + type: ChatMessageType.text, ); const meta = ChatMetaModel(lastPage: 1); diff --git a/test/features/chat/presentation/bloc/chats_cubit_test.dart b/test/features/chat/presentation/bloc/chats_cubit_test.dart index 9f8dca92..c3af3197 100644 --- a/test/features/chat/presentation/bloc/chats_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chats_cubit_test.dart @@ -336,7 +336,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: chatId, - type: 'text', + type: ChatMessageType.text, ); // Используем рефлексию для вызова приватного метода через тестовый доступ @@ -390,7 +390,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: chatId, - type: 'text', + type: ChatMessageType.text, ); final streamController = StreamController(); diff --git a/test/features/video_calls/models/video_call_models_test.dart b/test/features/video_calls/models/video_call_models_test.dart index ee2e730f..bc46c8f3 100644 --- a/test/features/video_calls/models/video_call_models_test.dart +++ b/test/features/video_calls/models/video_call_models_test.dart @@ -112,4 +112,24 @@ void main() { expect(restored, model); }); }); + + group('VideoCallAcceptResponse', () { + test('fromJson десериализует sent и devices_notified', () { + final json = FixtureReader.readAsMap('video_calls/video_call_accept_response.json'); + + final model = VideoCallAcceptResponse.fromJson(json); + + expect(model.sent, isTrue); + expect(model.devicesNotified, 2); + }); + + test('round-trip JSON сохраняет значения', () { + final json = FixtureReader.readAsMap('video_calls/video_call_accept_response.json'); + + final model = VideoCallAcceptResponse.fromJson(json); + final restored = VideoCallAcceptResponse.fromJson(model.toJson()); + + expect(restored, model); + }); + }); } diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index d6df22d4..2f9e9c3b 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -27,6 +27,9 @@ void main() { managerListener = invocation.positionalArguments[0] as VideoCallManagerListener; }); + when( + mockManager.notifyIncomingCallAccepted(any), + ).thenAnswer((_) async {}); cubit = _TestVideoCallSessionCubit(videoCallManager: mockManager); }); diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart index 91b3e8d6..4e30a006 100644 --- a/test/features/video_calls/services/video_call_push_payload_test.dart +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,14 +41,21 @@ void main() { expect(payload.counterpartName, 'Ivan'); }); - test('ignores ended video call push', () { - expect( - VideoCallPushPayload.tryParse({ - 'type': 'video_call_ended', - 'conversation_uuid': 'abc-123', - }), - isNull, - ); + test('parses ended video call push with type only', () { + final payload = VideoCallEndedPushPayload.tryParse({ + 'type': 'video_call_ended', + }); + + expect(payload, isNotNull); + }); + + test('parses ended video call push with optional conversation uuid', () { + final payload = VideoCallEndedPushPayload.tryParse({ + 'type': 'video_call_ended', + 'conversation_uuid': 'abc-123', + }); + + expect(payload, isNotNull); }); test('ignores regular chat push', () { diff --git a/test/features/video_calls/services/video_call_service_test.dart b/test/features/video_calls/services/video_call_service_test.dart index 3bada540..4d2aa887 100644 --- a/test/features/video_calls/services/video_call_service_test.dart +++ b/test/features/video_calls/services/video_call_service_test.dart @@ -90,5 +90,25 @@ void main() { throwsA(isA()), ); }); + + test('notifyCallAccepted - отправляет hw_id и получает ответ', () async { + const conversationUuid = 'conv-uuid-123'; + const body = {'hw_id': 'device-android-001'}; + final json = FixtureReader.readAsMap('video_calls/video_call_accept_response.json'); + final expected = VideoCallAcceptResponse.fromJson(json); + + when(mockService.notifyCallAccepted(conversationUuid: conversationUuid, body: body)) + .thenAnswer((_) async => expected); + + final result = await mockService.notifyCallAccepted( + conversationUuid: conversationUuid, + body: body, + ); + + expect(result, isA()); + expect(result.sent, isTrue); + expect(result.devicesNotified, 2); + verify(mockService.notifyCallAccepted(conversationUuid: conversationUuid, body: body)).called(1); + }); }); } diff --git a/test/features/video_calls/widgets/video_call_top_overlay_test.dart b/test/features/video_calls/widgets/video_call_top_overlay_test.dart index 77d7c312..f6dc791a 100644 --- a/test/features/video_calls/widgets/video_call_top_overlay_test.dart +++ b/test/features/video_calls/widgets/video_call_top_overlay_test.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_svg/flutter_svg.dart'; void main() { testWidgets('insertBanner shows message and can be removed manually', ( @@ -67,4 +68,36 @@ void main() { await tester.pump(); expect(find.text('Recording notice'), findsNothing); }); + + testWidgets('showPoorConnectionIcon renders compact icon chip', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); + + final context = tester.element(find.byType(SizedBox)); + VideoCallTopOverlay.showPoorConnectionIcon(context); + + await tester.pump(); + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.text('Poor connection'), findsNothing); + expect(VideoCallTopOverlay.isPoorConnectionIconVisible, isTrue); + }); + + testWidgets('hidePoorConnectionIcon removes poor connection chip', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); + + final context = tester.element(find.byType(SizedBox)); + VideoCallTopOverlay.showPoorConnectionIcon(context); + await tester.pump(); + expect(VideoCallTopOverlay.isPoorConnectionIconVisible, isTrue); + + VideoCallTopOverlay.hidePoorConnectionIcon(); + await tester.pump(); + expect(VideoCallTopOverlay.isPoorConnectionIconVisible, isFalse); + expect(find.byType(SvgPicture), findsNothing); + }); } diff --git a/test/fixtures/video_calls/video_call_accept_response.json b/test/fixtures/video_calls/video_call_accept_response.json new file mode 100644 index 00000000..ec80c0fd --- /dev/null +++ b/test/fixtures/video_calls/video_call_accept_response.json @@ -0,0 +1,4 @@ +{ + "sent": true, + "devices_notified": 2 +} From c8ad600d97336c6a583cf1b85b621d60dc9ea5d1 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 30 Jun 2026 17:17:58 +0300 Subject: [PATCH 51/56] fix: analyzer --- lib/core/error_reporting/error_report_policy.dart | 1 + pubspec.yaml | 2 +- test/core/error_reporting/error_report_policy_test.dart | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/core/error_reporting/error_report_policy.dart b/lib/core/error_reporting/error_report_policy.dart index 5e813fd8..b7ff92ec 100644 --- a/lib/core/error_reporting/error_report_policy.dart +++ b/lib/core/error_reporting/error_report_policy.dart @@ -44,6 +44,7 @@ class ErrorReportPolicy { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: + case DioExceptionType.transformTimeout: case DioExceptionType.connectionError: return true; case DioExceptionType.unknown: diff --git a/pubspec.yaml b/pubspec.yaml index a9be1b5e..ee16681e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -29,7 +29,7 @@ dependencies: keyboard_dismisser: ^3.0.0 retrofit: 4.7.2 get_it: ^9.2.0 - dio: ^5.9.0 + dio: ^5.10.0 dart_pusher_channels: ^1.2.3 photo_view: ^0.15.0 dismissible_page: ^1.0.2 diff --git a/test/core/error_reporting/error_report_policy_test.dart b/test/core/error_reporting/error_report_policy_test.dart index d504e50a..ee84f765 100644 --- a/test/core/error_reporting/error_report_policy_test.dart +++ b/test/core/error_reporting/error_report_policy_test.dart @@ -32,6 +32,7 @@ void main() { DioExceptionType.connectionTimeout, DioExceptionType.sendTimeout, DioExceptionType.receiveTimeout, + DioExceptionType.transformTimeout, ]) { final e = DioException( requestOptions: RequestOptions(path: '/x'), From 9ce7b035ff1ec839448ea9f200b2f85cf3d90c3f Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 30 Jun 2026 18:48:45 +0300 Subject: [PATCH 52/56] feat(video_calls): fixed call ended push handling --- .../kotlin/com/eatvioapp/app/MainActivity.kt | 16 +++ .../com/eatvioapp/app/VideoCallPushBridge.kt | 113 ++++++++++++++++-- .../reports/problems/problems-report.html | 2 +- .../video_call_ended_push_handler.dart | 18 +++ pubspec.yaml | 2 +- 5 files changed, 138 insertions(+), 13 deletions(-) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index 65903d96..c6b2fb43 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -25,8 +25,10 @@ class MainActivity: FlutterActivity() { private val channelName = "com.eatvioapp/hms_push" private val videoCallPipChannelName = "com.eatvioapp/video_call_pip" + private val videoCallPushChannelName = "com.eatvioapp/video_call_push" private var methodChannel: MethodChannel? = null private var videoCallPipChannel: MethodChannel? = null + private var videoCallPushChannel: MethodChannel? = null private var videoCallPipEnabled: Boolean = false override fun onResume() { @@ -44,6 +46,20 @@ class MainActivity: FlutterActivity() { methodChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName) videoCallPipChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPipChannelName) + videoCallPushChannel = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, videoCallPushChannelName) + videoCallPushChannel?.setMethodCallHandler { call, result -> + when (call.method) { + "dismissIncomingCalls" -> { + try { + VideoCallPushBridge.dismissAllIncomingCalls(applicationContext) + result.success(null) + } catch (e: Exception) { + result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null) + } + } + else -> result.notImplemented() + } + } videoCallPipChannel?.setMethodCallHandler { call, result -> when (call.method) { "setPipEnabled" -> { diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index a52976e9..fbad936e 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -2,12 +2,17 @@ package com.eatvioapp.app import android.content.Context import android.os.Bundle +import android.os.Handler +import android.os.Looper import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import com.hiennv.flutter_callkit_incoming.CallkitConstants -import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager +import com.hiennv.flutter_callkit_incoming.CallkitNotificationService import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager import com.hiennv.flutter_callkit_incoming.Data +import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin import com.hiennv.flutter_callkit_incoming.addCall import com.hiennv.flutter_callkit_incoming.getDataActiveCalls import com.hiennv.flutter_callkit_incoming.removeAllCalls @@ -16,6 +21,10 @@ import java.util.HashMap object VideoCallPushBridge { private const val TAG = "VideoCallPushBridge" private const val ANDROID_ACCENT_COLOR = "#2787F5" + private const val MISSED_CALL_CLEANUP_DELAY_MS = 250L + + private var activeSoundManager: CallkitSoundPlayerManager? = null + private var activeNotificationManager: CallkitNotificationManager? = null fun isIncomingVideoCall(data: Map): Boolean { val rawType = data["type"] @@ -90,6 +99,8 @@ object VideoCallPushBridge { val soundManager = CallkitSoundPlayerManager(appContext) val notificationManager = CallkitNotificationManager(appContext, soundManager) + activeSoundManager = soundManager + activeNotificationManager = notificationManager notificationManager.createNotificationChanel(callData) notificationManager.showIncomingNotification(callData) @@ -105,28 +116,108 @@ object VideoCallPushBridge { val calls = getDataActiveCalls(appContext) Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") - val soundManager = CallkitSoundPlayerManager(appContext) - val notificationManager = CallkitNotificationManager(appContext, soundManager) + stopIncomingRingtone(appContext) - for (call in calls) { - val bundle = call.toBundle() + val notificationManager = resolveNotificationManager(appContext) + val bundles = calls.map { it.toBundle() } + + for (bundle in bundles) { try { - notificationManager.clearIncomingNotification(bundle, isAccepted = false) - appContext.sendBroadcast( - CallkitIncomingBroadcastReceiver.getIntentEnded(appContext, bundle), - ) + dismissIncomingCallNotification(appContext, notificationManager, bundle) } catch (error: Exception) { - Log.w(TAG, "dismissAllIncomingCalls: failed for call ${call.id}", error) + Log.w( + TAG, + "dismissAllIncomingCalls: failed for call ${bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID)}", + error, + ) } } + try { + CallkitNotificationService.stopService(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed to stop callkit service", error) + } + try { removeAllCalls(appContext) } catch (error: Exception) { Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error) } - soundManager.stop() + stopIncomingRingtone(appContext) + scheduleMissedCallCleanup(notificationManager, bundles) + activeSoundManager = null + activeNotificationManager = null + } + + private fun resolveNotificationManager(context: Context): CallkitNotificationManager { + activeNotificationManager?.let { return it } + FlutterCallkitIncomingPlugin.getInstance() + ?.getCallkitNotificationManager() + ?.let { return it } + + val soundManager = CallkitSoundPlayerManager(context) + return CallkitNotificationManager(context, soundManager) + } + + private fun stopIncomingRingtone(context: Context) { + activeSoundManager?.stop() + FlutterCallkitIncomingPlugin.getInstance() + ?.getCallkitSoundPlayerManager() + ?.stop() + } + + /** + * Replaces the incoming notification before cancel so Android does not fire the + * timeout delete intent (which would show a missed-call notification). + */ + private fun dismissIncomingCallNotification( + context: Context, + notificationManager: CallkitNotificationManager, + bundle: Bundle, + ) { + val notificationId = incomingNotificationId(bundle) + + val silentNotification = NotificationCompat.Builder( + context, + CallkitNotificationManager.NOTIFICATION_CHANNEL_ID_INCOMING, + ) + .setSmallIcon(context.applicationInfo.icon) + .setContentTitle("") + .setSilent(true) + .setOngoing(false) + .setPriority(NotificationCompat.PRIORITY_MIN) + .build() + + val notificationManagerCompat = NotificationManagerCompat.from(context) + notificationManagerCompat.notify(notificationId, silentNotification) + notificationManagerCompat.cancel(notificationId) + + bundle.putBoolean(CallkitConstants.EXTRA_CALLKIT_MISSED_CALL_SHOW, false) + notificationManager.clearIncomingNotification(bundle, isAccepted = true) + notificationManager.clearMissCallNotification(bundle) + } + + private fun scheduleMissedCallCleanup( + notificationManager: CallkitNotificationManager, + bundles: List, + ) { + if (bundles.isEmpty()) return + + Handler(Looper.getMainLooper()).postDelayed({ + for (bundle in bundles) { + try { + notificationManager.clearMissCallNotification(bundle) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: delayed missed-call cleanup failed", error) + } + } + }, MISSED_CALL_CLEANUP_DELAY_MS) + } + + private fun incomingNotificationId(bundle: Bundle): Int { + return bundle.getString(CallkitConstants.EXTRA_CALLKIT_ID, "callkit_incoming").hashCode() } private fun buildCallDataBundle(data: Map): Bundle? { diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html index 379f47c9..fa7c1a97 100644 --- a/android/build/reports/problems/problems-report.html +++ b/android/build/reports/problems/problems-report.html @@ -650,7 +650,7 @@ code + .copy-button { diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart index 1b27f32f..5ff7ec6e 100644 --- a/lib/features/video_calls/services/video_call_ended_push_handler.dart +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -1,8 +1,26 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; /// Dismisses incoming call UI after a remote `video_call_ended` push. abstract final class VideoCallEndedPushHandler { + static const MethodChannel _androidPushChannel = MethodChannel( + 'com.eatvioapp/video_call_push', + ); + static Future dismissIncomingCallUi() async { + if (Platform.isAndroid) { + try { + await _androidPushChannel.invokeMethod('dismissIncomingCalls'); + return; + } catch (_) {} + } + + await _dismissViaCallKit(); + } + + static Future _dismissViaCallKit() async { try { final activeCalls = await FlutterCallkitIncoming.activeCalls(); for (final call in activeCalls) { diff --git a/pubspec.yaml b/pubspec.yaml index ee16681e..94fc5131 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.32+121 +version: 1.3.33+122 publish_to: 'none' environment: From 219908684f4982e9dccc7ab8ddbf56fe2d53198f Mon Sep 17 00:00:00 2001 From: misha Date: Mon, 6 Jul 2026 11:18:05 +0300 Subject: [PATCH 53/56] feat(video_calls): fixed naviagtion from push notification to call and added wake lock --- .../app/CallKitAcceptIntentMapper.kt | 53 ++ .../com/eatvioapp/app/CallKitIntentBridge.kt | 19 +- .../kotlin/com/eatvioapp/app/MainActivity.kt | 51 ++ .../app/PendingCallKitAcceptBridge.kt | 42 ++ .../managers/video_call_manager.dart | 2 +- .../video_call_session_cubit.dart | 8 + .../video_call_callkit_coordinator.dart | 471 +++++++++++++++--- .../video_call_notification_navigator.dart | 174 +++++-- ...deo_call_screen_wake_lock_coordinator.dart | 28 ++ pubspec.yaml | 3 +- 10 files changed, 752 insertions(+), 99 deletions(-) create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt create mode 100644 android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt create mode 100644 lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt new file mode 100644 index 00000000..3b6d3732 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitAcceptIntentMapper.kt @@ -0,0 +1,53 @@ +package com.eatvioapp.app + +import android.content.Intent +import android.os.Bundle +import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.FlutterCallkitIncomingPlugin + +object CallKitAcceptIntentMapper { + fun payloadFromBundle(data: Bundle, action: String): Map? { + if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) && + !action.contains(CallkitConstants.ACTION_CALL_CALLBACK) + ) { + return null + } + + return hashMapOf( + "action" to action, + "id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""), + "handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""), + "nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""), + "avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""), + "extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA), + ) + } + + fun payloadFromIntent(intent: Intent?): Map? { + if (intent == null) return null + + val data = intent.getBundleExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA) + ?: return null + val action = intent.action ?: return null + if (!action.contains(CallkitConstants.ACTION_CALL_ACCEPT) && + !action.contains(CallkitConstants.ACTION_CALL_CALLBACK) + ) { + return null + } + + return hashMapOf( + "action" to action, + "id" to data.getString(CallkitConstants.EXTRA_CALLKIT_ID, ""), + "handle" to data.getString(CallkitConstants.EXTRA_CALLKIT_HANDLE, ""), + "nameCaller" to data.getString(CallkitConstants.EXTRA_CALLKIT_NAME_CALLER, ""), + "avatar" to data.getString(CallkitConstants.EXTRA_CALLKIT_AVATAR, ""), + "extra" to data.getSerializable(CallkitConstants.EXTRA_CALLKIT_EXTRA), + ) + } + + fun clearCallKitLaunchIntent(intent: Intent?) { + if (intent == null) return + intent.removeExtra(FlutterCallkitIncomingPlugin.EXTRA_CALLKIT_CALL_DATA) + intent.action = null + } +} diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt index 2b412781..e433b4fc 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/CallKitIntentBridge.kt @@ -8,16 +8,31 @@ import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver object CallKitIntentBridge { fun dispatchCallKitAction(context: Context, action: String, data: Bundle?) { + if (data != null) { + CallKitAcceptIntentMapper.payloadFromBundle(data, action)?.let { payload -> + PendingCallKitAcceptBridge.store(payload) + } + } + val broadcastIntent = CallkitIncomingBroadcastReceiver.getIntent(context, action, data) broadcastIntent.addFlags(Intent.FLAG_RECEIVER_FOREGROUND) context.sendBroadcast(broadcastIntent) - if (MainActivity.isInForeground) { + val shouldLaunchMainActivity = + action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_ACCEPT || + action == com.hiennv.flutter_callkit_incoming.CallkitConstants.ACTION_CALL_CALLBACK || + !MainActivity.isInForeground + + if (!shouldLaunchMainActivity) { return } val activityIntent = AppUtils.getAppIntent(context, action, data) ?: return - activityIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + activityIntent.addFlags( + Intent.FLAG_ACTIVITY_NEW_TASK or + Intent.FLAG_ACTIVITY_SINGLE_TOP or + Intent.FLAG_ACTIVITY_REORDER_TO_FRONT, + ) context.startActivity(activityIntent) } } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt index c6b2fb43..1ae3354f 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.os.Build +import android.os.Bundle import android.util.Rational import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -34,6 +35,10 @@ class MainActivity: FlutterActivity() { override fun onResume() { super.onResume() isInForeground = true + captureCallKitLaunchIntent(intent) + if (PendingCallKitAcceptBridge.peek() != null) { + scheduleDeliverPendingCallKitAccept() + } } override fun onPause() { @@ -57,6 +62,12 @@ class MainActivity: FlutterActivity() { result.error("DISMISS_INCOMING_CALLS_FAILED", e.message, null) } } + "drainPendingCallKitAccept" -> { + result.success(PendingCallKitAcceptBridge.drain()) + } + "peekPendingCallKitAccept" -> { + result.success(PendingCallKitAcceptBridge.peekPending()) + } else -> result.notImplemented() } } @@ -124,6 +135,7 @@ class MainActivity: FlutterActivity() { // Обрабатываем возможный action_url, с которым была открыта Activity (например, из HMS уведомления). handleIntentForActionUrl(intent) + captureCallKitLaunchIntent(intent) } override fun onUserLeaveHint() { @@ -134,10 +146,49 @@ class MainActivity: FlutterActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) + captureCallKitLaunchIntent(intent) // Обработка нового интента, когда Activity уже запущена (тап по уведомлению). handleIntentForActionUrl(intent) } + private fun captureCallKitLaunchIntent(intent: Intent?) { + val payload = CallKitAcceptIntentMapper.payloadFromIntent(intent) ?: return + + android.util.Log.i( + "VideoCallPush", + "captureCallKitLaunchIntent: action=${payload["action"]} id=${payload["id"]}", + ) + + PendingCallKitAcceptBridge.store(payload) + CallKitAcceptIntentMapper.clearCallKitLaunchIntent(intent) + scheduleDeliverPendingCallKitAccept() + } + + private fun scheduleDeliverPendingCallKitAccept() { + PendingCallKitAcceptBridge.scheduleDeliverToFlutter( + deliver = { deliverPendingCallKitAcceptToFlutter() }, + ) + } + + private fun deliverPendingCallKitAcceptToFlutter() { + val payload = PendingCallKitAcceptBridge.peek() ?: return + val channel = videoCallPushChannel + if (channel == null) { + android.util.Log.w( + "VideoCallPush", + "deliverPendingCallKitAcceptToFlutter: channel not ready", + ) + return + } + + android.util.Log.i( + "VideoCallPush", + "deliverPendingCallKitAcceptToFlutter: id=${payload["id"]}", + ) + channel.invokeMethod("onCallKitLaunchIntent", payload) + } + private fun handleIntentForActionUrl(intent: Intent?) { if (intent == null) return diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt new file mode 100644 index 00000000..8be98e66 --- /dev/null +++ b/android/app/src/main/kotlin/com/eatvioapp/app/PendingCallKitAcceptBridge.kt @@ -0,0 +1,42 @@ +package com.eatvioapp.app + +import android.os.Handler +import android.os.Looper +import android.util.Log + +/// Stores accepted call payloads until the Flutter engine is ready to drain them. +object PendingCallKitAcceptBridge { + private const val TAG = "PendingCallKitAccept" + + @Volatile + private var pendingPayload: Map? = null + + private val mainHandler = Handler(Looper.getMainLooper()) + + fun store(payload: Map) { + pendingPayload = payload + Log.i(TAG, "stored pending accept id=${payload["id"]}") + } + + fun peek(): Map? = pendingPayload + + fun drain(): Map? { + val payload = pendingPayload + pendingPayload = null + if (payload != null) { + Log.i(TAG, "drained pending accept id=${payload["id"]}") + } + return payload + } + + fun peekPending(): Map? = pendingPayload + + fun scheduleDeliverToFlutter( + deliver: () -> Unit, + delaysMs: LongArray = longArrayOf(0L, 250L, 700L, 1500L, 2800L, 4200L, 6000L), + ) { + for (delayMs in delaysMs) { + mainHandler.postDelayed({ deliver() }, delayMs) + } + } +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 2501a772..12832204 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1136,4 +1136,4 @@ class VideoCallManager { await _audioSession.stop(); } catch (_) {} } -} +} \ No newline at end of file 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 index 220ec4fd..c35b9b34 100644 --- 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 @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/cubit/base_cubit.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.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/services/video_call_screen_wake_lock_coordinator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -14,9 +15,12 @@ class VideoCallSessionCubit extends BaseCubit { VideoCallSessionCubit({ required VideoCallManager videoCallManager, VideoCallSystemPipCoordinator? systemPipCoordinator, + VideoCallScreenWakeLockCoordinator? screenWakeLockCoordinator, }) : _videoCallManager = videoCallManager, _systemPipCoordinator = systemPipCoordinator ?? VideoCallSystemPipCoordinator(), + _screenWakeLock = + screenWakeLockCoordinator ?? VideoCallScreenWakeLockCoordinator(), super(const VideoCallSessionState()) { _videoCallManager.addListener(_onManagerSnapshot); _syncFromManager(_videoCallManager.snapshot); @@ -24,6 +28,7 @@ class VideoCallSessionCubit extends BaseCubit { final VideoCallManager _videoCallManager; final VideoCallSystemPipCoordinator _systemPipCoordinator; + final VideoCallScreenWakeLockCoordinator _screenWakeLock; bool _callScreenRouteVisible = false; bool _autoEnterInFlight = false; bool _counterpartPresentWhenLobbyReady = false; @@ -168,6 +173,7 @@ class VideoCallSessionCubit extends BaseCubit { VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { _callScreenRouteVisible = true; + await _screenWakeLock.acquire(); final effectiveEntryMode = state.entryMode == VideoCallEntryMode.incomingAccepted || @@ -507,6 +513,7 @@ class VideoCallSessionCubit extends BaseCubit { } Future endCall() async { + await _screenWakeLock.release(); await _systemPipCoordinator.disarm(); await _videoCallManager.endCall(); } @@ -567,6 +574,7 @@ class VideoCallSessionCubit extends BaseCubit { } Future _restoreSystemUiAfterCall() async { + await _screenWakeLock.release(); try { await SystemChrome.setPreferredOrientations(const []); await SystemChrome.setEnabledSystemUIMode( diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 57c718fa..8facc589 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -32,7 +32,8 @@ class VideoCallCallKitCoordinator { StreamSubscription? _eventSubscription; final Map _callKitParamsById = {}; - final Set _acceptHandledCallKitIds = {}; + final Set _acceptNavigationCompletedIds = {}; + final Map> _acceptDeliveriesInFlight = {}; final Set _suppressedEndCallKitIds = {}; String? _activeCallKitId; bool _initialized = false; @@ -41,12 +42,29 @@ class VideoCallCallKitCoordinator { static const MethodChannel _voipDiagnosticsChannel = MethodChannel( 'com.eatvioapp/voip_diagnostics', ); + static const MethodChannel _androidCallPushChannel = MethodChannel( + 'com.eatvioapp/video_call_push', + ); + + _PendingIncomingAccept? _pendingIncomingAccept; + _AndroidAcceptResumeObserver? _androidResumeObserver; + bool _pendingAcceptDrainScheduled = false; Future initialize() async { if (_initialized) return; _initialized = true; - _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); + _eventSubscription = FlutterCallkitIncoming.onEvent.listen( + _onCallKitEvent, + onError: (Object error, StackTrace stackTrace) { + log( + 'VideoCallCallKitCoordinator: CallKit event stream error: $error', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); + }, + cancelOnError: false, + ); VideoCallFcmHandler.registerForegroundListener(); @@ -70,6 +88,11 @@ class VideoCallCallKitCoordinator { if (Platform.isAndroid) { FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); + _androidResumeObserver = _AndroidAcceptResumeObserver( + onResumed: _onAndroidAppResumed, + ); + WidgetsBinding.instance.addObserver(_androidResumeObserver!); + _androidCallPushChannel.setMethodCallHandler(_onAndroidCallPushMethodCall); await FlutterCallkitIncoming.requestNotificationPermission(< String, dynamic @@ -80,13 +103,142 @@ class VideoCallCallKitCoordinator { 'postNotificationMessageRequired': 'Разрешите уведомления в настройках, чтобы получать входящие видеозвонки.', }); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(_pollNativePendingAcceptAfterResume()); + }); } } + Future _onAndroidCallPushMethodCall(MethodCall call) async { + if (call.method == 'onCallKitLaunchIntent') { + log( + 'VideoCallCallKitCoordinator: received onCallKitLaunchIntent push from native', + name: 'VideoCallCallKit', + ); + await _handleAndroidCallKitLaunchIntent(call.arguments); + } + } + + void _onAndroidAppResumed() { + unawaited(_pollNativePendingAcceptAfterResume()); + } + + Future _pollNativePendingAcceptAfterResume() async { + for (var attempt = 0; attempt < 16; attempt++) { + await _processNativePendingAndroidCallAccept(); + if (_pendingIncomingAccept == null) { + if (VideoCallNotificationNavigator.isOnCallRoute()) { + return; + } + final hasNativePending = await _hasNativePendingAndroidCallAccept(); + if (!hasNativePending && attempt >= 3) { + break; + } + } + await Future.delayed(const Duration(milliseconds: 300)); + } + unawaited(_drainPendingIncomingAcceptWithRetries()); + } + + Future _hasNativePendingAndroidCallAccept() async { + try { + final pending = await _androidCallPushChannel.invokeMethod( + 'peekPendingCallKitAccept', + ); + return pending != null; + } catch (_) { + return false; + } + } + + Future _processNativePendingAndroidCallAccept() async { + try { + final pending = await _androidCallPushChannel.invokeMethod( + 'peekPendingCallKitAccept', + ); + if (pending == null) return; + + log( + 'VideoCallCallKitCoordinator: processing native pending accept', + name: 'VideoCallCallKit', + ); + await _handleAndroidCallKitLaunchIntent(pending); + } on PlatformException catch (e, stackTrace) { + log( + 'VideoCallCallKitCoordinator: failed to peek native pending accept: $e', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); + } catch (e, stackTrace) { + log( + 'VideoCallCallKitCoordinator: failed to process native pending accept: $e', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); + } + } + + Future _handleAndroidCallKitLaunchIntent(Object? arguments) async { + final data = _stringKeyedMap(arguments); + final action = data['action']?.toString() ?? ''; + if (!action.contains('ACTION_CALL_ACCEPT') && + !action.contains('ACTION_CALL_CALLBACK')) { + return; + } + + final extra = _stringKeyedMap(data['extra']); + final callKitId = + _parseString(data, const ['id', 'uuid']) ?? + _parseString(extra, const ['conversationUuid', 'conversation_uuid']); + if (callKitId == null) return; + + final conversationUuid = _parseConversationUuid( + extra, + handle: _parseString(data, const ['handle']) ?? callKitId, + ); + if (conversationUuid.isEmpty) return; + + await _deliverIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: + _parseString(extra, const [ + 'counterpartName', + 'caller_name', + 'callerName', + ]) ?? + _parseString(data, const ['nameCaller']), + counterpartAvatarUrl: + _parseString(extra, const [ + 'counterpartAvatarUrl', + 'avatar', + 'avatarUrl', + 'caller_avatar', + ]) ?? + _parseString(data, const ['avatar']), + localParticipantName: _parseString(extra, const [ + 'localParticipantName', + 'local_participant_name', + ]), + counterpartIsConsultant: _parseBool(extra, const [ + 'counterpartIsConsultant', + 'counterpart_is_consultant', + 'is_consultant', + 'isConsultant', + ]), + ); + } + void dispose() { + final observer = _androidResumeObserver; + if (observer != null) { + WidgetsBinding.instance.removeObserver(observer); + _androidResumeObserver = null; + } unawaited(_eventSubscription?.cancel()); _eventSubscription = null; _callKitParamsById.clear(); + _pendingIncomingAccept = null; _initialized = false; } @@ -166,25 +318,34 @@ class VideoCallCallKitCoordinator { Future _onCallKitEvent(CallEvent? event) async { if (event == null) return; - unawaited(_captureCallKitEventToSentry(event)); - switch (event) { - case CallEventActionDidUpdateDevicePushTokenVoip(): - await _firebaseApi.registerPushToken(); - case CallEventActionCallIncoming(:final callKitParams): - _cacheCallKitParams(callKitParams); - case CallEventActionCallAccept(): - await _handleAcceptEvent(event); - case CallEventActionCallCallback(): - await _handleAcceptEvent(event); - case CallEventActionCallDecline(): - await _handleDeclineEvent(event); - case CallEventActionCallEnded(): - await _handleEndedEvent(event); - case CallEventActionCallTimeout(): - await _handleDeclineEvent(event); - default: - break; + try { + unawaited(_captureCallKitEventToSentry(event)); + + switch (event) { + case CallEventActionDidUpdateDevicePushTokenVoip(): + await _firebaseApi.registerPushToken(); + case CallEventActionCallIncoming(:final callKitParams): + _cacheCallKitParams(callKitParams); + case CallEventActionCallAccept(): + await _handleAcceptEvent(event); + case CallEventActionCallCallback(): + await _handleAcceptEvent(event); + case CallEventActionCallDecline(): + await _handleDeclineEvent(event); + case CallEventActionCallEnded(): + await _handleEndedEvent(event); + case CallEventActionCallTimeout(): + await _handleDeclineEvent(event); + default: + break; + } + } catch (error, stackTrace) { + log( + 'VideoCallCallKitCoordinator: failed to handle CallKit event $event: $error', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); } } @@ -314,7 +475,7 @@ class VideoCallCallKitCoordinator { Future _paramsForCallIdOrActiveCall(String id) async { final cached = _paramsForCallId(id); - if (cached != null || !Platform.isIOS) return cached; + if (cached != null) return cached; final activeCalls = await FlutterCallkitIncoming.activeCalls(); for (final params in activeCalls) { @@ -420,7 +581,7 @@ class VideoCallCallKitCoordinator { } Future _handleAccept(String callKitId) async { - if (_acceptHandledCallKitIds.contains(callKitId)) return; + if (_acceptNavigationCompletedIds.contains(callKitId)) return; _activeCallKitId = callKitId; final params = await _paramsForCallIdOrActiveCall(callKitId); @@ -469,6 +630,24 @@ class VideoCallCallKitCoordinator { ); } + void _rememberPendingIncomingAccept({ + required String callKitId, + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + bool? counterpartIsConsultant, + }) { + _pendingIncomingAccept = _PendingIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + } + Future _deliverIncomingAccept({ required String callKitId, required String conversationUuid, @@ -477,57 +656,202 @@ class VideoCallCallKitCoordinator { String? localParticipantName, bool? counterpartIsConsultant, }) async { - if (_acceptHandledCallKitIds.contains(callKitId)) return; - _acceptHandledCallKitIds.add(callKitId); - _suppressedEndCallKitIds.add(callKitId); + log( + 'VideoCallCallKitCoordinator: deliverIncomingAccept callKitId=$callKitId', + name: 'VideoCallCallKit', + ); - unawaited(_manager.notifyIncomingCallAccepted(conversationUuid)); + _rememberPendingIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + if (_acceptNavigationCompletedIds.contains(callKitId)) return; + + final inFlight = _acceptDeliveriesInFlight[callKitId]; + if (inFlight != null) { + await inFlight; + if (_acceptNavigationCompletedIds.contains(callKitId)) return; + } + + final delivery = _runIncomingAcceptDelivery( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + _acceptDeliveriesInFlight[callKitId] = delivery; try { - if (!Platform.isIOS) { - await FlutterCallkitIncoming.endCall(callKitId); - } - - final binding = WidgetsBinding.instance; - final completer = Completer(); - binding.addPostFrameCallback((_) { - unawaited( - _openConnectingScreenAfterAccept( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - counterpartAvatarUrl: counterpartAvatarUrl, - localParticipantName: localParticipantName, - counterpartIsConsultant: counterpartIsConsultant, - ).whenComplete(completer.complete), - ); - }); - await completer.future; - - if (Platform.isIOS) { - await FlutterCallkitIncoming.endCall(callKitId); - } + await delivery; } finally { - unawaited( - Future.delayed(const Duration(seconds: 2), () { - _acceptHandledCallKitIds.remove(callKitId); - _suppressedEndCallKitIds.remove(callKitId); - }), - ); + if (identical(_acceptDeliveriesInFlight[callKitId], delivery)) { + _acceptDeliveriesInFlight.remove(callKitId); + } } } - Future _openConnectingScreenAfterAccept({ + Future _runIncomingAcceptDelivery({ + required String callKitId, required String conversationUuid, String? counterpartName, String? counterpartAvatarUrl, String? localParticipantName, bool? counterpartIsConsultant, }) async { - if (Platform.isIOS) { - FlutterNativeSplash.remove(); + if (_acceptNavigationCompletedIds.contains(callKitId)) return; + + _suppressedEndCallKitIds.add(callKitId); + unawaited(_manager.notifyIncomingCallAccepted(conversationUuid)); + + try { + await _waitForAcceptNavigationReady(); + + final navigated = await _openConnectingScreenAfterAccept( + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + + if (navigated) { + _acceptNavigationCompletedIds.add(callKitId); + _pendingIncomingAccept = null; + if (Platform.isAndroid) { + try { + await _androidCallPushChannel.invokeMethod( + 'drainPendingCallKitAccept', + ); + } catch (_) {} + } + } else { + _pendingIncomingAccept = _PendingIncomingAccept( + callKitId: callKitId, + conversationUuid: conversationUuid, + counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, + localParticipantName: localParticipantName, + counterpartIsConsultant: counterpartIsConsultant, + ); + log( + 'VideoCallCallKitCoordinator: accept navigation deferred until UI is ready, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); + unawaited(_drainPendingIncomingAcceptWithRetries()); + } + + await FlutterCallkitIncoming.endCall(callKitId); + } catch (error, stackTrace) { + log( + 'VideoCallCallKitCoordinator: accept delivery failed, callKitId=$callKitId: $error', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); + } finally { + unawaited( + Future.delayed(const Duration(seconds: 2), () { + _suppressedEndCallKitIds.remove(callKitId); + _acceptNavigationCompletedIds.remove(callKitId); + }), + ); + } + } + + Future _drainPendingIncomingAcceptWithRetries() async { + if (_pendingIncomingAccept == null) return; + if (_pendingAcceptDrainScheduled) return; + + _pendingAcceptDrainScheduled = true; + try { + const retryDelaysMs = [0, 250, 600, 1200, 2000, 3200]; + for (final delayMs in retryDelaysMs) { + if (_pendingIncomingAccept == null) return; + if (delayMs > 0) { + await Future.delayed(Duration(milliseconds: delayMs)); + } + await _drainPendingIncomingAccept(); + if (_pendingIncomingAccept == null) return; + } + } finally { + _pendingAcceptDrainScheduled = false; + } + } + + Future _drainPendingIncomingAccept() async { + final pending = _pendingIncomingAccept; + if (pending == null) return; + if (_acceptNavigationCompletedIds.contains(pending.callKitId)) { + _pendingIncomingAccept = null; + return; } - await VideoCallNotificationNavigator.openIncomingCall( + await _waitForAcceptNavigationReady(); + await _settleUiAfterResume(); + + final navigated = await _openConnectingScreenAfterAccept( + conversationUuid: pending.conversationUuid, + counterpartName: pending.counterpartName, + counterpartAvatarUrl: pending.counterpartAvatarUrl, + localParticipantName: pending.localParticipantName, + counterpartIsConsultant: pending.counterpartIsConsultant, + ); + + if (!navigated) return; + + _acceptNavigationCompletedIds.add(pending.callKitId); + _pendingIncomingAccept = null; + if (Platform.isAndroid) { + try { + await _androidCallPushChannel.invokeMethod( + 'drainPendingCallKitAccept', + ); + } catch (_) {} + } + try { + await FlutterCallkitIncoming.endCall(pending.callKitId); + } catch (_) {} + } + + Future _settleUiAfterResume() async { + final binding = WidgetsBinding.instance; + for (var frame = 0; frame < 3; frame++) { + await binding.endOfFrame; + } + await Future.delayed(const Duration(milliseconds: 80)); + } + + Future _waitForAcceptNavigationReady({ + Duration timeout = const Duration(seconds: 10), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle == AppLifecycleState.resumed) { + for (var frame = 0; frame < 2; frame++) { + await WidgetsBinding.instance.endOfFrame; + } + return; + } + await Future.delayed(const Duration(milliseconds: 50)); + } + } + + Future _openConnectingScreenAfterAccept({ + required String conversationUuid, + String? counterpartName, + String? counterpartAvatarUrl, + String? localParticipantName, + bool? counterpartIsConsultant, + }) async { + FlutterNativeSplash.remove(); + + return VideoCallNotificationNavigator.openIncomingCall( conversationUuid: conversationUuid, counterpartName: counterpartName, counterpartAvatarUrl: counterpartAvatarUrl, @@ -663,3 +987,34 @@ class VideoCallCallKitCoordinator { }); } } + +class _PendingIncomingAccept { + const _PendingIncomingAccept({ + required this.callKitId, + required this.conversationUuid, + this.counterpartName, + this.counterpartAvatarUrl, + this.localParticipantName, + this.counterpartIsConsultant, + }); + + final String callKitId; + final String conversationUuid; + final String? counterpartName; + final String? counterpartAvatarUrl; + final String? localParticipantName; + final bool? counterpartIsConsultant; +} + +class _AndroidAcceptResumeObserver with WidgetsBindingObserver { + _AndroidAcceptResumeObserver({required this.onResumed}); + + final VoidCallback onResumed; + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + onResumed(); + } + } +} diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 5ed4bc16..8462baf0 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -1,14 +1,19 @@ import 'dart:async'; +import 'dart:developer'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager.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:go_router/go_router.dart'; +import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_native_splash/flutter_native_splash.dart'; class VideoCallNotificationNavigator { + static bool _pushInFlight = false; + static Future openActiveCall({String? conversationUuid}) async { final manager = sl(); final uuid = conversationUuid ?? manager.conversationUuid; @@ -27,7 +32,8 @@ class VideoCallNotificationNavigator { ); } - static Future openIncomingCall({ + /// Returns `true` when the call UI is visible or navigation succeeded. + static Future openIncomingCall({ required String conversationUuid, String? counterpartName, String? counterpartAvatarUrl, @@ -35,26 +41,103 @@ class VideoCallNotificationNavigator { bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { - if (conversationUuid.isEmpty) return; + if (conversationUuid.isEmpty) return false; - final pushed = await _pushCallScreen( - conversationUuid: conversationUuid, - counterpartName: counterpartName, - counterpartAvatarUrl: counterpartAvatarUrl, - localParticipantName: localParticipantName, - counterpartIsConsultant: counterpartIsConsultant, - entryMode: entryMode, - ); + FlutterNativeSplash.remove(); + final readySince = DateTime.now(); + await _waitUntilNavigationReady(since: readySince); - if (!pushed) { - await sl().onCallScreenVisible( + for (var attempt = 0; attempt < 8; attempt++) { + if (attempt > 0) { + await Future.delayed(Duration(milliseconds: 200 * attempt)); + await _waitUntilNavigationReady(since: readySince); + } + + if (isOnCallRoute()) { + return true; + } + + final pushed = await _pushCallScreen( conversationUuid: conversationUuid, counterpartName: counterpartName, + counterpartAvatarUrl: counterpartAvatarUrl, localParticipantName: localParticipantName, counterpartIsConsultant: counterpartIsConsultant, entryMode: entryMode, ); + + if (pushed) { + return true; + } } + + return isOnCallRoute(); + } + + static bool isOnCallRoute() { + final matches = + AppRouter.appRouter.routerDelegate.currentConfiguration.matches; + if (matches.isEmpty) return false; + return matches.last.matchedLocation.contains(VideoCallScreen.path); + } + + static Future _waitUntilNavigationReady({ + required DateTime since, + Duration timeout = const Duration(seconds: 12), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + final lifecycle = WidgetsBinding.instance.lifecycleState; + final context = AppRouter.navigatorKey.currentContext; + final routerReady = _isRouterReadyForCallNavigation(since: since); + + if (lifecycle == AppLifecycleState.resumed && + context != null && + context.mounted && + routerReady) { + for (var frame = 0; frame < 2; frame++) { + await SchedulerBinding.instance.endOfFrame; + } + return; + } + await Future.delayed(const Duration(milliseconds: 50)); + } + } + + static bool _isRouterReadyForCallNavigation({required DateTime since}) { + final matches = + AppRouter.appRouter.routerDelegate.currentConfiguration.matches; + if (matches.isEmpty) return false; + + final topLocation = matches.last.matchedLocation; + if (topLocation == RouteConstants.updateScreen) { + // Cold start: wait for version-check redirect unless it is taking too long. + return DateTime.now().difference(since) > + const Duration(seconds: 6); + } + return true; + } + + static Future _waitForRouteToSettle() async { + for (var frame = 0; frame < 4; frame++) { + await SchedulerBinding.instance.endOfFrame; + } + await Future.delayed(const Duration(milliseconds: 80)); + } + + static Future _waitForCallRouteVisible({ + Duration timeout = const Duration(seconds: 3), + }) async { + final deadline = DateTime.now().add(timeout); + while (DateTime.now().isBefore(deadline)) { + if (isOnCallRoute()) { + await _waitForRouteToSettle(); + return isOnCallRoute(); + } + await SchedulerBinding.instance.endOfFrame; + await Future.delayed(const Duration(milliseconds: 30)); + } + return isOnCallRoute(); } static Future _pushCallScreen({ @@ -65,32 +148,49 @@ class VideoCallNotificationNavigator { bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { - for (var attempt = 0; attempt < 100; attempt++) { - final ctx = AppRouter.navigatorKey.currentContext; - if (ctx != null && ctx.mounted) { - final router = GoRouter.of(ctx); - final matches = router.routerDelegate.currentConfiguration.matches; - final onCallRoute = matches.any( - (m) => m.matchedLocation.contains(VideoCallScreen.path), - ); - if (!onCallRoute) { - await router.pushNamed( + if (isOnCallRoute()) { + return true; + } + if (_pushInFlight) { + return false; + } + + final context = AppRouter.navigatorKey.currentContext; + if (context == null || !context.mounted) { + return false; + } + + final extra = { + 'conversationUuid': conversationUuid, + 'counterpartName': counterpartName, + 'counterpartAvatarUrl': counterpartAvatarUrl, + 'localParticipantName': localParticipantName, + 'counterpartIsConsultant': ?counterpartIsConsultant, + 'entryMode': entryMode.name, + }; + + _pushInFlight = true; + try { + try { + // pushNamed completes when the route is popped, not when it opens. + unawaited( + AppRouter.appRouter.pushNamed( VideoCallScreen.name, - extra: { - 'conversationUuid': conversationUuid, - 'counterpartName': counterpartName, - 'counterpartAvatarUrl': counterpartAvatarUrl, - 'localParticipantName': localParticipantName, - 'counterpartIsConsultant': ?counterpartIsConsultant, - 'entryMode': entryMode.name, - }, - ); - return true; - } + extra: extra, + ), + ); + } catch (error, stackTrace) { + log( + 'VideoCallNotificationNavigator: pushNamed failed: $error', + name: 'VideoCallCallKit', + stackTrace: stackTrace, + ); return false; } - await Future.delayed(const Duration(milliseconds: 100)); + + return _waitForCallRouteVisible(); + } finally { + _pushInFlight = false; } - return false; } } diff --git a/lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart b/lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart new file mode 100644 index 00000000..522177de --- /dev/null +++ b/lib/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart @@ -0,0 +1,28 @@ +import 'package:wakelock_plus/wakelock_plus.dart'; + + +class VideoCallScreenWakeLockCoordinator { + bool _held = false; + + bool get isHeld => _held; + + Future acquire() async { + if (_held) return; + + try { + await WakelockPlus.enable(); + _held = true; + } catch (_) { + _held = false; + } + } + + Future release() async { + if (!_held) return; + + _held = false; + try { + await WakelockPlus.disable(); + } catch (_) {} + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 94fc5131..81aa249c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.33+122 +version: 1.3.34+123 publish_to: 'none' environment: @@ -89,6 +89,7 @@ dependencies: flutter_foreground_task: ^9.2.1 flutter_callkit_incoming: ^3.1.1 floating: ^6.0.0 + wakelock_plus: ^1.5.2 dev_dependencies: build_runner: ^2.10.4 From dbaf639ac619ffcdc0121148e49d6b0e706d37db Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 15 Jul 2026 15:18:53 +0300 Subject: [PATCH 54/56] fix: analyzer fix --- lib/core/error_reporting/error_report_policy.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/core/error_reporting/error_report_policy.dart b/lib/core/error_reporting/error_report_policy.dart index b8fb63ea..b7ff92ec 100644 --- a/lib/core/error_reporting/error_report_policy.dart +++ b/lib/core/error_reporting/error_report_policy.dart @@ -46,7 +46,6 @@ class ErrorReportPolicy { case DioExceptionType.receiveTimeout: case DioExceptionType.transformTimeout: case DioExceptionType.connectionError: - case DioExceptionType.transformTimeout: return true; case DioExceptionType.unknown: case DioExceptionType.badResponse: From 5ec877ef72f6eef46e198aff0f479a9222ea9566 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 15 Jul 2026 15:19:56 +0300 Subject: [PATCH 55/56] version changed --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index cb90827b..e5cc431d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.34+123 +version: 1.3.35+124 publish_to: 'none' environment: From 5dc1f48fd1cfc9b48121cd3772d7aa6a501de349 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 15 Jul 2026 17:31:32 +0300 Subject: [PATCH 56/56] feat(ci/cd): added api key for tf --- ios/fastlane/Fastfile | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 242ef07d..904991ee 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -92,8 +92,14 @@ platform :ios do # codesigning_identity: "Apple Distribution", export_team_id: "VL68C36HYS" ) - + app_store_connect_api_key( + key_id: "325JS9JCZH", + issuer_id: "dfa27d1d-2bb8-4d4d-847e-0b32f9b880b3", + key_filepath: auth_key_path, + duration: 1200, + in_house: false + ) begin