From c5bf5acfe7cb76bb28e27cc8f6b3f741a6e4fa51 Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 24 Jun 2026 08:26:16 +0300 Subject: [PATCH] feat(video_calls): fixed online indicator and in app notifications for video call --- lib/core/const.dart | 1 + lib/core/routing/other_routes.dart | 3 + lib/core/websockets/ws_manager.dart | 132 ++++++++++++++++++ .../models/chat_room_pagination_model.dart | 2 + lib/features/chat/models/chat_user_model.dart | 2 + .../bloc/chat_room_cubit/chat_room_cubit.dart | 123 ++++++++++++++++ .../chat/presentation/pages/chat_screen.dart | 21 ++- .../widgets/chat_user_online_status_line.dart | 70 ++++++++++ .../chat/utils/chat_counterpart_utils.dart | 15 ++ .../managers/video_call_manager.dart | 17 ++- .../video_call_session_cubit.dart | 20 ++- .../video_call_session_state.dart | 2 +- .../presentation/pages/video_call_screen.dart | 91 ++++++++++++ .../widgets/video_call_app_shell.dart | 11 +- .../widgets/video_call_banner_overlay.dart | 10 ++ ...video_call_connecting_entrance_screen.dart | 58 ++++---- .../video_call_system_pip_listener.dart | 20 ++- .../widgets/video_call_top_overlay.dart | 11 +- pubspec.yaml | 2 +- test/core/const_test.dart | 1 + .../chat/models/chat_user_model_test.dart | 6 +- .../video_call_session_cubit_test.dart | 2 +- 22 files changed, 569 insertions(+), 51 deletions(-) create mode 100644 lib/features/chat/presentation/widgets/chat_user_online_status_line.dart create mode 100644 lib/features/chat/utils/chat_counterpart_utils.dart create mode 100644 lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart diff --git a/lib/core/const.dart b/lib/core/const.dart index 5aab4fd4..6811b710 100644 --- a/lib/core/const.dart +++ b/lib/core/const.dart @@ -9,4 +9,5 @@ const List planTypeValues = ['fit', 'slim', 'wellness', 'behavior']; const newMessagesChannelName = 'private-new-chat-messages'; const newNotificationsChannelName = 'private-message-events'; const directChatChannelName = 'private-direct-chat'; +const presenceDirectChatChannelName = 'presence-direct-chat'; const thresholdForPagination = 150; diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index cd139b77..d650445d 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -236,6 +236,9 @@ class OtherRoutes { conversationUuid: extra['conversationUuid'] as String, counterpartName: extra['counterpartName'] as String?, counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?, + counterpartUserId: extra['counterpartUserId'] as int?, + counterpartIsOnline: extra['counterpartIsOnline'] as bool?, + counterpartLastSeenText: extra['counterpartLastSeenText'] as String?, localParticipantName: extra['localParticipantName'] as String?, entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']), onBackToChat: extra['onBackToChat'] as void Function()?, diff --git a/lib/core/websockets/ws_manager.dart b/lib/core/websockets/ws_manager.dart index cb90f4f8..3631e0ac 100644 --- a/lib/core/websockets/ws_manager.dart +++ b/lib/core/websockets/ws_manager.dart @@ -26,6 +26,13 @@ class WsManager { PrivateChannel? _privateNotificationChannel; PrivateChannel? _privateMessagesChannel; PrivateChannel? _privateDirectChatChannel; + PresenceChannel? _presenceDirectChatChannel; + + StreamSubscription? _presenceMemberAddedSubscription; + StreamSubscription? _presenceMemberRemovedSubscription; + StreamSubscription? _presenceSubscriptionSucceededSubscription; + final List<_PresenceDirectChatListener> _presenceDirectChatListeners = []; + bool _presenceChannelEventsBound = false; StreamSubscription? _lifecycleSubscription; StreamSubscription? _eventSubscription; @@ -175,6 +182,107 @@ class WsManager { ); } + Future initPresenceDirectChatChannel(String chatId) async { + final token = await TokenStorage.getTokenFromStorage(); + _presenceDirectChatChannel = client?.presenceChannel( + '$presenceDirectChatChannelName.$chatId', + authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel( + authorizationEndpoint: Uri.parse('${sl().baseUrl}/broadcasting/auth'), + headers: { + 'Authorization': 'Bearer $token', + }, + ), + ); + } + + void addPresenceDirectChatListener({ + required int counterpartUserId, + required void Function(bool isOnline) onCounterpartOnlineChanged, + }) { + final channel = _presenceDirectChatChannel; + if (channel == null) return; + + _presenceDirectChatListeners.add( + _PresenceDirectChatListener( + counterpartUserId: counterpartUserId, + onCounterpartOnlineChanged: onCounterpartOnlineChanged, + ), + ); + + _bindPresenceChannelEventsIfNeeded(); + channel.subscribe(); + _emitCounterpartPresence(counterpartUserId); + } + + void removePresenceDirectChatListener( + void Function(bool isOnline) onCounterpartOnlineChanged, + ) { + _presenceDirectChatListeners.removeWhere( + (listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged, + ); + if (_presenceDirectChatListeners.isEmpty) { + unsubscribePresenceDirectChatChannel(); + } + } + + void _bindPresenceChannelEventsIfNeeded() { + if (_presenceChannelEventsBound) return; + final channel = _presenceDirectChatChannel; + if (channel == null) return; + + _presenceChannelEventsBound = true; + + _presenceSubscriptionSucceededSubscription = + channel.whenSubscriptionSucceeded().listen((_) { + final counterpartIds = _presenceDirectChatListeners + .map((listener) => listener.counterpartUserId) + .toSet(); + for (final counterpartUserId in counterpartIds) { + _emitCounterpartPresence(counterpartUserId); + } + }); + _presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) { + final memberUserId = _parsePresenceMemberUserId(event.data); + if (memberUserId == null) return; + _notifyPresenceListeners(memberUserId, isOnline: true); + }); + _presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) { + final memberUserId = _parsePresenceMemberUserId(event.data); + if (memberUserId == null) return; + _notifyPresenceListeners(memberUserId, isOnline: false); + }); + } + + void _emitCounterpartPresence(int counterpartUserId) { + final channel = _presenceDirectChatChannel; + if (channel == null) return; + final members = channel.state?.members; + if (members == null) return; + final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null; + _notifyPresenceListeners(counterpartUserId, isOnline: isOnline); + } + + void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) { + for (final listener in _presenceDirectChatListeners) { + if (listener.counterpartUserId == counterpartUserId) { + listener.onCounterpartOnlineChanged(isOnline); + } + } + } + + int? _parsePresenceMemberUserId(String? data) { + if (data == null || data.isEmpty) return null; + try { + final decoded = jsonDecode(data); + if (decoded is! Map) return null; + final rawId = decoded['user_id'] ?? decoded['id']; + if (rawId == null) return null; + return int.tryParse(rawId.toString()); + } catch (_) { + return null; + } + } + void _subscribe() { _privateMessagesChannel?.subscribe(); _privateNotificationChannel?.subscribe(); @@ -193,6 +301,19 @@ class WsManager { _privateDirectChatChannel?.unsubscribe(); } + void unsubscribePresenceDirectChatChannel() { + _presenceSubscriptionSucceededSubscription?.cancel(); + _presenceMemberAddedSubscription?.cancel(); + _presenceMemberRemovedSubscription?.cancel(); + _presenceSubscriptionSucceededSubscription = null; + _presenceMemberAddedSubscription = null; + _presenceMemberRemovedSubscription = null; + _presenceDirectChatListeners.clear(); + _presenceChannelEventsBound = false; + _presenceDirectChatChannel?.unsubscribe(); + _presenceDirectChatChannel = null; + } + Future clear() async { await client?.disconnect(); await _lifecycleSubscription?.cancel(); @@ -204,6 +325,7 @@ class WsManager { client = null; userId = null; _privateDirectChatChannel?.unsubscribe(); + unsubscribePresenceDirectChatChannel(); _privateMessagesChannel?.unsubscribe(); _privateNotificationChannel?.unsubscribe(); _privateDirectChatChannel = null; @@ -211,3 +333,13 @@ class WsManager { _privateNotificationChannel = null; } } + +class _PresenceDirectChatListener { + const _PresenceDirectChatListener({ + required this.counterpartUserId, + required this.onCounterpartOnlineChanged, + }); + + final int counterpartUserId; + final void Function(bool isOnline) onCounterpartOnlineChanged; +} diff --git a/lib/features/chat/models/chat_room_pagination_model.dart b/lib/features/chat/models/chat_room_pagination_model.dart index 52cb9f7c..fa62adc3 100644 --- a/lib/features/chat/models/chat_room_pagination_model.dart +++ b/lib/features/chat/models/chat_room_pagination_model.dart @@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser { @JsonKey(name: "name") required String name, @JsonKey(name: "profile_photo_path") required String? profilePhotoPath, @JsonKey(name: "profile_photo_url") required String profilePhotoUrl, + @Default(false) @JsonKey(name: 'is_online') bool isOnline, + @Default('') @JsonKey(name: 'last_seen_text') String lastSeenText, }) = _ChatUser; factory ChatUser.fromJson(Map json) => _$ChatUserFromJson(json); diff --git a/lib/features/chat/models/chat_user_model.dart b/lib/features/chat/models/chat_user_model.dart index ad876727..184c7889 100644 --- a/lib/features/chat/models/chat_user_model.dart +++ b/lib/features/chat/models/chat_user_model.dart @@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel { required String name, required String slug, @JsonKey(name: 'profile_photo_url') String? profilePhotoUrl, + @Default(false) @JsonKey(name: 'is_online') bool isOnline, + @Default('') @JsonKey(name: 'last_seen_text') String lastSeenText, }) = _ChatUserModel; factory ChatUserModel.fromJson(Map json) => _$ChatUserModelFromJson(json); diff --git a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart index dc56a0c1..dac801f7 100644 --- a/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart +++ b/lib/features/chat/presentation/bloc/chat_room_cubit/chat_room_cubit.dart @@ -16,6 +16,7 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; import 'package:eatvio_app/features/my_finance/models/card_model.dart'; import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; @@ -34,6 +35,8 @@ class ChatRoomCubit extends Cubit { late int lastPage; bool isLoading = false; Timer? _timer; + String? _activeChatUuid; + void Function(bool isOnline)? _presenceOnlineListener; var uuid = const Uuid(); ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading()); @@ -170,8 +173,10 @@ class ChatRoomCubit extends Cubit { Future _subscribeToChatChannel({required String uuid, required String chatId}) async { final client = wsManager.client; if (client == null) return; + _activeChatUuid = uuid; await wsManager.initPrivateDirectChatChannel(chatId); wsManager.subscribeDirectChatChannel(); + await _subscribeToPresenceChannel(chatId: chatId); messagesSubscription = wsManager.directChatStream?.listen((event) { AudioPlayer().play((AssetSource('audio/recive_message.wav'))); final message = ChatMessageModel.fromJson(jsonDecode(event.data)); @@ -197,6 +202,118 @@ class ChatRoomCubit extends Cubit { }); } + Future _subscribeToPresenceChannel({required String chatId}) async { + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final counterpart = resolveChatCounterpart( + users: currentState.chatRoom.users, + currentUserId: wsManager.userId, + ); + + await wsManager.initPresenceDirectChatChannel(chatId); + _presenceOnlineListener = (isOnline) { + unawaited(_handleCounterpartOnlineChanged( + counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id + ? _ChatCounterpartField.receiver + : _ChatCounterpartField.sender, + isOnline: isOnline, + )); + }; + wsManager.addPresenceDirectChatListener( + counterpartUserId: counterpart.id, + onCounterpartOnlineChanged: _presenceOnlineListener!, + ); + } + + Future _handleCounterpartOnlineChanged({ + required _ChatCounterpartField counterpartField, + required bool isOnline, + }) async { + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final counterpart = counterpartField == _ChatCounterpartField.receiver + ? currentState.chatRoom.users.receiver + : currentState.chatRoom.users.sender; + + if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) { + return; + } + + if (!isOnline) { + await _refreshCounterpartOnlineStatus(counterpartField: counterpartField); + return; + } + + _emitCounterpartOnlineStatus( + counterpartField: counterpartField, + isOnline: true, + lastSeenText: '', + ); + } + + Future _refreshCounterpartOnlineStatus({ + required _ChatCounterpartField counterpartField, + }) async { + final chatUuid = _activeChatUuid; + if (chatUuid == null) return; + + try { + final chatRoom = await chatRepository.getChatRoom(chatUuid); + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final counterpart = resolveChatCounterpart( + users: chatRoom.users, + currentUserId: wsManager.userId, + ); + final field = counterpart.id == currentState.chatRoom.users.receiver.id + ? _ChatCounterpartField.receiver + : _ChatCounterpartField.sender; + + if (field != counterpartField) return; + + _emitCounterpartOnlineStatus( + counterpartField: counterpartField, + isOnline: counterpart.isOnline, + lastSeenText: counterpart.lastSeenText, + ); + } catch (e, st) { + await reportFrom(e, st, this); + } + } + + void _emitCounterpartOnlineStatus({ + required _ChatCounterpartField counterpartField, + required bool isOnline, + required String lastSeenText, + }) { + final currentState = state; + if (currentState is! ChatRoomStateLoaded) return; + + final users = currentState.chatRoom.users; + final updatedUsers = counterpartField == _ChatCounterpartField.receiver + ? users.copyWith( + receiver: users.receiver.copyWith( + isOnline: isOnline, + lastSeenText: lastSeenText, + ), + ) + : users.copyWith( + sender: users.sender.copyWith( + isOnline: isOnline, + lastSeenText: lastSeenText, + ), + ); + + emit( + currentState.copyWith( + chatRoom: currentState.chatRoom.copyWith(users: updatedUsers), + ), + ); + } + Future sendMessage({String? data, List? files, required String userSlug}) async { try { final messageUuid = uuid.v4(); @@ -337,6 +454,10 @@ class ChatRoomCubit extends Cubit { @override Future close() async { + if (_presenceOnlineListener != null) { + wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); + _presenceOnlineListener = null; + } wsManager.unsubscribeDirectChatChannel(); _timer?.cancel(); messagesSubscription?.cancel(); @@ -435,3 +556,5 @@ class ChatRoomCubit extends Cubit { } } } + +enum _ChatCounterpartField { sender, receiver } diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 8d02a68a..5746beae 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -15,11 +15,14 @@ import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart'; +import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; @@ -141,6 +144,10 @@ class _ChatScreenState extends State { } Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) { + final chatCounterpart = resolveChatCounterpart( + users: state.chatRoom.users, + currentUserId: sl().userId, + ); final chosenPlan = state.chatRoom.chosenPlan; final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id; final showUserDetailsButton = [ @@ -236,7 +243,12 @@ class _ChatScreenState extends State { ), firstCurve: Curves.easeIn, secondCurve: Curves.easeOut, - secondChild: const SizedBox.shrink(), + secondChild: ChatUserOnlineStatusLine( + isOnline: chatCounterpart.isOnline, + lastSeenText: chatCounterpart.lastSeenText, + style: CustomTextStyles.bodySmallMulishPrimary, + showOnlineDot: false, + ), crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, duration: const Duration(milliseconds: 150), ), @@ -255,8 +267,11 @@ class _ChatScreenState extends State { VideoCallScreen.name, extra: { 'conversationUuid': widget.uuid, - 'counterpartName': state.chatRoom.users.receiver.name, - 'counterpartAvatarUrl': state.chatRoom.users.receiver.profilePhotoUrl, + 'counterpartName': chatCounterpart.name, + 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl, + 'counterpartUserId': chatCounterpart.id, + 'counterpartIsOnline': chatCounterpart.isOnline, + 'counterpartLastSeenText': chatCounterpart.lastSeenText, 'localParticipantName': state.chatRoom.users.sender.name, }, ); diff --git a/lib/features/chat/presentation/widgets/chat_user_online_status_line.dart b/lib/features/chat/presentation/widgets/chat_user_online_status_line.dart new file mode 100644 index 00000000..8f851ab2 --- /dev/null +++ b/lib/features/chat/presentation/widgets/chat_user_online_status_line.dart @@ -0,0 +1,70 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; + +class ChatUserOnlineStatusLine extends StatelessWidget { + const ChatUserOnlineStatusLine({ + super.key, + required this.isOnline, + required this.lastSeenText, + this.style, + this.showOnlineDot = true, + }); + + final bool isOnline; + final String lastSeenText; + final TextStyle? style; + final bool showOnlineDot; + + @override + Widget build(BuildContext context) { + final textStyle = style ?? + CustomTextStyles.bodySmallMulishBlack900.copyWith(height: 14 / 12); + + if (isOnline) { + if (!showOnlineDot) { + return Text( + S.of(context).video_call_status_online, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + } + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: appTheme.greenA700, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Flexible( + child: Text( + S.of(context).video_call_status_online, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ), + ), + ], + ); + } + + final trimmedLastSeen = lastSeenText.trim(); + if (trimmedLastSeen.isEmpty) { + return const SizedBox.shrink(); + } + + return Text( + trimmedLastSeen, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textStyle, + ); + } +} diff --git a/lib/features/chat/utils/chat_counterpart_utils.dart b/lib/features/chat/utils/chat_counterpart_utils.dart new file mode 100644 index 00000000..1fe562c5 --- /dev/null +++ b/lib/features/chat/utils/chat_counterpart_utils.dart @@ -0,0 +1,15 @@ +import 'package:eatvio_app/features/chat/models/chat_room_model.dart'; +import 'package:eatvio_app/features/chat/models/chat_user_model.dart'; + +ChatUserModel resolveChatCounterpart({ + required Users users, + required int? currentUserId, +}) { + if (currentUserId != null && users.sender.id == currentUserId) { + return users.receiver; + } + if (currentUserId != null && users.receiver.id == currentUserId) { + return users.sender; + } + return users.receiver; +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 6c967381..64834455 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -524,14 +524,16 @@ class VideoCallManager { if (_recordingOverlayScheduled) return; final ctx = _navigatorContext; if (ctx == null) return; - _recordingOverlayScheduled = true; - VideoCallTopOverlay.insertBanner( + final shown = VideoCallTopOverlay.insertBanner( ctx, message: S.of(ctx).video_call_recording_notice, backgroundColor: appTheme.videoCallNoticeBannerBackground, svgAsset: VideoCallTopOverlay.warningSvg, autoRemoveAfter: const Duration(seconds: 3), - ); + ).mounted; + if (shown) { + _recordingOverlayScheduled = true; + } } String resolvedLocalDisplayName() { @@ -804,13 +806,18 @@ class VideoCallManager { final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; if (bad) { - if (_poorConnectionEntry != null) return; - _poorConnectionEntry = VideoCallTopOverlay.insertBanner( + final existing = _poorConnectionEntry; + if (existing != null && existing.mounted) return; + _poorConnectionEntry = null; + final entry = VideoCallTopOverlay.insertBanner( ctx, message: S.of(ctx).video_call_poor_connection, backgroundColor: appTheme.strongRed, svgAsset: VideoCallTopOverlay.poorConnectionSvg, ); + if (entry.mounted) { + _poorConnectionEntry = entry; + } } else { final e = _poorConnectionEntry; if (e != null && e.mounted) { diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index d4888673..e1a37e71 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -67,11 +67,11 @@ class VideoCallSessionCubit extends BaseCubit { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { if (isClosed) return; if (_isCallSessionInactive(snapshot)) { + unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) { // [VideoCallManager.startCall] briefly emits idle while replacing the room. return; } - unawaited(_systemPipCoordinator.disarm()); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; @@ -84,6 +84,7 @@ class VideoCallSessionCubit extends BaseCubit { void _syncFromManager(VideoCallManagerSnapshot snapshot) { if (_isCallSessionInactive(snapshot)) { + unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) return; emit(const VideoCallSessionState()); return; @@ -210,9 +211,10 @@ class VideoCallSessionCubit extends BaseCubit { } Future _ensureSystemPipArmedIfInCall() async { - if (!state.callStageEntered) return; - if (state.connectPhase != VideoCallConnectPhase.connected) return; - if (state.linkIssue == VideoCallLinkIssue.lost) return; + if (!_videoCallManager.isActiveCall) { + await _systemPipCoordinator.disarm(); + return; + } await _systemPipCoordinator.armAutoPipOnLeave(); } @@ -227,6 +229,7 @@ class VideoCallSessionCubit extends BaseCubit { VideoCallSessionState sessionState, VideoCallManagerSnapshot snapshot, ) { + if (!_callScreenRouteVisible) return; if (sessionState.entryMode != VideoCallEntryMode.outgoing) return; if (sessionState.callStageEntered) return; if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; @@ -253,6 +256,7 @@ class VideoCallSessionCubit extends BaseCubit { Future _runOutgoingAutoEnterCallStage() async { if (isClosed || _autoEnterInFlight) return; + if (!_callScreenRouteVisible) return; if (state.entryMode != VideoCallEntryMode.outgoing) return; if (state.callStageEntered) return; if (state.connectPhase != VideoCallConnectPhase.connected) return; @@ -380,7 +384,7 @@ class VideoCallSessionCubit extends BaseCubit { Future restoreFullscreenFromPip() async { if (state.uiMode != VideoCallUiMode.pip) return; emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); - unawaited(_systemPipCoordinator.armAutoPipOnLeave()); + unawaited(_ensureSystemPipArmedIfInCall()); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyVideoCallSystemUi()); }); @@ -415,10 +419,14 @@ class VideoCallSessionCubit extends BaseCubit { mainStageShowsLocalVideo: false, ), ); - unawaited(_systemPipCoordinator.armAutoPipOnLeave()); + await _ensureSystemPipArmedIfInCall(); WidgetsBinding.instance.addPostFrameCallback((_) { if (isClosed) return; _videoCallManager.scheduleRecordingNoticeOnce(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (isClosed) return; + _videoCallManager.scheduleRecordingNoticeOnce(); + }); }); } diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart index 588a01ff..cc204991 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart @@ -14,7 +14,7 @@ enum VideoCallLinkIssue { none, reconnecting, lost } /// How the user reached [VideoCallScreen]. enum VideoCallEntryMode { - /// Outgoing call or push tap — lobby with manual enter. + /// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins. outgoing, /// Accepted incoming CallKit / VoIP / notification — loader then auto-enter. diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart index b8aefe95..459b85c2 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; +import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; +import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart'; @@ -21,6 +24,9 @@ class VideoCallScreen extends StatefulWidget { required this.conversationUuid, this.counterpartName, this.counterpartAvatarUrl, + this.counterpartUserId, + this.counterpartIsOnline, + this.counterpartLastSeenText, this.localParticipantName, this.entryMode = VideoCallEntryMode.outgoing, this.onBackToChat, @@ -33,6 +39,9 @@ class VideoCallScreen extends StatefulWidget { final String conversationUuid; final String? counterpartName; final String? counterpartAvatarUrl; + final int? counterpartUserId; + final bool? counterpartIsOnline; + final String? counterpartLastSeenText; final String? localParticipantName; final VideoCallEntryMode entryMode; final VoidCallback? onBackToChat; @@ -54,12 +63,20 @@ class _VideoCallScreenState extends State { ); VideoCallSessionCubit get _cubit => sl(); + WsManager get _wsManager => sl(); + + bool _counterpartIsOnline = false; + String _counterpartLastSeenText = ''; + void Function(bool isOnline)? _presenceOnlineListener; @override void initState() { super.initState(); + _counterpartIsOnline = widget.counterpartIsOnline ?? false; + _counterpartLastSeenText = widget.counterpartLastSeenText ?? ''; WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; + unawaited(_initCounterpartOnlineStatus()); unawaited( _cubit.onCallScreenVisible( conversationUuid: widget.conversationUuid, @@ -71,8 +88,80 @@ class _VideoCallScreenState extends State { }); } + Future _initCounterpartOnlineStatus() async { + var isOnline = widget.counterpartIsOnline ?? false; + var lastSeenText = widget.counterpartLastSeenText ?? ''; + var counterpartUserId = widget.counterpartUserId; + + final shouldFetchFromApi = + counterpartUserId == null || + widget.counterpartIsOnline == null || + widget.counterpartLastSeenText == null; + + if (shouldFetchFromApi) { + try { + final chatRoom = await sl().getChatRoom( + widget.conversationUuid, + ); + final counterpart = resolveChatCounterpart( + users: chatRoom.users, + currentUserId: _wsManager.userId, + ); + isOnline = counterpart.isOnline; + lastSeenText = counterpart.lastSeenText; + counterpartUserId = counterpart.id; + } catch (_) {} + } + + if (!mounted) return; + setState(() { + _counterpartIsOnline = isOnline; + _counterpartLastSeenText = lastSeenText; + }); + + if (counterpartUserId == null) return; + + _presenceOnlineListener = (isOnline) { + if (!mounted) return; + if (isOnline) { + setState(() { + _counterpartIsOnline = true; + _counterpartLastSeenText = ''; + }); + return; + } + unawaited(_refreshCounterpartOnlineStatus()); + }; + await _wsManager.initPresenceDirectChatChannel(widget.conversationUuid); + _wsManager.addPresenceDirectChatListener( + counterpartUserId: counterpartUserId, + onCounterpartOnlineChanged: _presenceOnlineListener!, + ); + } + + Future _refreshCounterpartOnlineStatus() async { + try { + final chatRoom = await sl().getChatRoom( + widget.conversationUuid, + ); + final counterpart = resolveChatCounterpart( + users: chatRoom.users, + currentUserId: _wsManager.userId, + ); + if (!mounted) return; + setState(() { + _counterpartIsOnline = counterpart.isOnline; + _counterpartLastSeenText = counterpart.lastSeenText; + }); + } catch (_) {} + } + @override void dispose() { + if (_presenceOnlineListener != null) { + _wsManager.removePresenceDirectChatListener(_presenceOnlineListener!); + _presenceOnlineListener = null; + } _cubit.onCallScreenDisposed(); super.dispose(); } @@ -197,6 +286,8 @@ class _VideoCallScreenState extends State { body: VideoCallConnectingEntranceScreen( counterpartDisplayName: remoteName, counterpartAvatarUrl: widget.counterpartAvatarUrl, + counterpartIsOnline: _counterpartIsOnline, + counterpartLastSeenText: _counterpartLastSeenText, micEnabled: sessionState.isMicActiveForUi, videoEnabled: sessionState.videoEnabled, canEnterCall: canEnterCall, diff --git a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart index e7fdd6fd..bf9d774a 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_app_shell.dart @@ -1,8 +1,10 @@ import 'dart:io'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart'; @@ -22,6 +24,10 @@ class VideoCallAppShell extends StatelessWidget { clipBehavior: Clip.none, children: [ child ?? const SizedBox.shrink(), + Overlay( + key: VideoCallBannerOverlay.overlayKey, + initialEntries: const [], + ), const VideoCallGlobalPipLayer(), const VideoCallSystemPipListener(), ], @@ -36,10 +42,7 @@ class VideoCallAppShell extends StatelessWidget { previous.connectPhase != current.connectPhase || previous.linkIssue != current.linkIssue, builder: (context, state) { - final allowSystemPipShell = - state.callStageEntered && - state.connectPhase == VideoCallConnectPhase.connected && - state.linkIssue != VideoCallLinkIssue.lost; + final allowSystemPipShell = sl().isActiveCall; if (!allowSystemPipShell) return shell; diff --git a/lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart new file mode 100644 index 00000000..9bdb2f40 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_banner_overlay.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +/// Dedicated overlay layer above the app navigator for in-call banners. +abstract final class VideoCallBannerOverlay { + static final GlobalKey overlayKey = GlobalKey( + debugLabel: 'videoCallBannerOverlay', + ); + + static OverlayState? get overlayState => overlayKey.currentState; +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart b/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart index ff37c96a..7870eac8 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart @@ -1,5 +1,6 @@ import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; @@ -10,6 +11,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { super.key, required this.counterpartDisplayName, this.counterpartAvatarUrl, + required this.counterpartIsOnline, + required this.counterpartLastSeenText, required this.micEnabled, required this.videoEnabled, required this.canEnterCall, @@ -24,6 +27,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { final String counterpartDisplayName; final String? counterpartAvatarUrl; + final bool counterpartIsOnline; + final String counterpartLastSeenText; final bool micEnabled; final bool videoEnabled; final bool canEnterCall; @@ -140,33 +145,7 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { const SizedBox(height: _topContentSpacing), _avatar(context, counterpartDisplayName, counterpartAvatarUrl), const SizedBox(height: 16), - if (canEnterCall) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: appTheme.greenA700, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Text( - s.video_call_status_online, - style: CustomTextStyles.bodySmallMulishBlack900 - .copyWith(height: 14 / 12), - ), - ], - ) - else - Text( - s.video_call_screen_connecting_subtitle, - textAlign: TextAlign.center, - style: CustomTextStyles.bodySmallMulishBlack900 - .copyWith(height: 14 / 12), - ), + _buildStatusLine(context, s), SizedBox(height: canEnterCall ? 20 : 16), Text( s.video_call_screen_connecting_title, @@ -345,4 +324,29 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget { ), ); } + + Widget _buildStatusLine(BuildContext context, S s) { + final statusStyle = CustomTextStyles.bodySmallMulishBlack900.copyWith( + height: 14 / 12, + ); + final onlineStatus = ChatUserOnlineStatusLine( + isOnline: counterpartIsOnline, + lastSeenText: counterpartLastSeenText, + style: statusStyle, + ); + + if (counterpartIsOnline || counterpartLastSeenText.trim().isNotEmpty) { + return Center(child: onlineStatus); + } + + if (!canEnterCall) { + return Text( + s.video_call_screen_connecting_subtitle, + textAlign: TextAlign.center, + style: statusStyle, + ); + } + + return const SizedBox.shrink(); + } } diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart index 4731d98f..7a0f8957 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; @@ -21,7 +22,8 @@ class VideoCallSystemPipListener extends StatefulWidget { _VideoCallSystemPipListenerState(); } -class _VideoCallSystemPipListenerState extends State { +class _VideoCallSystemPipListenerState extends State + with WidgetsBindingObserver { static const MethodChannel _userLeaveHintChannel = MethodChannel( 'com.eatvioapp/video_call_pip', ); @@ -34,9 +36,24 @@ class _VideoCallSystemPipListenerState extends State super.initState(); if (!Platform.isAndroid) return; + WidgetsBinding.instance.addObserver(this); _userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall); final coordinator = sl(); _subscription = coordinator.statusStream.listen(_onPipStatus); + unawaited(_disarmSystemPipIfNoActiveCall()); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + unawaited(_disarmSystemPipIfNoActiveCall()); + } + } + + Future _disarmSystemPipIfNoActiveCall() async { + if (!sl().isActiveCall) { + await sl().disarm(); + } } Future _onNativePipMethodCall(MethodCall call) async { @@ -88,6 +105,7 @@ class _VideoCallSystemPipListenerState extends State @override void dispose() { if (Platform.isAndroid) { + WidgetsBinding.instance.removeObserver(this); _userLeaveHintChannel.setMethodCallHandler(null); } unawaited(_subscription?.cancel()); diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart index 85f2dd0e..8732f24c 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -1,4 +1,6 @@ +import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; @@ -7,6 +9,13 @@ abstract final class VideoCallTopOverlay { 'assets/icons/video_call/poor_connection.svg'; static const String warningSvg = 'assets/icons/video_call/warning.svg'; + static OverlayState? _resolveOverlayState(BuildContext context) { + return VideoCallBannerOverlay.overlayState ?? + AppRouter.navigatorKey.currentState?.overlay ?? + Overlay.maybeOf(context, rootOverlay: true); + } + + static OverlayEntry insertBanner( BuildContext context, { required String message, @@ -14,7 +23,7 @@ abstract final class VideoCallTopOverlay { required String svgAsset, Duration? autoRemoveAfter, }) { - final overlayState = Overlay.maybeOf(context, rootOverlay: true); + final overlayState = _resolveOverlayState(context); late OverlayEntry entry; entry = OverlayEntry( builder: (ctx) => Positioned( diff --git a/pubspec.yaml b/pubspec.yaml index 56444d8a..e8cecc5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.28+117 +version: 1.3.29+118 publish_to: 'none' environment: diff --git a/test/core/const_test.dart b/test/core/const_test.dart index 1adf6b1b..415dfb37 100644 --- a/test/core/const_test.dart +++ b/test/core/const_test.dart @@ -33,6 +33,7 @@ void main() { expect(newMessagesChannelName, 'private-new-chat-messages'); expect(newNotificationsChannelName, 'private-message-events'); expect(directChatChannelName, 'private-direct-chat'); + expect(presenceDirectChatChannelName, 'presence-direct-chat'); }); test('thresholdForPagination has correct value', () { diff --git a/test/features/chat/models/chat_user_model_test.dart b/test/features/chat/models/chat_user_model_test.dart index fb825d10..bc913e9b 100644 --- a/test/features/chat/models/chat_user_model_test.dart +++ b/test/features/chat/models/chat_user_model_test.dart @@ -28,12 +28,14 @@ void main() { expect(model.profilePhotoUrl, isNull); }); - test('fromJson создает ChatUserModel из JSON', () { + test('fromJson creates ChatUserModel from JSON', () { final json = { 'id': 1, 'name': 'User Name', 'slug': 'user-slug', 'profile_photo_url': 'https://example.com/photo.jpg', + 'is_online': true, + 'last_seen_text': 'был(а) недавно', }; final model = ChatUserModel.fromJson(json); @@ -42,6 +44,8 @@ void main() { expect(model.name, 'User Name'); expect(model.slug, 'user-slug'); expect(model.profilePhotoUrl, 'https://example.com/photo.jpg'); + expect(model.isOnline, isTrue); + expect(model.lastSeenText, 'был(а) недавно'); }); test('fromJson создает ChatUserModel из JSON без фото', () { diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index b18404ff..d6df22d4 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -220,7 +220,7 @@ void main() { expect(cubit.state.callStageEntered, isTrue); }); - test('outgoing call keeps lobby until callee joins', () async { + test('outgoing call stays on connecting lobby until callee joins', () async { when( mockManager.startCall( conversationUuid: anyNamed('conversationUuid'),