forked from eatvio/eatvio_app
Merge branch 'consultant_scoring' into consultant_scoring
This commit is contained in:
@@ -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>()),
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:eatvio_app/core/env_config.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_report_policy.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart' as di;
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_fcm_handler.dart';
|
||||
import 'package:eatvio_app/firebase_options.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
@@ -47,6 +48,7 @@ class AppInitializer {
|
||||
throw TimeoutException('Firebase initialization timeout');
|
||||
},
|
||||
);
|
||||
VideoCallFcmHandler.registerBackgroundHandler();
|
||||
if (sl<AppConfig>().buildType == AppBuildType.prod) {
|
||||
try {
|
||||
await FirebaseMessaging.instance.requestPermission().timeout(const Duration(seconds: 5));
|
||||
|
||||
@@ -9,4 +9,5 @@ const List<String> planTypeValues = ['fit', 'slim', 'wellness', 'behavior'];
|
||||
const newMessagesChannelName = 'private-new-chat-messages';
|
||||
const newNotificationsChannelName = 'private-message-events';
|
||||
const directChatChannelName = 'private-direct-chat';
|
||||
const presenceDirectChatChannelName = 'presence-direct-chat';
|
||||
const thresholdForPagination = 150;
|
||||
|
||||
16
lib/core/device/device_hw_id_provider.dart
Normal file
16
lib/core/device/device_hw_id_provider.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
abstract final class DeviceHwIdProvider {
|
||||
static Future<String?> current() async {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
if (Platform.isIOS) {
|
||||
return (await deviceInfo.iosInfo).identifierForVendor;
|
||||
}
|
||||
if (Platform.isAndroid) {
|
||||
return (await deviceInfo.androidInfo).id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,8 @@ import 'package:eatvio_app/features/registration/presentation/bloc/verify_email_
|
||||
import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart';
|
||||
import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart';
|
||||
import 'package:eatvio_app/features/user_details/presentation/cubit/user_details_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
@@ -72,6 +74,18 @@ class CubitRegistrations {
|
||||
sl.registerLazySingleton<ProfileAvatarCubit>(() => ProfileAvatarCubit(profileRepository: sl()));
|
||||
sl.registerLazySingleton<ChatsCubit>(() => ChatsCubit(chatRepository: sl(), wsManager: sl(), profileRepository: sl()));
|
||||
sl.registerLazySingleton<InternetConnectionCubit>(() => InternetConnectionCubit());
|
||||
sl.registerLazySingleton<VideoCallManager>(
|
||||
() => VideoCallManager(
|
||||
videoCallRepository: sl(),
|
||||
profileRepository: sl(),
|
||||
),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallSessionCubit>(
|
||||
() => VideoCallSessionCubit(
|
||||
videoCallManager: sl(),
|
||||
systemPipCoordinator: sl(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Регистрация Cubit'ов как фабрик
|
||||
|
||||
@@ -36,7 +36,9 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart';
|
||||
import 'package:eatvio_app/features/registration/services/registration_service.dart';
|
||||
import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart';
|
||||
import 'package:eatvio_app/features/settings/services/settings_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
|
||||
/// Регистрация всех Service в системе внедрения зависимостей
|
||||
@@ -76,6 +78,12 @@ class ServiceRegistrations {
|
||||
sl.registerLazySingleton<CreatePlanService>(() => CreatePlanService(sl()));
|
||||
sl.registerLazySingleton<NotificationsService>(() => NotificationsService(sl()));
|
||||
sl.registerLazySingleton<FirebaseApi>(() => FirebaseApi(errorReporter: sl()));
|
||||
sl.registerLazySingleton<VideoCallCallKitCoordinator>(
|
||||
() => VideoCallCallKitCoordinator(firebaseApi: sl()),
|
||||
);
|
||||
sl.registerLazySingleton<VideoCallSystemPipCoordinator>(
|
||||
() => VideoCallSystemPipCoordinator(),
|
||||
);
|
||||
sl.registerLazySingleton<ResetPasswordService>(() => ResetPasswordService(sl()));
|
||||
sl.registerLazySingleton<BlockedUserService>(() => BlockedUserService(sl()));
|
||||
sl.registerLazySingleton<CommentsService>(() => CommentsService(sl()));
|
||||
|
||||
@@ -44,8 +44,8 @@ class ErrorReportPolicy {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
case DioExceptionType.transformTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return true;
|
||||
case DioExceptionType.unknown:
|
||||
case DioExceptionType.badResponse:
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart' show MethodChannel, PlatformException;
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
class FirebaseApi {
|
||||
final ErrorReporter _errorReporter;
|
||||
@@ -87,6 +88,9 @@ class FirebaseApi {
|
||||
throw TimeoutException('registerPushToken timeout');
|
||||
},
|
||||
);
|
||||
if (Platform.isIOS) {
|
||||
await _registerIosVoipPushToken();
|
||||
}
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
@@ -95,6 +99,88 @@ class FirebaseApi {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Future<void> unregisterPushTokens() async {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
try {
|
||||
await unregisterPushToken();
|
||||
} catch (_) {}
|
||||
if (Platform.isIOS) {
|
||||
try {
|
||||
await _unregisterIosVoipPushToken();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unregisterPushToken() async {
|
||||
if (!await TokenStorage.haveTokenInStorage()) return;
|
||||
try {
|
||||
final isHuawei = Platform.isAndroid ? await _isHuaweiDevice() : false;
|
||||
final platform = Platform.isIOS ? 'ios' : (isHuawei ? 'huawei' : 'android');
|
||||
final token = (await getToken()) ?? '';
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/unregister',
|
||||
data: {
|
||||
'platform': platform,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('unregisterPushToken timeout');
|
||||
},
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _registerIosVoipPushToken() async {
|
||||
try {
|
||||
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim();
|
||||
if (token == null || token.isEmpty) return;
|
||||
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/voip/register',
|
||||
data: {
|
||||
'platform': 'ios',
|
||||
'hw_id': (await deviceInfo.iosInfo).identifierForVendor,
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('registerPushToken voip timeout');
|
||||
},
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _unregisterIosVoipPushToken() async {
|
||||
try {
|
||||
final token = (await FlutterCallkitIncoming.getDevicePushTokenVoIP())?.trim() ?? '';
|
||||
await sl<Dio>().post(
|
||||
'/api/profile/notification/token/voip/unregister',
|
||||
data: {
|
||||
'token': token,
|
||||
},
|
||||
).timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
throw TimeoutException('unregisterPushToken voip timeout');
|
||||
},
|
||||
);
|
||||
} catch (e, st) {
|
||||
log(e.toString(), stackTrace: st);
|
||||
await _errorReporter.report(e, st, origin: 'FirebaseApi');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _isHuaweiDevice() async {
|
||||
if (!Platform.isAndroid) return false;
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
@@ -50,7 +57,37 @@ class PushNavigationHandler {
|
||||
}
|
||||
|
||||
static void _handleMessageInternal(RemoteMessage message, {required String source}) {
|
||||
final data = message.data;
|
||||
final data = Map<String, dynamic>.from(message.data);
|
||||
|
||||
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||
if (endedPayload != null) {
|
||||
developer.log(
|
||||
'PushNavigationHandler: dismiss incoming call UI, source=$source',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi());
|
||||
return;
|
||||
}
|
||||
|
||||
final videoCallPayload = VideoCallPushPayload.tryParse(data);
|
||||
if (videoCallPayload != null) {
|
||||
developer.log(
|
||||
'PushNavigationHandler: opening incoming video call, source=$source, conversation=${videoCallPayload.conversationUuid}',
|
||||
name: 'PushNavigationHandler',
|
||||
);
|
||||
unawaited(
|
||||
VideoCallNotificationNavigator.openIncomingCall(
|
||||
conversationUuid: videoCallPayload.conversationUuid,
|
||||
counterpartName: videoCallPayload.counterpartName,
|
||||
counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl,
|
||||
localParticipantName: videoCallPayload.localParticipantName,
|
||||
counterpartIsConsultant: videoCallPayload.counterpartIsConsultant,
|
||||
entryMode: VideoCallEntryMode.incomingAccepted,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final actionUrl = data['action_url'] as String?;
|
||||
if (actionUrl == null || actionUrl.isEmpty) {
|
||||
developer.log('PushNavigationHandler: no action_url in message data, source=$source, data=$data', name: 'PushNavigationHandler');
|
||||
|
||||
@@ -15,6 +15,7 @@ import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.da
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart';
|
||||
import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart';
|
||||
@@ -244,7 +245,13 @@ class OtherRoutes {
|
||||
return VideoCallScreen(
|
||||
conversationUuid: extra['conversationUuid'] as String,
|
||||
counterpartName: extra['counterpartName'] as String?,
|
||||
counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?,
|
||||
counterpartUserId: extra['counterpartUserId'] as int?,
|
||||
counterpartIsOnline: extra['counterpartIsOnline'] as bool?,
|
||||
counterpartLastSeenText: extra['counterpartLastSeenText'] as String?,
|
||||
counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?,
|
||||
localParticipantName: extra['localParticipantName'] as String?,
|
||||
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
|
||||
onBackToChat: extra['onBackToChat'] as void Function()?,
|
||||
onHangUp: extra['onHangUp'] as void Function()?,
|
||||
);
|
||||
@@ -339,3 +346,11 @@ class OtherRoutes {
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
VideoCallEntryMode _videoCallEntryModeFromRouteExtra(Object? raw) {
|
||||
if (raw is VideoCallEntryMode) return raw;
|
||||
if (raw == VideoCallEntryMode.incomingAccepted.name) {
|
||||
return VideoCallEntryMode.incomingAccepted;
|
||||
}
|
||||
return VideoCallEntryMode.outgoing;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -26,6 +26,13 @@ class WsManager {
|
||||
PrivateChannel? _privateNotificationChannel;
|
||||
PrivateChannel? _privateMessagesChannel;
|
||||
PrivateChannel? _privateDirectChatChannel;
|
||||
PresenceChannel? _presenceDirectChatChannel;
|
||||
|
||||
StreamSubscription? _presenceMemberAddedSubscription;
|
||||
StreamSubscription? _presenceMemberRemovedSubscription;
|
||||
StreamSubscription? _presenceSubscriptionSucceededSubscription;
|
||||
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
|
||||
bool _presenceChannelEventsBound = false;
|
||||
|
||||
StreamSubscription? _lifecycleSubscription;
|
||||
StreamSubscription? _eventSubscription;
|
||||
@@ -175,6 +182,107 @@ class WsManager {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> initPresenceDirectChatChannel(String chatId) async {
|
||||
final token = await TokenStorage.getTokenFromStorage();
|
||||
_presenceDirectChatChannel = client?.presenceChannel(
|
||||
'$presenceDirectChatChannelName.$chatId',
|
||||
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
|
||||
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void addPresenceDirectChatListener({
|
||||
required int counterpartUserId,
|
||||
required void Function(bool isOnline) onCounterpartOnlineChanged,
|
||||
}) {
|
||||
final channel = _presenceDirectChatChannel;
|
||||
if (channel == null) return;
|
||||
|
||||
_presenceDirectChatListeners.add(
|
||||
_PresenceDirectChatListener(
|
||||
counterpartUserId: counterpartUserId,
|
||||
onCounterpartOnlineChanged: onCounterpartOnlineChanged,
|
||||
),
|
||||
);
|
||||
|
||||
_bindPresenceChannelEventsIfNeeded();
|
||||
channel.subscribe();
|
||||
_emitCounterpartPresence(counterpartUserId);
|
||||
}
|
||||
|
||||
void removePresenceDirectChatListener(
|
||||
void Function(bool isOnline) onCounterpartOnlineChanged,
|
||||
) {
|
||||
_presenceDirectChatListeners.removeWhere(
|
||||
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
|
||||
);
|
||||
if (_presenceDirectChatListeners.isEmpty) {
|
||||
unsubscribePresenceDirectChatChannel();
|
||||
}
|
||||
}
|
||||
|
||||
void _bindPresenceChannelEventsIfNeeded() {
|
||||
if (_presenceChannelEventsBound) return;
|
||||
final channel = _presenceDirectChatChannel;
|
||||
if (channel == null) return;
|
||||
|
||||
_presenceChannelEventsBound = true;
|
||||
|
||||
_presenceSubscriptionSucceededSubscription =
|
||||
channel.whenSubscriptionSucceeded().listen((_) {
|
||||
final counterpartIds = _presenceDirectChatListeners
|
||||
.map((listener) => listener.counterpartUserId)
|
||||
.toSet();
|
||||
for (final counterpartUserId in counterpartIds) {
|
||||
_emitCounterpartPresence(counterpartUserId);
|
||||
}
|
||||
});
|
||||
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
|
||||
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||
if (memberUserId == null) return;
|
||||
_notifyPresenceListeners(memberUserId, isOnline: true);
|
||||
});
|
||||
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
|
||||
final memberUserId = _parsePresenceMemberUserId(event.data);
|
||||
if (memberUserId == null) return;
|
||||
_notifyPresenceListeners(memberUserId, isOnline: false);
|
||||
});
|
||||
}
|
||||
|
||||
void _emitCounterpartPresence(int counterpartUserId) {
|
||||
final channel = _presenceDirectChatChannel;
|
||||
if (channel == null) return;
|
||||
final members = channel.state?.members;
|
||||
if (members == null) return;
|
||||
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
|
||||
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
|
||||
}
|
||||
|
||||
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
|
||||
for (final listener in _presenceDirectChatListeners) {
|
||||
if (listener.counterpartUserId == counterpartUserId) {
|
||||
listener.onCounterpartOnlineChanged(isOnline);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int? _parsePresenceMemberUserId(String? data) {
|
||||
if (data == null || data.isEmpty) return null;
|
||||
try {
|
||||
final decoded = jsonDecode(data);
|
||||
if (decoded is! Map) return null;
|
||||
final rawId = decoded['user_id'] ?? decoded['id'];
|
||||
if (rawId == null) return null;
|
||||
return int.tryParse(rawId.toString());
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _subscribe() {
|
||||
_privateMessagesChannel?.subscribe();
|
||||
_privateNotificationChannel?.subscribe();
|
||||
@@ -193,6 +301,19 @@ class WsManager {
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
}
|
||||
|
||||
void unsubscribePresenceDirectChatChannel() {
|
||||
_presenceSubscriptionSucceededSubscription?.cancel();
|
||||
_presenceMemberAddedSubscription?.cancel();
|
||||
_presenceMemberRemovedSubscription?.cancel();
|
||||
_presenceSubscriptionSucceededSubscription = null;
|
||||
_presenceMemberAddedSubscription = null;
|
||||
_presenceMemberRemovedSubscription = null;
|
||||
_presenceDirectChatListeners.clear();
|
||||
_presenceChannelEventsBound = false;
|
||||
_presenceDirectChatChannel?.unsubscribe();
|
||||
_presenceDirectChatChannel = null;
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
await client?.disconnect();
|
||||
await _lifecycleSubscription?.cancel();
|
||||
@@ -204,6 +325,7 @@ class WsManager {
|
||||
client = null;
|
||||
userId = null;
|
||||
_privateDirectChatChannel?.unsubscribe();
|
||||
unsubscribePresenceDirectChatChannel();
|
||||
_privateMessagesChannel?.unsubscribe();
|
||||
_privateNotificationChannel?.unsubscribe();
|
||||
_privateDirectChatChannel = null;
|
||||
@@ -211,3 +333,13 @@ class WsManager {
|
||||
_privateNotificationChannel = null;
|
||||
}
|
||||
}
|
||||
|
||||
class _PresenceDirectChatListener {
|
||||
const _PresenceDirectChatListener({
|
||||
required this.counterpartUserId,
|
||||
required this.onCounterpartOnlineChanged,
|
||||
});
|
||||
|
||||
final int counterpartUserId;
|
||||
final void Function(bool isOnline) onCounterpartOnlineChanged;
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
@JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead,
|
||||
@JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor,
|
||||
@JsonKey(name: 'room_id') required String? roomId,
|
||||
@JsonKey(name: 'type') String? type,
|
||||
@JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type,
|
||||
@Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState,
|
||||
@Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid,
|
||||
}) = _ChatMessageModel;
|
||||
@@ -42,3 +42,22 @@ abstract class ChatMessageModel with _$ChatMessageModel {
|
||||
}
|
||||
|
||||
enum MessageState { loading, sent, error }
|
||||
|
||||
enum ChatMessageType {
|
||||
@JsonValue('text')
|
||||
text,
|
||||
@JsonValue('system')
|
||||
system,
|
||||
@JsonValue('attachment')
|
||||
attachment,
|
||||
@JsonValue('image')
|
||||
image,
|
||||
@JsonValue('plan_order')
|
||||
planOrder,
|
||||
@JsonValue('video_call_started')
|
||||
videoCallStarted,
|
||||
@JsonValue('video_call_ended')
|
||||
videoCallEnded,
|
||||
@JsonValue('unknown')
|
||||
unknown;
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser {
|
||||
@JsonKey(name: "name") required String name,
|
||||
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
|
||||
@JsonKey(name: "profile_photo_url") required String profilePhotoUrl,
|
||||
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
|
||||
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
|
||||
}) = _ChatUser;
|
||||
|
||||
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);
|
||||
|
||||
@@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel {
|
||||
required String name,
|
||||
required String slug,
|
||||
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
|
||||
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
|
||||
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
|
||||
}) = _ChatUserModel;
|
||||
|
||||
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
|
||||
import 'package:eatvio_app/features/my_finance/models/card_model.dart';
|
||||
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
|
||||
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
|
||||
@@ -34,6 +35,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
late int lastPage;
|
||||
bool isLoading = false;
|
||||
Timer? _timer;
|
||||
String? _activeChatUuid;
|
||||
void Function(bool isOnline)? _presenceOnlineListener;
|
||||
var uuid = const Uuid();
|
||||
|
||||
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
|
||||
@@ -193,8 +196,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
|
||||
final client = wsManager.client;
|
||||
if (client == null) return;
|
||||
_activeChatUuid = uuid;
|
||||
await wsManager.initPrivateDirectChatChannel(chatId);
|
||||
wsManager.subscribeDirectChatChannel();
|
||||
await _subscribeToPresenceChannel(chatId: chatId);
|
||||
messagesSubscription = wsManager.directChatStream?.listen((event) {
|
||||
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
|
||||
final message = ChatMessageModel.fromJson(jsonDecode(event.data));
|
||||
@@ -220,6 +225,118 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
|
||||
final counterpart = resolveChatCounterpart(
|
||||
users: currentState.chatRoom.users,
|
||||
currentUserId: wsManager.userId,
|
||||
);
|
||||
|
||||
await wsManager.initPresenceDirectChatChannel(chatId);
|
||||
_presenceOnlineListener = (isOnline) {
|
||||
unawaited(_handleCounterpartOnlineChanged(
|
||||
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender,
|
||||
isOnline: isOnline,
|
||||
));
|
||||
};
|
||||
wsManager.addPresenceDirectChatListener(
|
||||
counterpartUserId: counterpart.id,
|
||||
onCounterpartOnlineChanged: _presenceOnlineListener!,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleCounterpartOnlineChanged({
|
||||
required _ChatCounterpartField counterpartField,
|
||||
required bool isOnline,
|
||||
}) async {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
|
||||
final counterpart = counterpartField == _ChatCounterpartField.receiver
|
||||
? currentState.chatRoom.users.receiver
|
||||
: currentState.chatRoom.users.sender;
|
||||
|
||||
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isOnline) {
|
||||
await _refreshCounterpartOnlineStatus(counterpartField: counterpartField);
|
||||
return;
|
||||
}
|
||||
|
||||
_emitCounterpartOnlineStatus(
|
||||
counterpartField: counterpartField,
|
||||
isOnline: true,
|
||||
lastSeenText: '',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refreshCounterpartOnlineStatus({
|
||||
required _ChatCounterpartField counterpartField,
|
||||
}) async {
|
||||
final chatUuid = _activeChatUuid;
|
||||
if (chatUuid == null) return;
|
||||
|
||||
try {
|
||||
final chatRoom = await chatRepository.getChatRoom(chatUuid);
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
|
||||
final counterpart = resolveChatCounterpart(
|
||||
users: chatRoom.users,
|
||||
currentUserId: wsManager.userId,
|
||||
);
|
||||
final field = counterpart.id == currentState.chatRoom.users.receiver.id
|
||||
? _ChatCounterpartField.receiver
|
||||
: _ChatCounterpartField.sender;
|
||||
|
||||
if (field != counterpartField) return;
|
||||
|
||||
_emitCounterpartOnlineStatus(
|
||||
counterpartField: counterpartField,
|
||||
isOnline: counterpart.isOnline,
|
||||
lastSeenText: counterpart.lastSeenText,
|
||||
);
|
||||
} catch (e, st) {
|
||||
await reportFrom(e, st, this);
|
||||
}
|
||||
}
|
||||
|
||||
void _emitCounterpartOnlineStatus({
|
||||
required _ChatCounterpartField counterpartField,
|
||||
required bool isOnline,
|
||||
required String lastSeenText,
|
||||
}) {
|
||||
final currentState = state;
|
||||
if (currentState is! ChatRoomStateLoaded) return;
|
||||
|
||||
final users = currentState.chatRoom.users;
|
||||
final updatedUsers = counterpartField == _ChatCounterpartField.receiver
|
||||
? users.copyWith(
|
||||
receiver: users.receiver.copyWith(
|
||||
isOnline: isOnline,
|
||||
lastSeenText: lastSeenText,
|
||||
),
|
||||
)
|
||||
: users.copyWith(
|
||||
sender: users.sender.copyWith(
|
||||
isOnline: isOnline,
|
||||
lastSeenText: lastSeenText,
|
||||
),
|
||||
);
|
||||
|
||||
emit(
|
||||
currentState.copyWith(
|
||||
chatRoom: currentState.chatRoom.copyWith(users: updatedUsers),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
|
||||
try {
|
||||
final messageUuid = uuid.v4();
|
||||
@@ -360,6 +477,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
if (_presenceOnlineListener != null) {
|
||||
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||
_presenceOnlineListener = null;
|
||||
}
|
||||
wsManager.unsubscribeDirectChatChannel();
|
||||
_timer?.cancel();
|
||||
messagesSubscription?.cancel();
|
||||
@@ -458,3 +579,5 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum _ChatCounterpartField { sender, receiver }
|
||||
|
||||
@@ -13,13 +13,19 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cub
|
||||
import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_message_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/video_call_chat_message_widget.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
|
||||
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
|
||||
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
|
||||
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
|
||||
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
|
||||
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
|
||||
@@ -59,7 +65,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
/// Conversation uuid после успешного init (в т.ч. после resolve по orderId).
|
||||
String? _effectiveUuid;
|
||||
|
||||
String? get _conversationUuid => _effectiveUuid ?? widget.uuid;
|
||||
String? get conversationUuid => _effectiveUuid ?? widget.uuid;
|
||||
|
||||
void _onHide() {
|
||||
chatRoomCubit.cancelTimer();
|
||||
@@ -72,7 +78,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
Future<void> _initChatRoom() async {
|
||||
final uuid = await chatRoomCubit.initChat(
|
||||
_conversationUuid,
|
||||
conversationUuid,
|
||||
orderId: _effectiveUuid == null ? widget.orderId : null,
|
||||
sendInitialMessage: widget.sendInitialMessage ?? false,
|
||||
);
|
||||
@@ -101,7 +107,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final uuid = _conversationUuid;
|
||||
final uuid = conversationUuid;
|
||||
if (uuid == null) return;
|
||||
if (scrollController.offset >= scrollController.position.maxScrollExtent && !scrollController.position.outOfRange) {
|
||||
chatRoomCubit.loadNextPage(uuid);
|
||||
@@ -162,6 +168,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
|
||||
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
|
||||
final chatCounterpart = resolveChatCounterpart(users: state.chatRoom.users, currentUserId: sl<WsManager>().userId);
|
||||
final chosenPlan = state.chatRoom.chosenPlan;
|
||||
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
|
||||
final showUserDetailsButton = [
|
||||
@@ -238,19 +245,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(state.chatRoom.users.receiver.name, style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black)),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Text('Печатает...', style: CustomTextStyles.bodySmallMulishPrimary),
|
||||
firstCurve: Curves.easeIn,
|
||||
secondCurve: Curves.easeOut,
|
||||
secondChild: const SizedBox.shrink(),
|
||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
state.chatRoom.users.receiver.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: CustomTextStyles.titleSmallBold.copyWith(color: PrimaryColors().black),
|
||||
),
|
||||
AnimatedCrossFade(
|
||||
firstChild: Text('Печатает...', maxLines: 1, overflow: TextOverflow.ellipsis, style: CustomTextStyles.bodySmallMulishPrimary),
|
||||
firstCurve: Curves.easeIn,
|
||||
secondCurve: Curves.easeOut,
|
||||
secondChild: ChatUserOnlineStatusLine(
|
||||
isOnline: chatCounterpart.isOnline,
|
||||
lastSeenText: chatCounterpart.lastSeenText,
|
||||
style: CustomTextStyles.bodySmallMulishPrimary,
|
||||
showOnlineDot: false,
|
||||
),
|
||||
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -258,18 +277,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
actions: [
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: _ChatHeaderVideoCallButton(
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': _conversationUuid,
|
||||
'counterpartName': state.chatRoom.users.receiver.name,
|
||||
'localParticipantName': state.chatRoom.users.sender.name,
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
child: _ChatHeaderVideoCallButton(onPressed: () => _openVideoCall(context, state, chatCounterpart)),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -304,7 +312,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
index + 1 == state.chatMessages.length) {
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == 'plan_order';
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
@@ -345,18 +353,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ChatBubbleWidget(
|
||||
isRead: message.isRead,
|
||||
sendTime: message.updatedAt,
|
||||
text: message.data.trim(),
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
mediaData: message.mediaData ?? [],
|
||||
messageState: message.messageState,
|
||||
messageUuid: message.uuid ?? '',
|
||||
userSlug: state.chatRoom.users.receiver.slug,
|
||||
messageType: message.type,
|
||||
isConsultant: amIConsultant,
|
||||
urlsMediaInfo: message.urlsMediaInfo,
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -364,7 +367,7 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
}
|
||||
final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId;
|
||||
final message = state.chatMessages[index];
|
||||
final isPlanOrderMessage = message.type == 'plan_order';
|
||||
final isPlanOrderMessage = message.type == ChatMessageType.planOrder;
|
||||
|
||||
PacketOptions? messageOption;
|
||||
if (isPlanOrderMessage && chosenPlan != null) {
|
||||
@@ -401,18 +404,13 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
});
|
||||
}
|
||||
},
|
||||
child: ChatBubbleWidget(
|
||||
isRead: message.isRead,
|
||||
sendTime: message.updatedAt,
|
||||
text: message.data.trim(),
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
mediaData: message.mediaData ?? [],
|
||||
messageState: message.messageState,
|
||||
messageUuid: message.uuid ?? '',
|
||||
userSlug: state.chatRoom.users.receiver.slug,
|
||||
messageType: message.type,
|
||||
isConsultant: amIConsultant,
|
||||
urlsMediaInfo: message.urlsMediaInfo,
|
||||
child: _buildMessageBubble(
|
||||
context: context,
|
||||
state: state,
|
||||
chatCounterpart: chatCounterpart,
|
||||
isCurrentUserAuthor: isCurrentUserAuthor,
|
||||
message: message,
|
||||
amIConsultant: amIConsultant,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -779,6 +777,52 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) {
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': conversationUuid,
|
||||
'counterpartName': chatCounterpart.name,
|
||||
'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl,
|
||||
'counterpartUserId': chatCounterpart.id,
|
||||
'counterpartIsOnline': chatCounterpart.isOnline,
|
||||
'counterpartLastSeenText': chatCounterpart.lastSeenText,
|
||||
'localParticipantName': state.chatRoom.users.sender.name,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble({
|
||||
required BuildContext context,
|
||||
required ChatRoomStateLoaded state,
|
||||
required ChatUserModel chatCounterpart,
|
||||
required bool isCurrentUserAuthor,
|
||||
required ChatMessageModel message,
|
||||
required bool? amIConsultant,
|
||||
}) {
|
||||
if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) {
|
||||
return VideoCallChatMessageWidget(
|
||||
text: message.data.trim(),
|
||||
sendTime: message.updatedAt,
|
||||
onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart),
|
||||
);
|
||||
}
|
||||
|
||||
return ChatBubbleWidget(
|
||||
isRead: message.isRead,
|
||||
sendTime: message.updatedAt,
|
||||
text: message.data.trim(),
|
||||
isCurrentUser: isCurrentUserAuthor,
|
||||
mediaData: message.mediaData ?? [],
|
||||
messageState: message.messageState,
|
||||
messageUuid: message.uuid ?? '',
|
||||
userSlug: state.chatRoom.users.receiver.slug,
|
||||
messageType: message.type,
|
||||
isConsultant: amIConsultant,
|
||||
urlsMediaInfo: message.urlsMediaInfo,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChatHeaderVideoCallButton extends StatelessWidget {
|
||||
@@ -800,9 +844,7 @@ class _ChatHeaderVideoCallButton extends StatelessWidget {
|
||||
child: SizedBox(
|
||||
width: 40,
|
||||
height: 40,
|
||||
child: Center(
|
||||
child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18),
|
||||
),
|
||||
child: Center(child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget {
|
||||
final MessageState messageState;
|
||||
final String messageUuid;
|
||||
final String userSlug;
|
||||
final String? messageType;
|
||||
final ChatMessageType? messageType;
|
||||
final bool? isConsultant;
|
||||
final List<Map<String, dynamic>>? urlsMediaInfo;
|
||||
@override
|
||||
@@ -182,33 +182,36 @@ class ChatBubbleWidget extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.all(4),
|
||||
horizontalTitleGap: 8,
|
||||
onTap: () async {
|
||||
if (mediaItem is File) {
|
||||
// Для локальных файлов используем file:// URI
|
||||
final fileUri = Uri.file(mediaItem.path);
|
||||
if (await canLaunchUrl(fileUri)) {
|
||||
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(4),
|
||||
horizontalTitleGap: 8,
|
||||
onTap: () async {
|
||||
if (mediaItem is File) {
|
||||
// Для локальных файлов используем file:// URI
|
||||
final fileUri = Uri.file(mediaItem.path);
|
||||
if (await canLaunchUrl(fileUri)) {
|
||||
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
} else {
|
||||
// Для URL файлов используем обычный launchUrl
|
||||
final urlString = mediaItem.toString();
|
||||
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
|
||||
? Uri.parse(urlString)
|
||||
: Uri.parse('https://$urlString');
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Для URL файлов используем обычный launchUrl
|
||||
final urlString = mediaItem.toString();
|
||||
final uri = urlString.startsWith('http://') || urlString.startsWith('https://')
|
||||
? Uri.parse(urlString)
|
||||
: Uri.parse('https://$urlString');
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
},
|
||||
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
|
||||
trailing: const Icon(Icons.download_outlined, color: Colors.black),
|
||||
title: Text(
|
||||
fileName,
|
||||
maxLines: 2,
|
||||
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
|
||||
},
|
||||
leading: const Icon(Icons.file_copy_outlined, color: Colors.black),
|
||||
trailing: const Icon(Icons.download_outlined, color: Colors.black),
|
||||
title: Text(
|
||||
fileName,
|
||||
maxLines: 2,
|
||||
style: const TextStyle(color: Colors.black, fontSize: 12, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -255,7 +258,7 @@ class ChatBubbleWidget extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _getDisplayText() {
|
||||
if (messageType == 'plan_order') {
|
||||
if (messageType == ChatMessageType.planOrder) {
|
||||
if (isConsultant == true) {
|
||||
// Текст для консультанта
|
||||
return '1. Уточните у подопечного его цели и пожелания\n'
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ChatUserOnlineStatusLine extends StatelessWidget {
|
||||
const ChatUserOnlineStatusLine({
|
||||
super.key,
|
||||
required this.isOnline,
|
||||
required this.lastSeenText,
|
||||
this.style,
|
||||
this.showOnlineDot = true,
|
||||
});
|
||||
|
||||
final bool isOnline;
|
||||
final String lastSeenText;
|
||||
final TextStyle? style;
|
||||
final bool showOnlineDot;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textStyle = style ??
|
||||
CustomTextStyles.bodySmallMulishBlack900.copyWith(height: 14 / 12);
|
||||
|
||||
if (isOnline) {
|
||||
if (!showOnlineDot) {
|
||||
return Text(
|
||||
S.of(context).video_call_status_online,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.greenA700,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: Text(
|
||||
S.of(context).video_call_status_online,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textStyle,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final trimmedLastSeen = lastSeenText.trim();
|
||||
if (trimmedLastSeen.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Text(
|
||||
trimmedLastSeen,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: textStyle,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/core/utils/date_time_utils.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class VideoCallChatMessageWidget extends StatelessWidget {
|
||||
const VideoCallChatMessageWidget({
|
||||
super.key,
|
||||
required this.text,
|
||||
required this.sendTime,
|
||||
required this.onVideoCallPressed,
|
||||
});
|
||||
|
||||
final String text;
|
||||
final DateTime sendTime;
|
||||
final VoidCallback onVideoCallPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.sizeOf(context);
|
||||
final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16);
|
||||
|
||||
return SizedBox(
|
||||
width: size.width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 64, 4),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Container(
|
||||
constraints: BoxConstraints(maxWidth: size.width * 0.7),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.gray60004,
|
||||
borderRadius: const BorderRadius.horizontal(
|
||||
left: Radius.circular(16),
|
||||
right: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 16, 8),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
if (text.isNotEmpty)
|
||||
Flexible(
|
||||
child: Text(text, style: textStyle),
|
||||
),
|
||||
if (text.isNotEmpty) const SizedBox(width: 8),
|
||||
_VideoCallBubbleActionButton(onPressed: onVideoCallPressed),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 4),
|
||||
child: Text(
|
||||
sendTime.format('HH:mm', 'ru'),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF7B8794),
|
||||
height: 1,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoCallBubbleActionButton extends StatelessWidget {
|
||||
const _VideoCallBubbleActionButton({required this.onPressed});
|
||||
|
||||
final VoidCallback onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: appTheme.greenA700,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: Center(
|
||||
child: SvgPicture.asset(
|
||||
'assets/icons/video_call/vc_control_enter.svg',
|
||||
width: 20,
|
||||
height: 20,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
15
lib/features/chat/utils/chat_counterpart_utils.dart
Normal file
15
lib/features/chat/utils/chat_counterpart_utils.dart
Normal file
@@ -0,0 +1,15 @@
|
||||
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
|
||||
import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
|
||||
|
||||
ChatUserModel resolveChatCounterpart({
|
||||
required Users users,
|
||||
required int? currentUserId,
|
||||
}) {
|
||||
if (currentUserId != null && users.sender.id == currentUserId) {
|
||||
return users.receiver;
|
||||
}
|
||||
if (currentUserId != null && users.receiver.id == currentUserId) {
|
||||
return users.sender;
|
||||
}
|
||||
return users.receiver;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:eatvio_app/core/app_config.dart';
|
||||
import 'package:eatvio_app/core/error_reporting/error_reporter.dart';
|
||||
import 'package:eatvio_app/core/const.dart';
|
||||
import 'package:eatvio_app/core/firebase_api.dart';
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/token_storage.dart';
|
||||
import 'package:eatvio_app/core/utils/image_constant.dart';
|
||||
@@ -15,6 +16,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 +167,13 @@ class MenuScreen extends StatelessWidget {
|
||||
} catch (e, s) {
|
||||
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||
} finally {
|
||||
try {
|
||||
// Needs Authorization header → must run before removing token.
|
||||
await sl<FirebaseApi>().unregisterPushTokens();
|
||||
} catch (e, s) {
|
||||
await sl<ErrorReporter>().report(e, s, origin: 'MenuScreen');
|
||||
}
|
||||
await sl<VideoCallSessionCubit>().endCall();
|
||||
await sl<WsManager>().clear();
|
||||
await TokenStorage.removeTokenInStorage();
|
||||
if (context.mounted) {
|
||||
|
||||
@@ -15,17 +15,20 @@ class ExpandableBouble extends StatelessWidget {
|
||||
child: MainBackgroundBouble(
|
||||
child: Theme(
|
||||
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||
expandedAlignment: Alignment.bottomLeft,
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: ExpansionTile(
|
||||
initiallyExpanded: initiallyExpanded,
|
||||
expandedCrossAxisAlignment: CrossAxisAlignment.start,
|
||||
expandedAlignment: Alignment.bottomLeft,
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
childrenPadding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
title: Text(
|
||||
title,
|
||||
style: TextStyles.headingXLSemibold.copyWith(color: appTheme.black),
|
||||
),
|
||||
children: children,
|
||||
),
|
||||
children: children,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
1139
lib/features/video_calls/managers/video_call_manager.dart
Normal file
1139
lib/features/video_calls/managers/video_call_manager.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
|
||||
class VideoCallManagerSnapshot {
|
||||
const VideoCallManagerSnapshot({
|
||||
required this.conversationUuid,
|
||||
this.counterpartName,
|
||||
this.localParticipantName,
|
||||
required this.micEnabled,
|
||||
required this.videoEnabled,
|
||||
this.connectError,
|
||||
required this.connectPhase,
|
||||
required this.linkIssue,
|
||||
this.counterpartPresentInLobby = false,
|
||||
});
|
||||
|
||||
final String conversationUuid;
|
||||
final String? counterpartName;
|
||||
final String? localParticipantName;
|
||||
final bool micEnabled;
|
||||
final bool videoEnabled;
|
||||
final String? connectError;
|
||||
final VideoCallConnectPhase connectPhase;
|
||||
final VideoCallLinkIssue linkIssue;
|
||||
final bool counterpartPresentInLobby;
|
||||
|
||||
static const initial = VideoCallManagerSnapshot(
|
||||
conversationUuid: '',
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
connectPhase: VideoCallConnectPhase.idle,
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
);
|
||||
|
||||
VideoCallManagerSnapshot copyWith({
|
||||
String? conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
bool? micEnabled,
|
||||
bool? videoEnabled,
|
||||
String? connectError,
|
||||
VideoCallConnectPhase? connectPhase,
|
||||
VideoCallLinkIssue? linkIssue,
|
||||
bool? counterpartPresentInLobby,
|
||||
bool clearConnectError = false,
|
||||
}) {
|
||||
return VideoCallManagerSnapshot(
|
||||
conversationUuid: conversationUuid ?? this.conversationUuid,
|
||||
counterpartName: counterpartName ?? this.counterpartName,
|
||||
localParticipantName: localParticipantName ?? this.localParticipantName,
|
||||
micEnabled: micEnabled ?? this.micEnabled,
|
||||
videoEnabled: videoEnabled ?? this.videoEnabled,
|
||||
connectError: clearConnectError ? null : (connectError ?? this.connectError),
|
||||
connectPhase: connectPhase ?? this.connectPhase,
|
||||
linkIssue: linkIssue ?? this.linkIssue,
|
||||
counterpartPresentInLobby:
|
||||
counterpartPresentInLobby ?? this.counterpartPresentInLobby,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,3 +35,14 @@ abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
|
||||
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$VideoCallEndedResponseFromJson(json);
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse {
|
||||
const factory VideoCallAcceptResponse({
|
||||
required bool sent,
|
||||
@JsonKey(name: 'devices_notified') required int devicesNotified,
|
||||
}) = _VideoCallAcceptResponse;
|
||||
|
||||
factory VideoCallAcceptResponse.fromJson(Map<String, Object?> json) =>
|
||||
_$VideoCallAcceptResponseFromJson(json);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:eatvio_app/core/cubit/base_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_screen_wake_lock_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
VideoCallSessionCubit({
|
||||
required VideoCallManager videoCallManager,
|
||||
VideoCallSystemPipCoordinator? systemPipCoordinator,
|
||||
VideoCallScreenWakeLockCoordinator? screenWakeLockCoordinator,
|
||||
}) : _videoCallManager = videoCallManager,
|
||||
_systemPipCoordinator =
|
||||
systemPipCoordinator ?? VideoCallSystemPipCoordinator(),
|
||||
_screenWakeLock =
|
||||
screenWakeLockCoordinator ?? VideoCallScreenWakeLockCoordinator(),
|
||||
super(const VideoCallSessionState()) {
|
||||
_videoCallManager.addListener(_onManagerSnapshot);
|
||||
_syncFromManager(_videoCallManager.snapshot);
|
||||
}
|
||||
|
||||
final VideoCallManager _videoCallManager;
|
||||
final VideoCallSystemPipCoordinator _systemPipCoordinator;
|
||||
final VideoCallScreenWakeLockCoordinator _screenWakeLock;
|
||||
bool _callScreenRouteVisible = false;
|
||||
bool _autoEnterInFlight = false;
|
||||
bool _counterpartPresentWhenLobbyReady = false;
|
||||
bool _sawEmptyLobbyWhenConnected = false;
|
||||
bool _lobbyReadyTracked = false;
|
||||
String? _lobbyReadyTrackedConversationUuid;
|
||||
Future<void>? _autoEnterFuture;
|
||||
Future<void>? _outgoingAutoEnterFuture;
|
||||
|
||||
lk.Room? get liveKitRoom => _videoCallManager.room;
|
||||
bool get isCallScreenRouteVisible => _callScreenRouteVisible;
|
||||
|
||||
static bool _isCallSessionInactive(VideoCallManagerSnapshot snapshot) {
|
||||
return snapshot.conversationUuid.isEmpty ||
|
||||
snapshot.connectPhase == VideoCallConnectPhase.idle;
|
||||
}
|
||||
|
||||
VideoCallSessionState _sessionStateFromSnapshot(
|
||||
VideoCallManagerSnapshot snapshot,
|
||||
) {
|
||||
final hideStaleConnectError =
|
||||
snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||
!snapshot.linkIssue.needsManualReconnect;
|
||||
final resetCallStage =
|
||||
_isCallSessionInactive(snapshot) ||
|
||||
snapshot.connectPhase == VideoCallConnectPhase.connecting;
|
||||
|
||||
return state.copyWith(
|
||||
conversationUuid: snapshot.conversationUuid,
|
||||
counterpartName: snapshot.counterpartName,
|
||||
localParticipantName: snapshot.localParticipantName,
|
||||
micEnabled: snapshot.micEnabled,
|
||||
videoEnabled: snapshot.videoEnabled,
|
||||
connectError: hideStaleConnectError ? null : snapshot.connectError,
|
||||
connectPhase: snapshot.connectPhase,
|
||||
linkIssue: snapshot.linkIssue,
|
||||
callStageEntered: resetCallStage ? false : state.callStageEntered,
|
||||
uiMode:
|
||||
_isCallSessionInactive(snapshot)
|
||||
? VideoCallUiMode.hidden
|
||||
: state.uiMode,
|
||||
);
|
||||
}
|
||||
|
||||
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
|
||||
if (isClosed) return;
|
||||
if (_isCallSessionInactive(snapshot)) {
|
||||
unawaited(_systemPipCoordinator.disarm());
|
||||
if (_callScreenRouteVisible) {
|
||||
// [VideoCallManager.startCall] briefly emits idle while replacing the room.
|
||||
return;
|
||||
}
|
||||
_resetLobbyReadyTracking();
|
||||
emit(const VideoCallSessionState());
|
||||
unawaited(_restoreSystemUiAfterCall());
|
||||
return;
|
||||
}
|
||||
_trackLobbyReadyCounterpartPresence(snapshot);
|
||||
final next = _sessionStateFromSnapshot(snapshot);
|
||||
emit(next);
|
||||
_scheduleAutoEnterIfNeeded(next);
|
||||
_scheduleOutgoingAutoEnterIfNeeded(next, snapshot);
|
||||
}
|
||||
|
||||
void _syncFromManager(VideoCallManagerSnapshot snapshot) {
|
||||
if (_isCallSessionInactive(snapshot)) {
|
||||
unawaited(_systemPipCoordinator.disarm());
|
||||
if (_callScreenRouteVisible) return;
|
||||
_resetLobbyReadyTracking();
|
||||
emit(const VideoCallSessionState());
|
||||
return;
|
||||
}
|
||||
_trackLobbyReadyCounterpartPresence(snapshot);
|
||||
emit(_sessionStateFromSnapshot(snapshot));
|
||||
}
|
||||
|
||||
void _resetLobbyReadyTracking() {
|
||||
_lobbyReadyTrackedConversationUuid = null;
|
||||
_counterpartPresentWhenLobbyReady = false;
|
||||
_sawEmptyLobbyWhenConnected = false;
|
||||
_lobbyReadyTracked = false;
|
||||
}
|
||||
|
||||
void _trackLobbyReadyCounterpartPresence(VideoCallManagerSnapshot snapshot) {
|
||||
final uuid = snapshot.conversationUuid;
|
||||
if (uuid.isEmpty) {
|
||||
_resetLobbyReadyTracking();
|
||||
return;
|
||||
}
|
||||
if (snapshot.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
|
||||
if (!_lobbyReadyTracked || _lobbyReadyTrackedConversationUuid != uuid) {
|
||||
_lobbyReadyTracked = true;
|
||||
_lobbyReadyTrackedConversationUuid = uuid;
|
||||
_counterpartPresentWhenLobbyReady = snapshot.counterpartPresentInLobby;
|
||||
_sawEmptyLobbyWhenConnected = !snapshot.counterpartPresentInLobby;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_sawEmptyLobbyWhenConnected && snapshot.counterpartPresentInLobby) {
|
||||
_counterpartPresentWhenLobbyReady = false;
|
||||
_sawEmptyLobbyWhenConnected = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _notifyIncomingCallAcceptedIfNeeded(String conversationUuid) {
|
||||
return _videoCallManager.notifyIncomingCallAccepted(conversationUuid);
|
||||
}
|
||||
|
||||
/// Синхронизирует UI-состояние после [VideoCallManager.endCall] из notification /
|
||||
/// foreground task (без повторного disconnect).
|
||||
Future<void> onCallEndedExternally() async {
|
||||
await _onCallEndedExternally();
|
||||
}
|
||||
|
||||
Future<void> _onCallEndedExternally() async {
|
||||
if (isClosed) return;
|
||||
await _systemPipCoordinator.disarm();
|
||||
await _restoreSystemUiAfterCall();
|
||||
emit(const VideoCallSessionState());
|
||||
}
|
||||
|
||||
Future<void> startCall({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
}) async {
|
||||
await _videoCallManager.startCall(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
micEnabled: state.micEnabled,
|
||||
videoEnabled: state.videoEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onCallScreenVisible({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
|
||||
}) async {
|
||||
_callScreenRouteVisible = true;
|
||||
await _screenWakeLock.acquire();
|
||||
|
||||
final effectiveEntryMode =
|
||||
state.entryMode == VideoCallEntryMode.incomingAccepted ||
|
||||
entryMode == VideoCallEntryMode.incomingAccepted
|
||||
? VideoCallEntryMode.incomingAccepted
|
||||
: entryMode;
|
||||
final isIncomingAccepted =
|
||||
effectiveEntryMode == VideoCallEntryMode.incomingAccepted;
|
||||
final isResumingActiveCall =
|
||||
state.conversationUuid == conversationUuid &&
|
||||
state.callStageEntered &&
|
||||
_videoCallManager.room != null;
|
||||
final isSameCallConnecting =
|
||||
state.conversationUuid == conversationUuid &&
|
||||
!state.callStageEntered &&
|
||||
state.connectPhase != VideoCallConnectPhase.idle;
|
||||
|
||||
if (isIncomingAccepted && !isResumingActiveCall) {
|
||||
unawaited(_notifyIncomingCallAcceptedIfNeeded(conversationUuid));
|
||||
}
|
||||
|
||||
if (!isResumingActiveCall && !isSameCallConnecting) {
|
||||
_resetLobbyReadyTracking();
|
||||
await _systemPipCoordinator.disarm();
|
||||
}
|
||||
|
||||
if (isSameCallConnecting) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
counterpartName: counterpartName ?? state.counterpartName,
|
||||
localParticipantName:
|
||||
localParticipantName ?? state.localParticipantName,
|
||||
counterpartIsConsultant:
|
||||
counterpartIsConsultant ?? state.counterpartIsConsultant,
|
||||
entryMode: effectiveEntryMode,
|
||||
),
|
||||
);
|
||||
if (isIncomingAccepted) {
|
||||
await _tryAutoEnterCallStage();
|
||||
}
|
||||
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
|
||||
await _ensureSystemPipArmedIfInCall();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isResumingActiveCall) {
|
||||
await _videoCallManager.onCallScreenVisible(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
micEnabled: state.micEnabled,
|
||||
videoEnabled: state.videoEnabled,
|
||||
);
|
||||
if (counterpartIsConsultant != null) {
|
||||
emit(state.copyWith(counterpartIsConsultant: counterpartIsConsultant));
|
||||
}
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant ?? false,
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
callStageEntered: false,
|
||||
connectError: null,
|
||||
linkIssue: VideoCallLinkIssue.none,
|
||||
connectPhase: VideoCallConnectPhase.connecting,
|
||||
entryMode: effectiveEntryMode,
|
||||
),
|
||||
);
|
||||
await _videoCallManager.startCall(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
);
|
||||
emit(state.copyWith(entryMode: effectiveEntryMode));
|
||||
if (isIncomingAccepted) {
|
||||
await _tryAutoEnterCallStage();
|
||||
}
|
||||
}
|
||||
|
||||
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
|
||||
await _ensureSystemPipArmedIfInCall();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _ensureSystemPipArmedIfInCall() async {
|
||||
if (!_videoCallManager.isActiveCall) {
|
||||
await _systemPipCoordinator.disarm();
|
||||
return;
|
||||
}
|
||||
await _systemPipCoordinator.armAutoPipOnLeave();
|
||||
}
|
||||
|
||||
void _scheduleAutoEnterIfNeeded(VideoCallSessionState sessionState) {
|
||||
if (sessionState.entryMode != VideoCallEntryMode.incomingAccepted) return;
|
||||
if (sessionState.callStageEntered) return;
|
||||
if (sessionState.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
unawaited(_tryAutoEnterCallStage());
|
||||
}
|
||||
|
||||
void _scheduleOutgoingAutoEnterIfNeeded(
|
||||
VideoCallSessionState sessionState,
|
||||
VideoCallManagerSnapshot snapshot,
|
||||
) {
|
||||
if (!_callScreenRouteVisible) return;
|
||||
if (sessionState.entryMode != VideoCallEntryMode.outgoing) return;
|
||||
if (sessionState.callStageEntered) return;
|
||||
if (sessionState.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
|
||||
if (!snapshot.counterpartPresentInLobby) {
|
||||
unawaited(_videoCallManager.ensureLobbyRoomJoined());
|
||||
return;
|
||||
}
|
||||
unawaited(_tryAutoEnterWhenCounterpartJoins());
|
||||
}
|
||||
|
||||
Future<void> _tryAutoEnterWhenCounterpartJoins() {
|
||||
final pending = _outgoingAutoEnterFuture;
|
||||
if (pending != null) return pending;
|
||||
|
||||
final run = _runOutgoingAutoEnterCallStage();
|
||||
_outgoingAutoEnterFuture = run;
|
||||
return run.whenComplete(() {
|
||||
if (identical(_outgoingAutoEnterFuture, run)) {
|
||||
_outgoingAutoEnterFuture = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runOutgoingAutoEnterCallStage() async {
|
||||
if (isClosed || _autoEnterInFlight) return;
|
||||
if (!_callScreenRouteVisible) return;
|
||||
if (state.entryMode != VideoCallEntryMode.outgoing) return;
|
||||
if (state.callStageEntered) return;
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
|
||||
_autoEnterInFlight = true;
|
||||
try {
|
||||
await enterCallStage();
|
||||
} finally {
|
||||
_autoEnterInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _tryAutoEnterCallStage() {
|
||||
final pending = _autoEnterFuture;
|
||||
if (pending != null) return pending;
|
||||
|
||||
final run = _runAutoEnterCallStage();
|
||||
_autoEnterFuture = run;
|
||||
return run.whenComplete(() {
|
||||
if (identical(_autoEnterFuture, run)) {
|
||||
_autoEnterFuture = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _runAutoEnterCallStage() async {
|
||||
if (isClosed || _autoEnterInFlight) return;
|
||||
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
|
||||
if (state.callStageEntered) return;
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
|
||||
_autoEnterInFlight = true;
|
||||
try {
|
||||
await _requestMicBeforeIncomingEnter();
|
||||
await enterCallStage();
|
||||
} finally {
|
||||
_autoEnterInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// After the loading/connect phase, request mic before joining the room.
|
||||
Future<void> _requestMicBeforeIncomingEnter() async {
|
||||
if (state.entryMode != VideoCallEntryMode.incomingAccepted) return;
|
||||
|
||||
final granted = await _requestMicPermissionForCall();
|
||||
if (!granted || isClosed) return;
|
||||
|
||||
await _videoCallManager.toggleMic(state.isMicActiveForUi);
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(micEnabled: true));
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<bool> requestMicPermissionForCall() => _requestMicPermissionForCall();
|
||||
|
||||
Future<bool> _requestMicPermissionForCall() async {
|
||||
try {
|
||||
final status = await Permission.microphone.request();
|
||||
return status.isGranted;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
void onCallScreenDisposed() {
|
||||
_callScreenRouteVisible = false;
|
||||
if (state.uiMode != VideoCallUiMode.fullscreen) {
|
||||
if (state.uiMode == VideoCallUiMode.pip && state.callStageEntered) {
|
||||
// [isCallScreenRouteVisible] is not in bloc state; refresh the global
|
||||
// PiP overlay after the call route is removed.
|
||||
emit(state.copyWith());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (state.connectPhase == VideoCallConnectPhase.connected &&
|
||||
!state.linkIssue.needsManualReconnect) {
|
||||
if (state.callStageEntered) {
|
||||
unawaited(minimizeToPip());
|
||||
} else {
|
||||
unawaited(endCall());
|
||||
}
|
||||
} else if (state.connectPhase == VideoCallConnectPhase.connecting) {
|
||||
unawaited(endCall());
|
||||
} else if (state.linkIssue.needsManualReconnect) {
|
||||
unawaited(endCall());
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canMinimizeToPip =>
|
||||
state.connectPhase == VideoCallConnectPhase.connected &&
|
||||
!state.linkIssue.needsManualReconnect &&
|
||||
state.callStageEntered;
|
||||
|
||||
Future<void> _applyPipUiState() async {
|
||||
emit(state.copyWith(uiMode: VideoCallUiMode.pip));
|
||||
await _restoreSystemUiAfterCall();
|
||||
await _ensureSystemPipArmedIfInCall();
|
||||
}
|
||||
|
||||
/// Called when Android enters system PiP (e.g. Home gesture).
|
||||
Future<void> syncPipUiStateFromSystem() async {
|
||||
if (!_canMinimizeToPip) return;
|
||||
if (state.uiMode == VideoCallUiMode.pip) return;
|
||||
// Call screen already renders the in-call mini preview; enabling the global
|
||||
// overlay here would duplicate PiP after returning from system PiP.
|
||||
if (_callScreenRouteVisible) return;
|
||||
await _applyPipUiState();
|
||||
}
|
||||
|
||||
/// Re-applies immersive UI after Android system PiP while the call screen
|
||||
/// route is still on the stack.
|
||||
Future<void> onSystemPipExitedWithCallScreenVisible() async {
|
||||
if (!_callScreenRouteVisible) return;
|
||||
if (state.uiMode != VideoCallUiMode.fullscreen) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> minimizeToPip() async {
|
||||
if (!_canMinimizeToPip) return;
|
||||
await _applyPipUiState();
|
||||
}
|
||||
|
||||
Future<void> restoreFullscreenFromPip() async {
|
||||
if (state.uiMode != VideoCallUiMode.pip) return;
|
||||
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
|
||||
unawaited(_ensureSystemPipArmedIfInCall());
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
unawaited(_applyVideoCallSystemUi());
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> leaveCallRoute() async {
|
||||
if (state.connectPhase == VideoCallConnectPhase.connected &&
|
||||
!state.linkIssue.needsManualReconnect) {
|
||||
if (state.callStageEntered) {
|
||||
_callScreenRouteVisible = false;
|
||||
await minimizeToPip();
|
||||
} else {
|
||||
await endCall();
|
||||
}
|
||||
} else {
|
||||
await endCall();
|
||||
}
|
||||
}
|
||||
|
||||
void updateCounterpartIsConsultant(bool isConsultant) {
|
||||
if (isClosed || state.counterpartIsConsultant == isConsultant) return;
|
||||
emit(state.copyWith(counterpartIsConsultant: isConsultant));
|
||||
}
|
||||
|
||||
Future<void> enterCallStage() async {
|
||||
if (isClosed) return;
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
if (state.callStageEntered) return;
|
||||
|
||||
if (state.entryMode == VideoCallEntryMode.outgoing &&
|
||||
_counterpartPresentWhenLobbyReady) {
|
||||
unawaited(_notifyIncomingCallAcceptedIfNeeded(state.conversationUuid));
|
||||
}
|
||||
|
||||
final entered = await _videoCallManager.onEnterCallStage();
|
||||
if (!entered || isClosed) return;
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
callStageEntered: true,
|
||||
splitParticipantView: false,
|
||||
mainStageShowsLocalVideo: false,
|
||||
),
|
||||
);
|
||||
await _ensureSystemPipArmedIfInCall();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isClosed) return;
|
||||
_videoCallManager.scheduleRecordingNoticeOnce();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (isClosed) return;
|
||||
_videoCallManager.scheduleRecordingNoticeOnce();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> retryAfterConnectionLoss() async {
|
||||
final entryMode = state.entryMode;
|
||||
final wasInCallStage = state.callStageEntered;
|
||||
await _videoCallManager.retryAfterConnectionLoss();
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(callStageEntered: false, entryMode: entryMode));
|
||||
if (wasInCallStage &&
|
||||
state.connectPhase == VideoCallConnectPhase.connected &&
|
||||
!state.linkIssue.needsManualReconnect) {
|
||||
await enterCallStage();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> endCall() async {
|
||||
await _screenWakeLock.release();
|
||||
await _systemPipCoordinator.disarm();
|
||||
await _videoCallManager.endCall();
|
||||
}
|
||||
|
||||
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 {
|
||||
await _videoCallManager.toggleMic(state.isMicActiveForUi);
|
||||
}
|
||||
|
||||
Future<void> toggleVideo() async {
|
||||
await _videoCallManager.toggleVideo(state.videoEnabled);
|
||||
}
|
||||
|
||||
Future<void> switchCameraFacing() async {
|
||||
await _videoCallManager.switchCameraFacing(state.useFrontCamera);
|
||||
emit(state.copyWith(useFrontCamera: !state.useFrontCamera));
|
||||
}
|
||||
|
||||
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 {
|
||||
await _screenWakeLock.release();
|
||||
try {
|
||||
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
} on PlatformException catch (_) {}
|
||||
}
|
||||
|
||||
String resolvedLocalDisplayName() =>
|
||||
_videoCallManager.resolvedLocalDisplayName();
|
||||
|
||||
String resolvedRemoteDisplayName([String fallback = '']) =>
|
||||
_videoCallManager.resolvedRemoteDisplayName(fallback);
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_videoCallManager.removeListener(_onManagerSnapshot);
|
||||
await endCall();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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 }
|
||||
|
||||
extension VideoCallLinkIssueX on VideoCallLinkIssue {
|
||||
/// True when the user must manually retry (lost link or LiveKit auto-reconnect).
|
||||
bool get needsManualReconnect =>
|
||||
this == VideoCallLinkIssue.lost || this == VideoCallLinkIssue.reconnecting;
|
||||
}
|
||||
|
||||
/// How the user reached [VideoCallScreen].
|
||||
enum VideoCallEntryMode {
|
||||
/// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins.
|
||||
outgoing,
|
||||
|
||||
/// Accepted incoming CallKit / VoIP / notification — loader then auto-enter.
|
||||
incomingAccepted,
|
||||
}
|
||||
|
||||
@freezed
|
||||
abstract class VideoCallSessionState with _$VideoCallSessionState {
|
||||
const VideoCallSessionState._();
|
||||
|
||||
const factory VideoCallSessionState({
|
||||
@Default('') String conversationUuid,
|
||||
String? counterpartName,
|
||||
@Default(false) bool counterpartIsConsultant,
|
||||
String? localParticipantName,
|
||||
@Default(false) bool micEnabled,
|
||||
@Default(false) bool videoEnabled,
|
||||
@Default(false) 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,
|
||||
@Default(VideoCallEntryMode.outgoing) VideoCallEntryMode entryMode,
|
||||
}) = _VideoCallSessionState;
|
||||
|
||||
bool get isMicActiveForUi => micEnabled;
|
||||
}
|
||||
@@ -2,24 +2,35 @@ 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/core/websockets/ws_manager.dart';
|
||||
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
|
||||
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
|
||||
import 'package:eatvio_app/features/profile/repositories/profile_repository.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_flow_placeholder_screens.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
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.counterpartUserId,
|
||||
this.counterpartIsOnline,
|
||||
this.counterpartLastSeenText,
|
||||
this.counterpartIsConsultant,
|
||||
this.localParticipantName,
|
||||
this.entryMode = VideoCallEntryMode.outgoing,
|
||||
this.onBackToChat,
|
||||
this.onHangUp,
|
||||
});
|
||||
@@ -29,7 +40,13 @@ class VideoCallScreen extends StatefulWidget {
|
||||
|
||||
final String conversationUuid;
|
||||
final String? counterpartName;
|
||||
final String? counterpartAvatarUrl;
|
||||
final int? counterpartUserId;
|
||||
final bool? counterpartIsOnline;
|
||||
final String? counterpartLastSeenText;
|
||||
final bool? counterpartIsConsultant;
|
||||
final String? localParticipantName;
|
||||
final VideoCallEntryMode entryMode;
|
||||
final VoidCallback? onBackToChat;
|
||||
final VoidCallback? onHangUp;
|
||||
|
||||
@@ -38,437 +55,151 @@ 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();
|
||||
VideoCallSessionCubit get _cubit => sl<VideoCallSessionCubit>();
|
||||
WsManager get _wsManager => sl<WsManager>();
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
bool _counterpartIsOnline = false;
|
||||
String _counterpartLastSeenText = '';
|
||||
void Function(bool isOnline)? _presenceOnlineListener;
|
||||
|
||||
@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;
|
||||
}),
|
||||
);
|
||||
});
|
||||
_counterpartIsOnline = widget.counterpartIsOnline ?? false;
|
||||
_counterpartLastSeenText = widget.counterpartLastSeenText ?? '';
|
||||
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(_initCounterpartMetadata());
|
||||
unawaited(
|
||||
_cubit.onCallScreenVisible(
|
||||
conversationUuid: widget.conversationUuid,
|
||||
counterpartName: widget.counterpartName,
|
||||
localParticipantName: widget.localParticipantName,
|
||||
counterpartIsConsultant: widget.counterpartIsConsultant,
|
||||
entryMode: widget.entryMode,
|
||||
),
|
||||
);
|
||||
} 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;
|
||||
Future<void> _initCounterpartMetadata() async {
|
||||
var isOnline = widget.counterpartIsOnline ?? false;
|
||||
var lastSeenText = widget.counterpartLastSeenText ?? '';
|
||||
var counterpartUserId = widget.counterpartUserId;
|
||||
var counterpartIsConsultant = widget.counterpartIsConsultant;
|
||||
|
||||
final shouldFetchChatRoom =
|
||||
counterpartUserId == null ||
|
||||
widget.counterpartIsOnline == null ||
|
||||
widget.counterpartLastSeenText == null ||
|
||||
counterpartIsConsultant == null;
|
||||
|
||||
if (shouldFetchChatRoom) {
|
||||
try {
|
||||
final chatRoom = await sl<ChatRepository>().getChatRoom(
|
||||
widget.conversationUuid,
|
||||
);
|
||||
final counterpart = resolveChatCounterpart(
|
||||
users: chatRoom.users,
|
||||
currentUserId: _wsManager.userId,
|
||||
);
|
||||
isOnline = counterpart.isOnline;
|
||||
lastSeenText = counterpart.lastSeenText;
|
||||
counterpartUserId = counterpart.id;
|
||||
|
||||
if (counterpartIsConsultant == null) {
|
||||
try {
|
||||
final profile = await sl<ProfileRepository>().getProfileBySlug(
|
||||
counterpart.slug,
|
||||
);
|
||||
counterpartIsConsultant = profile.roles.contains('consultant');
|
||||
} catch (_) {
|
||||
counterpartIsConsultant = false;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
_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),
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_counterpartIsOnline = isOnline;
|
||||
_counterpartLastSeenText = lastSeenText;
|
||||
});
|
||||
if (counterpartIsConsultant != null) {
|
||||
_cubit.updateCounterpartIsConsultant(counterpartIsConsultant);
|
||||
}
|
||||
|
||||
if (counterpartUserId == null) return;
|
||||
|
||||
_presenceOnlineListener = (isOnline) {
|
||||
if (!mounted) return;
|
||||
if (isOnline) {
|
||||
setState(() {
|
||||
_counterpartIsOnline = true;
|
||||
_counterpartLastSeenText = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
unawaited(_refreshCounterpartOnlineStatus());
|
||||
};
|
||||
await _wsManager.initPresenceDirectChatChannel(widget.conversationUuid);
|
||||
_wsManager.addPresenceDirectChatListener(
|
||||
counterpartUserId: counterpartUserId,
|
||||
onCounterpartOnlineChanged: _presenceOnlineListener!,
|
||||
);
|
||||
}
|
||||
|
||||
Future<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;
|
||||
Future<void> _refreshCounterpartOnlineStatus() async {
|
||||
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,
|
||||
]);
|
||||
final chatRoom = await sl<ChatRepository>().getChatRoom(
|
||||
widget.conversationUuid,
|
||||
);
|
||||
final counterpart = resolveChatCounterpart(
|
||||
users: chatRoom.users,
|
||||
currentUserId: _wsManager.userId,
|
||||
);
|
||||
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();
|
||||
}
|
||||
setState(() {
|
||||
_counterpartIsOnline = counterpart.isOnline;
|
||||
_counterpartLastSeenText = counterpart.lastSeenText;
|
||||
});
|
||||
} 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());
|
||||
if (_presenceOnlineListener != null) {
|
||||
_wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
|
||||
_presenceOnlineListener = null;
|
||||
}
|
||||
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);
|
||||
@@ -476,56 +207,202 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
final topInset = viewPadding.top > 0 ? viewPadding.top : 16.0;
|
||||
final bottomInset = viewPadding.bottom;
|
||||
|
||||
final counterpartFallback = s.video_call_counterpart_default;
|
||||
final controlLabelStyle = TextStyles.textSMedium.copyWith(color: appTheme.whiteA700, height: 20 / 14);
|
||||
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.counterpartIsConsultant != b.counterpartIsConsultant ||
|
||||
a.localParticipantName != b.localParticipantName ||
|
||||
a.callStageEntered != b.callStageEntered ||
|
||||
a.micEnabled != b.micEnabled ||
|
||||
a.videoEnabled != b.videoEnabled ||
|
||||
a.splitParticipantView != b.splitParticipantView ||
|
||||
a.mainStageShowsLocalVideo != b.mainStageShowsLocalVideo ||
|
||||
a.useFrontCamera != b.useFrontCamera ||
|
||||
a.entryMode != b.entryMode,
|
||||
builder: (context, sessionState) {
|
||||
final room = _cubit.liveKitRoom;
|
||||
|
||||
final showLost =
|
||||
sessionState.linkIssue.needsManualReconnect ||
|
||||
(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 showEntranceLobby =
|
||||
sessionState.entryMode == VideoCallEntryMode.outgoing &&
|
||||
!sessionState.callStageEntered &&
|
||||
sessionState.linkIssue == VideoCallLinkIssue.none &&
|
||||
sessionState.connectPhase != VideoCallConnectPhase.idle;
|
||||
|
||||
final showAutoEnterLoading =
|
||||
sessionState.entryMode == VideoCallEntryMode.incomingAccepted &&
|
||||
!sessionState.callStageEntered &&
|
||||
sessionState.linkIssue == VideoCallLinkIssue.none &&
|
||||
sessionState.connectPhase != VideoCallConnectPhase.idle;
|
||||
|
||||
if (showAutoEnterLoading) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: _videoCallOverlayStyle,
|
||||
child: Scaffold(
|
||||
backgroundColor: appTheme.whiteA700,
|
||||
body: VideoCallLoadingScreen(
|
||||
onClose: () => unawaited(_handleHangUp()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (showEntranceLobby) {
|
||||
final remoteName = _resolveRemoteDisplayName(sessionState);
|
||||
final canEnterCall =
|
||||
sessionState.connectPhase == VideoCallConnectPhase.connected;
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: _videoCallOverlayStyle,
|
||||
child: Scaffold(
|
||||
backgroundColor: appTheme.whiteA700,
|
||||
body: VideoCallConnectingEntranceScreen(
|
||||
counterpartDisplayName: remoteName,
|
||||
counterpartAvatarUrl: widget.counterpartAvatarUrl,
|
||||
counterpartIsOnline: _counterpartIsOnline,
|
||||
counterpartLastSeenText: _counterpartLastSeenText,
|
||||
micEnabled: sessionState.isMicActiveForUi,
|
||||
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: videoCallRoomTracksListenable(activeRoom),
|
||||
builder: (context, _) {
|
||||
return VideoCallStageLayout(
|
||||
room: activeRoom,
|
||||
connectError: sessionState.connectError,
|
||||
localDisplayName: _resolveLocalDisplayName(sessionState),
|
||||
remoteDisplayName: _resolveRemoteDisplayName(sessionState),
|
||||
remoteIsConsultant: sessionState.counterpartIsConsultant,
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveLocalDisplayName(VideoCallSessionState sessionState) {
|
||||
final resolved = _cubit.resolvedLocalDisplayName().trim();
|
||||
if (resolved.isNotEmpty) return resolved;
|
||||
return sessionState.localParticipantName?.trim() ??
|
||||
widget.localParticipantName?.trim() ??
|
||||
'';
|
||||
}
|
||||
|
||||
String _resolveRemoteDisplayName(VideoCallSessionState sessionState) {
|
||||
final resolved = _cubit.resolvedRemoteDisplayName().trim();
|
||||
if (resolved.isNotEmpty) return resolved;
|
||||
return sessionState.counterpartName?.trim() ??
|
||||
widget.counterpartName?.trim() ??
|
||||
'';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart';
|
||||
import 'package:floating/floating.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Root overlay shell: system PiP on Android, in-app PiP fallback on iOS.
|
||||
class VideoCallAppShell extends StatelessWidget {
|
||||
const VideoCallAppShell({super.key, required this.child});
|
||||
|
||||
final Widget? child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shell = Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
child ?? const SizedBox.shrink(),
|
||||
Overlay(
|
||||
key: VideoCallBannerOverlay.overlayKey,
|
||||
initialEntries: const <OverlayEntry>[],
|
||||
),
|
||||
const VideoCallGlobalPipLayer(),
|
||||
const VideoCallSystemPipListener(),
|
||||
],
|
||||
);
|
||||
|
||||
if (!Platform.isAndroid) return shell;
|
||||
|
||||
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
|
||||
bloc: sl<VideoCallSessionCubit>(),
|
||||
buildWhen: (previous, current) =>
|
||||
previous.callStageEntered != current.callStageEntered ||
|
||||
previous.connectPhase != current.connectPhase ||
|
||||
previous.linkIssue != current.linkIssue,
|
||||
builder: (context, state) {
|
||||
final allowSystemPipShell = sl<VideoCallManager>().isActiveCall;
|
||||
|
||||
if (!allowSystemPipShell) return shell;
|
||||
|
||||
return PiPSwitcher(
|
||||
childWhenDisabled: shell,
|
||||
childWhenEnabled: const VideoCallSystemPipContent(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Dedicated overlay layer above the app navigator for in-call banners.
|
||||
abstract final class VideoCallBannerOverlay {
|
||||
static final GlobalKey<OverlayState> overlayKey = GlobalKey<OverlayState>(
|
||||
debugLabel: 'videoCallBannerOverlay',
|
||||
);
|
||||
|
||||
static OverlayState? get overlayState => overlayKey.currentState;
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class VideoCallConnectingEntranceScreen extends StatelessWidget {
|
||||
const VideoCallConnectingEntranceScreen({
|
||||
super.key,
|
||||
required this.counterpartDisplayName,
|
||||
this.counterpartAvatarUrl,
|
||||
required this.counterpartIsOnline,
|
||||
required this.counterpartLastSeenText,
|
||||
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 counterpartIsOnline;
|
||||
final String counterpartLastSeenText;
|
||||
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),
|
||||
_buildStatusLine(context, s),
|
||||
SizedBox(height: canEnterCall ? 20 : 16),
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusLine(BuildContext context, S s) {
|
||||
final statusStyle = CustomTextStyles.bodySmallMulishBlack900.copyWith(
|
||||
height: 14 / 12,
|
||||
);
|
||||
final onlineStatus = ChatUserOnlineStatusLine(
|
||||
isOnline: counterpartIsOnline,
|
||||
lastSeenText: counterpartLastSeenText,
|
||||
style: statusStyle,
|
||||
);
|
||||
|
||||
if (counterpartIsOnline || counterpartLastSeenText.trim().isNotEmpty) {
|
||||
return Center(child: onlineStatus);
|
||||
}
|
||||
|
||||
if (!canEnterCall) {
|
||||
return Text(
|
||||
s.video_call_screen_connecting_subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: statusStyle,
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
@@ -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,160 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
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;
|
||||
unawaited(sl<VideoCallSessionCubit>().restoreFullscreenFromPip());
|
||||
GoRouter.of(ctx).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': s.conversationUuid,
|
||||
'counterpartName': s.counterpartName,
|
||||
'localParticipantName': s.localParticipantName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
|
||||
bloc: sl<VideoCallSessionCubit>(),
|
||||
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>();
|
||||
if (Platform.isAndroid && cubit.isCallScreenRouteVisible) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
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: videoCallRoomTracksListenable(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: VideoCallPipVideoPreview(
|
||||
sessionState: state,
|
||||
room: room,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ abstract final class VideoCallLiveKitTrackUtils {
|
||||
|
||||
static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) {
|
||||
if (pub == null || pub.muted) return null;
|
||||
if (pub is lk.RemoteTrackPublication && !pub.subscribed) {
|
||||
return null;
|
||||
}
|
||||
final v = asVideoTrack(pub.track);
|
||||
if (v == null || v.muted) return null;
|
||||
return v;
|
||||
@@ -37,4 +40,6 @@ abstract final class VideoCallLiveKitTrackUtils {
|
||||
room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera),
|
||||
);
|
||||
}
|
||||
|
||||
static bool isSoloInRoom(lk.Room room) => room.remoteParticipants.isEmpty;
|
||||
}
|
||||
|
||||
@@ -2,40 +2,74 @@ import 'dart:ui';
|
||||
|
||||
import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class VideoCallParticipantNameLabel extends StatelessWidget {
|
||||
const VideoCallParticipantNameLabel({super.key, required this.name});
|
||||
const VideoCallParticipantNameLabel({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.isConsultant = false,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final bool isConsultant;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final roleLabel = isConsultant ? S.of(context).video_call_role_consultant : null;
|
||||
|
||||
return Positioned(
|
||||
left: 12,
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.transparentBlack,
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (roleLabel != null) ...[
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.whiteA700,
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
child: Text(
|
||||
roleLabel,
|
||||
style: TextStyles.textXSSemibold.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
height: 14 / 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.transparentBlack,
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
|
||||
/// Shared compact preview for both in-app and Android system PiP.
|
||||
class VideoCallPipVideoPreview extends StatelessWidget {
|
||||
const VideoCallPipVideoPreview({
|
||||
super.key,
|
||||
required this.sessionState,
|
||||
required this.room,
|
||||
});
|
||||
|
||||
final VideoCallSessionState sessionState;
|
||||
final lk.Room room;
|
||||
|
||||
static const double _pipNoVideoLogoSize = 56;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: videoCallRoomTracksListenable(room),
|
||||
builder: (context, _) {
|
||||
if (VideoCallLiveKitTrackUtils.isSoloInRoom(room)) {
|
||||
return _VideoCallPipLocalPreview(
|
||||
room: room,
|
||||
videoEnabled: sessionState.videoEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
return VideoCallRemoteVideoStage(
|
||||
room: room,
|
||||
connectError: null,
|
||||
noVideoLogoSize: _pipNoVideoLogoSize,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoCallPipLocalPreview extends StatelessWidget {
|
||||
const _VideoCallPipLocalPreview({
|
||||
required this.room,
|
||||
required this.videoEnabled,
|
||||
});
|
||||
|
||||
final lk.Room room;
|
||||
final bool videoEnabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!videoEnabled) {
|
||||
return const VideoCallNoVideoBackground(logoSize: 56);
|
||||
}
|
||||
|
||||
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
|
||||
if (local != null) {
|
||||
return IgnorePointer(
|
||||
child: lk.VideoTrackRenderer(
|
||||
local,
|
||||
key: ValueKey(local.sid),
|
||||
fit: lk.VideoViewFit.cover,
|
||||
mirrorMode: lk.VideoViewMirrorMode.auto,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const VideoCallNoVideoBackground(logoSize: 56);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
|
||||
@@ -10,25 +11,34 @@ class VideoCallRemoteVideoStage extends StatelessWidget {
|
||||
super.key,
|
||||
required this.room,
|
||||
required this.connectError,
|
||||
this.noVideoLogoSize = 96,
|
||||
});
|
||||
|
||||
final lk.Room room;
|
||||
final String? connectError;
|
||||
final double noVideoLogoSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: room,
|
||||
listenable: videoCallRoomTracksListenable(room),
|
||||
builder: (context, _) {
|
||||
final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room);
|
||||
if (remote != null) {
|
||||
return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover);
|
||||
return lk.VideoTrackRenderer(
|
||||
remote,
|
||||
key: ValueKey(remote.sid),
|
||||
fit: lk.VideoViewFit.cover,
|
||||
);
|
||||
}
|
||||
final showConnectError =
|
||||
connectError != null &&
|
||||
room.connectionState != lk.ConnectionState.connected;
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
const VideoCallNoVideoBackground(),
|
||||
if (connectError != null)
|
||||
VideoCallNoVideoBackground(logoSize: noVideoLogoSize),
|
||||
if (showConnectError)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
|
||||
|
||||
Listenable videoCallRoomTracksListenable(lk.Room room) {
|
||||
return Listenable.merge([
|
||||
room,
|
||||
...room.remoteParticipants.values,
|
||||
if (room.localParticipant != null) room.localParticipant!,
|
||||
]);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'package:livekit_client/livekit_client.dart' as lk;
|
||||
@@ -24,6 +25,7 @@ class VideoCallStageLayout extends StatefulWidget {
|
||||
required this.connectError,
|
||||
required this.localDisplayName,
|
||||
required this.remoteDisplayName,
|
||||
this.remoteIsConsultant = false,
|
||||
required this.micEnabled,
|
||||
required this.videoEnabled,
|
||||
required this.splitParticipantView,
|
||||
@@ -48,6 +50,7 @@ class VideoCallStageLayout extends StatefulWidget {
|
||||
final String? connectError;
|
||||
final String localDisplayName;
|
||||
final String remoteDisplayName;
|
||||
final bool remoteIsConsultant;
|
||||
final bool micEnabled;
|
||||
final bool videoEnabled;
|
||||
final bool splitParticipantView;
|
||||
@@ -83,6 +86,8 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
||||
static const double _pipInitialExtraDown = 16;
|
||||
static const double _bottomGradientHeight = 140;
|
||||
static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop;
|
||||
static const int _splitRemoteFlex = 7;
|
||||
static const int _splitLocalFlex = 3;
|
||||
|
||||
double? _pipLeft;
|
||||
double? _pipTop;
|
||||
@@ -90,13 +95,6 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
||||
double? _pipPanOriginLeft;
|
||||
double? _pipPanOriginTop;
|
||||
|
||||
bool _pipHasVisibleVideo() {
|
||||
if (widget.mainStageShowsLocalVideo) {
|
||||
return VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(widget.room) != null;
|
||||
}
|
||||
return widget.videoEnabled && VideoCallLiveKitTrackUtils.localCameraVideoTrack(widget.room) != null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
@@ -110,15 +108,17 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
||||
}
|
||||
|
||||
Widget _buildSplitLayout(BoxConstraints constraints) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
return ListenableBuilder(
|
||||
listenable: videoCallRoomTracksListenable(widget.room),
|
||||
builder: (context, _) {
|
||||
final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room);
|
||||
if (soloInRoom) {
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
||||
child: _SplitParticipantPane(
|
||||
room: widget.room,
|
||||
displayName: widget.localDisplayName,
|
||||
@@ -128,40 +128,73 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
||||
useFrontCamera: widget.useFrontCamera,
|
||||
onExpand: widget.onExpandLocal,
|
||||
onSwitchCamera: widget.onSwitchCamera,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(_mainStageCornerRadius),
|
||||
topRight: Radius.circular(_mainStageCornerRadius),
|
||||
bottomLeft: Radius.zero,
|
||||
bottomRight: Radius.zero,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(_mainStageCornerRadius),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _edgeInset),
|
||||
Expanded(
|
||||
child: _SplitParticipantPane(
|
||||
room: widget.room,
|
||||
displayName: widget.remoteDisplayName,
|
||||
isLocal: false,
|
||||
connectError: widget.connectError,
|
||||
videoEnabled: widget.videoEnabled,
|
||||
useFrontCamera: widget.useFrontCamera,
|
||||
onExpand: widget.onExpandRemote,
|
||||
onSwitchCamera: widget.onSwitchCamera,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
bottomLeft: Radius.circular(_mainStageCornerRadius),
|
||||
bottomRight: Radius.circular(_mainStageCornerRadius),
|
||||
),
|
||||
..._bottomBarGradient(),
|
||||
..._bottomControlsRow(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: _splitRemoteFlex,
|
||||
child: _SplitParticipantPane(
|
||||
room: widget.room,
|
||||
displayName: widget.remoteDisplayName,
|
||||
isConsultant: widget.remoteIsConsultant,
|
||||
isLocal: false,
|
||||
connectError: widget.connectError,
|
||||
videoEnabled: widget.videoEnabled,
|
||||
useFrontCamera: widget.useFrontCamera,
|
||||
onExpand: widget.onExpandRemote,
|
||||
onSwitchCamera: widget.onSwitchCamera,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(_mainStageCornerRadius),
|
||||
topRight: Radius.circular(_mainStageCornerRadius),
|
||||
bottomLeft: Radius.zero,
|
||||
bottomRight: Radius.zero,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: _edgeInset),
|
||||
Expanded(
|
||||
flex: _splitLocalFlex,
|
||||
child: _SplitParticipantPane(
|
||||
room: widget.room,
|
||||
displayName: widget.localDisplayName,
|
||||
isLocal: true,
|
||||
connectError: null,
|
||||
videoEnabled: widget.videoEnabled,
|
||||
useFrontCamera: widget.useFrontCamera,
|
||||
onExpand: widget.onExpandLocal,
|
||||
onSwitchCamera: widget.onSwitchCamera,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.zero,
|
||||
topRight: Radius.zero,
|
||||
bottomLeft: Radius.circular(_mainStageCornerRadius),
|
||||
bottomRight: Radius.circular(_mainStageCornerRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
..._bottomBarGradient(),
|
||||
..._bottomControlsRow(),
|
||||
],
|
||||
..._bottomBarGradient(),
|
||||
..._bottomControlsRow(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -176,99 +209,106 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
||||
final pipLeft = ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num).toDouble();
|
||||
final pipTop = ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num).toDouble();
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_VideoCallMainStage(
|
||||
room: widget.room,
|
||||
connectError: widget.connectError,
|
||||
mainShowsLocal: widget.mainStageShowsLocalVideo,
|
||||
videoEnabled: widget.videoEnabled,
|
||||
),
|
||||
VideoCallParticipantNameLabel(name: widget.mainStageShowsLocalVideo ? widget.localDisplayName : widget.remoteDisplayName),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
..._bottomBarGradient(),
|
||||
..._bottomControlsRow(),
|
||||
Positioned(
|
||||
top: widget.topInset + _edgeInset + _shrinkExpandExtraTop,
|
||||
left: _controlsHorizontalInset,
|
||||
child: Padding(
|
||||
padding: _kExpandShrinkOuterPadding,
|
||||
child: VideoCallPipStyleIconButton(
|
||||
onPressed: widget.onShrinkFromFullscreen,
|
||||
child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: widget.room,
|
||||
builder: (context, _) {
|
||||
if (!_pipHasVisibleVideo()) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Positioned(
|
||||
left: pipLeft,
|
||||
top: pipTop,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onToggleStageSwap,
|
||||
onPanStart: (details) {
|
||||
_pipPanPointerGlobal = details.globalPosition;
|
||||
_pipPanOriginLeft = pipLeft;
|
||||
_pipPanOriginTop = pipTop;
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) {
|
||||
return;
|
||||
}
|
||||
final delta = details.globalPosition - _pipPanPointerGlobal!;
|
||||
setState(() {
|
||||
final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft);
|
||||
final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop);
|
||||
_pipLeft = (nx as num).toDouble();
|
||||
_pipTop = (ny as num).toDouble();
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
_resetPipPanState();
|
||||
},
|
||||
onPanCancel: _resetPipPanState,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _pipW,
|
||||
height: _pipH,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: widget.mainStageShowsLocalVideo
|
||||
? _VideoCallPipRemoteContent(room: widget.room)
|
||||
: _VideoCallPipLocalContent(
|
||||
room: widget.room,
|
||||
videoEnabled: widget.videoEnabled,
|
||||
useFrontCamera: widget.useFrontCamera,
|
||||
onSwitchCamera: widget.onSwitchCamera,
|
||||
),
|
||||
return ListenableBuilder(
|
||||
listenable: videoCallRoomTracksListenable(widget.room),
|
||||
builder: (context, _) {
|
||||
final soloInRoom = VideoCallLiveKitTrackUtils.isSoloInRoom(widget.room);
|
||||
final mainShowsLocal =
|
||||
soloInRoom || widget.mainStageShowsLocalVideo;
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset),
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_VideoCallMainStage(
|
||||
room: widget.room,
|
||||
connectError: widget.connectError,
|
||||
mainShowsLocal: mainShowsLocal,
|
||||
videoEnabled: widget.videoEnabled,
|
||||
),
|
||||
VideoCallParticipantNameLabel(
|
||||
name: mainShowsLocal
|
||||
? widget.localDisplayName
|
||||
: widget.remoteDisplayName,
|
||||
isConsultant: !mainShowsLocal && widget.remoteIsConsultant,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
..._bottomBarGradient(),
|
||||
..._bottomControlsRow(),
|
||||
Positioned(
|
||||
top: widget.topInset + _edgeInset + _shrinkExpandExtraTop,
|
||||
left: _controlsHorizontalInset,
|
||||
child: Padding(
|
||||
padding: _kExpandShrinkOuterPadding,
|
||||
child: VideoCallPipStyleIconButton(
|
||||
onPressed: widget.onShrinkFromFullscreen,
|
||||
child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!soloInRoom)
|
||||
Positioned(
|
||||
left: pipLeft,
|
||||
top: pipTop,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.onToggleStageSwap,
|
||||
onPanStart: (details) {
|
||||
_pipPanPointerGlobal = details.globalPosition;
|
||||
_pipPanOriginLeft = pipLeft;
|
||||
_pipPanOriginTop = pipTop;
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) {
|
||||
return;
|
||||
}
|
||||
final delta = details.globalPosition - _pipPanPointerGlobal!;
|
||||
setState(() {
|
||||
final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft);
|
||||
final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop);
|
||||
_pipLeft = (nx as num).toDouble();
|
||||
_pipTop = (ny as num).toDouble();
|
||||
});
|
||||
},
|
||||
onPanEnd: (_) {
|
||||
_resetPipPanState();
|
||||
},
|
||||
onPanCancel: _resetPipPanState,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
width: _pipW,
|
||||
height: _pipH,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: mainShowsLocal
|
||||
? _VideoCallPipRemoteContent(room: widget.room)
|
||||
: _VideoCallPipLocalContent(
|
||||
room: widget.room,
|
||||
videoEnabled: widget.videoEnabled,
|
||||
useFrontCamera: widget.useFrontCamera,
|
||||
onSwitchCamera: widget.onSwitchCamera,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -332,6 +372,7 @@ class _SplitParticipantPane extends StatelessWidget {
|
||||
const _SplitParticipantPane({
|
||||
required this.room,
|
||||
required this.displayName,
|
||||
this.isConsultant = false,
|
||||
required this.isLocal,
|
||||
required this.connectError,
|
||||
required this.videoEnabled,
|
||||
@@ -343,6 +384,7 @@ class _SplitParticipantPane extends StatelessWidget {
|
||||
|
||||
final lk.Room room;
|
||||
final String displayName;
|
||||
final bool isConsultant;
|
||||
final bool isLocal;
|
||||
final String? connectError;
|
||||
final bool videoEnabled;
|
||||
@@ -362,7 +404,7 @@ class _SplitParticipantPane extends StatelessWidget {
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ListenableBuilder(
|
||||
listenable: room,
|
||||
listenable: videoCallRoomTracksListenable(room),
|
||||
builder: (context, _) {
|
||||
if (isLocal) {
|
||||
if (!videoEnabled) {
|
||||
@@ -371,7 +413,12 @@ class _SplitParticipantPane extends StatelessWidget {
|
||||
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
|
||||
if (local != null) {
|
||||
return IgnorePointer(
|
||||
child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto),
|
||||
child: lk.VideoTrackRenderer(
|
||||
local,
|
||||
key: ValueKey(local.sid),
|
||||
fit: lk.VideoViewFit.cover,
|
||||
mirrorMode: lk.VideoViewMirrorMode.auto,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const VideoCallNoVideoBackground();
|
||||
@@ -380,7 +427,10 @@ class _SplitParticipantPane extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
VideoCallParticipantNameLabel(name: displayName),
|
||||
VideoCallParticipantNameLabel(
|
||||
name: displayName,
|
||||
isConsultant: !isLocal && isConsultant,
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: _VideoCallStageLayoutState._splitExpandButtonTop,
|
||||
@@ -421,7 +471,7 @@ class _VideoCallMainStage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: room,
|
||||
listenable: videoCallRoomTracksListenable(room),
|
||||
builder: (context, _) {
|
||||
if (mainShowsLocal) {
|
||||
if (!videoEnabled) {
|
||||
@@ -430,7 +480,12 @@ class _VideoCallMainStage extends StatelessWidget {
|
||||
final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room);
|
||||
if (local != null) {
|
||||
return IgnorePointer(
|
||||
child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto),
|
||||
child: lk.VideoTrackRenderer(
|
||||
local,
|
||||
key: ValueKey(local.sid),
|
||||
fit: lk.VideoViewFit.cover,
|
||||
mirrorMode: lk.VideoViewMirrorMode.auto,
|
||||
),
|
||||
);
|
||||
}
|
||||
return const VideoCallNoVideoBackground();
|
||||
@@ -448,7 +503,13 @@ class _VideoCallPipRemoteContent extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(child: VideoCallRemoteVideoStage(room: room, connectError: null));
|
||||
return IgnorePointer(
|
||||
child: VideoCallRemoteVideoStage(
|
||||
room: room,
|
||||
connectError: null,
|
||||
noVideoLogoSize: 56,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -470,7 +531,12 @@ class _VideoCallPipLocalContent extends StatelessWidget {
|
||||
const VideoCallNoVideoBackground(logoSize: 56)
|
||||
else if (local != null)
|
||||
IgnorePointer(
|
||||
child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto),
|
||||
child: lk.VideoTrackRenderer(
|
||||
local,
|
||||
key: ValueKey(local.sid),
|
||||
fit: lk.VideoViewFit.cover,
|
||||
mirrorMode: lk.VideoViewMirrorMode.auto,
|
||||
),
|
||||
)
|
||||
else
|
||||
const VideoCallNoVideoBackground(logoSize: 56),
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_pip_video_preview.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_room_tracks_listenable.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
/// Compact call UI rendered by [PiPSwitcher] while Android is in system PiP mode.
|
||||
class VideoCallSystemPipContent extends StatelessWidget {
|
||||
const VideoCallSystemPipContent({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<VideoCallSessionCubit, VideoCallSessionState>(
|
||||
bloc: sl<VideoCallSessionCubit>(),
|
||||
buildWhen: (a, b) =>
|
||||
a.connectPhase != b.connectPhase ||
|
||||
a.linkIssue != b.linkIssue ||
|
||||
a.callStageEntered != b.callStageEntered ||
|
||||
a.videoEnabled != b.videoEnabled,
|
||||
builder: (context, state) {
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected ||
|
||||
state.linkIssue.needsManualReconnect ||
|
||||
!state.callStageEntered) {
|
||||
if (state.conversationUuid.isNotEmpty &&
|
||||
state.connectPhase != VideoCallConnectPhase.idle &&
|
||||
!state.linkIssue.needsManualReconnect) {
|
||||
return ColoredBox(
|
||||
color: appTheme.gray100,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: appTheme.blueA400,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const ColoredBox(color: Colors.black);
|
||||
}
|
||||
|
||||
final cubit = sl<VideoCallSessionCubit>();
|
||||
final room = cubit.liveKitRoom;
|
||||
if (room == null) {
|
||||
return const ColoredBox(color: Colors.black);
|
||||
}
|
||||
|
||||
return Material(
|
||||
color: Colors.black,
|
||||
child: ListenableBuilder(
|
||||
listenable: videoCallRoomTracksListenable(room),
|
||||
builder: (context, _) => VideoCallPipVideoPreview(
|
||||
sessionState: state,
|
||||
room: room,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_system_pip_coordinator.dart';
|
||||
import 'package:floating/floating.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// Syncs [VideoCallSessionCubit] with Android system PiP lifecycle events.
|
||||
class VideoCallSystemPipListener extends StatefulWidget {
|
||||
const VideoCallSystemPipListener({super.key});
|
||||
|
||||
@override
|
||||
State<VideoCallSystemPipListener> createState() =>
|
||||
_VideoCallSystemPipListenerState();
|
||||
}
|
||||
|
||||
class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener>
|
||||
with WidgetsBindingObserver {
|
||||
static const MethodChannel _userLeaveHintChannel = MethodChannel(
|
||||
'com.eatvioapp/video_call_pip',
|
||||
);
|
||||
|
||||
StreamSubscription<PiPStatus>? _subscription;
|
||||
PiPStatus? _lastStatus;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (!Platform.isAndroid) return;
|
||||
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall);
|
||||
final coordinator = sl<VideoCallSystemPipCoordinator>();
|
||||
_subscription = coordinator.statusStream.listen(_onPipStatus);
|
||||
unawaited(_disarmSystemPipIfNoActiveCall());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
unawaited(_disarmSystemPipIfNoActiveCall());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _disarmSystemPipIfNoActiveCall() async {
|
||||
if (!sl<VideoCallManager>().isActiveCall) {
|
||||
await sl<VideoCallSystemPipCoordinator>().disarm();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onNativePipMethodCall(MethodCall call) async {
|
||||
if (call.method != 'onUserLeaveHint') return;
|
||||
await sl<VideoCallSessionCubit>().syncPipUiStateFromSystem();
|
||||
}
|
||||
|
||||
Future<void> _onPipStatus(PiPStatus status) async {
|
||||
final cubit = sl<VideoCallSessionCubit>();
|
||||
final wasEnabled = _lastStatus == PiPStatus.enabled;
|
||||
_lastStatus = status;
|
||||
|
||||
if (status == PiPStatus.enabled) {
|
||||
await cubit.syncPipUiStateFromSystem();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!wasEnabled || status != PiPStatus.disabled) return;
|
||||
|
||||
if (cubit.isCallScreenRouteVisible) {
|
||||
await cubit.onSystemPipExitedWithCallScreenVisible();
|
||||
return;
|
||||
}
|
||||
|
||||
if (cubit.state.uiMode != VideoCallUiMode.pip) return;
|
||||
|
||||
await cubit.restoreFullscreenFromPip();
|
||||
if (!mounted) return;
|
||||
if (cubit.isCallScreenRouteVisible) return;
|
||||
_openCallScreen(cubit.state);
|
||||
}
|
||||
|
||||
void _openCallScreen(VideoCallSessionState state) {
|
||||
if (state.conversationUuid.isEmpty) return;
|
||||
|
||||
final ctx = AppRouter.navigatorKey.currentContext;
|
||||
if (ctx == null) return;
|
||||
|
||||
GoRouter.of(ctx).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': state.conversationUuid,
|
||||
'counterpartName': state.counterpartName,
|
||||
'localParticipantName': state.localParticipantName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (Platform.isAndroid) {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_userLeaveHintChannel.setMethodCallHandler(null);
|
||||
}
|
||||
unawaited(_subscription?.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const SizedBox.shrink();
|
||||
}
|
||||
@@ -1,12 +1,75 @@
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
|
||||
abstract final class VideoCallTopOverlay {
|
||||
static const String poorConnectionSvg = 'assets/icons/video_call/poor_connection.svg';
|
||||
static const String poorConnectionSvg =
|
||||
'assets/icons/video_call/poor_connection.svg';
|
||||
static const String warningSvg = 'assets/icons/video_call/warning.svg';
|
||||
|
||||
static const double _poorConnectionIconSize = 44;
|
||||
static const double _poorConnectionGlyphSize = 22;
|
||||
|
||||
static OverlayEntry? _poorConnectionEntry;
|
||||
|
||||
static bool get isPoorConnectionIconVisible =>
|
||||
_poorConnectionEntry?.mounted ?? false;
|
||||
|
||||
static OverlayState? _resolveOverlayState(BuildContext context) {
|
||||
return VideoCallBannerOverlay.overlayState ??
|
||||
AppRouter.navigatorKey.currentState?.overlay ??
|
||||
Overlay.maybeOf(context, rootOverlay: true);
|
||||
}
|
||||
|
||||
/// Compact poor-connection indicator: red square with icon only.
|
||||
static void showPoorConnectionIcon(BuildContext context) {
|
||||
if (isPoorConnectionIconVisible) return;
|
||||
|
||||
hidePoorConnectionIcon();
|
||||
|
||||
final overlayState = _resolveOverlayState(context);
|
||||
late OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (ctx) {
|
||||
final topInset = MediaQuery.viewPaddingOf(ctx).top;
|
||||
return Positioned(
|
||||
left: 16,
|
||||
top: topInset + 8,
|
||||
width: _poorConnectionIconSize,
|
||||
height: _poorConnectionIconSize,
|
||||
child: Material(
|
||||
color: appTheme.strongRed,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
elevation: 6,
|
||||
child: Center(
|
||||
child: SvgPicture.asset(
|
||||
poorConnectionSvg,
|
||||
width: _poorConnectionGlyphSize,
|
||||
height: _poorConnectionGlyphSize,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
_poorConnectionEntry = entry;
|
||||
if (overlayState == null) {
|
||||
_poorConnectionEntry = null;
|
||||
return;
|
||||
}
|
||||
overlayState.insert(entry);
|
||||
}
|
||||
|
||||
static void hidePoorConnectionIcon() {
|
||||
final entry = _poorConnectionEntry;
|
||||
_poorConnectionEntry = null;
|
||||
if (entry != null && entry.mounted) {
|
||||
entry.remove();
|
||||
}
|
||||
}
|
||||
|
||||
static OverlayEntry insertBanner(
|
||||
BuildContext context, {
|
||||
@@ -15,7 +78,7 @@ abstract final class VideoCallTopOverlay {
|
||||
required String svgAsset,
|
||||
Duration? autoRemoveAfter,
|
||||
}) {
|
||||
final overlayState = Overlay.of(context, rootOverlay: true);
|
||||
final overlayState = _resolveOverlayState(context);
|
||||
late OverlayEntry entry;
|
||||
entry = OverlayEntry(
|
||||
builder: (ctx) => Positioned(
|
||||
@@ -50,6 +113,9 @@ abstract final class VideoCallTopOverlay {
|
||||
),
|
||||
),
|
||||
);
|
||||
if (overlayState == null) {
|
||||
return entry;
|
||||
}
|
||||
overlayState.insert(entry);
|
||||
if (autoRemoveAfter != null) {
|
||||
Future<void>.delayed(autoRemoveAfter, () {
|
||||
|
||||
@@ -24,4 +24,14 @@ class VideoCallRepository {
|
||||
body: _emptyJsonBody,
|
||||
);
|
||||
}
|
||||
|
||||
Future<VideoCallAcceptResponse> notifyCallAccepted({
|
||||
required String conversationUuid,
|
||||
required String hwId,
|
||||
}) {
|
||||
return videoCallService.notifyCallAccepted(
|
||||
conversationUuid: conversationUuid,
|
||||
body: <String, dynamic>{'hw_id': hwId},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
|
||||
typedef VideoCallAudioSessionFactory = Future<AudioSession> Function();
|
||||
|
||||
/// Configures and owns the platform audio session for an active VoIP call.
|
||||
class VideoCallAudioSessionCoordinator {
|
||||
VideoCallAudioSessionCoordinator({
|
||||
VideoCallAudioSessionFactory? audioSessionFactory,
|
||||
}) : _audioSessionFactory =
|
||||
audioSessionFactory ?? (() => AudioSession.instance);
|
||||
|
||||
final VideoCallAudioSessionFactory _audioSessionFactory;
|
||||
|
||||
static final AudioSessionConfiguration callConfiguration =
|
||||
AudioSessionConfiguration(
|
||||
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
|
||||
avAudioSessionCategoryOptions:
|
||||
AVAudioSessionCategoryOptions.allowBluetooth |
|
||||
AVAudioSessionCategoryOptions.defaultToSpeaker,
|
||||
avAudioSessionMode: AVAudioSessionMode.voiceChat,
|
||||
avAudioSessionRouteSharingPolicy:
|
||||
AVAudioSessionRouteSharingPolicy.defaultPolicy,
|
||||
avAudioSessionSetActiveOptions: AVAudioSessionSetActiveOptions.none,
|
||||
androidAudioAttributes: const AndroidAudioAttributes(
|
||||
contentType: AndroidAudioContentType.speech,
|
||||
flags: AndroidAudioFlags.none,
|
||||
usage: AndroidAudioUsage.voiceCommunication,
|
||||
),
|
||||
androidAudioFocusGainType: AndroidAudioFocusGainType.gain,
|
||||
androidWillPauseWhenDucked: true,
|
||||
);
|
||||
|
||||
AudioSession? _session;
|
||||
bool _configured = false;
|
||||
|
||||
bool get isActive => _session != null;
|
||||
|
||||
Future<void> start() async {
|
||||
final session = await _audioSessionFactory();
|
||||
_session = session;
|
||||
if (!_configured) {
|
||||
await session.configure(callConfiguration);
|
||||
_configured = true;
|
||||
}
|
||||
await session.setActive(true);
|
||||
}
|
||||
|
||||
Future<bool> reactivate() async {
|
||||
final session = _session;
|
||||
if (session == null) return false;
|
||||
try {
|
||||
return await session.setActive(true);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
final session = _session;
|
||||
_session = null;
|
||||
if (session == null) return;
|
||||
try {
|
||||
await session.setActive(false);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
/// Dismisses incoming call UI after a remote `video_call_ended` push.
|
||||
abstract final class VideoCallEndedPushHandler {
|
||||
static const MethodChannel _androidPushChannel = MethodChannel(
|
||||
'com.eatvioapp/video_call_push',
|
||||
);
|
||||
|
||||
static Future<void> dismissIncomingCallUi() async {
|
||||
if (Platform.isAndroid) {
|
||||
try {
|
||||
await _androidPushChannel.invokeMethod<void>('dismissIncomingCalls');
|
||||
return;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
await _dismissViaCallKit();
|
||||
}
|
||||
|
||||
static Future<void> _dismissViaCallKit() async {
|
||||
try {
|
||||
final activeCalls = await FlutterCallkitIncoming.activeCalls();
|
||||
for (final call in activeCalls) {
|
||||
try {
|
||||
await FlutterCallkitIncoming.endCall(call.id);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
try {
|
||||
await FlutterCallkitIncoming.endAllCalls();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
|
||||
/// Marker for a data-only `video_call_ended` push (no conversation uuid required).
|
||||
class VideoCallEndedPushPayload {
|
||||
const VideoCallEndedPushPayload._();
|
||||
|
||||
static VideoCallEndedPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!VideoCallPushPayload.isEndedVideoCall(data)) return null;
|
||||
return const VideoCallEndedPushPayload._();
|
||||
}
|
||||
}
|
||||
111
lib/features/video_calls/services/video_call_fcm_handler.dart
Normal file
111
lib/features/video_calls/services/video_call_fcm_handler.dart
Normal file
@@ -0,0 +1,111 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:eatvio_app/firebase_options.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
/// Top-level FCM background handler (separate isolate).
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> videoCallFirebaseMessagingBackgroundHandler(
|
||||
RemoteMessage message,
|
||||
) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
await VideoCallFcmHandler.handleRemoteMessage(
|
||||
message,
|
||||
source: 'background',
|
||||
);
|
||||
}
|
||||
|
||||
abstract final class VideoCallFcmHandler {
|
||||
static bool _foregroundListenerRegistered = false;
|
||||
|
||||
static void registerBackgroundHandler() {
|
||||
FirebaseMessaging.onBackgroundMessage(
|
||||
videoCallFirebaseMessagingBackgroundHandler,
|
||||
);
|
||||
}
|
||||
|
||||
static void registerForegroundListener() {
|
||||
if (_foregroundListenerRegistered) return;
|
||||
_foregroundListenerRegistered = true;
|
||||
|
||||
FirebaseMessaging.onMessage.listen((message) {
|
||||
handleRemoteMessage(message, source: 'foreground');
|
||||
});
|
||||
}
|
||||
|
||||
static Future<void> handleRemoteMessage(
|
||||
RemoteMessage message, {
|
||||
required String source,
|
||||
}) async {
|
||||
final data = Map<String, dynamic>.from(message.data);
|
||||
|
||||
final endedPayload = VideoCallEndedPushPayload.tryParse(data);
|
||||
if (endedPayload != null) {
|
||||
if (Platform.isAndroid && source == 'background') {
|
||||
log(
|
||||
'VideoCallFcmHandler: skip background ended call, handled by native FCM service',
|
||||
name: 'VideoCallFcm',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
'VideoCallFcmHandler: dismiss incoming call UI, source=$source',
|
||||
name: 'VideoCallFcm',
|
||||
);
|
||||
try {
|
||||
await VideoCallEndedPushHandler.dismissIncomingCallUi();
|
||||
} catch (e, st) {
|
||||
log(
|
||||
'VideoCallFcmHandler: failed to dismiss incoming call UI: $e',
|
||||
name: 'VideoCallFcm',
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Platform.isAndroid) return;
|
||||
if (data.isEmpty) return;
|
||||
|
||||
final payload = VideoCallPushPayload.tryParse(data);
|
||||
if (payload == null) {
|
||||
log(
|
||||
'VideoCallFcmHandler: skip non-video-call push, source=$source, data=$data',
|
||||
name: 'VideoCallFcm',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source == 'background') {
|
||||
log(
|
||||
'VideoCallFcmHandler: skip background video call, handled by native FCM service, conversation=${payload.conversationUuid}',
|
||||
name: 'VideoCallFcm',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log(
|
||||
'VideoCallFcmHandler: show incoming call UI, source=$source, conversation=${payload.conversationUuid}',
|
||||
name: 'VideoCallFcm',
|
||||
);
|
||||
|
||||
try {
|
||||
await VideoCallIncomingPushPresenter.showIncomingCall(payload);
|
||||
} catch (e, st) {
|
||||
log(
|
||||
'VideoCallFcmHandler: failed to show incoming call UI: $e',
|
||||
name: 'VideoCallFcm',
|
||||
stackTrace: st,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
|
||||
abstract final class VideoCallForegroundBootstrap {
|
||||
static const int _serviceId = 1001;
|
||||
|
||||
static bool _initialized = false;
|
||||
|
||||
static void ensureInitialized() {
|
||||
if (_initialized) return;
|
||||
FlutterForegroundTask.init(
|
||||
androidNotificationOptions: AndroidNotificationOptions(
|
||||
channelId: 'video_call_ongoing',
|
||||
channelName: 'Video call',
|
||||
channelDescription: 'Ongoing video call notifications',
|
||||
channelImportance: NotificationChannelImportance.LOW,
|
||||
priority: NotificationPriority.LOW,
|
||||
onlyAlertOnce: true,
|
||||
),
|
||||
iosNotificationOptions: const IOSNotificationOptions(
|
||||
showNotification: true,
|
||||
playSound: false,
|
||||
),
|
||||
foregroundTaskOptions: ForegroundTaskOptions(
|
||||
eventAction: ForegroundTaskEventAction.nothing(),
|
||||
autoRunOnBoot: false,
|
||||
autoRunOnMyPackageReplaced: false,
|
||||
allowWakeLock: true,
|
||||
allowWifiLock: true,
|
||||
),
|
||||
);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
static int get serviceId => _serviceId;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_task_handler.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class VideoCallForegroundCoordinator {
|
||||
Future<void> ensureNotificationPermission() async {
|
||||
final permission = await FlutterForegroundTask.checkNotificationPermission();
|
||||
if (permission == NotificationPermission.granted) return;
|
||||
await FlutterForegroundTask.requestNotificationPermission();
|
||||
}
|
||||
|
||||
Future<void> startOngoingCall({
|
||||
required String conversationUuid,
|
||||
required String notificationTitle,
|
||||
required String notificationBody,
|
||||
required String endCallButtonLabel,
|
||||
}) async {
|
||||
VideoCallForegroundBootstrap.ensureInitialized();
|
||||
await ensureNotificationPermission();
|
||||
|
||||
await FlutterForegroundTask.saveData(
|
||||
key: videoCallActiveConversationStorageKey,
|
||||
value: conversationUuid,
|
||||
);
|
||||
|
||||
if (await FlutterForegroundTask.isRunningService) {
|
||||
await FlutterForegroundTask.updateService(
|
||||
notificationTitle: notificationTitle,
|
||||
notificationText: notificationBody,
|
||||
notificationButtons: [
|
||||
NotificationButton(
|
||||
id: videoCallForegroundEndCallButtonId,
|
||||
text: endCallButtonLabel,
|
||||
),
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final micStatus = await Permission.microphone.status;
|
||||
if (!micStatus.isGranted) {
|
||||
await Permission.microphone.request();
|
||||
}
|
||||
}
|
||||
|
||||
await FlutterForegroundTask.startService(
|
||||
serviceId: VideoCallForegroundBootstrap.serviceId,
|
||||
serviceTypes: const [ForegroundServiceTypes.microphone],
|
||||
notificationTitle: notificationTitle,
|
||||
notificationText: notificationBody,
|
||||
notificationInitialRoute: '/video-call-screen',
|
||||
notificationButtons: [
|
||||
NotificationButton(
|
||||
id: videoCallForegroundEndCallButtonId,
|
||||
text: endCallButtonLabel,
|
||||
),
|
||||
],
|
||||
callback: videoCallForegroundTaskStartCallback,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> stopOngoingCall() async {
|
||||
if (!await FlutterForegroundTask.isRunningService) return;
|
||||
await FlutterForegroundTask.stopService();
|
||||
await FlutterForegroundTask.removeData(
|
||||
key: videoCallActiveConversationStorageKey,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String?> readActiveConversationUuid() {
|
||||
return FlutterForegroundTask.getData<String>(
|
||||
key: videoCallActiveConversationStorageKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart';
|
||||
import 'package:flutter_callkit_incoming/entities/entities.dart';
|
||||
import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart';
|
||||
|
||||
abstract final class VideoCallIncomingPushPresenter {
|
||||
static const String _androidAccentColor = '#2787F5';
|
||||
|
||||
static Future<void> showIncomingCall(VideoCallPushPayload payload) async {
|
||||
final callerName =
|
||||
payload.counterpartName?.trim().isNotEmpty == true
|
||||
? payload.counterpartName!.trim()
|
||||
: 'Итвио';
|
||||
|
||||
final params = CallKitParams(
|
||||
id: payload.conversationUuid,
|
||||
nameCaller: callerName,
|
||||
appName: 'Итвио',
|
||||
avatar: payload.counterpartAvatarUrl,
|
||||
handle: payload.conversationUuid,
|
||||
type: 0,
|
||||
duration: 45000,
|
||||
extra: <String, dynamic>{
|
||||
'conversationUuid': payload.conversationUuid,
|
||||
if (payload.counterpartName != null)
|
||||
'counterpartName': payload.counterpartName,
|
||||
if (payload.counterpartAvatarUrl != null)
|
||||
'counterpartAvatarUrl': payload.counterpartAvatarUrl,
|
||||
if (payload.localParticipantName != null)
|
||||
'localParticipantName': payload.localParticipantName,
|
||||
if (payload.counterpartIsConsultant != null)
|
||||
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
||||
},
|
||||
callingNotification: const NotificationParams(showNotification: false),
|
||||
android: const AndroidParams(
|
||||
isCustomNotification: true,
|
||||
isShowLogo: false,
|
||||
isShowFullLockedScreen: false,
|
||||
isFullScreen: false,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
backgroundColor: _androidAccentColor,
|
||||
actionColor: _androidAccentColor,
|
||||
textColor: '#ffffff',
|
||||
textAccept: 'Answer',
|
||||
textDecline: 'Decline',
|
||||
isShowCallID: false,
|
||||
),
|
||||
ios: const IOSParams(
|
||||
handleType: 'generic',
|
||||
supportsVideo: true,
|
||||
ringtonePath: 'system_ringtone_default',
|
||||
),
|
||||
);
|
||||
|
||||
await FlutterCallkitIncoming.showCallkitIncoming(params);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
typedef VideoCallLifecycleCallback = void Function(AppLifecycleState state);
|
||||
|
||||
class VideoCallLifecycleBinding {
|
||||
AppLifecycleListener? _listener;
|
||||
VideoCallLifecycleCallback? _callback;
|
||||
|
||||
@visibleForTesting
|
||||
void simulateLifecycle(AppLifecycleState state) => _callback?.call(state);
|
||||
|
||||
void attach(VideoCallLifecycleCallback onStateChanged) {
|
||||
_callback = onStateChanged;
|
||||
_listener?.dispose();
|
||||
_listener = AppLifecycleListener(
|
||||
onResume: () => onStateChanged(AppLifecycleState.resumed),
|
||||
onInactive: () => onStateChanged(AppLifecycleState.inactive),
|
||||
onHide: () => onStateChanged(AppLifecycleState.hidden),
|
||||
onPause: () => onStateChanged(AppLifecycleState.paused),
|
||||
onDetach: () => onStateChanged(AppLifecycleState.detached),
|
||||
);
|
||||
}
|
||||
|
||||
void detach() {
|
||||
_listener?.dispose();
|
||||
_listener = null;
|
||||
_callback = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/routing/route_constants.dart';
|
||||
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
|
||||
class VideoCallNotificationNavigator {
|
||||
static bool _pushInFlight = false;
|
||||
|
||||
static Future<void> openActiveCall({String? conversationUuid}) async {
|
||||
final manager = sl<VideoCallManager>();
|
||||
final uuid = conversationUuid ?? manager.conversationUuid;
|
||||
if (uuid.isEmpty) return;
|
||||
|
||||
await _pushCallScreen(
|
||||
conversationUuid: uuid,
|
||||
counterpartName: manager.counterpartName,
|
||||
localParticipantName: manager.localParticipantName,
|
||||
);
|
||||
|
||||
await manager.onCallScreenVisible(
|
||||
conversationUuid: uuid,
|
||||
counterpartName: manager.counterpartName,
|
||||
localParticipantName: manager.localParticipantName,
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns `true` when the call UI is visible or navigation succeeded.
|
||||
static Future<bool> openIncomingCall({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
|
||||
}) async {
|
||||
if (conversationUuid.isEmpty) return false;
|
||||
|
||||
FlutterNativeSplash.remove();
|
||||
final readySince = DateTime.now();
|
||||
await _waitUntilNavigationReady(since: readySince);
|
||||
|
||||
for (var attempt = 0; attempt < 8; attempt++) {
|
||||
if (attempt > 0) {
|
||||
await Future<void>.delayed(Duration(milliseconds: 200 * attempt));
|
||||
await _waitUntilNavigationReady(since: readySince);
|
||||
}
|
||||
|
||||
if (isOnCallRoute()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final pushed = await _pushCallScreen(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
entryMode: entryMode,
|
||||
);
|
||||
|
||||
if (pushed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return isOnCallRoute();
|
||||
}
|
||||
|
||||
static bool isOnCallRoute() {
|
||||
final matches =
|
||||
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;
|
||||
if (matches.isEmpty) return false;
|
||||
return matches.last.matchedLocation.contains(VideoCallScreen.path);
|
||||
}
|
||||
|
||||
static Future<void> _waitUntilNavigationReady({
|
||||
required DateTime since,
|
||||
Duration timeout = const Duration(seconds: 12),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
final lifecycle = WidgetsBinding.instance.lifecycleState;
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
final routerReady = _isRouterReadyForCallNavigation(since: since);
|
||||
|
||||
if (lifecycle == AppLifecycleState.resumed &&
|
||||
context != null &&
|
||||
context.mounted &&
|
||||
routerReady) {
|
||||
for (var frame = 0; frame < 2; frame++) {
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
}
|
||||
return;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
|
||||
static bool _isRouterReadyForCallNavigation({required DateTime since}) {
|
||||
final matches =
|
||||
AppRouter.appRouter.routerDelegate.currentConfiguration.matches;
|
||||
if (matches.isEmpty) return false;
|
||||
|
||||
final topLocation = matches.last.matchedLocation;
|
||||
if (topLocation == RouteConstants.updateScreen) {
|
||||
// Cold start: wait for version-check redirect unless it is taking too long.
|
||||
return DateTime.now().difference(since) >
|
||||
const Duration(seconds: 6);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Future<void> _waitForRouteToSettle() async {
|
||||
for (var frame = 0; frame < 4; frame++) {
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 80));
|
||||
}
|
||||
|
||||
static Future<bool> _waitForCallRouteVisible({
|
||||
Duration timeout = const Duration(seconds: 3),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (DateTime.now().isBefore(deadline)) {
|
||||
if (isOnCallRoute()) {
|
||||
await _waitForRouteToSettle();
|
||||
return isOnCallRoute();
|
||||
}
|
||||
await SchedulerBinding.instance.endOfFrame;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 30));
|
||||
}
|
||||
return isOnCallRoute();
|
||||
}
|
||||
|
||||
static Future<bool> _pushCallScreen({
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
|
||||
}) async {
|
||||
if (isOnCallRoute()) {
|
||||
return true;
|
||||
}
|
||||
if (_pushInFlight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final context = AppRouter.navigatorKey.currentContext;
|
||||
if (context == null || !context.mounted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final extra = <String, dynamic>{
|
||||
'conversationUuid': conversationUuid,
|
||||
'counterpartName': counterpartName,
|
||||
'counterpartAvatarUrl': counterpartAvatarUrl,
|
||||
'localParticipantName': localParticipantName,
|
||||
'counterpartIsConsultant': ?counterpartIsConsultant,
|
||||
'entryMode': entryMode.name,
|
||||
};
|
||||
|
||||
_pushInFlight = true;
|
||||
try {
|
||||
try {
|
||||
// pushNamed completes when the route is popped, not when it opens.
|
||||
unawaited(
|
||||
AppRouter.appRouter.pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: extra,
|
||||
),
|
||||
);
|
||||
} catch (error, stackTrace) {
|
||||
log(
|
||||
'VideoCallNotificationNavigator: pushNamed failed: $error',
|
||||
name: 'VideoCallCallKit',
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _waitForCallRouteVisible();
|
||||
} finally {
|
||||
_pushInFlight = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
187
lib/features/video_calls/services/video_call_push_payload.dart
Normal file
187
lib/features/video_calls/services/video_call_push_payload.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
class VideoCallPushPayload {
|
||||
const VideoCallPushPayload({
|
||||
required this.conversationUuid,
|
||||
this.counterpartName,
|
||||
this.counterpartAvatarUrl,
|
||||
this.localParticipantName,
|
||||
this.counterpartIsConsultant,
|
||||
});
|
||||
|
||||
final String conversationUuid;
|
||||
final String? counterpartName;
|
||||
final String? counterpartAvatarUrl;
|
||||
final String? localParticipantName;
|
||||
final bool? counterpartIsConsultant;
|
||||
|
||||
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!isIncomingVideoCall(data)) return null;
|
||||
|
||||
final conversationUuid = _conversationUuid(data);
|
||||
if (conversationUuid == null || conversationUuid.isEmpty) return null;
|
||||
|
||||
return VideoCallPushPayload(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: _string(data, const [
|
||||
'counterpartName',
|
||||
'counterpart_name',
|
||||
'caller_name',
|
||||
'callerName',
|
||||
'nameCaller',
|
||||
'caller',
|
||||
]),
|
||||
counterpartAvatarUrl: _resolveAvatarUrl(
|
||||
_string(data, const [
|
||||
'counterpartAvatarUrl',
|
||||
'counterpart_avatar_url',
|
||||
'avatar',
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
'caller_avatar',
|
||||
]),
|
||||
),
|
||||
localParticipantName: _string(data, const [
|
||||
'localParticipantName',
|
||||
'local_participant_name',
|
||||
]),
|
||||
counterpartIsConsultant: _bool(data, const [
|
||||
'counterpartIsConsultant',
|
||||
'counterpart_is_consultant',
|
||||
'is_consultant',
|
||||
'isConsultant',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
static bool isEndedVideoCall(Map<String, dynamic> data) {
|
||||
final type = _string(data, const [
|
||||
'type',
|
||||
'notification_type',
|
||||
'event',
|
||||
'action',
|
||||
'push_type',
|
||||
'call_type',
|
||||
]);
|
||||
if (type != null) {
|
||||
final normalized = type.toLowerCase().replaceAll('-', '_');
|
||||
if (normalized == 'video_call_ended' || normalized == 'video_call_end') {
|
||||
return true;
|
||||
}
|
||||
if (normalized.contains('video_call') &&
|
||||
(normalized.contains('ended') || normalized.endsWith('_end'))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool isIncomingVideoCall(Map<String, dynamic> data) {
|
||||
final type = _string(data, const [
|
||||
'type',
|
||||
'notification_type',
|
||||
'event',
|
||||
'action',
|
||||
'push_type',
|
||||
'call_type',
|
||||
]);
|
||||
if (type != null) {
|
||||
final normalized = type.toLowerCase().replaceAll('-', '_');
|
||||
if (normalized.contains('video_call')) {
|
||||
if (normalized.contains('end') || normalized.contains('ended')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (normalized == 'incoming_call' ||
|
||||
normalized == 'video_call_incoming' ||
|
||||
normalized == 'incoming_video_call') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (_isTruthy(data['is_video_call']) ||
|
||||
_isTruthy(data['incoming_call']) ||
|
||||
_isTruthy(data['is_incoming_call'])) {
|
||||
return _conversationUuid(data) != null;
|
||||
}
|
||||
|
||||
final actionUrl = _string(data, const ['action_url']);
|
||||
if (actionUrl != null &&
|
||||
(actionUrl.contains('video-call') || actionUrl.contains('videoCall'))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static String? conversationUuidFromData(Map<String, dynamic> data) =>
|
||||
_conversationUuid(data);
|
||||
|
||||
static String? _conversationUuid(Map<String, dynamic> data) {
|
||||
final fromData = _string(data, const [
|
||||
'conversationUuid',
|
||||
'conversation_uuid',
|
||||
'conversation_id',
|
||||
'chat_uuid',
|
||||
'uuid',
|
||||
]);
|
||||
if (fromData != null && fromData.isNotEmpty) return fromData;
|
||||
|
||||
final actionUrl = _string(data, const ['action_url']);
|
||||
if (actionUrl == null) return null;
|
||||
|
||||
final uri = Uri.tryParse(actionUrl);
|
||||
if (uri == null) return null;
|
||||
|
||||
final fromQuery = uri.queryParameters['uuid'] ??
|
||||
uri.queryParameters['conversationUuid'] ??
|
||||
uri.queryParameters['conversation_uuid'];
|
||||
if (fromQuery != null && fromQuery.trim().isNotEmpty) {
|
||||
return fromQuery.trim();
|
||||
}
|
||||
|
||||
final segments = uri.pathSegments;
|
||||
for (var i = 0; i < segments.length - 1; i++) {
|
||||
if (segments[i] == 'video-call' && segments[i + 1].isNotEmpty) {
|
||||
return segments[i + 1];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static String? _string(Map<String, dynamic> map, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = map[key];
|
||||
if (value is String && value.trim().isNotEmpty) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool? _bool(Map<String, dynamic> map, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
if (!map.containsKey(key)) continue;
|
||||
return _isTruthy(map[key]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _isTruthy(Object? value) {
|
||||
if (value == null) return false;
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
final normalized = value.toString().trim().toLowerCase();
|
||||
return normalized == '1' ||
|
||||
normalized == 'true' ||
|
||||
normalized == 'yes';
|
||||
}
|
||||
|
||||
static String? _resolveAvatarUrl(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
if (raw.startsWith('http://') || raw.startsWith('https://')) {
|
||||
return raw;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
|
||||
class VideoCallScreenWakeLockCoordinator {
|
||||
bool _held = false;
|
||||
|
||||
bool get isHeld => _held;
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_held) return;
|
||||
|
||||
try {
|
||||
await WakelockPlus.enable();
|
||||
_held = true;
|
||||
} catch (_) {
|
||||
_held = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> release() async {
|
||||
if (!_held) return;
|
||||
|
||||
_held = false;
|
||||
try {
|
||||
await WakelockPlus.disable();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,10 @@ abstract class VideoCallService {
|
||||
@Path('conversation') required String conversationUuid,
|
||||
@Body() required Map<String, dynamic> body,
|
||||
});
|
||||
|
||||
@POST('/api/v2/chat/{conversation}/video-call/accept')
|
||||
Future<VideoCallAcceptResponse> notifyCallAccepted({
|
||||
@Path('conversation') required String conversationUuid,
|
||||
@Body() required Map<String, dynamic> body,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:floating/floating.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Android system Picture-in-Picture via the [floating] package.
|
||||
class VideoCallSystemPipCoordinator {
|
||||
VideoCallSystemPipCoordinator({Floating? floating})
|
||||
: _floating = floating ?? Floating();
|
||||
|
||||
static const MethodChannel _nativePipChannel = MethodChannel(
|
||||
'com.eatvioapp/video_call_pip',
|
||||
);
|
||||
|
||||
final Floating _floating;
|
||||
|
||||
/// Portrait ratio close to the in-call mini preview (142×201).
|
||||
static const Rational callAspectRatio = Rational(2, 3);
|
||||
|
||||
bool get isPlatformSupported => Platform.isAndroid;
|
||||
|
||||
Future<bool> get isAvailable async {
|
||||
if (!isPlatformSupported) return false;
|
||||
return _floating.isPipAvailable;
|
||||
}
|
||||
|
||||
Stream<PiPStatus> get statusStream => _floating.pipStatusStream;
|
||||
|
||||
/// Enter PiP when the user leaves the app (Home / recent apps).
|
||||
///
|
||||
/// Requires Android 12+ (API 31); on older versions this is a no-op.
|
||||
Future<void> armAutoPipOnLeave() async {
|
||||
if (!await isAvailable) return;
|
||||
await _setNativeUserLeaveHintPipEnabled(true);
|
||||
try {
|
||||
await _floating.enable(
|
||||
const OnLeavePiP(aspectRatio: callAspectRatio),
|
||||
);
|
||||
} on PlatformException {
|
||||
// OnLeavePiP is unavailable below Android 12; native onUserLeaveHint
|
||||
// still handles Home / recent apps on Android 8+.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> disarm() async {
|
||||
if (!isPlatformSupported) return;
|
||||
await _setNativeUserLeaveHintPipEnabled(false);
|
||||
try {
|
||||
await _floating.cancelOnLeavePiP();
|
||||
} on PlatformException {
|
||||
// The activity may already be detached while the call is being torn down.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setNativeUserLeaveHintPipEnabled(bool enabled) async {
|
||||
if (!isPlatformSupported) return;
|
||||
try {
|
||||
await _nativePipChannel.invokeMethod<void>('setPipEnabled', enabled);
|
||||
} on PlatformException {
|
||||
// Native channel may be unavailable during startup/teardown.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
|
||||
const String videoCallForegroundTaskActionKey = 'action';
|
||||
const String videoCallForegroundOpenAction = 'open';
|
||||
const String videoCallForegroundEndCallAction = 'end_call';
|
||||
const String videoCallForegroundEndCallButtonId = 'end_call';
|
||||
const String videoCallActiveConversationStorageKey = 'video_call_active_conversation_uuid';
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
void videoCallForegroundTaskStartCallback() {
|
||||
FlutterForegroundTask.setTaskHandler(VideoCallTaskHandler());
|
||||
}
|
||||
|
||||
class VideoCallTaskHandler extends TaskHandler {
|
||||
@override
|
||||
Future<void> onStart(DateTime timestamp, TaskStarter starter) async {}
|
||||
|
||||
@override
|
||||
void onRepeatEvent(DateTime timestamp) {}
|
||||
|
||||
@override
|
||||
Future<void> onDestroy(DateTime timestamp, bool isTimeout) async {}
|
||||
|
||||
@override
|
||||
void onReceiveData(Object data) {}
|
||||
|
||||
@override
|
||||
void onNotificationButtonPressed(String id) {
|
||||
if (id == videoCallForegroundEndCallButtonId) {
|
||||
FlutterForegroundTask.sendDataToMain(<String, String>{
|
||||
videoCallForegroundTaskActionKey: videoCallForegroundEndCallAction,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void onNotificationPressed() {
|
||||
FlutterForegroundTask.sendDataToMain(<String, String>{
|
||||
videoCallForegroundTaskActionKey: videoCallForegroundOpenAction,
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void onNotificationDismissed() {}
|
||||
}
|
||||
@@ -898,13 +898,33 @@ 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. Tap Retry to reconnect.",
|
||||
),
|
||||
"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_mic_muted_phone_call": MessageLookupByLibrary.simpleMessage(
|
||||
"Microphone is muted during the phone call",
|
||||
),
|
||||
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
|
||||
"Attention!\nYour microphone is muted",
|
||||
),
|
||||
"video_call_notification_end": MessageLookupByLibrary.simpleMessage(
|
||||
"End call",
|
||||
),
|
||||
"video_call_notification_in_progress": MessageLookupByLibrary.simpleMessage(
|
||||
"Call in progress",
|
||||
),
|
||||
"video_call_notification_return": MessageLookupByLibrary.simpleMessage(
|
||||
"Return to call",
|
||||
),
|
||||
"video_call_permission_camera_description":
|
||||
MessageLookupByLibrary.simpleMessage(
|
||||
"Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.",
|
||||
@@ -927,7 +947,24 @@ 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_system_interruption": MessageLookupByLibrary.simpleMessage(
|
||||
"Another call or app is using audio.\nYour consultation is paused until it ends.",
|
||||
),
|
||||
"video_call_try_again": MessageLookupByLibrary.simpleMessage("Retry"),
|
||||
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
|
||||
"video_call_video": MessageLookupByLibrary.simpleMessage("Video"),
|
||||
"video_call_you": MessageLookupByLibrary.simpleMessage("You"),
|
||||
|
||||
@@ -378,13 +378,33 @@ 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_mic_muted_phone_call": MessageLookupByLibrary.simpleMessage(
|
||||
"Микрофон выключен на время телефонного звонка",
|
||||
),
|
||||
"video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage(
|
||||
"Внимание!\nУ вас выключен микрофон",
|
||||
),
|
||||
"video_call_notification_end": MessageLookupByLibrary.simpleMessage(
|
||||
"Завершить звонок",
|
||||
),
|
||||
"video_call_notification_in_progress": MessageLookupByLibrary.simpleMessage(
|
||||
"Звонок идёт",
|
||||
),
|
||||
"video_call_notification_return": MessageLookupByLibrary.simpleMessage(
|
||||
"Вернуться к звонку",
|
||||
),
|
||||
"video_call_permission_camera_description":
|
||||
MessageLookupByLibrary.simpleMessage(
|
||||
"Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.",
|
||||
@@ -407,7 +427,24 @@ 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_system_interruption": MessageLookupByLibrary.simpleMessage(
|
||||
"Другой звонок или приложение использует аудио.\nКонсультация приостановлена до его завершения.",
|
||||
),
|
||||
"video_call_try_again": MessageLookupByLibrary.simpleMessage("Повторить"),
|
||||
"video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"),
|
||||
"video_call_video": MessageLookupByLibrary.simpleMessage("Видео"),
|
||||
"video_call_you": MessageLookupByLibrary.simpleMessage("Вы"),
|
||||
|
||||
@@ -4109,6 +4109,36 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `Another call or app is using audio.\nYour consultation is paused until it ends.`
|
||||
String get video_call_system_interruption {
|
||||
return Intl.message(
|
||||
'Another call or app is using audio.\nYour consultation is paused until it ends.',
|
||||
name: 'video_call_system_interruption',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Microphone is muted during the phone call`
|
||||
String get video_call_mic_muted_phone_call {
|
||||
return Intl.message(
|
||||
'Microphone is muted during the phone call',
|
||||
name: 'video_call_mic_muted_phone_call',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Online`
|
||||
String get video_call_status_online {
|
||||
return Intl.message(
|
||||
'Online',
|
||||
name: 'video_call_status_online',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Chat`
|
||||
String get video_call_chat {
|
||||
return Intl.message('Chat', name: 'video_call_chat', desc: '', args: []);
|
||||
@@ -4233,6 +4263,126 @@ class S {
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Loading…`
|
||||
String get video_call_screen_loading_title {
|
||||
return Intl.message(
|
||||
'Loading…',
|
||||
name: 'video_call_screen_loading_title',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Connecting to\nthe consultation`
|
||||
String get video_call_screen_connecting_title {
|
||||
return Intl.message(
|
||||
'Connecting to\nthe consultation',
|
||||
name: 'video_call_screen_connecting_title',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `This may take a few seconds`
|
||||
String get video_call_screen_connecting_subtitle {
|
||||
return Intl.message(
|
||||
'This may take a few seconds',
|
||||
name: 'video_call_screen_connecting_subtitle',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Reconnecting`
|
||||
String get video_call_screen_reconnecting_title {
|
||||
return Intl.message(
|
||||
'Reconnecting',
|
||||
name: 'video_call_screen_reconnecting_title',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Check your internet and wait`
|
||||
String get video_call_screen_reconnecting_subtitle {
|
||||
return Intl.message(
|
||||
'Check your internet and wait',
|
||||
name: 'video_call_screen_reconnecting_subtitle',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Connection lost`
|
||||
String get video_call_connection_lost_title {
|
||||
return Intl.message(
|
||||
'Connection lost',
|
||||
name: 'video_call_connection_lost_title',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `It looks like there is an internet issue. Tap Retry to reconnect.`
|
||||
String get video_call_connection_lost_message {
|
||||
return Intl.message(
|
||||
'It looks like there is an internet issue. Tap Retry to reconnect.',
|
||||
name: 'video_call_connection_lost_message',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Retry`
|
||||
String get video_call_try_again {
|
||||
return Intl.message(
|
||||
'Retry',
|
||||
name: 'video_call_try_again',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Enter`
|
||||
String get video_call_enter {
|
||||
return Intl.message('Enter', name: 'video_call_enter', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Close`
|
||||
String get video_call_close {
|
||||
return Intl.message('Close', name: 'video_call_close', desc: '', args: []);
|
||||
}
|
||||
|
||||
/// `Call in progress`
|
||||
String get video_call_notification_in_progress {
|
||||
return Intl.message(
|
||||
'Call in progress',
|
||||
name: 'video_call_notification_in_progress',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `Return to call`
|
||||
String get video_call_notification_return {
|
||||
return Intl.message(
|
||||
'Return to call',
|
||||
name: 'video_call_notification_return',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
|
||||
/// `End call`
|
||||
String get video_call_notification_end {
|
||||
return Intl.message(
|
||||
'End call',
|
||||
name: 'video_call_notification_end',
|
||||
desc: '',
|
||||
args: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AppLocalizationDelegate extends LocalizationsDelegate<S> {
|
||||
|
||||
@@ -483,6 +483,9 @@
|
||||
"video_call_poor_connection": "You have a poor internet connection",
|
||||
"video_call_recording_notice": "The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.",
|
||||
"video_call_muted_mic_warning": "Attention!\nYour microphone is muted",
|
||||
"video_call_system_interruption": "Another call or app is using audio.\nYour consultation is paused until it ends.",
|
||||
"video_call_mic_muted_phone_call": "Microphone is muted during the phone call",
|
||||
"video_call_status_online": "Online",
|
||||
"video_call_chat": "Chat",
|
||||
"video_call_sound": "Sound",
|
||||
"video_call_video": "Video",
|
||||
@@ -498,6 +501,19 @@
|
||||
"have_promo_code_form": "Have a promo code?",
|
||||
"promo_code": "Promo code",
|
||||
"enter_promo_code": "Enter promo code",
|
||||
"promo_invalid": "Promo code not found or expired"
|
||||
"promo_invalid": "Promo code not found or expired",
|
||||
"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. Tap Retry to reconnect.",
|
||||
"video_call_try_again": "Retry",
|
||||
"video_call_enter": "Enter",
|
||||
"video_call_close": "Close",
|
||||
"video_call_notification_in_progress": "Call in progress",
|
||||
"video_call_notification_return": "Return to call",
|
||||
"video_call_notification_end": "End call"
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,9 @@
|
||||
"video_call_poor_connection": "У вас плохое интернет соединение",
|
||||
"video_call_recording_notice": "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.",
|
||||
"video_call_muted_mic_warning": "Внимание!\nУ вас выключен микрофон",
|
||||
"video_call_system_interruption": "Другой звонок или приложение использует аудио.\nКонсультация приостановлена до его завершения.",
|
||||
"video_call_mic_muted_phone_call": "Микрофон выключен на время телефонного звонка",
|
||||
"video_call_status_online": "Онлайн",
|
||||
"video_call_chat": "Чат",
|
||||
"video_call_sound": "Звук",
|
||||
"video_call_video": "Видео",
|
||||
@@ -233,7 +236,6 @@
|
||||
"video_call_permission_camera_title": "Доступ к камере",
|
||||
"video_call_permission_camera_description": "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.",
|
||||
"video_call_permission_microphone_title": "Доступ к микрофону",
|
||||
"video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.",
|
||||
"choose_bank_for_payment": "Выберите банк",
|
||||
"no_installed_banks": "Нет установленных банков",
|
||||
"sbp_payment_error": "Не удалось начать оплату. Попробуйте позже",
|
||||
@@ -243,5 +245,19 @@
|
||||
"have_promo_code_form": "Есть промокод?",
|
||||
"promo_code": "Промокод",
|
||||
"enter_promo_code": "Введите промокод",
|
||||
"promo_invalid": "Промокод не найден или истёк"
|
||||
"promo_invalid": "Промокод не найден или истёк",
|
||||
"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": "Закрыть",
|
||||
"video_call_notification_in_progress": "Звонок идёт",
|
||||
"video_call_notification_return": "Вернуться к звонку",
|
||||
"video_call_notification_end": "Завершить звонок"
|
||||
}
|
||||
|
||||
@@ -4,14 +4,24 @@ import 'package:eatvio_app/core/app_router.dart';
|
||||
import 'package:eatvio_app/core/locator_service.dart';
|
||||
import 'package:eatvio_app/core/notifications/push_navigation_handler.dart';
|
||||
import 'package:eatvio_app/core/theme/theme_helper.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_callkit_coordinator.dart';
|
||||
import 'package:eatvio_app/features/video_calls/services/video_call_foreground_bootstrap.dart';
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_app_shell.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_portal/flutter_portal.dart';
|
||||
import 'package:keyboard_dismisser/keyboard_dismisser.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
FlutterForegroundTask.initCommunicationPort();
|
||||
VideoCallForegroundBootstrap.ensureInitialized();
|
||||
await AppInitializer.initialize(buildType: AppBuildType.prod);
|
||||
await sl<VideoCallCallKitCoordinator>().initialize();
|
||||
await PushNavigationHandler.initialize();
|
||||
await AppInitializer.runAppWithSentry(() => runApp(const MyApp()));
|
||||
}
|
||||
|
||||
@@ -26,7 +36,6 @@ class _MyAppState extends State<MyApp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
PushNavigationHandler.initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -35,12 +44,15 @@ class _MyAppState extends State<MyApp> {
|
||||
providers: sl<AppConfig>().globalBlocProviders,
|
||||
child: Portal(
|
||||
child: KeyboardDismisser(
|
||||
child: MaterialApp.router(
|
||||
theme: ThemeHelper().themeData(),
|
||||
routerConfig: AppRouter.appRouter,
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: sl<AppConfig>().localizationDelegates,
|
||||
supportedLocales: sl<AppConfig>().supportedLocales,
|
||||
child: WithForegroundTask(
|
||||
child: MaterialApp.router(
|
||||
theme: ThemeHelper().themeData(),
|
||||
routerConfig: AppRouter.appRouter,
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: sl<AppConfig>().localizationDelegates,
|
||||
supportedLocales: sl<AppConfig>().supportedLocales,
|
||||
builder: (context, child) => VideoCallAppShell(child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user