From f493e07f9fdef040bfebef8ebc79b8733952f251 Mon Sep 17 00:00:00 2001 From: misha Date: Tue, 30 Jun 2026 16:11:18 +0300 Subject: [PATCH] feat(video_calls): call ended push, recall button for message --- .../app/EatvioFcmMessagingService.kt | 5 + .../com/eatvioapp/app/HmsPushService.kt | 5 + .../com/eatvioapp/app/VideoCallPushBridge.kt | 55 +++++++++ lib/core/device/device_hw_id_provider.dart | 16 +++ .../push_navigation_handler.dart | 12 ++ .../chat/models/chat_message_model.dart | 21 +++- .../chat/presentation/pages/chat_screen.dart | 106 +++++++++++------- .../widgets/chat_bubble_widget.dart | 4 +- .../video_call_chat_message_widget.dart | 103 +++++++++++++++++ .../managers/video_call_manager.dart | 55 +++++---- .../video_calls/models/video_call_models.dart | 11 ++ .../video_call_session_cubit.dart | 51 +++++++++ .../widgets/video_call_top_overlay.dart | 55 +++++++++ .../repositories/video_call_repository.dart | 10 ++ .../video_call_callkit_coordinator.dart | 38 ++++++- .../video_call_ended_push_handler.dart | 19 ++++ .../video_call_ended_push_payload.dart | 11 ++ .../services/video_call_fcm_handler.dart | 32 +++++- .../services/video_call_push_payload.dart | 25 +++++ .../services/video_call_service.dart | 6 + pubspec.yaml | 2 +- .../chat/models/chat_message_model_test.dart | 2 +- .../chat/models/chat_messages_model_test.dart | 2 +- .../presentation/bloc/chats_cubit_test.dart | 4 +- .../models/video_call_models_test.dart | 20 ++++ .../video_call_session_cubit_test.dart | 3 + .../video_call_push_payload_test.dart | 24 ++-- .../services/video_call_service_test.dart | 20 ++++ .../widgets/video_call_top_overlay_test.dart | 33 ++++++ .../video_call_accept_response.json | 4 + 30 files changed, 671 insertions(+), 83 deletions(-) create mode 100644 lib/core/device/device_hw_id_provider.dart create mode 100644 lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart create mode 100644 lib/features/video_calls/services/video_call_ended_push_handler.dart create mode 100644 lib/features/video_calls/services/video_call_ended_push_payload.dart create mode 100644 test/fixtures/video_calls/video_call_accept_response.json diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt index 7e9e7377..4a370800 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/EatvioFcmMessagingService.kt @@ -15,6 +15,11 @@ class EatvioFcmMessagingService : FirebaseMessagingService() { override fun onMessageReceived(message: RemoteMessage) { Log.i(TAG, "onMessageReceived: ${message.data}") + if (VideoCallPushBridge.isEndedVideoCall(message.data)) { + VideoCallPushBridge.dismissAllIncomingCalls(this) + return + } + if (!VideoCallPushBridge.isIncomingVideoCall(message.data)) { return } diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt index b7b986a6..5c1c0d3e 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/HmsPushService.kt @@ -20,6 +20,11 @@ class HmsPushService : HmsMessageService() { if (message == null) return + if (VideoCallPushBridge.isEndedVideoCall(message.dataOfMap)) { + VideoCallPushBridge.dismissAllIncomingCalls(this) + return + } + if (VideoCallPushBridge.isIncomingVideoCall(message.dataOfMap)) { VideoCallPushBridge.showIncomingCall(this, message.dataOfMap) return diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index 358f0540..a52976e9 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -4,10 +4,13 @@ import android.content.Context import android.os.Bundle import android.util.Log import com.hiennv.flutter_callkit_incoming.CallkitConstants +import com.hiennv.flutter_callkit_incoming.CallkitIncomingBroadcastReceiver import com.hiennv.flutter_callkit_incoming.CallkitNotificationManager import com.hiennv.flutter_callkit_incoming.CallkitSoundPlayerManager import com.hiennv.flutter_callkit_incoming.Data import com.hiennv.flutter_callkit_incoming.addCall +import com.hiennv.flutter_callkit_incoming.getDataActiveCalls +import com.hiennv.flutter_callkit_incoming.removeAllCalls import java.util.HashMap object VideoCallPushBridge { @@ -52,6 +55,29 @@ object VideoCallPushBridge { return false } + fun isEndedVideoCall(data: Map): Boolean { + val rawType = data["type"] + ?: data["notification_type"] + ?: data["event"] + ?: data["action"] + ?: data["push_type"] + ?: data["call_type"] + + if (rawType != null) { + val normalized = rawType.lowercase().replace("-", "_") + if (normalized == "video_call_ended" || normalized == "video_call_end") { + return true + } + if (normalized.contains("video_call") && + (normalized.contains("ended") || normalized.endsWith("_end")) + ) { + return true + } + } + + return false + } + fun showIncomingCall(context: Context, data: Map) { val appContext = context.applicationContext val callData = buildCallDataBundle(data) @@ -74,6 +100,35 @@ object VideoCallPushBridge { } } + fun dismissAllIncomingCalls(context: Context) { + val appContext = context.applicationContext + val calls = getDataActiveCalls(appContext) + Log.i(TAG, "dismissAllIncomingCalls: active calls=${calls.size}") + + val soundManager = CallkitSoundPlayerManager(appContext) + val notificationManager = CallkitNotificationManager(appContext, soundManager) + + for (call in calls) { + val bundle = call.toBundle() + try { + notificationManager.clearIncomingNotification(bundle, isAccepted = false) + appContext.sendBroadcast( + CallkitIncomingBroadcastReceiver.getIntentEnded(appContext, bundle), + ) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed for call ${call.id}", error) + } + } + + try { + removeAllCalls(appContext) + } catch (error: Exception) { + Log.w(TAG, "dismissAllIncomingCalls: failed to remove persisted calls", error) + } + + soundManager.stop() + } + private fun buildCallDataBundle(data: Map): Bundle? { val conversationUuid = conversationUuid(data) ?: return null val callerName = data["counterpartName"] diff --git a/lib/core/device/device_hw_id_provider.dart b/lib/core/device/device_hw_id_provider.dart new file mode 100644 index 00000000..383aac2b --- /dev/null +++ b/lib/core/device/device_hw_id_provider.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; + +abstract final class DeviceHwIdProvider { + static Future current() async { + final deviceInfo = DeviceInfoPlugin(); + if (Platform.isIOS) { + return (await deviceInfo.iosInfo).identifierForVendor; + } + if (Platform.isAndroid) { + return (await deviceInfo.androidInfo).id; + } + return null; + } +} diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index e44c32ab..22191b85 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -4,6 +4,8 @@ import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_notification_navigator.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; @@ -57,6 +59,16 @@ class PushNavigationHandler { static void _handleMessageInternal(RemoteMessage message, {required String source}) { final data = Map.from(message.data); + final endedPayload = VideoCallEndedPushPayload.tryParse(data); + if (endedPayload != null) { + developer.log( + 'PushNavigationHandler: dismiss incoming call UI, source=$source', + name: 'PushNavigationHandler', + ); + unawaited(VideoCallEndedPushHandler.dismissIncomingCallUi()); + return; + } + final videoCallPayload = VideoCallPushPayload.tryParse(data); if (videoCallPayload != null) { developer.log( diff --git a/lib/features/chat/models/chat_message_model.dart b/lib/features/chat/models/chat_message_model.dart index c50a68f8..4bffd8b7 100644 --- a/lib/features/chat/models/chat_message_model.dart +++ b/lib/features/chat/models/chat_message_model.dart @@ -33,7 +33,7 @@ abstract class ChatMessageModel with _$ChatMessageModel { @JsonKey(name: 'read_by_to_user', defaultValue: false) required bool isRead, @JsonKey(name: 'is_author_message_auth_user') required bool? isCurrentUserAuthor, @JsonKey(name: 'room_id') required String? roomId, - @JsonKey(name: 'type') String? type, + @JsonKey(name: 'type', unknownEnumValue: ChatMessageType.unknown) ChatMessageType? type, @Default(MessageState.sent) @JsonKey(includeToJson: false, includeFromJson: false) MessageState messageState, @Default(null) @JsonKey(includeToJson: false, includeFromJson: false) String? uuid, }) = _ChatMessageModel; @@ -42,3 +42,22 @@ abstract class ChatMessageModel with _$ChatMessageModel { } enum MessageState { loading, sent, error } + +enum ChatMessageType { + @JsonValue('text') + text, + @JsonValue('system') + system, + @JsonValue('attachment') + attachment, + @JsonValue('image') + image, + @JsonValue('plan_order') + planOrder, + @JsonValue('video_call_started') + videoCallStarted, + @JsonValue('video_call_ended') + videoCallEnded, + @JsonValue('unknown') + unknown; +} diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 5746beae..e712955b 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -13,7 +13,10 @@ import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cub import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; +import 'package:eatvio_app/features/chat/models/chat_message_model.dart'; +import 'package:eatvio_app/features/chat/models/chat_user_model.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart'; +import 'package:eatvio_app/features/chat/presentation/widgets/video_call_chat_message_widget.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart'; @@ -262,20 +265,7 @@ class _ChatScreenState extends State { Padding( padding: const EdgeInsetsDirectional.only(end: 8), child: _ChatHeaderVideoCallButton( - onPressed: () { - GoRouter.of(context).pushNamed( - VideoCallScreen.name, - extra: { - 'conversationUuid': widget.uuid, - 'counterpartName': chatCounterpart.name, - 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl, - 'counterpartUserId': chatCounterpart.id, - 'counterpartIsOnline': chatCounterpart.isOnline, - 'counterpartLastSeenText': chatCounterpart.lastSeenText, - 'localParticipantName': state.chatRoom.users.sender.name, - }, - ); - }, + onPressed: () => _openVideoCall(context, state, chatCounterpart), ), ), ], @@ -311,7 +301,7 @@ class _ChatScreenState extends State { index + 1 == state.chatMessages.length) { final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; final message = state.chatMessages[index]; - final isPlanOrderMessage = message.type == 'plan_order'; + final isPlanOrderMessage = message.type == ChatMessageType.planOrder; PacketOptions? messageOption; if (isPlanOrderMessage && chosenPlan != null) { @@ -352,18 +342,13 @@ class _ChatScreenState extends State { }); } }, - child: ChatBubbleWidget( - isRead: message.isRead, - sendTime: message.updatedAt, - text: message.data.trim(), - isCurrentUser: isCurrentUserAuthor, - mediaData: message.mediaData ?? [], - messageState: message.messageState, - messageUuid: message.uuid ?? '', - userSlug: state.chatRoom.users.receiver.slug, - messageType: message.type, - isConsultant: amIConsultant, - urlsMediaInfo: message.urlsMediaInfo, + child: _buildMessageBubble( + context: context, + state: state, + chatCounterpart: chatCounterpart, + isCurrentUserAuthor: isCurrentUserAuthor, + message: message, + amIConsultant: amIConsultant, ), ), ], @@ -371,7 +356,7 @@ class _ChatScreenState extends State { } final isCurrentUserAuthor = state.chatRoom.users.receiver.id != state.chatMessages[index].authorId; final message = state.chatMessages[index]; - final isPlanOrderMessage = message.type == 'plan_order'; + final isPlanOrderMessage = message.type == ChatMessageType.planOrder; PacketOptions? messageOption; if (isPlanOrderMessage && chosenPlan != null) { @@ -408,18 +393,13 @@ class _ChatScreenState extends State { }); } }, - child: ChatBubbleWidget( - isRead: message.isRead, - sendTime: message.updatedAt, - text: message.data.trim(), - isCurrentUser: isCurrentUserAuthor, - mediaData: message.mediaData ?? [], - messageState: message.messageState, - messageUuid: message.uuid ?? '', - userSlug: state.chatRoom.users.receiver.slug, - messageType: message.type, - isConsultant: amIConsultant, - urlsMediaInfo: message.urlsMediaInfo, + child: _buildMessageBubble( + context: context, + state: state, + chatCounterpart: chatCounterpart, + isCurrentUserAuthor: isCurrentUserAuthor, + message: message, + amIConsultant: amIConsultant, ), ), ], @@ -786,6 +766,52 @@ class _ChatScreenState extends State { ), ); } + + void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) { + GoRouter.of(context).pushNamed( + VideoCallScreen.name, + extra: { + 'conversationUuid': widget.uuid, + 'counterpartName': chatCounterpart.name, + 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl, + 'counterpartUserId': chatCounterpart.id, + 'counterpartIsOnline': chatCounterpart.isOnline, + 'counterpartLastSeenText': chatCounterpart.lastSeenText, + 'localParticipantName': state.chatRoom.users.sender.name, + }, + ); + } + + Widget _buildMessageBubble({ + required BuildContext context, + required ChatRoomStateLoaded state, + required ChatUserModel chatCounterpart, + required bool isCurrentUserAuthor, + required ChatMessageModel message, + required bool? amIConsultant, + }) { + if (message.type == ChatMessageType.videoCallStarted && !isCurrentUserAuthor) { + return VideoCallChatMessageWidget( + text: message.data.trim(), + sendTime: message.updatedAt, + onVideoCallPressed: () => _openVideoCall(context, state, chatCounterpart), + ); + } + + return ChatBubbleWidget( + isRead: message.isRead, + sendTime: message.updatedAt, + text: message.data.trim(), + isCurrentUser: isCurrentUserAuthor, + mediaData: message.mediaData ?? [], + messageState: message.messageState, + messageUuid: message.uuid ?? '', + userSlug: state.chatRoom.users.receiver.slug, + messageType: message.type, + isConsultant: amIConsultant, + urlsMediaInfo: message.urlsMediaInfo, + ); + } } class _ChatHeaderVideoCallButton extends StatelessWidget { diff --git a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart index 00d4e729..8d83e961 100644 --- a/lib/features/chat/presentation/widgets/chat_bubble_widget.dart +++ b/lib/features/chat/presentation/widgets/chat_bubble_widget.dart @@ -39,7 +39,7 @@ class ChatBubbleWidget extends StatelessWidget { final MessageState messageState; final String messageUuid; final String userSlug; - final String? messageType; + final ChatMessageType? messageType; final bool? isConsultant; final List>? urlsMediaInfo; @override @@ -258,7 +258,7 @@ class ChatBubbleWidget extends StatelessWidget { } String _getDisplayText() { - if (messageType == 'plan_order') { + if (messageType == ChatMessageType.planOrder) { if (isConsultant == true) { // Текст для консультанта return '1. Уточните у подопечного его цели и пожелания\n' diff --git a/lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart b/lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart new file mode 100644 index 00000000..90930e1d --- /dev/null +++ b/lib/features/chat/presentation/widgets/video_call_chat_message_widget.dart @@ -0,0 +1,103 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/utils/date_time_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +class VideoCallChatMessageWidget extends StatelessWidget { + const VideoCallChatMessageWidget({ + super.key, + required this.text, + required this.sendTime, + required this.onVideoCallPressed, + }); + + final String text; + final DateTime sendTime; + final VoidCallback onVideoCallPressed; + + @override + Widget build(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final textStyle = TextStyles.textFS13FW400.copyWith(color: appTheme.black, fontSize: 16); + + return SizedBox( + width: size.width, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 64, 4), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Container( + constraints: BoxConstraints(maxWidth: size.width * 0.7), + decoration: BoxDecoration( + color: appTheme.gray60004, + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(16), + right: Radius.circular(24), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 16, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (text.isNotEmpty) + Flexible( + child: Text(text, style: textStyle), + ), + if (text.isNotEmpty) const SizedBox(width: 8), + _VideoCallBubbleActionButton(onPressed: onVideoCallPressed), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 4), + child: Text( + sendTime.format('HH:mm', 'ru'), + style: const TextStyle( + color: Color(0xFF7B8794), + height: 1, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _VideoCallBubbleActionButton extends StatelessWidget { + const _VideoCallBubbleActionButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: appTheme.greenA700, + borderRadius: BorderRadius.circular(10), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onPressed, + child: SizedBox( + width: 36, + height: 36, + child: Center( + child: SvgPicture.asset( + 'assets/icons/video_call/vc_control_enter.svg', + width: 20, + height: 20, + fit: BoxFit.contain, + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 6bd2294b..2501a772 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/device/device_hw_id_provider.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/video_calls/managers/video_call_manager_snapshot.dart'; @@ -56,7 +57,6 @@ class VideoCallManager { lk.Room? _room; lk.EventsListener? _roomEventsListener; - OverlayEntry? _poorConnectionEntry; Timer? _mutedMicPollTimer; DateTime? _lastMutedMicOverlayAt; bool _recordingOverlayScheduled = false; @@ -64,6 +64,7 @@ class VideoCallManager { num? _lastMutedMicTotalEnergy; bool _liveKitDisposed = true; bool _callEndedNotified = true; + final Set _callAcceptNotifiedConversationUuids = {}; bool _callStageEntered = false; bool _joinInProgress = false; bool _counterpartJoinedNotified = false; @@ -409,6 +410,27 @@ class VideoCallManager { _emitSnapshot(VideoCallManagerSnapshot.initial); } + /// Notifies backend that this device accepted an incoming call so ringing + /// stops on the user's other devices. + Future notifyIncomingCallAccepted(String conversationUuid) async { + final uuid = conversationUuid.trim(); + if (uuid.isEmpty) return; + if (_callAcceptNotifiedConversationUuids.contains(uuid)) return; + + final hwId = (await DeviceHwIdProvider.current())?.trim(); + if (hwId == null || hwId.length < 8) return; + + _callAcceptNotifiedConversationUuids.add(uuid); + try { + await _videoCallRepository.notifyCallAccepted( + conversationUuid: uuid, + hwId: hwId, + ); + } catch (_) { + _callAcceptNotifiedConversationUuids.remove(uuid); + } + } + /// Decline from incoming UI before joining the LiveKit room. Future declineIncomingCall(String conversationUuid) async { final uuid = conversationUuid.trim(); @@ -716,6 +738,7 @@ class VideoCallManager { _counterpartJoinedNotified = false; _roomConnectFuture = null; _clearPreparedConnectionCredentials(); + _callAcceptNotifiedConversationUuids.clear(); _emitSnapshot( _snapshot.copyWith( counterpartPresentInLobby: false, @@ -736,11 +759,7 @@ class VideoCallManager { if (listener != null) { unawaited(listener.dispose()); } - final poor = _poorConnectionEntry; - _poorConnectionEntry = null; - if (poor != null && poor.mounted) { - poor.remove(); - } + VideoCallTopOverlay.hidePoorConnectionIcon(); } void _startMutedMicPollTimer() { @@ -775,6 +794,7 @@ class VideoCallManager { _abortingLiveKitAutoReconnect = true; try { _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost)); + VideoCallTopOverlay.hidePoorConnectionIcon(); final room = _room; if (room != null && room.connectionState != lk.ConnectionState.disconnected) { @@ -822,29 +842,18 @@ class VideoCallManager { } void _syncPoorConnectionOverlay(lk.ConnectionQuality q) { + if (_liveKitDisposed || !_callStageEntered) { + VideoCallTopOverlay.hidePoorConnectionIcon(); + return; + } final ctx = _navigatorContext; if (ctx == null) return; final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; if (bad) { - final existing = _poorConnectionEntry; - if (existing != null && existing.mounted) return; - _poorConnectionEntry = null; - final entry = VideoCallTopOverlay.insertBanner( - ctx, - message: S.of(ctx).video_call_poor_connection, - backgroundColor: appTheme.strongRed, - svgAsset: VideoCallTopOverlay.poorConnectionSvg, - ); - if (entry.mounted) { - _poorConnectionEntry = entry; - } + VideoCallTopOverlay.showPoorConnectionIcon(ctx); } else { - final e = _poorConnectionEntry; - if (e != null && e.mounted) { - e.remove(); - } - _poorConnectionEntry = null; + VideoCallTopOverlay.hidePoorConnectionIcon(); } } diff --git a/lib/features/video_calls/models/video_call_models.dart b/lib/features/video_calls/models/video_call_models.dart index 7ab0a747..f344822d 100644 --- a/lib/features/video_calls/models/video_call_models.dart +++ b/lib/features/video_calls/models/video_call_models.dart @@ -35,3 +35,14 @@ abstract class VideoCallEndedResponse with _$VideoCallEndedResponse { factory VideoCallEndedResponse.fromJson(Map json) => _$VideoCallEndedResponseFromJson(json); } + +@freezed +abstract class VideoCallAcceptResponse with _$VideoCallAcceptResponse { + const factory VideoCallAcceptResponse({ + required bool sent, + @JsonKey(name: 'devices_notified') required int devicesNotified, + }) = _VideoCallAcceptResponse; + + factory VideoCallAcceptResponse.fromJson(Map json) => + _$VideoCallAcceptResponseFromJson(json); +} diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index 2ad362d1..220ec4fd 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -26,6 +26,10 @@ class VideoCallSessionCubit extends BaseCubit { final VideoCallSystemPipCoordinator _systemPipCoordinator; bool _callScreenRouteVisible = false; bool _autoEnterInFlight = false; + bool _counterpartPresentWhenLobbyReady = false; + bool _sawEmptyLobbyWhenConnected = false; + bool _lobbyReadyTracked = false; + String? _lobbyReadyTrackedConversationUuid; Future? _autoEnterFuture; Future? _outgoingAutoEnterFuture; @@ -72,10 +76,12 @@ class VideoCallSessionCubit extends BaseCubit { // [VideoCallManager.startCall] briefly emits idle while replacing the room. return; } + _resetLobbyReadyTracking(); emit(const VideoCallSessionState()); unawaited(_restoreSystemUiAfterCall()); return; } + _trackLobbyReadyCounterpartPresence(snapshot); final next = _sessionStateFromSnapshot(snapshot); emit(next); _scheduleAutoEnterIfNeeded(next); @@ -86,12 +92,47 @@ class VideoCallSessionCubit extends BaseCubit { if (_isCallSessionInactive(snapshot)) { unawaited(_systemPipCoordinator.disarm()); if (_callScreenRouteVisible) return; + _resetLobbyReadyTracking(); emit(const VideoCallSessionState()); return; } + _trackLobbyReadyCounterpartPresence(snapshot); emit(_sessionStateFromSnapshot(snapshot)); } + void _resetLobbyReadyTracking() { + _lobbyReadyTrackedConversationUuid = null; + _counterpartPresentWhenLobbyReady = false; + _sawEmptyLobbyWhenConnected = false; + _lobbyReadyTracked = false; + } + + void _trackLobbyReadyCounterpartPresence(VideoCallManagerSnapshot snapshot) { + final uuid = snapshot.conversationUuid; + if (uuid.isEmpty) { + _resetLobbyReadyTracking(); + return; + } + if (snapshot.connectPhase != VideoCallConnectPhase.connected) return; + + if (!_lobbyReadyTracked || _lobbyReadyTrackedConversationUuid != uuid) { + _lobbyReadyTracked = true; + _lobbyReadyTrackedConversationUuid = uuid; + _counterpartPresentWhenLobbyReady = snapshot.counterpartPresentInLobby; + _sawEmptyLobbyWhenConnected = !snapshot.counterpartPresentInLobby; + return; + } + + if (_sawEmptyLobbyWhenConnected && snapshot.counterpartPresentInLobby) { + _counterpartPresentWhenLobbyReady = false; + _sawEmptyLobbyWhenConnected = false; + } + } + + Future _notifyIncomingCallAcceptedIfNeeded(String conversationUuid) { + return _videoCallManager.notifyIncomingCallAccepted(conversationUuid); + } + /// Синхронизирует UI-состояние после [VideoCallManager.endCall] из notification / /// foreground task (без повторного disconnect). Future onCallEndedExternally() async { @@ -144,7 +185,12 @@ class VideoCallSessionCubit extends BaseCubit { !state.callStageEntered && state.connectPhase != VideoCallConnectPhase.idle; + if (isIncomingAccepted && !isResumingActiveCall) { + unawaited(_notifyIncomingCallAcceptedIfNeeded(conversationUuid)); + } + if (!isResumingActiveCall && !isSameCallConnecting) { + _resetLobbyReadyTracking(); await _systemPipCoordinator.disarm(); } @@ -421,6 +467,11 @@ class VideoCallSessionCubit extends BaseCubit { if (state.connectPhase != VideoCallConnectPhase.connected) return; if (state.callStageEntered) return; + if (state.entryMode == VideoCallEntryMode.outgoing && + _counterpartPresentWhenLobbyReady) { + unawaited(_notifyIncomingCallAcceptedIfNeeded(state.conversationUuid)); + } + final entered = await _videoCallManager.onEnterCallStage(); if (!entered || isClosed) return; diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart index 8732f24c..8ea2ddb4 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -9,12 +9,67 @@ abstract final class VideoCallTopOverlay { 'assets/icons/video_call/poor_connection.svg'; static const String warningSvg = 'assets/icons/video_call/warning.svg'; + static const double _poorConnectionIconSize = 44; + static const double _poorConnectionGlyphSize = 22; + + static OverlayEntry? _poorConnectionEntry; + + static bool get isPoorConnectionIconVisible => + _poorConnectionEntry?.mounted ?? false; + static OverlayState? _resolveOverlayState(BuildContext context) { return VideoCallBannerOverlay.overlayState ?? AppRouter.navigatorKey.currentState?.overlay ?? Overlay.maybeOf(context, rootOverlay: true); } + /// Compact poor-connection indicator: red square with icon only. + static void showPoorConnectionIcon(BuildContext context) { + if (isPoorConnectionIconVisible) return; + + hidePoorConnectionIcon(); + + final overlayState = _resolveOverlayState(context); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) { + final topInset = MediaQuery.viewPaddingOf(ctx).top; + return Positioned( + left: 16, + top: topInset + 8, + width: _poorConnectionIconSize, + height: _poorConnectionIconSize, + child: Material( + color: appTheme.strongRed, + borderRadius: BorderRadius.circular(10), + elevation: 6, + child: Center( + child: SvgPicture.asset( + poorConnectionSvg, + width: _poorConnectionGlyphSize, + height: _poorConnectionGlyphSize, + ), + ), + ), + ); + }, + ); + + _poorConnectionEntry = entry; + if (overlayState == null) { + _poorConnectionEntry = null; + return; + } + overlayState.insert(entry); + } + + static void hidePoorConnectionIcon() { + final entry = _poorConnectionEntry; + _poorConnectionEntry = null; + if (entry != null && entry.mounted) { + entry.remove(); + } + } static OverlayEntry insertBanner( BuildContext context, { diff --git a/lib/features/video_calls/repositories/video_call_repository.dart b/lib/features/video_calls/repositories/video_call_repository.dart index 1b9835b5..15cbb4f1 100644 --- a/lib/features/video_calls/repositories/video_call_repository.dart +++ b/lib/features/video_calls/repositories/video_call_repository.dart @@ -24,4 +24,14 @@ class VideoCallRepository { body: _emptyJsonBody, ); } + + Future notifyCallAccepted({ + required String conversationUuid, + required String hwId, + }) { + return videoCallService.notifyCallAccepted( + conversationUuid: conversationUuid, + body: {'hw_id': hwId}, + ); + } } diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 3c5b1ad0..57c718fa 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -48,6 +48,8 @@ class VideoCallCallKitCoordinator { _eventSubscription = FlutterCallkitIncoming.onEvent.listen(_onCallKitEvent); + VideoCallFcmHandler.registerForegroundListener(); + if (Platform.isIOS) { _voipDiagnosticsChannel.setMethodCallHandler((call) async { switch (call.method) { @@ -67,7 +69,6 @@ class VideoCallCallKitCoordinator { } if (Platform.isAndroid) { - VideoCallFcmHandler.registerForegroundListener(); FlutterCallkitIncoming.acceptCallHandle(_onNativeAcceptCallHandle); await FlutterCallkitIncoming.requestNotificationPermission(< String, @@ -179,7 +180,7 @@ class VideoCallCallKitCoordinator { case CallEventActionCallDecline(): await _handleDeclineEvent(event); case CallEventActionCallEnded(): - await _handleDeclineEvent(event); + await _handleEndedEvent(event); case CallEventActionCallTimeout(): await _handleDeclineEvent(event); default: @@ -211,6 +212,18 @@ class VideoCallCallKitCoordinator { await _handleDecline(id); } + Future _handleEndedEvent(CallEvent event) async { + final id = _callKitEventId(event); + if (id == null) { + log( + 'VideoCallCallKitCoordinator: ended event without callKit id, event=$event', + name: 'VideoCallCallKit', + ); + return; + } + await _handleEnded(id); + } + Future _captureCallKitEventToSentry(CallEvent event) async { if (!Platform.isIOS) return; final context = { @@ -468,6 +481,8 @@ class VideoCallCallKitCoordinator { _acceptHandledCallKitIds.add(callKitId); _suppressedEndCallKitIds.add(callKitId); + unawaited(_manager.notifyIncomingCallAccepted(conversationUuid)); + try { if (!Platform.isIOS) { await FlutterCallkitIncoming.endCall(callKitId); @@ -565,6 +580,25 @@ class VideoCallCallKitCoordinator { } } + Future _handleEnded(String callKitId) async { + if (_suppressedEndCallKitIds.contains(callKitId)) { + log( + 'VideoCallCallKitCoordinator: ignore spurious ended during accept, callKitId=$callKitId', + name: 'VideoCallCallKit', + ); + return; + } + + _callKitParamsById.remove(callKitId); + if (_activeCallKitId == callKitId) { + _activeCallKitId = null; + } + + try { + await FlutterCallkitIncoming.endCall(callKitId); + } catch (_) {} + } + static String _parseConversationUuid( Map extra, { String? handle, diff --git a/lib/features/video_calls/services/video_call_ended_push_handler.dart b/lib/features/video_calls/services/video_call_ended_push_handler.dart new file mode 100644 index 00000000..1b27f32f --- /dev/null +++ b/lib/features/video_calls/services/video_call_ended_push_handler.dart @@ -0,0 +1,19 @@ +import 'package:flutter_callkit_incoming/flutter_callkit_incoming.dart'; + +/// Dismisses incoming call UI after a remote `video_call_ended` push. +abstract final class VideoCallEndedPushHandler { + static Future dismissIncomingCallUi() async { + try { + final activeCalls = await FlutterCallkitIncoming.activeCalls(); + for (final call in activeCalls) { + try { + await FlutterCallkitIncoming.endCall(call.id); + } catch (_) {} + } + } catch (_) {} + + try { + await FlutterCallkitIncoming.endAllCalls(); + } catch (_) {} + } +} diff --git a/lib/features/video_calls/services/video_call_ended_push_payload.dart b/lib/features/video_calls/services/video_call_ended_push_payload.dart new file mode 100644 index 00000000..4defe8fe --- /dev/null +++ b/lib/features/video_calls/services/video_call_ended_push_payload.dart @@ -0,0 +1,11 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; + +/// Marker for a data-only `video_call_ended` push (no conversation uuid required). +class VideoCallEndedPushPayload { + const VideoCallEndedPushPayload._(); + + static VideoCallEndedPushPayload? tryParse(Map data) { + if (!VideoCallPushPayload.isEndedVideoCall(data)) return null; + return const VideoCallEndedPushPayload._(); + } +} diff --git a/lib/features/video_calls/services/video_call_fcm_handler.dart b/lib/features/video_calls/services/video_call_fcm_handler.dart index 9d582d66..941da2f9 100644 --- a/lib/features/video_calls/services/video_call_fcm_handler.dart +++ b/lib/features/video_calls/services/video_call_fcm_handler.dart @@ -1,6 +1,8 @@ import 'dart:developer'; import 'dart:io'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_handler.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_incoming_push_presenter.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:eatvio_app/firebase_options.dart'; @@ -43,9 +45,35 @@ abstract final class VideoCallFcmHandler { RemoteMessage message, { required String source, }) async { - if (!Platform.isAndroid) return; - final data = Map.from(message.data); + + final endedPayload = VideoCallEndedPushPayload.tryParse(data); + if (endedPayload != null) { + if (Platform.isAndroid && source == 'background') { + log( + 'VideoCallFcmHandler: skip background ended call, handled by native FCM service', + name: 'VideoCallFcm', + ); + return; + } + + log( + 'VideoCallFcmHandler: dismiss incoming call UI, source=$source', + name: 'VideoCallFcm', + ); + try { + await VideoCallEndedPushHandler.dismissIncomingCallUi(); + } catch (e, st) { + log( + 'VideoCallFcmHandler: failed to dismiss incoming call UI: $e', + name: 'VideoCallFcm', + stackTrace: st, + ); + } + return; + } + + if (!Platform.isAndroid) return; if (data.isEmpty) return; final payload = VideoCallPushPayload.tryParse(data); diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart index ceb0767a..4b1f578d 100644 --- a/lib/features/video_calls/services/video_call_push_payload.dart +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -52,6 +52,28 @@ class VideoCallPushPayload { ); } + static bool isEndedVideoCall(Map data) { + final type = _string(data, const [ + 'type', + 'notification_type', + 'event', + 'action', + 'push_type', + 'call_type', + ]); + if (type != null) { + final normalized = type.toLowerCase().replaceAll('-', '_'); + if (normalized == 'video_call_ended' || normalized == 'video_call_end') { + return true; + } + if (normalized.contains('video_call') && + (normalized.contains('ended') || normalized.endsWith('_end'))) { + return true; + } + } + return false; + } + static bool isIncomingVideoCall(Map data) { final type = _string(data, const [ 'type', @@ -91,6 +113,9 @@ class VideoCallPushPayload { return false; } + static String? conversationUuidFromData(Map data) => + _conversationUuid(data); + static String? _conversationUuid(Map data) { final fromData = _string(data, const [ 'conversationUuid', diff --git a/lib/features/video_calls/services/video_call_service.dart b/lib/features/video_calls/services/video_call_service.dart index e46b0194..eff4e99b 100644 --- a/lib/features/video_calls/services/video_call_service.dart +++ b/lib/features/video_calls/services/video_call_service.dart @@ -23,4 +23,10 @@ abstract class VideoCallService { @Path('conversation') required String conversationUuid, @Body() required Map body, }); + + @POST('/api/v2/chat/{conversation}/video-call/accept') + Future notifyCallAccepted({ + @Path('conversation') required String conversationUuid, + @Body() required Map body, + }); } diff --git a/pubspec.yaml b/pubspec.yaml index 06d61909..a9be1b5e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: eatvio_app description: A new Flutter project. -version: 1.3.31+120 +version: 1.3.32+121 publish_to: 'none' environment: diff --git a/test/features/chat/models/chat_message_model_test.dart b/test/features/chat/models/chat_message_model_test.dart index fc23b1b4..b731b951 100644 --- a/test/features/chat/models/chat_message_model_test.dart +++ b/test/features/chat/models/chat_message_model_test.dart @@ -54,7 +54,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: 'room-1', - type: 'text', + type: ChatMessageType.text, ); final json = model.toJson(); diff --git a/test/features/chat/models/chat_messages_model_test.dart b/test/features/chat/models/chat_messages_model_test.dart index 0eab83cf..630bcb36 100644 --- a/test/features/chat/models/chat_messages_model_test.dart +++ b/test/features/chat/models/chat_messages_model_test.dart @@ -48,7 +48,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: 'room-1', - type: 'text', + type: ChatMessageType.text, ); const meta = ChatMetaModel(lastPage: 1); diff --git a/test/features/chat/presentation/bloc/chats_cubit_test.dart b/test/features/chat/presentation/bloc/chats_cubit_test.dart index 9f8dca92..c3af3197 100644 --- a/test/features/chat/presentation/bloc/chats_cubit_test.dart +++ b/test/features/chat/presentation/bloc/chats_cubit_test.dart @@ -336,7 +336,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: chatId, - type: 'text', + type: ChatMessageType.text, ); // Используем рефлексию для вызова приватного метода через тестовый доступ @@ -390,7 +390,7 @@ void main() { isRead: false, isCurrentUserAuthor: true, roomId: chatId, - type: 'text', + type: ChatMessageType.text, ); final streamController = StreamController(); diff --git a/test/features/video_calls/models/video_call_models_test.dart b/test/features/video_calls/models/video_call_models_test.dart index ee2e730f..bc46c8f3 100644 --- a/test/features/video_calls/models/video_call_models_test.dart +++ b/test/features/video_calls/models/video_call_models_test.dart @@ -112,4 +112,24 @@ void main() { expect(restored, model); }); }); + + group('VideoCallAcceptResponse', () { + test('fromJson десериализует sent и devices_notified', () { + final json = FixtureReader.readAsMap('video_calls/video_call_accept_response.json'); + + final model = VideoCallAcceptResponse.fromJson(json); + + expect(model.sent, isTrue); + expect(model.devicesNotified, 2); + }); + + test('round-trip JSON сохраняет значения', () { + final json = FixtureReader.readAsMap('video_calls/video_call_accept_response.json'); + + final model = VideoCallAcceptResponse.fromJson(json); + final restored = VideoCallAcceptResponse.fromJson(model.toJson()); + + expect(restored, model); + }); + }); } diff --git a/test/features/video_calls/presentation/video_call_session_cubit_test.dart b/test/features/video_calls/presentation/video_call_session_cubit_test.dart index d6df22d4..2f9e9c3b 100644 --- a/test/features/video_calls/presentation/video_call_session_cubit_test.dart +++ b/test/features/video_calls/presentation/video_call_session_cubit_test.dart @@ -27,6 +27,9 @@ void main() { managerListener = invocation.positionalArguments[0] as VideoCallManagerListener; }); + when( + mockManager.notifyIncomingCallAccepted(any), + ).thenAnswer((_) async {}); cubit = _TestVideoCallSessionCubit(videoCallManager: mockManager); }); diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart index 91b3e8d6..4e30a006 100644 --- a/test/features/video_calls/services/video_call_push_payload_test.dart +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/features/video_calls/services/video_call_ended_push_payload.dart'; import 'package:eatvio_app/features/video_calls/services/video_call_push_payload.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -40,14 +41,21 @@ void main() { expect(payload.counterpartName, 'Ivan'); }); - test('ignores ended video call push', () { - expect( - VideoCallPushPayload.tryParse({ - 'type': 'video_call_ended', - 'conversation_uuid': 'abc-123', - }), - isNull, - ); + test('parses ended video call push with type only', () { + final payload = VideoCallEndedPushPayload.tryParse({ + 'type': 'video_call_ended', + }); + + expect(payload, isNotNull); + }); + + test('parses ended video call push with optional conversation uuid', () { + final payload = VideoCallEndedPushPayload.tryParse({ + 'type': 'video_call_ended', + 'conversation_uuid': 'abc-123', + }); + + expect(payload, isNotNull); }); test('ignores regular chat push', () { diff --git a/test/features/video_calls/services/video_call_service_test.dart b/test/features/video_calls/services/video_call_service_test.dart index 3bada540..4d2aa887 100644 --- a/test/features/video_calls/services/video_call_service_test.dart +++ b/test/features/video_calls/services/video_call_service_test.dart @@ -90,5 +90,25 @@ void main() { throwsA(isA()), ); }); + + test('notifyCallAccepted - отправляет hw_id и получает ответ', () async { + const conversationUuid = 'conv-uuid-123'; + const body = {'hw_id': 'device-android-001'}; + final json = FixtureReader.readAsMap('video_calls/video_call_accept_response.json'); + final expected = VideoCallAcceptResponse.fromJson(json); + + when(mockService.notifyCallAccepted(conversationUuid: conversationUuid, body: body)) + .thenAnswer((_) async => expected); + + final result = await mockService.notifyCallAccepted( + conversationUuid: conversationUuid, + body: body, + ); + + expect(result, isA()); + expect(result.sent, isTrue); + expect(result.devicesNotified, 2); + verify(mockService.notifyCallAccepted(conversationUuid: conversationUuid, body: body)).called(1); + }); }); } diff --git a/test/features/video_calls/widgets/video_call_top_overlay_test.dart b/test/features/video_calls/widgets/video_call_top_overlay_test.dart index 77d7c312..f6dc791a 100644 --- a/test/features/video_calls/widgets/video_call_top_overlay_test.dart +++ b/test/features/video_calls/widgets/video_call_top_overlay_test.dart @@ -1,6 +1,7 @@ import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_svg/flutter_svg.dart'; void main() { testWidgets('insertBanner shows message and can be removed manually', ( @@ -67,4 +68,36 @@ void main() { await tester.pump(); expect(find.text('Recording notice'), findsNothing); }); + + testWidgets('showPoorConnectionIcon renders compact icon chip', (tester) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); + + final context = tester.element(find.byType(SizedBox)); + VideoCallTopOverlay.showPoorConnectionIcon(context); + + await tester.pump(); + expect(find.byType(SvgPicture), findsOneWidget); + expect(find.text('Poor connection'), findsNothing); + expect(VideoCallTopOverlay.isPoorConnectionIconVisible, isTrue); + }); + + testWidgets('hidePoorConnectionIcon removes poor connection chip', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp(home: Scaffold(body: SizedBox.shrink())), + ); + + final context = tester.element(find.byType(SizedBox)); + VideoCallTopOverlay.showPoorConnectionIcon(context); + await tester.pump(); + expect(VideoCallTopOverlay.isPoorConnectionIconVisible, isTrue); + + VideoCallTopOverlay.hidePoorConnectionIcon(); + await tester.pump(); + expect(VideoCallTopOverlay.isPoorConnectionIconVisible, isFalse); + expect(find.byType(SvgPicture), findsNothing); + }); } diff --git a/test/fixtures/video_calls/video_call_accept_response.json b/test/fixtures/video_calls/video_call_accept_response.json new file mode 100644 index 00000000..ec80c0fd --- /dev/null +++ b/test/fixtures/video_calls/video_call_accept_response.json @@ -0,0 +1,4 @@ +{ + "sent": true, + "devices_notified": 2 +}