forked from eatvio/eatvio_app
feat(vide_calls): gms calls handling
This commit is contained in:
3
android/app/proguard-rules.pro
vendored
3
android/app/proguard-rules.pro
vendored
@@ -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.**
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
@@ -77,6 +80,11 @@
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<service
|
||||
android:name="com.pravera.flutter_foreground_task.service.ForegroundService"
|
||||
android:foregroundServiceType="microphone"
|
||||
android:exported="false"
|
||||
android:stopWithTask="true" />
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -548,7 +548,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
@@ -587,7 +587,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Runner/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = Eatvio;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import YandexLoginSDK
|
||||
import flutter_foreground_task
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
@@ -9,6 +10,9 @@ import YandexLoginSDK
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
SwiftFlutterForegroundTaskPlugin.setPluginRegistrantCallback { registry in
|
||||
GeneratedPluginRegistrant.register(with: registry)
|
||||
}
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
<string>fetch</string>
|
||||
<string>remote-notification</string>
|
||||
</array>
|
||||
|
||||
@@ -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/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/core/comments/comments_cubit/comments_cubit.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
@@ -72,7 +73,12 @@ class CubitRegistrations {
|
||||
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||
sl.registerLazySingleton<VideoCallSessionCubit>(() => VideoCallSessionCubit(videoCallRepository: sl()));
|
||||
sl.registerLazySingleton<VideoCallManager>(
|
||||
() => VideoCallManager(videoCallRepository: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallSessionCubit>(
|
||||
() => VideoCallSessionCubit(videoCallManager: sl()),
|
||||
);
|
||||
}
|
||||
|
||||
/// Регистрация Cubit'ов как фабрик
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
@@ -25,7 +24,6 @@ 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';
|
||||
@@ -241,13 +239,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: _ChatHeaderVideoCallButton(
|
||||
onPressed: () {
|
||||
unawaited(
|
||||
sl<VideoCallSessionCubit>().startCall(
|
||||
conversationUuid: widget.uuid,
|
||||
counterpartName: state.chatRoom.users.receiver.name,
|
||||
localParticipantName: state.chatRoom.users.sender.name,
|
||||
),
|
||||
);
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
|
||||
1091
lib/features/video_calls/managers/video_call_manager.dart
Normal file
1091
lib/features/video_calls/managers/video_call_manager.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
|
||||
class VideoCallManagerSnapshot {
|
||||
const VideoCallManagerSnapshot({
|
||||
required this.conversationUuid,
|
||||
this.counterpartName,
|
||||
this.localParticipantName,
|
||||
required this.micEnabled,
|
||||
required this.videoEnabled,
|
||||
this.connectError,
|
||||
required this.connectPhase,
|
||||
required this.linkIssue,
|
||||
this.systemAudioInterrupted = false,
|
||||
});
|
||||
|
||||
final String conversationUuid;
|
||||
final String? counterpartName;
|
||||
final String? localParticipantName;
|
||||
final bool micEnabled;
|
||||
final bool videoEnabled;
|
||||
final String? connectError;
|
||||
final VideoCallConnectPhase connectPhase;
|
||||
final VideoCallLinkIssue linkIssue;
|
||||
final bool systemAudioInterrupted;
|
||||
|
||||
static const initial = VideoCallManagerSnapshot(
|
||||
conversationUuid: '',
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
connectPhase: VideoCallConnectPhase.idle,
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
);
|
||||
|
||||
VideoCallManagerSnapshot copyWith({
|
||||
String? conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
bool? micEnabled,
|
||||
bool? videoEnabled,
|
||||
String? connectError,
|
||||
VideoCallConnectPhase? connectPhase,
|
||||
VideoCallLinkIssue? linkIssue,
|
||||
bool? systemAudioInterrupted,
|
||||
bool clearConnectError = false,
|
||||
}) {
|
||||
return VideoCallManagerSnapshot(
|
||||
conversationUuid: conversationUuid ?? this.conversationUuid,
|
||||
counterpartName: counterpartName ?? this.counterpartName,
|
||||
localParticipantName: localParticipantName ?? this.localParticipantName,
|
||||
micEnabled: micEnabled ?? this.micEnabled,
|
||||
videoEnabled: videoEnabled ?? this.videoEnabled,
|
||||
connectError: clearConnectError ? null : (connectError ?? this.connectError),
|
||||
connectPhase: connectPhase ?? this.connectPhase,
|
||||
linkIssue: linkIssue ?? this.linkIssue,
|
||||
systemAudioInterrupted:
|
||||
systemAudioInterrupted ?? this.systemAudioInterrupted,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,120 +1,100 @@
|
||||
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/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/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<VideoCallSessionState> {
|
||||
VideoCallSessionCubit({required VideoCallRepository videoCallRepository})
|
||||
: _videoCallRepository = videoCallRepository,
|
||||
super(const VideoCallSessionState());
|
||||
VideoCallSessionCubit({required VideoCallManager videoCallManager})
|
||||
: _videoCallManager = videoCallManager,
|
||||
super(const VideoCallSessionState()) {
|
||||
_videoCallManager.addListener(_onManagerSnapshot);
|
||||
_syncFromManager(_videoCallManager.snapshot);
|
||||
}
|
||||
|
||||
final VideoCallRepository _videoCallRepository;
|
||||
final VideoCallManager _videoCallManager;
|
||||
|
||||
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? get liveKitRoom => _videoCallManager.room;
|
||||
|
||||
lk.Room? _room;
|
||||
lk.EventsListener<lk.RoomEvent>? _roomEventsListener;
|
||||
OverlayEntry? _poorConnectionEntry;
|
||||
Timer? _mutedMicPollTimer;
|
||||
DateTime? _lastMutedMicOverlayAt;
|
||||
bool _recordingOverlayScheduled = false;
|
||||
bool _mutedMicStatsInFlight = false;
|
||||
num? _lastMutedMicTotalEnergy;
|
||||
bool _liveKitDisposed = true;
|
||||
bool _callEndedNotified = true;
|
||||
static bool _isCallSessionInactive(VideoCallManagerSnapshot snapshot) {
|
||||
return snapshot.conversationUuid.isEmpty ||
|
||||
snapshot.connectPhase == VideoCallConnectPhase.idle;
|
||||
}
|
||||
|
||||
lk.Room? get liveKitRoom => _room;
|
||||
VideoCallSessionState _sessionStateFromSnapshot(
|
||||
VideoCallManagerSnapshot snapshot,
|
||||
) {
|
||||
final hideStaleConnectError =
|
||||
snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||
snapshot.linkIssue != VideoCallLinkIssue.lost;
|
||||
final resetCallStage =
|
||||
_isCallSessionInactive(snapshot) ||
|
||||
snapshot.connectPhase == VideoCallConnectPhase.connecting;
|
||||
|
||||
lk.CameraCaptureOptions _cameraCaptureOptions({
|
||||
lk.CameraPosition? cameraPosition,
|
||||
}) {
|
||||
return lk.CameraCaptureOptions(
|
||||
cameraPosition:
|
||||
cameraPosition ??
|
||||
(state.useFrontCamera
|
||||
? lk.CameraPosition.front
|
||||
: lk.CameraPosition.back),
|
||||
params: lk.VideoParametersPresets.h1080_169,
|
||||
return state.copyWith(
|
||||
conversationUuid: snapshot.conversationUuid,
|
||||
counterpartName: snapshot.counterpartName,
|
||||
localParticipantName: snapshot.localParticipantName,
|
||||
micEnabled: snapshot.micEnabled,
|
||||
videoEnabled: snapshot.videoEnabled,
|
||||
connectError: hideStaleConnectError ? null : snapshot.connectError,
|
||||
connectPhase: snapshot.connectPhase,
|
||||
linkIssue: snapshot.linkIssue,
|
||||
systemAudioInterrupted: snapshot.systemAudioInterrupted,
|
||||
callStageEntered: resetCallStage ? false : state.callStageEntered,
|
||||
uiMode:
|
||||
_isCallSessionInactive(snapshot)
|
||||
? VideoCallUiMode.hidden
|
||||
: state.uiMode,
|
||||
);
|
||||
}
|
||||
|
||||
BuildContext? get _navigatorContext => AppRouter.navigatorKey.currentContext;
|
||||
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
|
||||
if (isClosed) return;
|
||||
if (_isCallSessionInactive(snapshot)) {
|
||||
emit(const VideoCallSessionState());
|
||||
unawaited(_restoreSystemUiAfterCall());
|
||||
return;
|
||||
}
|
||||
emit(_sessionStateFromSnapshot(snapshot));
|
||||
}
|
||||
|
||||
void _syncFromManager(VideoCallManagerSnapshot snapshot) {
|
||||
if (_isCallSessionInactive(snapshot)) {
|
||||
emit(const VideoCallSessionState());
|
||||
return;
|
||||
}
|
||||
emit(_sessionStateFromSnapshot(snapshot));
|
||||
}
|
||||
|
||||
/// Синхронизирует UI-состояние после [VideoCallManager.endCall] из notification /
|
||||
/// foreground task (без повторного disconnect).
|
||||
Future<void> onCallEndedExternally() async {
|
||||
await _onCallEndedExternally();
|
||||
}
|
||||
|
||||
Future<void> _onCallEndedExternally() async {
|
||||
if (isClosed) return;
|
||||
await _restoreSystemUiAfterCall();
|
||||
emit(const VideoCallSessionState());
|
||||
}
|
||||
|
||||
Future<void> 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,
|
||||
),
|
||||
await _videoCallManager.startCall(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
micEnabled: state.micEnabled,
|
||||
videoEnabled: state.videoEnabled,
|
||||
);
|
||||
_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<void> onCallScreenVisible({
|
||||
@@ -122,22 +102,39 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
}) async {
|
||||
if (state.conversationUuid != conversationUuid ||
|
||||
_room == null ||
|
||||
_liveKitDisposed) {
|
||||
await startCall(
|
||||
final isResumingActiveCall =
|
||||
state.conversationUuid == conversationUuid &&
|
||||
state.callStageEntered &&
|
||||
_videoCallManager.room != null;
|
||||
|
||||
if (isResumingActiveCall) {
|
||||
await _videoCallManager.onCallScreenVisible(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
micEnabled: state.micEnabled,
|
||||
videoEnabled: state.videoEnabled,
|
||||
);
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
counterpartName: counterpartName ?? state.counterpartName,
|
||||
localParticipantName:
|
||||
localParticipantName ?? state.localParticipantName,
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
callStageEntered: false,
|
||||
connectError: null,
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
),
|
||||
);
|
||||
await _videoCallManager.startCall(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
);
|
||||
}
|
||||
|
||||
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
|
||||
@@ -170,6 +167,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
await _restoreSystemUiAfterCall();
|
||||
}
|
||||
|
||||
Future<void> restoreFullscreenFromPip() async {
|
||||
if (state.uiMode != VideoCallUiMode.pip) return;
|
||||
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> leaveCallRoute() async {
|
||||
if (state.connectPhase == VideoCallConnectPhase.connected &&
|
||||
state.linkIssue != VideoCallLinkIssue.lost) {
|
||||
@@ -183,73 +188,25 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
}
|
||||
}
|
||||
|
||||
void enterCallStage() {
|
||||
Future<void> enterCallStage() async {
|
||||
if (isClosed) return;
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
if (state.callStageEntered) return;
|
||||
emit(state.copyWith(callStageEntered: true));
|
||||
await _videoCallManager.onEnterCallStage();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isClosed) return;
|
||||
_scheduleRecordingNoticeOnce();
|
||||
_videoCallManager.scheduleRecordingNoticeOnce();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> 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();
|
||||
}
|
||||
await _videoCallManager.retryAfterConnectionLoss();
|
||||
emit(state.copyWith(callStageEntered: false));
|
||||
}
|
||||
|
||||
Future<void> 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());
|
||||
await _videoCallManager.endCall();
|
||||
}
|
||||
|
||||
void expandLocal() {
|
||||
@@ -281,240 +238,16 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
}
|
||||
|
||||
Future<void> 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,
|
||||
);
|
||||
await _videoCallManager.toggleMic(state.isMicActiveForUi);
|
||||
}
|
||||
|
||||
Future<void> 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);
|
||||
}
|
||||
await _videoCallManager.toggleVideo(state.videoEnabled);
|
||||
}
|
||||
|
||||
Future<void> 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<lk.RoomReconnectingEvent>((_) {
|
||||
if (isClosed) return;
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
emit(state.copyWith(linkIssue: VideoCallLinkIssue.reconnecting));
|
||||
})
|
||||
..on<lk.RoomReconnectedEvent>((_) {
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(linkIssue: VideoCallLinkIssue.none));
|
||||
})
|
||||
..on<lk.RoomDisconnectedEvent>((_) {
|
||||
if (isClosed) return;
|
||||
if (_liveKitDisposed) return;
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
emit(state.copyWith(linkIssue: VideoCallLinkIssue.lost));
|
||||
})
|
||||
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
|
||||
if (e.participant != room.localParticipant) return;
|
||||
if (isClosed) return;
|
||||
_syncPoorConnectionOverlay(e.connectionQuality);
|
||||
})
|
||||
..on<lk.ActiveSpeakersChangedEvent>((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<void> _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 (_) {}
|
||||
await _videoCallManager.switchCameraFacing(state.useFrontCamera);
|
||||
emit(state.copyWith(useFrontCamera: !state.useFrontCamera));
|
||||
}
|
||||
|
||||
Future<void> _applyVideoCallSystemUi() async {
|
||||
@@ -541,185 +274,15 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
} on PlatformException catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _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;
|
||||
String resolvedLocalDisplayName() =>
|
||||
_videoCallManager.resolvedLocalDisplayName();
|
||||
|
||||
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<void> _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<bool> _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<bool> _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;
|
||||
}
|
||||
String resolvedRemoteDisplayName(String fallback) =>
|
||||
_videoCallManager.resolvedRemoteDisplayName(fallback);
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_mutedMicPollTimer?.cancel();
|
||||
_videoCallManager.removeListener(_onManagerSnapshot);
|
||||
await endCall();
|
||||
return super.close();
|
||||
}
|
||||
|
||||
@@ -14,12 +14,14 @@ enum VideoCallLinkIssue { none, reconnecting, lost }
|
||||
|
||||
@freezed
|
||||
abstract class VideoCallSessionState with _$VideoCallSessionState {
|
||||
const VideoCallSessionState._();
|
||||
|
||||
const factory VideoCallSessionState({
|
||||
@Default('') String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
@Default(true) bool micEnabled,
|
||||
@Default(true) bool videoEnabled,
|
||||
@Default(false) bool micEnabled,
|
||||
@Default(false) bool videoEnabled,
|
||||
@Default(true) bool splitParticipantView,
|
||||
@Default(false) bool mainStageShowsLocalVideo,
|
||||
@Default(true) bool useFrontCamera,
|
||||
@@ -28,5 +30,9 @@ abstract class VideoCallSessionState with _$VideoCallSessionState {
|
||||
@Default(VideoCallConnectPhase.idle) VideoCallConnectPhase connectPhase,
|
||||
@Default(VideoCallLinkIssue.none) VideoCallLinkIssue linkIssue,
|
||||
@Default(false) bool callStageEntered,
|
||||
@Default(false) bool systemAudioInterrupted,
|
||||
}) = _VideoCallSessionState;
|
||||
|
||||
/// UI for mic control: off while GSM / system audio holds the session.
|
||||
bool get isMicActiveForUi => micEnabled && !systemAudioInterrupted;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
a.counterpartName != b.counterpartName ||
|
||||
a.callStageEntered != b.callStageEntered ||
|
||||
a.micEnabled != b.micEnabled ||
|
||||
a.systemAudioInterrupted != b.systemAudioInterrupted ||
|
||||
a.videoEnabled != b.videoEnabled ||
|
||||
a.splitParticipantView != b.splitParticipantView ||
|
||||
a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo ||
|
||||
@@ -125,7 +126,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
final room = _cubit.liveKitRoom;
|
||||
|
||||
final showLost =
|
||||
sessionState.linkIssue == VideoCallLinkIssue.lost ||
|
||||
sessionState.linkIssue == VideoCallLinkIssue.lost ||
|
||||
(sessionState.connectPhase == VideoCallConnectPhase.idle &&
|
||||
sessionState.connectError != null);
|
||||
|
||||
@@ -160,8 +161,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
final showEntranceLobby =
|
||||
!sessionState.callStageEntered &&
|
||||
sessionState.linkIssue == VideoCallLinkIssue.none &&
|
||||
(sessionState.connectPhase == VideoCallConnectPhase.connecting ||
|
||||
sessionState.connectPhase == VideoCallConnectPhase.connected);
|
||||
sessionState.connectPhase != VideoCallConnectPhase.idle;
|
||||
|
||||
if (showEntranceLobby) {
|
||||
final remoteName = _cubit.resolvedRemoteDisplayName(
|
||||
@@ -176,7 +176,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
body: VideoCallConnectingEntranceScreen(
|
||||
counterpartDisplayName: remoteName,
|
||||
counterpartAvatarUrl: widget.counterpartAvatarUrl,
|
||||
micEnabled: sessionState.micEnabled,
|
||||
micEnabled: sessionState.isMicActiveForUi,
|
||||
videoEnabled: sessionState.videoEnabled,
|
||||
canEnterCall: canEnterCall,
|
||||
onEnterCall: _cubit.enterCallStage,
|
||||
@@ -237,8 +237,9 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
remoteDisplayName: _cubit.resolvedRemoteDisplayName(
|
||||
counterpartFallback,
|
||||
),
|
||||
micEnabled: sessionState.micEnabled,
|
||||
micEnabled: sessionState.isMicActiveForUi,
|
||||
videoEnabled: sessionState.videoEnabled,
|
||||
systemAudioInterrupted: sessionState.systemAudioInterrupted,
|
||||
splitParticipantView: sessionState.splitParticipantView,
|
||||
mainStageShowsLocalVideo:
|
||||
sessionState.mainStageShowsLocalVideo,
|
||||
|
||||
@@ -140,26 +140,34 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget {
|
||||
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,
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
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),
|
||||
),
|
||||
SizedBox(height: canEnterCall ? 20 : 16),
|
||||
Text(
|
||||
s.video_call_screen_connecting_title,
|
||||
textAlign: TextAlign.center,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
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';
|
||||
@@ -41,6 +43,7 @@ class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
|
||||
void _openFullscreenRoute(VideoCallSessionState s) {
|
||||
final ctx = AppRouter.navigatorKey.currentContext;
|
||||
if (ctx == null) return;
|
||||
unawaited(sl<VideoCallSessionCubit>().restoreFullscreenFromPip());
|
||||
GoRouter.of(ctx).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
@@ -71,6 +74,7 @@ class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
|
||||
bloc: sl<VideoCallSessionCubit>(),
|
||||
buildWhen: (a, b) =>
|
||||
a.uiMode != b.uiMode ||
|
||||
a.connectPhase != b.connectPhase ||
|
||||
|
||||
@@ -8,6 +8,11 @@ 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;
|
||||
}
|
||||
}
|
||||
final v = asVideoTrack(pub.track);
|
||||
if (v == null || v.muted) return null;
|
||||
return v;
|
||||
|
||||
@@ -24,11 +24,14 @@ class VideoCallRemoteVideoStage extends StatelessWidget {
|
||||
if (remote != null) {
|
||||
return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover);
|
||||
}
|
||||
final showConnectError =
|
||||
connectError != null &&
|
||||
room.connectionState != lk.ConnectionState.connected;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const VideoCallNoVideoBackground(),
|
||||
if (connectError != null)
|
||||
if (showConnectError)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
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';
|
||||
@@ -25,6 +28,7 @@ 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,
|
||||
@@ -49,6 +53,7 @@ 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;
|
||||
@@ -161,10 +166,23 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
||||
),
|
||||
..._bottomBarGradient(),
|
||||
..._bottomControlsRow(),
|
||||
..._systemInterruptionBanner(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _systemInterruptionBanner() {
|
||||
if (!widget.systemAudioInterrupted) return const [];
|
||||
return [
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: widget.topInset + 8,
|
||||
child: const _VideoCallSystemInterruptionBanner(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildFullscreenLayout(BoxConstraints constraints) {
|
||||
final maxW = constraints.maxWidth;
|
||||
final maxH = constraints.maxHeight;
|
||||
@@ -203,6 +221,7 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
||||
),
|
||||
..._bottomBarGradient(),
|
||||
..._bottomControlsRow(),
|
||||
..._systemInterruptionBanner(),
|
||||
Positioned(
|
||||
top: widget.topInset + _edgeInset + _shrinkExpandExtraTop,
|
||||
left: _controlsHorizontalInset,
|
||||
@@ -487,3 +506,57 @@ 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
|
||||
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
|
||||
|
||||
/// Configures and owns the platform audio session for an active VoIP call.
|
||||
class VideoCallAudioSessionCoordinator {
|
||||
VideoCallAudioSessionCoordinator({
|
||||
VideoCallAudioSessionFactory? audioSessionFactory,
|
||||
}) : _audioSessionFactory =
|
||||
audioSessionFactory ?? (() => AudioSession.instance);
|
||||
|
||||
final VideoCallAudioSessionFactory _audioSessionFactory;
|
||||
|
||||
static final AudioSessionConfiguration callConfiguration =
|
||||
AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
|
||||
avAudioSessionCategoryOptions:
|
||||
AVAudioSessionCategoryOptions.allowBluetooth |
|
||||
AVAudioSessionCategoryOptions.defaultToSpeaker,
|
||||
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
||||
avAudioSessionRouteSharingPolicy:
|
||||
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
||||
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
||||
androidAudioAttributes: const AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.speech,
|
||||
flags: AndroidAudioFlags.none,
|
||||
usage: AndroidAudioUsage.voiceCommunication,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
||||
androidWillPauseWhenDucked: true,
|
||||
);
|
||||
|
||||
StreamSubscription<AudioInterruptionEvent>? _interruptionSubscription;
|
||||
AudioSession? _session;
|
||||
bool _configured = false;
|
||||
|
||||
bool get isActive => _session != null;
|
||||
|
||||
Future<void> start({
|
||||
required void Function(AudioInterruptionEvent event) onInterruption,
|
||||
}) async {
|
||||
final session = await _audioSessionFactory();
|
||||
_session = session;
|
||||
if (!_configured) {
|
||||
await session.configure(callConfiguration);
|
||||
_configured = true;
|
||||
}
|
||||
await session.setActive(true);
|
||||
await _interruptionSubscription?.cancel();
|
||||
_interruptionSubscription = session.interruptionEventStream.listen(
|
||||
onInterruption,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> reactivate() async {
|
||||
final session = _session;
|
||||
if (session == null) return false;
|
||||
try {
|
||||
return await session.setActive(true);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _interruptionSubscription?.cancel();
|
||||
_interruptionSubscription = null;
|
||||
final session = _session;
|
||||
_session = null;
|
||||
if (session == null) return;
|
||||
try {
|
||||
await session.setActive(false);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
|
||||
abstract final class VideoCallForegroundBootstrap {
|
||||
static const int _serviceId = 1001;
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
static void ensureInitialized() {
|
||||
if (_initialized) return;
|
||||
FlutterForegroundTask.init(
|
||||
androidNotificationOptions: AndroidNotificationOptions(
|
||||
channelId: 'video_call_ongoing',
|
||||
channelName: 'Video call',
|
||||
channelDescription: 'Ongoing video call notifications',
|
||||
channelImportance: NotificationChannelImportance.LOW,
|
||||
priority: NotificationPriority.LOW,
|
||||
onlyAlertOnce: true,
|
||||
),
|
||||
iosNotificationOptions: const IOSNotificationOptions(
|
||||
showNotification: true,
|
||||
playSound: false,
|
||||
),
|
||||
foregroundTaskOptions: ForegroundTaskOptions(
|
||||
eventAction: ForegroundTaskEventAction.nothing(),
|
||||
autoRunOnBoot: false,
|
||||
autoRunOnMyPackageReplaced: false,
|
||||
allowWakeLock: true,
|
||||
allowWifiLock: true,
|
||||
),
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static int get serviceId => _serviceId;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class VideoCallForegroundCoordinator {
|
||||
Future<void> ensureNotificationPermission() async {
|
||||
final permission = await FlutterForegroundTask.checkNotificationPermission();
|
||||
if (permission == NotificationPermission.granted) return;
|
||||
await FlutterForegroundTask.requestNotificationPermission();
|
||||
}
|
||||
|
||||
Future<void> startOngoingCall({
|
||||
required String conversationUuid,
|
||||
required String notificationTitle,
|
||||
required String notificationBody,
|
||||
required String endCallButtonLabel,
|
||||
}) async {
|
||||
VideoCallForegroundBootstrap.ensureInitialized();
|
||||
await ensureNotificationPermission();
|
||||
|
||||
await FlutterForegroundTask.saveData(
|
||||
key: videoCallActiveConversationStorageKey,
|
||||
value: conversationUuid,
|
||||
);
|
||||
|
||||
if (await FlutterForegroundTask.isRunningService) {
|
||||
await FlutterForegroundTask.updateService(
|
||||
notificationTitle: notificationTitle,
|
||||
notificationText: notificationBody,
|
||||
notificationButtons: [
|
||||
NotificationButton(
|
||||
id: videoCallForegroundEndCallButtonId,
|
||||
text: endCallButtonLabel,
|
||||
),
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final micStatus = await Permission.microphone.status;
|
||||
if (!micStatus.isGranted) {
|
||||
await Permission.microphone.request();
|
||||
}
|
||||
}
|
||||
|
||||
await FlutterForegroundTask.startService(
|
||||
serviceId: VideoCallForegroundBootstrap.serviceId,
|
||||
serviceTypes: const [ForegroundServiceTypes.microphone],
|
||||
notificationTitle: notificationTitle,
|
||||
notificationText: notificationBody,
|
||||
notificationInitialRoute: '/video-call-screen',
|
||||
notificationButtons: [
|
||||
NotificationButton(
|
||||
id: videoCallForegroundEndCallButtonId,
|
||||
text: endCallButtonLabel,
|
||||
),
|
||||
],
|
||||
callback: videoCallForegroundTaskStartCallback,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stopOngoingCall() async {
|
||||
if (!await FlutterForegroundTask.isRunningService) return;
|
||||
await FlutterForegroundTask.stopService();
|
||||
await FlutterForegroundTask.removeData(
|
||||
key: videoCallActiveConversationStorageKey,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> readActiveConversationUuid() {
|
||||
return FlutterForegroundTask.getData<String>(
|
||||
key: videoCallActiveConversationStorageKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef VideoCallLifecycleCallback = void Function(AppLifecycleState state);
|
||||
|
||||
class VideoCallLifecycleBinding {
|
||||
AppLifecycleListener? _listener;
|
||||
VideoCallLifecycleCallback? _callback;
|
||||
|
||||
@visibleForTesting
|
||||
void simulateLifecycle(AppLifecycleState state) => _callback?.call(state);
|
||||
|
||||
void attach(VideoCallLifecycleCallback onStateChanged) {
|
||||
_callback = onStateChanged;
|
||||
_listener?.dispose();
|
||||
_listener = AppLifecycleListener(
|
||||
onResume: () => onStateChanged(AppLifecycleState.resumed),
|
||||
onInactive: () => onStateChanged(AppLifecycleState.inactive),
|
||||
onHide: () => onStateChanged(AppLifecycleState.hidden),
|
||||
onPause: () => onStateChanged(AppLifecycleState.paused),
|
||||
onDetach: () => onStateChanged(AppLifecycleState.detached),
|
||||
);
|
||||
}
|
||||
|
||||
void detach() {
|
||||
_listener?.dispose();
|
||||
_listener = null;
|
||||
_callback = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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/pages/video_call_screen.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class VideoCallNotificationNavigator {
|
||||
static Future<void> openActiveCall({String? conversationUuid}) async {
|
||||
final manager = sl<VideoCallManager>();
|
||||
final uuid = conversationUuid ?? manager.conversationUuid;
|
||||
if (uuid.isEmpty) return;
|
||||
|
||||
for (var attempt = 0; attempt < 40; 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(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': uuid,
|
||||
'counterpartName': manager.counterpartName,
|
||||
'localParticipantName': manager.localParticipantName,
|
||||
},
|
||||
);
|
||||
}
|
||||
await manager.onCallScreenVisible(
|
||||
conversationUuid: uuid,
|
||||
counterpartName: manager.counterpartName,
|
||||
localParticipantName: manager.localParticipantName,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
|
||||
const String videoCallForegroundTaskActionKey = 'action';
|
||||
const String videoCallForegroundOpenAction = 'open';
|
||||
const String videoCallForegroundEndCallAction = 'end_call';
|
||||
const String videoCallForegroundEndCallButtonId = 'end_call';
|
||||
const String videoCallActiveConversationStorageKey = 'video_call_active_conversation_uuid';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void videoCallForegroundTaskStartCallback() {
|
||||
FlutterForegroundTask.setTaskHandler(VideoCallTaskHandler());
|
||||
}
|
||||
|
||||
class VideoCallTaskHandler extends TaskHandler {
|
||||
@override
|
||||
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
|
||||
|
||||
@override
|
||||
void onRepeatEvent(DateTime timestamp) {}
|
||||
|
||||
@override
|
||||
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
|
||||
|
||||
@override
|
||||
void onReceiveData(Object data) {}
|
||||
|
||||
@override
|
||||
void onNotificationButtonPressed(String id) {
|
||||
if (id == videoCallForegroundEndCallButtonId) {
|
||||
FlutterForegroundTask.sendDataToMain(<String, String>{
|
||||
videoCallForegroundTaskActionKey: videoCallForegroundEndCallAction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onNotificationPressed() {
|
||||
FlutterForegroundTask.sendDataToMain(<String, String>{
|
||||
videoCallForegroundTaskActionKey: videoCallForegroundOpenAction,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onNotificationDismissed() {}
|
||||
}
|
||||
@@ -887,9 +887,21 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"video_call_enter": MessageLookupByLibrary.simpleMessage("Enter"),
|
||||
"video_call_leave": MessageLookupByLibrary.simpleMessage("Leave"),
|
||||
"video_call_mic_muted_phone_call": MessageLookupByLibrary.simpleMessage(
|
||||
"Microphone is muted during the phone call",
|
||||
),
|
||||
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
|
||||
"Attention!\nYour microphone is muted",
|
||||
),
|
||||
"video_call_notification_end": MessageLookupByLibrary.simpleMessage(
|
||||
"End call",
|
||||
),
|
||||
"video_call_notification_in_progress": MessageLookupByLibrary.simpleMessage(
|
||||
"Call in progress",
|
||||
),
|
||||
"video_call_notification_return": MessageLookupByLibrary.simpleMessage(
|
||||
"Return to call",
|
||||
),
|
||||
"video_call_permission_camera_description":
|
||||
MessageLookupByLibrary.simpleMessage(
|
||||
"Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.",
|
||||
@@ -926,6 +938,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
MessageLookupByLibrary.simpleMessage("Reconnecting"),
|
||||
"video_call_sound": MessageLookupByLibrary.simpleMessage("Sound"),
|
||||
"video_call_status_online": MessageLookupByLibrary.simpleMessage("Online"),
|
||||
"video_call_system_interruption": MessageLookupByLibrary.simpleMessage(
|
||||
"Another call or app is using audio.\nYour consultation is paused until it ends.",
|
||||
),
|
||||
"video_call_try_again": MessageLookupByLibrary.simpleMessage("Retry"),
|
||||
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
|
||||
"video_call_video": MessageLookupByLibrary.simpleMessage("Video"),
|
||||
|
||||
@@ -367,9 +367,21 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
),
|
||||
"video_call_enter": MessageLookupByLibrary.simpleMessage("Войти"),
|
||||
"video_call_leave": MessageLookupByLibrary.simpleMessage("Выйти"),
|
||||
"video_call_mic_muted_phone_call": MessageLookupByLibrary.simpleMessage(
|
||||
"Микрофон выключен на время телефонного звонка",
|
||||
),
|
||||
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
|
||||
"Внимание!\nУ вас выключен микрофон",
|
||||
),
|
||||
"video_call_notification_end": MessageLookupByLibrary.simpleMessage(
|
||||
"Завершить звонок",
|
||||
),
|
||||
"video_call_notification_in_progress": MessageLookupByLibrary.simpleMessage(
|
||||
"Звонок идёт",
|
||||
),
|
||||
"video_call_notification_return": MessageLookupByLibrary.simpleMessage(
|
||||
"Вернуться к звонку",
|
||||
),
|
||||
"video_call_permission_camera_description":
|
||||
MessageLookupByLibrary.simpleMessage(
|
||||
"Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.",
|
||||
@@ -406,6 +418,9 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
MessageLookupByLibrary.simpleMessage("Восстанавливаем соединение"),
|
||||
"video_call_sound": MessageLookupByLibrary.simpleMessage("Звук"),
|
||||
"video_call_status_online": MessageLookupByLibrary.simpleMessage("Онлайн"),
|
||||
"video_call_system_interruption": MessageLookupByLibrary.simpleMessage(
|
||||
"Другой звонок или приложение использует аудио.\nКонсультация приостановлена до его завершения.",
|
||||
),
|
||||
"video_call_try_again": MessageLookupByLibrary.simpleMessage("Повторить"),
|
||||
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
|
||||
"video_call_video": MessageLookupByLibrary.simpleMessage("Видео"),
|
||||
|
||||
@@ -4079,6 +4079,27 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Another call or app is using audio.\nYour consultation is paused until it ends.`
|
||||
String get video_call_system_interruption {
|
||||
return Intl.message(
|
||||
'Another call or app is using audio.\nYour consultation is paused until it ends.',
|
||||
name: 'video_call_system_interruption',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Microphone is muted during the phone call`
|
||||
String get video_call_mic_muted_phone_call {
|
||||
return Intl.message(
|
||||
'Microphone is muted during the phone call',
|
||||
name: 'video_call_mic_muted_phone_call',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Online`
|
||||
String get video_call_status_online {
|
||||
return Intl.message(
|
||||
'Online',
|
||||
@@ -4158,6 +4179,7 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Loading…`
|
||||
String get video_call_screen_loading_title {
|
||||
return Intl.message(
|
||||
'Loading…',
|
||||
@@ -4167,6 +4189,7 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Connecting to\nthe consultation`
|
||||
String get video_call_screen_connecting_title {
|
||||
return Intl.message(
|
||||
'Connecting to\nthe consultation',
|
||||
@@ -4176,6 +4199,7 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `This may take a few seconds`
|
||||
String get video_call_screen_connecting_subtitle {
|
||||
return Intl.message(
|
||||
'This may take a few seconds',
|
||||
@@ -4185,6 +4209,7 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Reconnecting`
|
||||
String get video_call_screen_reconnecting_title {
|
||||
return Intl.message(
|
||||
'Reconnecting',
|
||||
@@ -4194,6 +4219,7 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Check your internet and wait`
|
||||
String get video_call_screen_reconnecting_subtitle {
|
||||
return Intl.message(
|
||||
'Check your internet and wait',
|
||||
@@ -4203,6 +4229,7 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Connection lost`
|
||||
String get video_call_connection_lost_title {
|
||||
return Intl.message(
|
||||
'Connection lost',
|
||||
@@ -4212,6 +4239,7 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `It looks like there is an internet issue. We're trying to restore the connection...`
|
||||
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...',
|
||||
@@ -4221,6 +4249,7 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Retry`
|
||||
String get video_call_try_again {
|
||||
return Intl.message(
|
||||
'Retry',
|
||||
@@ -4230,13 +4259,45 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Enter`
|
||||
String get video_call_enter {
|
||||
return Intl.message('Enter', name: 'video_call_enter', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Close`
|
||||
String get video_call_close {
|
||||
return Intl.message('Close', name: 'video_call_close', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Call in progress`
|
||||
String get video_call_notification_in_progress {
|
||||
return Intl.message(
|
||||
'Call in progress',
|
||||
name: 'video_call_notification_in_progress',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Return to call`
|
||||
String get video_call_notification_return {
|
||||
return Intl.message(
|
||||
'Return to call',
|
||||
name: 'video_call_notification_return',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `End call`
|
||||
String get video_call_notification_end {
|
||||
return Intl.message(
|
||||
'End call',
|
||||
name: 'video_call_notification_end',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppLocalizationDelegate extends LocalizationsDelegate<S> {
|
||||
|
||||
@@ -480,6 +480,8 @@
|
||||
"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_system_interruption": "Another call or app is using audio.\nYour consultation is paused until it ends.",
|
||||
"video_call_mic_muted_phone_call": "Microphone is muted during the phone call",
|
||||
"video_call_status_online": "Online",
|
||||
"video_call_chat": "Chat",
|
||||
"video_call_sound": "Sound",
|
||||
@@ -499,6 +501,9 @@
|
||||
"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"
|
||||
"video_call_close": "Close",
|
||||
"video_call_notification_in_progress": "Call in progress",
|
||||
"video_call_notification_return": "Return to call",
|
||||
"video_call_notification_end": "End call"
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,8 @@
|
||||
"video_call_poor_connection": "У вас плохое интернет соединение",
|
||||
"video_call_recording_notice": "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.",
|
||||
"video_call_muted_mic_warning": "Внимание!\nУ вас выключен микрофон",
|
||||
"video_call_system_interruption": "Другой звонок или приложение использует аудио.\nКонсультация приостановлена до его завершения.",
|
||||
"video_call_mic_muted_phone_call": "Микрофон выключен на время телефонного звонка",
|
||||
"video_call_status_online": "Онлайн",
|
||||
"video_call_chat": "Чат",
|
||||
"video_call_sound": "Звук",
|
||||
@@ -244,5 +246,8 @@
|
||||
"video_call_connection_lost_message": "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...",
|
||||
"video_call_try_again": "Повторить",
|
||||
"video_call_enter": "Войти",
|
||||
"video_call_close": "Закрыть"
|
||||
"video_call_close": "Закрыть",
|
||||
"video_call_notification_in_progress": "Звонок идёт",
|
||||
"video_call_notification_return": "Вернуться к звонку",
|
||||
"video_call_notification_end": "Завершить звонок"
|
||||
}
|
||||
|
||||
@@ -4,14 +4,18 @@ 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/services/video_call_foreground_bootstrap.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_portal/flutter_portal.dart';
|
||||
import 'package:keyboard_dismisser/keyboard_dismisser.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
FlutterForegroundTask.initCommunicationPort();
|
||||
VideoCallForegroundBootstrap.ensureInitialized();
|
||||
await AppInitializer.initialize(buildType: AppBuildType.prod);
|
||||
await AppInitializer.runAppWithSentry(() => runApp(const MyApp()));
|
||||
}
|
||||
@@ -36,21 +40,23 @@ class _MyAppState extends State<MyApp> {
|
||||
providers: sl<AppConfig>().globalBlocProviders,
|
||||
child: Portal(
|
||||
child: KeyboardDismisser(
|
||||
child: MaterialApp.router(
|
||||
theme: ThemeHelper().themeData(),
|
||||
routerConfig: AppRouter.appRouter,
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: sl<AppConfig>().localizationDelegates,
|
||||
supportedLocales: sl<AppConfig>().supportedLocales,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
if (child != null) child,
|
||||
const VideoCallGlobalPipLayer(),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: WithForegroundTask(
|
||||
child: MaterialApp.router(
|
||||
theme: ThemeHelper().themeData(),
|
||||
routerConfig: AppRouter.appRouter,
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: sl<AppConfig>().localizationDelegates,
|
||||
supportedLocales: sl<AppConfig>().supportedLocales,
|
||||
builder: (context, child) {
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
if (child != null) child,
|
||||
const VideoCallGlobalPipLayer(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -85,6 +85,8 @@ dependencies:
|
||||
intl_utils: ^2.8.13
|
||||
sentry_dio: ^9.10.0
|
||||
livekit_client: ^2.6.1
|
||||
audio_session: ^0.1.25
|
||||
flutter_foreground_task: ^9.2.1
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.10.4
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
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<void> start({required void Function(AudioInterruptionEvent event) onInterruption}) async {
|
||||
onStart(onInterruption);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> reactivate() async {
|
||||
reactivateCalled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stop() async {}
|
||||
}
|
||||
|
||||
@GenerateNiceMocks([MockSpec<VideoCallRepository>(), MockSpec<lk.Room>(), MockSpec<lk.LocalParticipant>()])
|
||||
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<void>.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<void>.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<void>.delayed(Duration.zero);
|
||||
|
||||
manager.handleAudioInterruptionForTesting(AudioInterruptionEvent(false, AudioInterruptionType.pause));
|
||||
await Future<void>.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<void>.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<void>.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'),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.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/widgets.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_lifecycle_test.mocks.dart';
|
||||
|
||||
class _RecordingForeground extends VideoCallForegroundCoordinator {
|
||||
int startCount = 0;
|
||||
int stopCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> startOngoingCall({
|
||||
required String conversationUuid,
|
||||
required String notificationTitle,
|
||||
required String notificationBody,
|
||||
required String endCallButtonLabel,
|
||||
}) async {
|
||||
startCount++;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> stopOngoingCall() async {
|
||||
stopCount++;
|
||||
}
|
||||
}
|
||||
|
||||
@GenerateNiceMocks([MockSpec<VideoCallRepository>(), MockSpec<lk.Room>()])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('VideoCallManager lifecycle', () {
|
||||
late MockVideoCallRepository mockRepository;
|
||||
late _RecordingForeground foreground;
|
||||
late VideoCallLifecycleBinding lifecycleBinding;
|
||||
late VideoCallManager manager;
|
||||
late MockRoom mockRoom;
|
||||
|
||||
setUp(() {
|
||||
mockRepository = MockVideoCallRepository();
|
||||
foreground = _RecordingForeground();
|
||||
lifecycleBinding = VideoCallLifecycleBinding();
|
||||
manager = VideoCallManager(
|
||||
videoCallRepository: mockRepository,
|
||||
foregroundCoordinator: foreground,
|
||||
lifecycleBinding: lifecycleBinding,
|
||||
);
|
||||
mockRoom = MockRoom();
|
||||
when(mockRoom.connectionState).thenReturn(lk.ConnectionState.connected);
|
||||
when(mockRoom.localParticipant).thenReturn(null);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
test('paused starts foreground and does not disconnect room', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
lifecycleBinding.simulateLifecycle(AppLifecycleState.paused);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(foreground.startCount, 1);
|
||||
expect(manager.liveKitDisposedForTesting, isFalse);
|
||||
verifyNever(mockRepository.notifyCallEnded(any));
|
||||
});
|
||||
|
||||
test('paused without active call does not start foreground', () async {
|
||||
lifecycleBinding.simulateLifecycle(AppLifecycleState.paused);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(foreground.startCount, 0);
|
||||
});
|
||||
|
||||
test('endCall stops foreground service', () async {
|
||||
manager.markActiveCallForTesting(mockRoom);
|
||||
|
||||
await manager.endCall();
|
||||
|
||||
expect(foreground.stopCount, 1);
|
||||
expect(manager.liveKitDisposedForTesting, isTrue);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/annotations.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
|
||||
import 'video_call_session_cubit_test.mocks.dart';
|
||||
|
||||
@GenerateNiceMocks([MockSpec<VideoCallManager>()])
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
group('VideoCallSessionCubit', () {
|
||||
late MockVideoCallManager mockManager;
|
||||
late VideoCallSessionCubit cubit;
|
||||
|
||||
late VideoCallManagerListener managerListener;
|
||||
|
||||
setUp(() {
|
||||
mockManager = MockVideoCallManager();
|
||||
when(mockManager.snapshot).thenReturn(VideoCallManagerSnapshot.initial);
|
||||
when(mockManager.room).thenReturn(null);
|
||||
when(mockManager.addListener(any)).thenAnswer((invocation) {
|
||||
managerListener =
|
||||
invocation.positionalArguments[0] as VideoCallManagerListener;
|
||||
});
|
||||
cubit = VideoCallSessionCubit(videoCallManager: mockManager);
|
||||
});
|
||||
|
||||
tearDown(() async {
|
||||
when(mockManager.endCall()).thenAnswer((_) async {});
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('onCallScreenDisposed minimizes to pip when in call stage', () async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: true,
|
||||
uiMode: VideoCallUiMode.fullscreen,
|
||||
),
|
||||
);
|
||||
|
||||
cubit.onCallScreenDisposed();
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(cubit.state.uiMode, VideoCallUiMode.pip);
|
||||
verifyNever(mockManager.endCall());
|
||||
});
|
||||
|
||||
test('onCallScreenDisposed ends call when not in call stage', () async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: false,
|
||||
uiMode: VideoCallUiMode.fullscreen,
|
||||
),
|
||||
);
|
||||
|
||||
when(mockManager.endCall()).thenAnswer((_) async {});
|
||||
|
||||
cubit.onCallScreenDisposed();
|
||||
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
verify(mockManager.endCall()).called(1);
|
||||
});
|
||||
|
||||
test(
|
||||
'manager idle snapshot resets callStageEntered and uiMode',
|
||||
() async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: true,
|
||||
uiMode: VideoCallUiMode.pip,
|
||||
),
|
||||
);
|
||||
|
||||
managerListener(VideoCallManagerSnapshot.initial);
|
||||
|
||||
expect(cubit.state.callStageEntered, isFalse);
|
||||
expect(cubit.state.uiMode, VideoCallUiMode.hidden);
|
||||
expect(cubit.state.connectPhase, VideoCallConnectPhase.idle);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'manager connecting snapshot resets callStageEntered for new call',
|
||||
() async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'old',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: true,
|
||||
uiMode: VideoCallUiMode.fullscreen,
|
||||
),
|
||||
);
|
||||
|
||||
managerListener(
|
||||
const VideoCallManagerSnapshot(
|
||||
conversationUuid: 'new-uuid',
|
||||
micEnabled: true,
|
||||
videoEnabled: true,
|
||||
connectPhase: VideoCallConnectPhase.connecting,
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
),
|
||||
);
|
||||
|
||||
expect(cubit.state.conversationUuid, 'new-uuid');
|
||||
expect(cubit.state.callStageEntered, isFalse);
|
||||
expect(cubit.state.uiMode, VideoCallUiMode.fullscreen);
|
||||
expect(cubit.state.connectPhase, VideoCallConnectPhase.connecting);
|
||||
},
|
||||
);
|
||||
|
||||
test('isMicActiveForUi is false when system audio interrupted', () {
|
||||
const state = VideoCallSessionState(
|
||||
micEnabled: true,
|
||||
systemAudioInterrupted: true,
|
||||
);
|
||||
expect(state.isMicActiveForUi, isFalse);
|
||||
});
|
||||
|
||||
test('onCallEndedExternally resets session state', () async {
|
||||
cubit.emit(
|
||||
const VideoCallSessionState(
|
||||
conversationUuid: 'uuid',
|
||||
connectPhase: VideoCallConnectPhase.connected,
|
||||
callStageEntered: true,
|
||||
uiMode: VideoCallUiMode.pip,
|
||||
),
|
||||
);
|
||||
|
||||
await cubit.onCallEndedExternally();
|
||||
|
||||
expect(cubit.state, const VideoCallSessionState());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -25,7 +25,9 @@ void main() {
|
||||
);
|
||||
}
|
||||
|
||||
testWidgets('shows error text when connectError is provided', (tester) async {
|
||||
testWidgets('hides connectError text when room is connected without remote video', (
|
||||
tester,
|
||||
) async {
|
||||
final room = buildRoom(state: lk.ConnectionState.connected);
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -37,10 +39,27 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Connection failed'), findsOneWidget);
|
||||
expect(find.text('Connection failed'), findsNothing);
|
||||
expect(find.byType(CircularProgressIndicator), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('shows error text when connectError and room is not connected', (
|
||||
tester,
|
||||
) async {
|
||||
final room = buildRoom(state: lk.ConnectionState.disconnected);
|
||||
|
||||
await tester.pumpWidget(
|
||||
wrap(
|
||||
VideoCallRemoteVideoStage(
|
||||
room: room,
|
||||
connectError: 'Connection failed',
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Connection failed'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows progress indicator when not connected and no error', (tester) async {
|
||||
final room = buildRoom(state: lk.ConnectionState.disconnected);
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ void main() {
|
||||
expect(shrinkPressed, greaterThanOrEqualTo(0));
|
||||
});
|
||||
|
||||
testWidgets('split layout shows connectError on remote pane and uses bottomInset', (tester) async {
|
||||
testWidgets('split layout hides connectError on remote pane when connected', (tester) async {
|
||||
final room = buildRoom();
|
||||
|
||||
await tester.pumpWidget(
|
||||
@@ -145,7 +145,7 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('No connection'), findsOneWidget);
|
||||
expect(find.text('No connection'), findsNothing);
|
||||
expect(find.text('Doctor'), findsOneWidget);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user