feat(video_calls): call ended push, recall button for message

This commit is contained in:
misha
2026-06-30 16:11:18 +03:00
parent c0c7e2e658
commit f493e07f9f
30 changed files with 671 additions and 83 deletions

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"]

View 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;
}
}

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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'

View File

@@ -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,
),
),
),
),
);
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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, {

View File

@@ -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},
);
}
}

View File

@@ -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,

View File

@@ -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 (_) {}
}
}

View File

@@ -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._();
}
}

View File

@@ -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);

View File

@@ -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',

View File

@@ -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,
});
}

View File

@@ -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:

View File

@@ -54,7 +54,7 @@ void main() {
isRead: false,
isCurrentUserAuthor: true,
roomId: 'room-1',
type: 'text',
type: ChatMessageType.text,
);
final json = model.toJson();

View File

@@ -48,7 +48,7 @@ void main() {
isRead: false,
isCurrentUserAuthor: true,
roomId: 'room-1',
type: 'text',
type: ChatMessageType.text,
);
const meta = ChatMetaModel(lastPage: 1);

View File

@@ -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>();

View File

@@ -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);
});
});
}

View File

@@ -27,6 +27,9 @@ void main() {
managerListener =
invocation.positionalArguments[0] as VideoCallManagerListener;
});
when(
mockManager.notifyIncomingCallAccepted(any),
).thenAnswer((_) async {});
cubit = _TestVideoCallSessionCubit(videoCallManager: mockManager);
});

View File

@@ -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', () {

View File

@@ -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);
});
});
}

View File

@@ -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);
});
}

View File

@@ -0,0 +1,4 @@
{
"sent": true,
"devices_notified": 2
}