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

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