forked from eatvio/eatvio_app
feat(video_calls): call ended push, recall button for message
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, String>): 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<String, String>) {
|
||||
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<String, String>): Bundle? {
|
||||
val conversationUuid = conversationUuid(data) ?: return null
|
||||
val callerName = data["counterpartName"]
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ChatScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 8),
|
||||
child: _ChatHeaderVideoCallButton(
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
'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<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) {
|
||||
@@ -352,18 +342,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -371,7 +356,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) {
|
||||
@@ -408,18 +393,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,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -786,6 +766,52 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openVideoCall(BuildContext context, ChatRoomStateLoaded state, ChatUserModel chatCounterpart) {
|
||||
GoRouter.of(context).pushNamed(
|
||||
VideoCallScreen.name,
|
||||
extra: <String, dynamic>{
|
||||
'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 {
|
||||
|
||||
@@ -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
|
||||
@@ -258,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,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<lk.RoomEvent>? _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<String> _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<void> 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<void> 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
final VideoCallSystemPipCoordinator _systemPipCoordinator;
|
||||
bool _callScreenRouteVisible = false;
|
||||
bool _autoEnterInFlight = false;
|
||||
bool _counterpartPresentWhenLobbyReady = false;
|
||||
bool _sawEmptyLobbyWhenConnected = false;
|
||||
bool _lobbyReadyTracked = false;
|
||||
String? _lobbyReadyTrackedConversationUuid;
|
||||
Future<void>? _autoEnterFuture;
|
||||
Future<void>? _outgoingAutoEnterFuture;
|
||||
|
||||
@@ -72,10 +76,12 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
// [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<VideoCallSessionState> {
|
||||
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 {
|
||||
@@ -144,7 +185,12 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
!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<VideoCallSessionState> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> _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<void> _captureCallKitEventToSentry(CallEvent event) async {
|
||||
if (!Platform.isIOS) return;
|
||||
final context = <String, Object?>{
|
||||
@@ -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<void> _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<String, Object?> extra, {
|
||||
String? handle,
|
||||
|
||||
@@ -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<void> 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 (_) {}
|
||||
}
|
||||
}
|
||||
@@ -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._();
|
||||
}
|
||||
}
|
||||
@@ -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<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);
|
||||
|
||||
@@ -52,6 +52,28 @@ class VideoCallPushPayload {
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -91,6 +113,9 @@ class VideoCallPushPayload {
|
||||
return false;
|
||||
}
|
||||
|
||||
static String? conversationUuidFromData(Map<String, dynamic> data) =>
|
||||
_conversationUuid(data);
|
||||
|
||||
static String? _conversationUuid(Map<String, dynamic> data) {
|
||||
final fromData = _string(data, const [
|
||||
'conversationUuid',
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -54,7 +54,7 @@ void main() {
|
||||
isRead: false,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: 'room-1',
|
||||
type: 'text',
|
||||
type: ChatMessageType.text,
|
||||
);
|
||||
|
||||
final json = model.toJson();
|
||||
|
||||
@@ -48,7 +48,7 @@ void main() {
|
||||
isRead: false,
|
||||
isCurrentUserAuthor: true,
|
||||
roomId: 'room-1',
|
||||
type: 'text',
|
||||
type: ChatMessageType.text,
|
||||
);
|
||||
|
||||
const meta = ChatMetaModel(lastPage: 1);
|
||||
|
||||
@@ -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<ChatMessageModel>();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@ void main() {
|
||||
managerListener =
|
||||
invocation.positionalArguments[0] as VideoCallManagerListener;
|
||||
});
|
||||
when(
|
||||
mockManager.notifyIncomingCallAccepted(any),
|
||||
).thenAnswer((_) async {});
|
||||
cubit = _TestVideoCallSessionCubit(videoCallManager: mockManager);
|
||||
});
|
||||
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
@@ -90,5 +90,25 @@ void main() {
|
||||
throwsA(isA<Exception>()),
|
||||
);
|
||||
});
|
||||
|
||||
test('notifyCallAccepted - отправляет hw_id и получает ответ', () async {
|
||||
const conversationUuid = 'conv-uuid-123';
|
||||
const body = <String, dynamic>{'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<VideoCallAcceptResponse>());
|
||||
expect(result.sent, isTrue);
|
||||
expect(result.devicesNotified, 2);
|
||||
verify(mockService.notifyCallAccepted(conversationUuid: conversationUuid, body: body)).called(1);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
4
test/fixtures/video_calls/video_call_accept_response.json
vendored
Normal file
4
test/fixtures/video_calls/video_call_accept_response.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"sent": true,
|
||||
"devices_notified": 2
|
||||
}
|
||||
Reference in New Issue
Block a user