forked from eatvio/eatvio_app
feat(vidoe_calls): global cideo call state, connection, loading and error screens for video calls
This commit is contained in:
3
assets/icons/video_call/vc_control_enter.svg
Normal file
3
assets/icons/video_call/vc_control_enter.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.3438 5.34375C14.9405 5.34375 15.5128 5.5808 15.9347 6.00276C16.3567 6.42472 16.5938 6.99701 16.5938 7.59375M14.3438 0.84375C16.134 0.84375 17.8509 1.55491 19.1167 2.82078C20.3826 4.08665 21.0938 5.80354 21.0938 7.59375M3.09375 1.96875H7.59375L9.84375 7.59375L7.03125 9.28125C8.23608 11.7242 10.2133 13.7014 12.6562 14.9062L14.3438 12.0938L19.9688 14.3438V18.8438C19.9688 19.4405 19.7317 20.0128 19.3097 20.4347C18.8878 20.8567 18.3155 21.0938 17.7188 21.0938C13.3304 20.8271 9.19142 18.9636 6.08268 15.8548C2.97394 12.7461 1.11043 8.60708 0.84375 4.21875C0.84375 3.62201 1.0808 3.04972 1.50276 2.62776C1.92472 2.2058 2.49701 1.96875 3.09375 1.96875Z" stroke="white" stroke-width="1.6875" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 853 B |
@@ -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<BlocProvider> get globalBlocProviders => [
|
||||
BlocProvider<VideoCallSessionCubit>.value(value: sl<VideoCallSessionCubit>()),
|
||||
BlocProvider<NewMessagesCubit>(lazy: true, create: (context) => sl<NewMessagesCubit>()),
|
||||
BlocProvider<NotificationsCubit>(lazy: true, create: (context) => sl<NotificationsCubit>()),
|
||||
BlocProvider<FeedCubit>(lazy: true, create: (context) => sl<FeedCubit>()),
|
||||
|
||||
@@ -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>(() => ProfileAvatarCubit(profileRepository: sl()));
|
||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||
sl.registerLazySingleton<VideoCallSessionCubit>(() => VideoCallSessionCubit(videoCallRepository: sl()));
|
||||
}
|
||||
|
||||
/// Регистрация Cubit'ов как фабрик
|
||||
|
||||
@@ -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()?,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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>{
|
||||
'conversationUuid': widget.uuid,
|
||||
'counterpartName': state.chatRoom.users.receiver.name,
|
||||
'counterpartAvatarUrl': state.chatRoom.users.receiver.profilePhotoUrl,
|
||||
'localParticipantName': state.chatRoom.users.sender.name,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||
} finally {
|
||||
await sl<VideoCallSessionCubit>().endCall();
|
||||
await sl<WsManager>().clear();
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -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<VideoCallSessionState> {
|
||||
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<lk.RoomEvent>? _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<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,
|
||||
),
|
||||
);
|
||||
_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({
|
||||
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<void> 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<void> 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<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();
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
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<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,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 (_) {}
|
||||
}
|
||||
|
||||
Future<void> _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<void> _restoreSystemUiAfterCall() async {
|
||||
try {
|
||||
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
} 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;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_mutedMicPollTimer?.cancel();
|
||||
await endCall();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<VideoCallScreen> {
|
||||
static const lk.AudioCaptureOptions _micCaptureOptions = lk.AudioCaptureOptions(stopAudioCaptureOnMute: false);
|
||||
static const Duration _mutedMicPollInterval = Duration(milliseconds: 320);
|
||||
static const Duration _mutedMicOverlayMinInterval = Duration(milliseconds: 2600);
|
||||
static const double _mutedMicAudioLevelThreshold = 0.008;
|
||||
static const double _mutedMicEnergyDelta = 1e-6;
|
||||
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<lk.RoomEvent>? _roomEventsListener;
|
||||
OverlayEntry? _poorConnectionEntry;
|
||||
Timer? _mutedMicPollTimer;
|
||||
DateTime? _lastMutedMicOverlayAt;
|
||||
bool _recordingOverlayScheduled = false;
|
||||
bool _mutedMicStatsInFlight = false;
|
||||
num? _lastMutedMicTotalEnergy;
|
||||
|
||||
lk.CameraCaptureOptions _cameraCaptureOptions({lk.CameraPosition? cameraPosition}) {
|
||||
return lk.CameraCaptureOptions(
|
||||
cameraPosition: cameraPosition ?? (_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back),
|
||||
params: lk.VideoParametersPresets.h1080_169,
|
||||
);
|
||||
}
|
||||
VideoCallSessionCubit get _cubit => sl<VideoCallSessionCubit>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_attachRoomEventListeners();
|
||||
_mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) {
|
||||
if (!mounted || _liveKitDisposed || _micEnabled || _mutedMicStatsInFlight) return;
|
||||
if (_room.connectionState != lk.ConnectionState.connected) return;
|
||||
_mutedMicStatsInFlight = true;
|
||||
unawaited(
|
||||
_pollMutedMicFromWebRtcStats().whenComplete(() {
|
||||
_mutedMicStatsInFlight = false;
|
||||
}),
|
||||
);
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
_scheduleRecordingNoticeOnce();
|
||||
});
|
||||
unawaited(_connectLiveKit());
|
||||
}
|
||||
|
||||
void _attachRoomEventListeners() {
|
||||
_roomEventsListener = _room.createListener()
|
||||
..on<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
|
||||
if (e.participant != _room.localParticipant) return;
|
||||
if (!mounted) return;
|
||||
_syncPoorConnectionOverlay(e.connectionQuality);
|
||||
})
|
||||
..on<lk.ActiveSpeakersChangedEvent>((e) {
|
||||
if (!mounted || _micEnabled) return;
|
||||
final lp = _room.localParticipant;
|
||||
if (lp == null) return;
|
||||
if (e.speakers.any((p) => p.sid == lp.sid)) {
|
||||
_maybeShowMutedMicOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _scheduleRecordingNoticeOnce() {
|
||||
if (_recordingOverlayScheduled || !mounted) return;
|
||||
_recordingOverlayScheduled = true;
|
||||
VideoCallTopOverlay.insertBanner(
|
||||
context,
|
||||
message: S.of(context).video_call_recording_notice,
|
||||
backgroundColor: appTheme.videoCallNoticeBannerBackground,
|
||||
svgAsset: VideoCallTopOverlay.warningSvg,
|
||||
autoRemoveAfter: const Duration(seconds: 3),
|
||||
);
|
||||
}
|
||||
|
||||
void _syncPoorConnectionOverlay(lk.ConnectionQuality q) {
|
||||
final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost;
|
||||
if (bad) {
|
||||
if (_poorConnectionEntry != null) return;
|
||||
if (!mounted) return;
|
||||
_poorConnectionEntry = VideoCallTopOverlay.insertBanner(
|
||||
context,
|
||||
message: S.of(context).video_call_poor_connection,
|
||||
backgroundColor: appTheme.strongRed,
|
||||
svgAsset: VideoCallTopOverlay.poorConnectionSvg,
|
||||
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<void> _pollMutedMicFromWebRtcStats() async {
|
||||
if (!mounted || _liveKitDisposed || _micEnabled) return;
|
||||
if (_room.connectionState != lk.ConnectionState.connected) return;
|
||||
final lp = _room.localParticipant;
|
||||
if (lp == null) return;
|
||||
final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone);
|
||||
final track = pub?.track;
|
||||
if (track is! lk.LocalAudioTrack) return;
|
||||
if (!track.muted) return;
|
||||
try {
|
||||
final stats = await track.getSenderStats();
|
||||
if (!mounted || _liveKitDisposed || _micEnabled) return;
|
||||
final src = stats?.audioSourceStats;
|
||||
if (src == null) return;
|
||||
|
||||
final level = src.audioLevel;
|
||||
if (level != null && level >= _mutedMicAudioLevelThreshold) {
|
||||
_maybeShowMutedMicOverlay();
|
||||
_lastMutedMicTotalEnergy = src.totalAudioEnergy;
|
||||
return;
|
||||
}
|
||||
|
||||
final energy = src.totalAudioEnergy;
|
||||
if (energy != null) {
|
||||
final prev = _lastMutedMicTotalEnergy;
|
||||
if (prev != null && energy > prev + _mutedMicEnergyDelta) {
|
||||
_maybeShowMutedMicOverlay();
|
||||
}
|
||||
_lastMutedMicTotalEnergy = energy;
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _applyVideoCallSystemUi() async {
|
||||
try {
|
||||
await SystemChrome.setPreferredOrientations(const [
|
||||
DeviceOrientation.portraitUp,
|
||||
DeviceOrientation.portraitDown,
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
if (!mounted) return;
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
} on PlatformException catch (_) {
|
||||
if (!mounted) return;
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _restoreSystemUiAfterCall() async {
|
||||
try {
|
||||
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
|
||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
|
||||
} on PlatformException catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _connectLiveKit() async {
|
||||
setState(() => _connectError = null);
|
||||
try {
|
||||
final tokenResponse = await _videoCallRepository.getParticipantToken(widget.conversationUuid);
|
||||
if (!mounted) return;
|
||||
|
||||
var micOn = _micEnabled;
|
||||
var videoOn = _videoEnabled;
|
||||
if (micOn) {
|
||||
final granted = await _ensureMicrophonePermission();
|
||||
if (!mounted) return;
|
||||
if (!granted) micOn = false;
|
||||
}
|
||||
if (videoOn) {
|
||||
final granted = await _ensureCameraPermission();
|
||||
if (!mounted) return;
|
||||
if (!granted) videoOn = false;
|
||||
}
|
||||
if (micOn != _micEnabled || videoOn != _videoEnabled) {
|
||||
setState(() {
|
||||
_micEnabled = micOn;
|
||||
_videoEnabled = videoOn;
|
||||
});
|
||||
}
|
||||
|
||||
await _room.connect(tokenResponse.wsUrl, tokenResponse.token);
|
||||
if (!mounted || _liveKitDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
var effectiveMic = micOn;
|
||||
var effectiveVideo = videoOn;
|
||||
final lp = _room.localParticipant;
|
||||
|
||||
if (micOn) {
|
||||
try {
|
||||
await lp?.setMicrophoneEnabled(true, audioCaptureOptions: _micCaptureOptions);
|
||||
} catch (_) {
|
||||
effectiveMic = false;
|
||||
}
|
||||
}
|
||||
if (!mounted || _liveKitDisposed) return;
|
||||
|
||||
if (videoOn) {
|
||||
try {
|
||||
await lp?.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions());
|
||||
} catch (_) {
|
||||
effectiveVideo = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (effectiveMic != _micEnabled || effectiveVideo != _videoEnabled) {
|
||||
setState(() {
|
||||
_micEnabled = effectiveMic;
|
||||
_videoEnabled = effectiveVideo;
|
||||
});
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
_syncPoorConnectionOverlay(_room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) setState(() => _connectError = e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disconnectLiveKitOnly() async {
|
||||
if (_liveKitDisposed) return;
|
||||
_liveKitDisposed = true;
|
||||
try {
|
||||
if (_room.connectionState != lk.ConnectionState.disconnected) {
|
||||
await _room.disconnect();
|
||||
}
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _room.dispose();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _notifyCallEndedOnce() async {
|
||||
if (_callEndedNotified) return;
|
||||
_callEndedNotified = true;
|
||||
try {
|
||||
await _videoCallRepository.notifyCallEnded(widget.conversationUuid);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _cleanupLiveKitAndNotifyBackend() async {
|
||||
await _disconnectLiveKitOnly();
|
||||
await _notifyCallEndedOnce();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_mutedMicPollTimer?.cancel();
|
||||
final listener = _roomEventsListener;
|
||||
_roomEventsListener = null;
|
||||
if (listener != null) {
|
||||
unawaited(listener.dispose());
|
||||
}
|
||||
final poor = _poorConnectionEntry;
|
||||
_poorConnectionEntry = null;
|
||||
if (poor != null && poor.mounted) {
|
||||
poor.remove();
|
||||
}
|
||||
unawaited(_cleanupLiveKitAndNotifyBackend());
|
||||
unawaited(_restoreSystemUiAfterCall());
|
||||
_cubit.onCallScreenDisposed();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleBackToChat() async {
|
||||
widget.onBackToChat?.call();
|
||||
await _cleanupLiveKitAndNotifyBackend();
|
||||
await _cubit.leaveCallRoute();
|
||||
if (!mounted) return;
|
||||
if (context.canPop()) context.pop();
|
||||
}
|
||||
|
||||
Future<void> _handleHangUp() async {
|
||||
widget.onHangUp?.call();
|
||||
await _cleanupLiveKitAndNotifyBackend();
|
||||
await _cubit.endCall();
|
||||
if (!mounted) return;
|
||||
if (context.canPop()) context.pop();
|
||||
}
|
||||
|
||||
Future<void> _toggleMic() async {
|
||||
if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) {
|
||||
setState(() => _micEnabled = !_micEnabled);
|
||||
_lastMutedMicTotalEnergy = null;
|
||||
return;
|
||||
}
|
||||
final next = !_micEnabled;
|
||||
if (next) {
|
||||
final granted = await _ensureMicrophonePermission();
|
||||
if (!mounted) return;
|
||||
if (!granted) return;
|
||||
}
|
||||
setState(() => _micEnabled = next);
|
||||
_lastMutedMicTotalEnergy = null;
|
||||
await _room.localParticipant?.setMicrophoneEnabled(next, audioCaptureOptions: _micCaptureOptions);
|
||||
}
|
||||
|
||||
Future<void> _toggleVideo() async {
|
||||
if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) {
|
||||
setState(() => _videoEnabled = !_videoEnabled);
|
||||
return;
|
||||
}
|
||||
final next = !_videoEnabled;
|
||||
if (next) {
|
||||
final granted = await _ensureCameraPermission();
|
||||
if (!mounted) return;
|
||||
if (!granted) return;
|
||||
}
|
||||
setState(() => _videoEnabled = next);
|
||||
final lp = _room.localParticipant;
|
||||
if (lp == null) return;
|
||||
if (next) {
|
||||
await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions());
|
||||
} else {
|
||||
await lp.setCameraEnabled(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _switchCameraFacing() async {
|
||||
if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) {
|
||||
setState(() => _useFrontCamera = !_useFrontCamera);
|
||||
return;
|
||||
}
|
||||
if (!_videoEnabled) return;
|
||||
|
||||
final lp = _room.localParticipant;
|
||||
if (lp == null) return;
|
||||
|
||||
final currentPosition = _useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back;
|
||||
final newPosition = currentPosition.switched();
|
||||
|
||||
final pub = lp.getTrackPublicationBySource(lk.TrackSource.camera);
|
||||
final track = pub?.track;
|
||||
if (track is lk.LocalVideoTrack) {
|
||||
try {
|
||||
await track.setCameraPosition(newPosition);
|
||||
if (mounted) {
|
||||
setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front);
|
||||
}
|
||||
} catch (_) {}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions(cameraPosition: newPosition));
|
||||
if (mounted) {
|
||||
setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
bool _permissionNeedsSettings(PermissionStatus status) => status.isPermanentlyDenied || status.isRestricted;
|
||||
|
||||
Future<bool> _ensureCameraPermission() async {
|
||||
var status = await Permission.camera.status;
|
||||
if (status.isGranted) return true;
|
||||
status = await Permission.camera.request();
|
||||
if (status.isGranted) return true;
|
||||
if (!mounted) return false;
|
||||
final s = S.of(context);
|
||||
await showErrorDialog(
|
||||
context,
|
||||
title: s.video_call_permission_camera_title,
|
||||
description: s.video_call_permission_camera_description,
|
||||
buttonTitle: s.allowAccess,
|
||||
needToOpenSettings: _permissionNeedsSettings(status),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<bool> _ensureMicrophonePermission() async {
|
||||
var status = await Permission.microphone.status;
|
||||
if (status.isGranted) return true;
|
||||
status = await Permission.microphone.request();
|
||||
if (status.isGranted) return true;
|
||||
if (!mounted) return false;
|
||||
final s = S.of(context);
|
||||
await showErrorDialog(
|
||||
context,
|
||||
title: s.video_call_permission_microphone_title,
|
||||
description: s.video_call_permission_microphone_description,
|
||||
buttonTitle: s.allowAccess,
|
||||
needToOpenSettings: _permissionNeedsSettings(status),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
String _resolvedLocalDisplayName(BuildContext context) {
|
||||
final fromNav = widget.localParticipantName?.trim();
|
||||
if (fromNav != null && fromNav.isNotEmpty) return fromNav;
|
||||
final n = (_room.localParticipant?.name ?? '').trim();
|
||||
if (n.isNotEmpty) return n;
|
||||
return S.of(context).video_call_you;
|
||||
}
|
||||
|
||||
String _resolvedRemoteDisplayName(BuildContext context, String fallback) {
|
||||
for (final p in _room.remoteParticipants.values) {
|
||||
final n = p.name.trim();
|
||||
if (n.isNotEmpty) return n;
|
||||
}
|
||||
final c = widget.counterpartName?.trim();
|
||||
if (c != null && c.isNotEmpty) return c;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final s = S.of(context);
|
||||
@@ -477,54 +95,179 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
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<SystemUiOverlayStyle>(
|
||||
value: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
statusBarBrightness: Brightness.dark,
|
||||
systemNavigationBarColor: Colors.transparent,
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarContrastEnforced: false,
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: appTheme.whiteA700,
|
||||
body: ListenableBuilder(
|
||||
listenable: _room,
|
||||
builder: (context, _) {
|
||||
return VideoCallStageLayout(
|
||||
room: _room,
|
||||
connectError: _connectError,
|
||||
localDisplayName: _resolvedLocalDisplayName(context),
|
||||
remoteDisplayName: _resolvedRemoteDisplayName(context, counterpartFallback),
|
||||
micEnabled: _micEnabled,
|
||||
videoEnabled: _videoEnabled,
|
||||
splitParticipantView: _splitParticipantView,
|
||||
mainStageShowsLocalVideo: _mainStageShowsLocalVideo,
|
||||
useFrontCamera: _useFrontCamera,
|
||||
onExpandLocal: () => setState(() {
|
||||
_splitParticipantView = false;
|
||||
_mainStageShowsLocalVideo = true;
|
||||
}),
|
||||
onExpandRemote: () => setState(() {
|
||||
_splitParticipantView = false;
|
||||
_mainStageShowsLocalVideo = false;
|
||||
}),
|
||||
onShrinkFromFullscreen: () => setState(() => _splitParticipantView = true),
|
||||
onToggleStageSwap: () => setState(() => _mainStageShowsLocalVideo = !_mainStageShowsLocalVideo),
|
||||
onChat: () => unawaited(_handleBackToChat()),
|
||||
onToggleMic: () => unawaited(_toggleMic()),
|
||||
onToggleVideo: () => unawaited(_toggleVideo()),
|
||||
onHangUp: () => unawaited(_handleHangUp()),
|
||||
onSwitchCamera: () => unawaited(_switchCameraFacing()),
|
||||
controlLabelStyle: controlLabelStyle,
|
||||
labels: (chat: s.video_call_chat, sound: s.video_call_sound, video: s.video_call_video, leave: s.video_call_leave),
|
||||
topInset: topInset,
|
||||
bottomInset: bottomInset,
|
||||
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<VideoCallSessionCubit, VideoCallSessionState>(
|
||||
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<SystemUiOverlayStyle>(
|
||||
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<SystemUiOverlayStyle>(
|
||||
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<SystemUiOverlayStyle>(
|
||||
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<SystemUiOverlayStyle>(
|
||||
value: _videoCallOverlayStyle,
|
||||
child: Scaffold(
|
||||
backgroundColor: appTheme.whiteA700,
|
||||
body: VideoCallLoadingScreen(
|
||||
onClose: () => unawaited(_handleHangUp()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final activeRoom = room;
|
||||
if (activeRoom == null) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: _videoCallOverlayStyle,
|
||||
child: Scaffold(
|
||||
backgroundColor: appTheme.whiteA700,
|
||||
body: VideoCallLoadingScreen(
|
||||
onClose: () => unawaited(_handleHangUp()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<VideoCallGlobalPipLayer> createState() =>
|
||||
_VideoCallGlobalPipLayerState();
|
||||
}
|
||||
|
||||
class _VideoCallGlobalPipLayerState extends State<VideoCallGlobalPipLayer> {
|
||||
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: <String, dynamic>{
|
||||
'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<VideoCallSessionCubit, VideoCallSessionState>(
|
||||
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<VideoCallSessionCubit>();
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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("Вы"),
|
||||
|
||||
@@ -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<S> {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "Закрыть"
|
||||
}
|
||||
|
||||
@@ -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<MyApp> {
|
||||
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(),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user