forked from eatvio/eatvio_app
feat(video_calls): call ended push, recall button for message
This commit is contained in:
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user