feat(video_calls): fixed online indicator and in app notifications for video call

This commit is contained in:
misha
2026-06-24 08:26:16 +03:00
parent 8ff826fa3e
commit c5bf5acfe7
22 changed files with 569 additions and 51 deletions

View File

@@ -9,4 +9,5 @@ const List<String> planTypeValues = ['fit', 'slim', 'wellness', 'behavior'];
const newMessagesChannelName = 'private-new-chat-messages'; const newMessagesChannelName = 'private-new-chat-messages';
const newNotificationsChannelName = 'private-message-events'; const newNotificationsChannelName = 'private-message-events';
const directChatChannelName = 'private-direct-chat'; const directChatChannelName = 'private-direct-chat';
const presenceDirectChatChannelName = 'presence-direct-chat';
const thresholdForPagination = 150; const thresholdForPagination = 150;

View File

@@ -236,6 +236,9 @@ class OtherRoutes {
conversationUuid: extra['conversationUuid'] as String, conversationUuid: extra['conversationUuid'] as String,
counterpartName: extra['counterpartName'] as String?, counterpartName: extra['counterpartName'] as String?,
counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?, counterpartAvatarUrl: extra['counterpartAvatarUrl'] as String?,
counterpartUserId: extra['counterpartUserId'] as int?,
counterpartIsOnline: extra['counterpartIsOnline'] as bool?,
counterpartLastSeenText: extra['counterpartLastSeenText'] as String?,
localParticipantName: extra['localParticipantName'] as String?, localParticipantName: extra['localParticipantName'] as String?,
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']), entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
onBackToChat: extra['onBackToChat'] as void Function()?, onBackToChat: extra['onBackToChat'] as void Function()?,

View File

@@ -26,6 +26,13 @@ class WsManager {
PrivateChannel? _privateNotificationChannel; PrivateChannel? _privateNotificationChannel;
PrivateChannel? _privateMessagesChannel; PrivateChannel? _privateMessagesChannel;
PrivateChannel? _privateDirectChatChannel; PrivateChannel? _privateDirectChatChannel;
PresenceChannel? _presenceDirectChatChannel;
StreamSubscription? _presenceMemberAddedSubscription;
StreamSubscription? _presenceMemberRemovedSubscription;
StreamSubscription? _presenceSubscriptionSucceededSubscription;
final List<_PresenceDirectChatListener> _presenceDirectChatListeners = [];
bool _presenceChannelEventsBound = false;
StreamSubscription? _lifecycleSubscription; StreamSubscription? _lifecycleSubscription;
StreamSubscription? _eventSubscription; StreamSubscription? _eventSubscription;
@@ -175,6 +182,107 @@ class WsManager {
); );
} }
Future<void> initPresenceDirectChatChannel(String chatId) async {
final token = await TokenStorage.getTokenFromStorage();
_presenceDirectChatChannel = client?.presenceChannel(
'$presenceDirectChatChannelName.$chatId',
authorizationDelegate: EndpointAuthorizableChannelTokenAuthorizationDelegate.forPresenceChannel(
authorizationEndpoint: Uri.parse('${sl<AppConfig>().baseUrl}/broadcasting/auth'),
headers: {
'Authorization': 'Bearer $token',
},
),
);
}
void addPresenceDirectChatListener({
required int counterpartUserId,
required void Function(bool isOnline) onCounterpartOnlineChanged,
}) {
final channel = _presenceDirectChatChannel;
if (channel == null) return;
_presenceDirectChatListeners.add(
_PresenceDirectChatListener(
counterpartUserId: counterpartUserId,
onCounterpartOnlineChanged: onCounterpartOnlineChanged,
),
);
_bindPresenceChannelEventsIfNeeded();
channel.subscribe();
_emitCounterpartPresence(counterpartUserId);
}
void removePresenceDirectChatListener(
void Function(bool isOnline) onCounterpartOnlineChanged,
) {
_presenceDirectChatListeners.removeWhere(
(listener) => listener.onCounterpartOnlineChanged == onCounterpartOnlineChanged,
);
if (_presenceDirectChatListeners.isEmpty) {
unsubscribePresenceDirectChatChannel();
}
}
void _bindPresenceChannelEventsIfNeeded() {
if (_presenceChannelEventsBound) return;
final channel = _presenceDirectChatChannel;
if (channel == null) return;
_presenceChannelEventsBound = true;
_presenceSubscriptionSucceededSubscription =
channel.whenSubscriptionSucceeded().listen((_) {
final counterpartIds = _presenceDirectChatListeners
.map((listener) => listener.counterpartUserId)
.toSet();
for (final counterpartUserId in counterpartIds) {
_emitCounterpartPresence(counterpartUserId);
}
});
_presenceMemberAddedSubscription = channel.whenMemberAdded().listen((event) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: true);
});
_presenceMemberRemovedSubscription = channel.whenMemberRemoved().listen((event) {
final memberUserId = _parsePresenceMemberUserId(event.data);
if (memberUserId == null) return;
_notifyPresenceListeners(memberUserId, isOnline: false);
});
}
void _emitCounterpartPresence(int counterpartUserId) {
final channel = _presenceDirectChatChannel;
if (channel == null) return;
final members = channel.state?.members;
if (members == null) return;
final isOnline = members.getMemberInfo(counterpartUserId.toString()) != null;
_notifyPresenceListeners(counterpartUserId, isOnline: isOnline);
}
void _notifyPresenceListeners(int counterpartUserId, {required bool isOnline}) {
for (final listener in _presenceDirectChatListeners) {
if (listener.counterpartUserId == counterpartUserId) {
listener.onCounterpartOnlineChanged(isOnline);
}
}
}
int? _parsePresenceMemberUserId(String? data) {
if (data == null || data.isEmpty) return null;
try {
final decoded = jsonDecode(data);
if (decoded is! Map) return null;
final rawId = decoded['user_id'] ?? decoded['id'];
if (rawId == null) return null;
return int.tryParse(rawId.toString());
} catch (_) {
return null;
}
}
void _subscribe() { void _subscribe() {
_privateMessagesChannel?.subscribe(); _privateMessagesChannel?.subscribe();
_privateNotificationChannel?.subscribe(); _privateNotificationChannel?.subscribe();
@@ -193,6 +301,19 @@ class WsManager {
_privateDirectChatChannel?.unsubscribe(); _privateDirectChatChannel?.unsubscribe();
} }
void unsubscribePresenceDirectChatChannel() {
_presenceSubscriptionSucceededSubscription?.cancel();
_presenceMemberAddedSubscription?.cancel();
_presenceMemberRemovedSubscription?.cancel();
_presenceSubscriptionSucceededSubscription = null;
_presenceMemberAddedSubscription = null;
_presenceMemberRemovedSubscription = null;
_presenceDirectChatListeners.clear();
_presenceChannelEventsBound = false;
_presenceDirectChatChannel?.unsubscribe();
_presenceDirectChatChannel = null;
}
Future<void> clear() async { Future<void> clear() async {
await client?.disconnect(); await client?.disconnect();
await _lifecycleSubscription?.cancel(); await _lifecycleSubscription?.cancel();
@@ -204,6 +325,7 @@ class WsManager {
client = null; client = null;
userId = null; userId = null;
_privateDirectChatChannel?.unsubscribe(); _privateDirectChatChannel?.unsubscribe();
unsubscribePresenceDirectChatChannel();
_privateMessagesChannel?.unsubscribe(); _privateMessagesChannel?.unsubscribe();
_privateNotificationChannel?.unsubscribe(); _privateNotificationChannel?.unsubscribe();
_privateDirectChatChannel = null; _privateDirectChatChannel = null;
@@ -211,3 +333,13 @@ class WsManager {
_privateNotificationChannel = null; _privateNotificationChannel = null;
} }
} }
class _PresenceDirectChatListener {
const _PresenceDirectChatListener({
required this.counterpartUserId,
required this.onCounterpartOnlineChanged,
});
final int counterpartUserId;
final void Function(bool isOnline) onCounterpartOnlineChanged;
}

View File

@@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser {
@JsonKey(name: "name") required String name, @JsonKey(name: "name") required String name,
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath, @JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
@JsonKey(name: "profile_photo_url") required String profilePhotoUrl, @JsonKey(name: "profile_photo_url") required String profilePhotoUrl,
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
}) = _ChatUser; }) = _ChatUser;
factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json); factory ChatUser.fromJson(Map<String, dynamic> json) => _$ChatUserFromJson(json);

View File

@@ -10,6 +10,8 @@ abstract class ChatUserModel with _$ChatUserModel {
required String name, required String name,
required String slug, required String slug,
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl, @JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
}) = _ChatUserModel; }) = _ChatUserModel;
factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json); factory ChatUserModel.fromJson(Map<String, Object?> json) => _$ChatUserModelFromJson(json);

View File

@@ -16,6 +16,7 @@ import 'package:eatvio_app/features/chat/models/plan_complain_model.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chat_room_cubit/chat_room_state.dart';
import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/my_finance/models/card_model.dart'; import 'package:eatvio_app/features/my_finance/models/card_model.dart';
import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart'; import 'package:eatvio_app/features/my_finance/repositories/cards_repository.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart'; import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
@@ -34,6 +35,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
late int lastPage; late int lastPage;
bool isLoading = false; bool isLoading = false;
Timer? _timer; Timer? _timer;
String? _activeChatUuid;
void Function(bool isOnline)? _presenceOnlineListener;
var uuid = const Uuid(); var uuid = const Uuid();
ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading()); ChatRoomCubit({required this.chatRepository, required this.wsManager, required this.cardsRepository}) : super(const ChatRoomState.loading());
@@ -170,8 +173,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async { Future<void> _subscribeToChatChannel({required String uuid, required String chatId}) async {
final client = wsManager.client; final client = wsManager.client;
if (client == null) return; if (client == null) return;
_activeChatUuid = uuid;
await wsManager.initPrivateDirectChatChannel(chatId); await wsManager.initPrivateDirectChatChannel(chatId);
wsManager.subscribeDirectChatChannel(); wsManager.subscribeDirectChatChannel();
await _subscribeToPresenceChannel(chatId: chatId);
messagesSubscription = wsManager.directChatStream?.listen((event) { messagesSubscription = wsManager.directChatStream?.listen((event) {
AudioPlayer().play((AssetSource('audio/recive_message.wav'))); AudioPlayer().play((AssetSource('audio/recive_message.wav')));
final message = ChatMessageModel.fromJson(jsonDecode(event.data)); final message = ChatMessageModel.fromJson(jsonDecode(event.data));
@@ -197,6 +202,118 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
}); });
} }
Future<void> _subscribeToPresenceChannel({required String chatId}) async {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = resolveChatCounterpart(
users: currentState.chatRoom.users,
currentUserId: wsManager.userId,
);
await wsManager.initPresenceDirectChatChannel(chatId);
_presenceOnlineListener = (isOnline) {
unawaited(_handleCounterpartOnlineChanged(
counterpartField: counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender,
isOnline: isOnline,
));
};
wsManager.addPresenceDirectChatListener(
counterpartUserId: counterpart.id,
onCounterpartOnlineChanged: _presenceOnlineListener!,
);
}
Future<void> _handleCounterpartOnlineChanged({
required _ChatCounterpartField counterpartField,
required bool isOnline,
}) async {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = counterpartField == _ChatCounterpartField.receiver
? currentState.chatRoom.users.receiver
: currentState.chatRoom.users.sender;
if (counterpart.isOnline == isOnline && (isOnline || counterpart.lastSeenText.isNotEmpty)) {
return;
}
if (!isOnline) {
await _refreshCounterpartOnlineStatus(counterpartField: counterpartField);
return;
}
_emitCounterpartOnlineStatus(
counterpartField: counterpartField,
isOnline: true,
lastSeenText: '',
);
}
Future<void> _refreshCounterpartOnlineStatus({
required _ChatCounterpartField counterpartField,
}) async {
final chatUuid = _activeChatUuid;
if (chatUuid == null) return;
try {
final chatRoom = await chatRepository.getChatRoom(chatUuid);
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final counterpart = resolveChatCounterpart(
users: chatRoom.users,
currentUserId: wsManager.userId,
);
final field = counterpart.id == currentState.chatRoom.users.receiver.id
? _ChatCounterpartField.receiver
: _ChatCounterpartField.sender;
if (field != counterpartField) return;
_emitCounterpartOnlineStatus(
counterpartField: counterpartField,
isOnline: counterpart.isOnline,
lastSeenText: counterpart.lastSeenText,
);
} catch (e, st) {
await reportFrom(e, st, this);
}
}
void _emitCounterpartOnlineStatus({
required _ChatCounterpartField counterpartField,
required bool isOnline,
required String lastSeenText,
}) {
final currentState = state;
if (currentState is! ChatRoomStateLoaded) return;
final users = currentState.chatRoom.users;
final updatedUsers = counterpartField == _ChatCounterpartField.receiver
? users.copyWith(
receiver: users.receiver.copyWith(
isOnline: isOnline,
lastSeenText: lastSeenText,
),
)
: users.copyWith(
sender: users.sender.copyWith(
isOnline: isOnline,
lastSeenText: lastSeenText,
),
);
emit(
currentState.copyWith(
chatRoom: currentState.chatRoom.copyWith(users: updatedUsers),
),
);
}
Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async { Future<bool> sendMessage({String? data, List<File>? files, required String userSlug}) async {
try { try {
final messageUuid = uuid.v4(); final messageUuid = uuid.v4();
@@ -337,6 +454,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
@override @override
Future<void> close() async { Future<void> close() async {
if (_presenceOnlineListener != null) {
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null;
}
wsManager.unsubscribeDirectChatChannel(); wsManager.unsubscribeDirectChatChannel();
_timer?.cancel(); _timer?.cancel();
messagesSubscription?.cancel(); messagesSubscription?.cancel();
@@ -435,3 +556,5 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
} }
} }
} }
enum _ChatCounterpartField { sender, receiver }

View File

@@ -15,11 +15,14 @@ import 'package:eatvio_app/features/chat/presentation/widgets/about_order_button
import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/chat_bubble_widget.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/plan_order_message_widget.dart';
import 'package:eatvio_app/core/locator_service.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'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart'; import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_state.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart'; import 'package:eatvio_app/features/chat/presentation/widgets/notification_banner.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart';
import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart';
import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart';
@@ -141,6 +144,10 @@ class _ChatScreenState extends State<ChatScreen> {
} }
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) { Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
final chatCounterpart = resolveChatCounterpart(
users: state.chatRoom.users,
currentUserId: sl<WsManager>().userId,
);
final chosenPlan = state.chatRoom.chosenPlan; final chosenPlan = state.chatRoom.chosenPlan;
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id; final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
final showUserDetailsButton = [ final showUserDetailsButton = [
@@ -236,7 +243,12 @@ class _ChatScreenState extends State<ChatScreen> {
), ),
firstCurve: Curves.easeIn, firstCurve: Curves.easeIn,
secondCurve: Curves.easeOut, secondCurve: Curves.easeOut,
secondChild: const SizedBox.shrink(), secondChild: ChatUserOnlineStatusLine(
isOnline: chatCounterpart.isOnline,
lastSeenText: chatCounterpart.lastSeenText,
style: CustomTextStyles.bodySmallMulishPrimary,
showOnlineDot: false,
),
crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond, crossFadeState: state.isTyping ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: const Duration(milliseconds: 150), duration: const Duration(milliseconds: 150),
), ),
@@ -255,8 +267,11 @@ class _ChatScreenState extends State<ChatScreen> {
VideoCallScreen.name, VideoCallScreen.name,
extra: <String, dynamic>{ extra: <String, dynamic>{
'conversationUuid': widget.uuid, 'conversationUuid': widget.uuid,
'counterpartName': state.chatRoom.users.receiver.name, 'counterpartName': chatCounterpart.name,
'counterpartAvatarUrl': state.chatRoom.users.receiver.profilePhotoUrl, 'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl,
'counterpartUserId': chatCounterpart.id,
'counterpartIsOnline': chatCounterpart.isOnline,
'counterpartLastSeenText': chatCounterpart.lastSeenText,
'localParticipantName': state.chatRoom.users.sender.name, 'localParticipantName': state.chatRoom.users.sender.name,
}, },
); );

View File

@@ -0,0 +1,70 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart';
class ChatUserOnlineStatusLine extends StatelessWidget {
const ChatUserOnlineStatusLine({
super.key,
required this.isOnline,
required this.lastSeenText,
this.style,
this.showOnlineDot = true,
});
final bool isOnline;
final String lastSeenText;
final TextStyle? style;
final bool showOnlineDot;
@override
Widget build(BuildContext context) {
final textStyle = style ??
CustomTextStyles.bodySmallMulishBlack900.copyWith(height: 14 / 12);
if (isOnline) {
if (!showOnlineDot) {
return Text(
S.of(context).video_call_status_online,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: appTheme.greenA700,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
S.of(context).video_call_status_online,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
),
),
],
);
}
final trimmedLastSeen = lastSeenText.trim();
if (trimmedLastSeen.isEmpty) {
return const SizedBox.shrink();
}
return Text(
trimmedLastSeen,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle,
);
}
}

View File

@@ -0,0 +1,15 @@
import 'package:eatvio_app/features/chat/models/chat_room_model.dart';
import 'package:eatvio_app/features/chat/models/chat_user_model.dart';
ChatUserModel resolveChatCounterpart({
required Users users,
required int? currentUserId,
}) {
if (currentUserId != null && users.sender.id == currentUserId) {
return users.receiver;
}
if (currentUserId != null && users.receiver.id == currentUserId) {
return users.sender;
}
return users.receiver;
}

View File

@@ -524,14 +524,16 @@ class VideoCallManager {
if (_recordingOverlayScheduled) return; if (_recordingOverlayScheduled) return;
final ctx = _navigatorContext; final ctx = _navigatorContext;
if (ctx == null) return; if (ctx == null) return;
_recordingOverlayScheduled = true; final shown = VideoCallTopOverlay.insertBanner(
VideoCallTopOverlay.insertBanner(
ctx, ctx,
message: S.of(ctx).video_call_recording_notice, message: S.of(ctx).video_call_recording_notice,
backgroundColor: appTheme.videoCallNoticeBannerBackground, backgroundColor: appTheme.videoCallNoticeBannerBackground,
svgAsset: VideoCallTopOverlay.warningSvg, svgAsset: VideoCallTopOverlay.warningSvg,
autoRemoveAfter: const Duration(seconds: 3), autoRemoveAfter: const Duration(seconds: 3),
); ).mounted;
if (shown) {
_recordingOverlayScheduled = true;
}
} }
String resolvedLocalDisplayName() { String resolvedLocalDisplayName() {
@@ -804,13 +806,18 @@ class VideoCallManager {
final bad = final bad =
q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost;
if (bad) { if (bad) {
if (_poorConnectionEntry != null) return; final existing = _poorConnectionEntry;
_poorConnectionEntry = VideoCallTopOverlay.insertBanner( if (existing != null && existing.mounted) return;
_poorConnectionEntry = null;
final entry = VideoCallTopOverlay.insertBanner(
ctx, ctx,
message: S.of(ctx).video_call_poor_connection, message: S.of(ctx).video_call_poor_connection,
backgroundColor: appTheme.strongRed, backgroundColor: appTheme.strongRed,
svgAsset: VideoCallTopOverlay.poorConnectionSvg, svgAsset: VideoCallTopOverlay.poorConnectionSvg,
); );
if (entry.mounted) {
_poorConnectionEntry = entry;
}
} else { } else {
final e = _poorConnectionEntry; final e = _poorConnectionEntry;
if (e != null && e.mounted) { if (e != null && e.mounted) {

View File

@@ -67,11 +67,11 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) { void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
if (isClosed) return; if (isClosed) return;
if (_isCallSessionInactive(snapshot)) { if (_isCallSessionInactive(snapshot)) {
unawaited(_systemPipCoordinator.disarm());
if (_callScreenRouteVisible) { if (_callScreenRouteVisible) {
// [VideoCallManager.startCall] briefly emits idle while replacing the room. // [VideoCallManager.startCall] briefly emits idle while replacing the room.
return; return;
} }
unawaited(_systemPipCoordinator.disarm());
emit(const VideoCallSessionState()); emit(const VideoCallSessionState());
unawaited(_restoreSystemUiAfterCall()); unawaited(_restoreSystemUiAfterCall());
return; return;
@@ -84,6 +84,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
void _syncFromManager(VideoCallManagerSnapshot snapshot) { void _syncFromManager(VideoCallManagerSnapshot snapshot) {
if (_isCallSessionInactive(snapshot)) { if (_isCallSessionInactive(snapshot)) {
unawaited(_systemPipCoordinator.disarm());
if (_callScreenRouteVisible) return; if (_callScreenRouteVisible) return;
emit(const VideoCallSessionState()); emit(const VideoCallSessionState());
return; return;
@@ -210,9 +211,10 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
} }
Future<void> _ensureSystemPipArmedIfInCall() async { Future<void> _ensureSystemPipArmedIfInCall() async {
if (!state.callStageEntered) return; if (!_videoCallManager.isActiveCall) {
if (state.connectPhase != VideoCallConnectPhase.connected) return; await _systemPipCoordinator.disarm();
if (state.linkIssue == VideoCallLinkIssue.lost) return; return;
}
await _systemPipCoordinator.armAutoPipOnLeave(); await _systemPipCoordinator.armAutoPipOnLeave();
} }
@@ -227,6 +229,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
VideoCallSessionState sessionState, VideoCallSessionState sessionState,
VideoCallManagerSnapshot snapshot, VideoCallManagerSnapshot snapshot,
) { ) {
if (!_callScreenRouteVisible) return;
if (sessionState.entryMode != VideoCallEntryMode.outgoing) return; if (sessionState.entryMode != VideoCallEntryMode.outgoing) return;
if (sessionState.callStageEntered) return; if (sessionState.callStageEntered) return;
if (sessionState.connectPhase != VideoCallConnectPhase.connected) return; if (sessionState.connectPhase != VideoCallConnectPhase.connected) return;
@@ -253,6 +256,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<void> _runOutgoingAutoEnterCallStage() async { Future<void> _runOutgoingAutoEnterCallStage() async {
if (isClosed || _autoEnterInFlight) return; if (isClosed || _autoEnterInFlight) return;
if (!_callScreenRouteVisible) return;
if (state.entryMode != VideoCallEntryMode.outgoing) return; if (state.entryMode != VideoCallEntryMode.outgoing) return;
if (state.callStageEntered) return; if (state.callStageEntered) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return; if (state.connectPhase != VideoCallConnectPhase.connected) return;
@@ -380,7 +384,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<void> restoreFullscreenFromPip() async { Future<void> restoreFullscreenFromPip() async {
if (state.uiMode != VideoCallUiMode.pip) return; if (state.uiMode != VideoCallUiMode.pip) return;
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen)); emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
unawaited(_systemPipCoordinator.armAutoPipOnLeave()); unawaited(_ensureSystemPipArmedIfInCall());
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi()); unawaited(_applyVideoCallSystemUi());
}); });
@@ -415,10 +419,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
mainStageShowsLocalVideo: false, mainStageShowsLocalVideo: false,
), ),
); );
unawaited(_systemPipCoordinator.armAutoPipOnLeave()); await _ensureSystemPipArmedIfInCall();
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (isClosed) return; if (isClosed) return;
_videoCallManager.scheduleRecordingNoticeOnce(); _videoCallManager.scheduleRecordingNoticeOnce();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isClosed) return;
_videoCallManager.scheduleRecordingNoticeOnce();
});
}); });
} }

View File

@@ -14,7 +14,7 @@ enum VideoCallLinkIssue { none, reconnecting, lost }
/// How the user reached [VideoCallScreen]. /// How the user reached [VideoCallScreen].
enum VideoCallEntryMode { enum VideoCallEntryMode {
/// Outgoing call or push tap — lobby with manual enter. /// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins.
outgoing, outgoing,
/// Accepted incoming CallKit / VoIP / notification — loader then auto-enter. /// Accepted incoming CallKit / VoIP / notification — loader then auto-enter.

View File

@@ -3,6 +3,9 @@ import 'dart:async';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/chat/repositories/chat_repository.dart';
import 'package:eatvio_app/features/chat/utils/chat_counterpart_utils.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.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/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_connecting_entrance_screen.dart';
@@ -21,6 +24,9 @@ class VideoCallScreen extends StatefulWidget {
required this.conversationUuid, required this.conversationUuid,
this.counterpartName, this.counterpartName,
this.counterpartAvatarUrl, this.counterpartAvatarUrl,
this.counterpartUserId,
this.counterpartIsOnline,
this.counterpartLastSeenText,
this.localParticipantName, this.localParticipantName,
this.entryMode = VideoCallEntryMode.outgoing, this.entryMode = VideoCallEntryMode.outgoing,
this.onBackToChat, this.onBackToChat,
@@ -33,6 +39,9 @@ class VideoCallScreen extends StatefulWidget {
final String conversationUuid; final String conversationUuid;
final String? counterpartName; final String? counterpartName;
final String? counterpartAvatarUrl; final String? counterpartAvatarUrl;
final int? counterpartUserId;
final bool? counterpartIsOnline;
final String? counterpartLastSeenText;
final String? localParticipantName; final String? localParticipantName;
final VideoCallEntryMode entryMode; final VideoCallEntryMode entryMode;
final VoidCallback? onBackToChat; final VoidCallback? onBackToChat;
@@ -54,12 +63,20 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
); );
VideoCallSessionCubit get _cubit => sl<VideoCallSessionCubit>(); VideoCallSessionCubit get _cubit => sl<VideoCallSessionCubit>();
WsManager get _wsManager => sl<WsManager>();
bool _counterpartIsOnline = false;
String _counterpartLastSeenText = '';
void Function(bool isOnline)? _presenceOnlineListener;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_counterpartIsOnline = widget.counterpartIsOnline ?? false;
_counterpartLastSeenText = widget.counterpartLastSeenText ?? '';
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return; if (!mounted) return;
unawaited(_initCounterpartOnlineStatus());
unawaited( unawaited(
_cubit.onCallScreenVisible( _cubit.onCallScreenVisible(
conversationUuid: widget.conversationUuid, conversationUuid: widget.conversationUuid,
@@ -71,8 +88,80 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
}); });
} }
Future<void> _initCounterpartOnlineStatus() async {
var isOnline = widget.counterpartIsOnline ?? false;
var lastSeenText = widget.counterpartLastSeenText ?? '';
var counterpartUserId = widget.counterpartUserId;
final shouldFetchFromApi =
counterpartUserId == null ||
widget.counterpartIsOnline == null ||
widget.counterpartLastSeenText == null;
if (shouldFetchFromApi) {
try {
final chatRoom = await sl<ChatRepository>().getChatRoom(
widget.conversationUuid,
);
final counterpart = resolveChatCounterpart(
users: chatRoom.users,
currentUserId: _wsManager.userId,
);
isOnline = counterpart.isOnline;
lastSeenText = counterpart.lastSeenText;
counterpartUserId = counterpart.id;
} catch (_) {}
}
if (!mounted) return;
setState(() {
_counterpartIsOnline = isOnline;
_counterpartLastSeenText = lastSeenText;
});
if (counterpartUserId == null) return;
_presenceOnlineListener = (isOnline) {
if (!mounted) return;
if (isOnline) {
setState(() {
_counterpartIsOnline = true;
_counterpartLastSeenText = '';
});
return;
}
unawaited(_refreshCounterpartOnlineStatus());
};
await _wsManager.initPresenceDirectChatChannel(widget.conversationUuid);
_wsManager.addPresenceDirectChatListener(
counterpartUserId: counterpartUserId,
onCounterpartOnlineChanged: _presenceOnlineListener!,
);
}
Future<void> _refreshCounterpartOnlineStatus() async {
try {
final chatRoom = await sl<ChatRepository>().getChatRoom(
widget.conversationUuid,
);
final counterpart = resolveChatCounterpart(
users: chatRoom.users,
currentUserId: _wsManager.userId,
);
if (!mounted) return;
setState(() {
_counterpartIsOnline = counterpart.isOnline;
_counterpartLastSeenText = counterpart.lastSeenText;
});
} catch (_) {}
}
@override @override
void dispose() { void dispose() {
if (_presenceOnlineListener != null) {
_wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null;
}
_cubit.onCallScreenDisposed(); _cubit.onCallScreenDisposed();
super.dispose(); super.dispose();
} }
@@ -197,6 +286,8 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
body: VideoCallConnectingEntranceScreen( body: VideoCallConnectingEntranceScreen(
counterpartDisplayName: remoteName, counterpartDisplayName: remoteName,
counterpartAvatarUrl: widget.counterpartAvatarUrl, counterpartAvatarUrl: widget.counterpartAvatarUrl,
counterpartIsOnline: _counterpartIsOnline,
counterpartLastSeenText: _counterpartLastSeenText,
micEnabled: sessionState.isMicActiveForUi, micEnabled: sessionState.isMicActiveForUi,
videoEnabled: sessionState.videoEnabled, videoEnabled: sessionState.videoEnabled,
canEnterCall: canEnterCall, canEnterCall: canEnterCall,

View File

@@ -1,8 +1,10 @@
import 'dart:io'; import 'dart:io';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.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/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_global_pip_layer.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_content.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_system_pip_listener.dart';
@@ -22,6 +24,10 @@ class VideoCallAppShell extends StatelessWidget {
clipBehavior: Clip.none, clipBehavior: Clip.none,
children: [ children: [
child ?? const SizedBox.shrink(), child ?? const SizedBox.shrink(),
Overlay(
key: VideoCallBannerOverlay.overlayKey,
initialEntries: const <OverlayEntry>[],
),
const VideoCallGlobalPipLayer(), const VideoCallGlobalPipLayer(),
const VideoCallSystemPipListener(), const VideoCallSystemPipListener(),
], ],
@@ -36,10 +42,7 @@ class VideoCallAppShell extends StatelessWidget {
previous.connectPhase != current.connectPhase || previous.connectPhase != current.connectPhase ||
previous.linkIssue != current.linkIssue, previous.linkIssue != current.linkIssue,
builder: (context, state) { builder: (context, state) {
final allowSystemPipShell = final allowSystemPipShell = sl<VideoCallManager>().isActiveCall;
state.callStageEntered &&
state.connectPhase == VideoCallConnectPhase.connected &&
state.linkIssue != VideoCallLinkIssue.lost;
if (!allowSystemPipShell) return shell; if (!allowSystemPipShell) return shell;

View File

@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
/// Dedicated overlay layer above the app navigator for in-call banners.
abstract final class VideoCallBannerOverlay {
static final GlobalKey<OverlayState> overlayKey = GlobalKey<OverlayState>(
debugLabel: 'videoCallBannerOverlay',
);
static OverlayState? get overlayState => overlayKey.currentState;
}

View File

@@ -1,5 +1,6 @@
import 'package:eatvio_app/core/theme/custom_text_style.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/chat/presentation/widgets/chat_user_online_status_line.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart';
import 'package:eatvio_app/generated/l10n.dart'; import 'package:eatvio_app/generated/l10n.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -10,6 +11,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget {
super.key, super.key,
required this.counterpartDisplayName, required this.counterpartDisplayName,
this.counterpartAvatarUrl, this.counterpartAvatarUrl,
required this.counterpartIsOnline,
required this.counterpartLastSeenText,
required this.micEnabled, required this.micEnabled,
required this.videoEnabled, required this.videoEnabled,
required this.canEnterCall, required this.canEnterCall,
@@ -24,6 +27,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget {
final String counterpartDisplayName; final String counterpartDisplayName;
final String? counterpartAvatarUrl; final String? counterpartAvatarUrl;
final bool counterpartIsOnline;
final String counterpartLastSeenText;
final bool micEnabled; final bool micEnabled;
final bool videoEnabled; final bool videoEnabled;
final bool canEnterCall; final bool canEnterCall;
@@ -140,33 +145,7 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget {
const SizedBox(height: _topContentSpacing), const SizedBox(height: _topContentSpacing),
_avatar(context, counterpartDisplayName, counterpartAvatarUrl), _avatar(context, counterpartDisplayName, counterpartAvatarUrl),
const SizedBox(height: 16), const SizedBox(height: 16),
if (canEnterCall) _buildStatusLine(context, s),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: appTheme.greenA700,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Text(
s.video_call_status_online,
style: CustomTextStyles.bodySmallMulishBlack900
.copyWith(height: 14 / 12),
),
],
)
else
Text(
s.video_call_screen_connecting_subtitle,
textAlign: TextAlign.center,
style: CustomTextStyles.bodySmallMulishBlack900
.copyWith(height: 14 / 12),
),
SizedBox(height: canEnterCall ? 20 : 16), SizedBox(height: canEnterCall ? 20 : 16),
Text( Text(
s.video_call_screen_connecting_title, s.video_call_screen_connecting_title,
@@ -345,4 +324,29 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget {
), ),
); );
} }
Widget _buildStatusLine(BuildContext context, S s) {
final statusStyle = CustomTextStyles.bodySmallMulishBlack900.copyWith(
height: 14 / 12,
);
final onlineStatus = ChatUserOnlineStatusLine(
isOnline: counterpartIsOnline,
lastSeenText: counterpartLastSeenText,
style: statusStyle,
);
if (counterpartIsOnline || counterpartLastSeenText.trim().isNotEmpty) {
return Center(child: onlineStatus);
}
if (!canEnterCall) {
return Text(
s.video_call_screen_connecting_subtitle,
textAlign: TextAlign.center,
style: statusStyle,
);
}
return const SizedBox.shrink();
}
} }

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/features/video_calls/managers/video_call_manager.dart';
import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.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/presentation/bloc/video_call_session/video_call_session_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
@@ -21,7 +22,8 @@ class VideoCallSystemPipListener extends StatefulWidget {
_VideoCallSystemPipListenerState(); _VideoCallSystemPipListenerState();
} }
class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener> { class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener>
with WidgetsBindingObserver {
static const MethodChannel _userLeaveHintChannel = MethodChannel( static const MethodChannel _userLeaveHintChannel = MethodChannel(
'com.eatvioapp/video_call_pip', 'com.eatvioapp/video_call_pip',
); );
@@ -34,9 +36,24 @@ class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener>
super.initState(); super.initState();
if (!Platform.isAndroid) return; if (!Platform.isAndroid) return;
WidgetsBinding.instance.addObserver(this);
_userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall); _userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall);
final coordinator = sl<VideoCallSystemPipCoordinator>(); final coordinator = sl<VideoCallSystemPipCoordinator>();
_subscription = coordinator.statusStream.listen(_onPipStatus); _subscription = coordinator.statusStream.listen(_onPipStatus);
unawaited(_disarmSystemPipIfNoActiveCall());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
unawaited(_disarmSystemPipIfNoActiveCall());
}
}
Future<void> _disarmSystemPipIfNoActiveCall() async {
if (!sl<VideoCallManager>().isActiveCall) {
await sl<VideoCallSystemPipCoordinator>().disarm();
}
} }
Future<void> _onNativePipMethodCall(MethodCall call) async { Future<void> _onNativePipMethodCall(MethodCall call) async {
@@ -88,6 +105,7 @@ class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener>
@override @override
void dispose() { void dispose() {
if (Platform.isAndroid) { if (Platform.isAndroid) {
WidgetsBinding.instance.removeObserver(this);
_userLeaveHintChannel.setMethodCallHandler(null); _userLeaveHintChannel.setMethodCallHandler(null);
} }
unawaited(_subscription?.cancel()); unawaited(_subscription?.cancel());

View File

@@ -1,4 +1,6 @@
import 'package:eatvio_app/core/app_router.dart';
import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart';
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_banner_overlay.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter_svg/flutter_svg.dart';
@@ -7,6 +9,13 @@ abstract final class VideoCallTopOverlay {
'assets/icons/video_call/poor_connection.svg'; 'assets/icons/video_call/poor_connection.svg';
static const String warningSvg = 'assets/icons/video_call/warning.svg'; static const String warningSvg = 'assets/icons/video_call/warning.svg';
static OverlayState? _resolveOverlayState(BuildContext context) {
return VideoCallBannerOverlay.overlayState ??
AppRouter.navigatorKey.currentState?.overlay ??
Overlay.maybeOf(context, rootOverlay: true);
}
static OverlayEntry insertBanner( static OverlayEntry insertBanner(
BuildContext context, { BuildContext context, {
required String message, required String message,
@@ -14,7 +23,7 @@ abstract final class VideoCallTopOverlay {
required String svgAsset, required String svgAsset,
Duration? autoRemoveAfter, Duration? autoRemoveAfter,
}) { }) {
final overlayState = Overlay.maybeOf(context, rootOverlay: true); final overlayState = _resolveOverlayState(context);
late OverlayEntry entry; late OverlayEntry entry;
entry = OverlayEntry( entry = OverlayEntry(
builder: (ctx) => Positioned( builder: (ctx) => Positioned(

View File

@@ -1,6 +1,6 @@
name: eatvio_app name: eatvio_app
description: A new Flutter project. description: A new Flutter project.
version: 1.3.28+117 version: 1.3.29+118
publish_to: 'none' publish_to: 'none'
environment: environment:

View File

@@ -33,6 +33,7 @@ void main() {
expect(newMessagesChannelName, 'private-new-chat-messages'); expect(newMessagesChannelName, 'private-new-chat-messages');
expect(newNotificationsChannelName, 'private-message-events'); expect(newNotificationsChannelName, 'private-message-events');
expect(directChatChannelName, 'private-direct-chat'); expect(directChatChannelName, 'private-direct-chat');
expect(presenceDirectChatChannelName, 'presence-direct-chat');
}); });
test('thresholdForPagination has correct value', () { test('thresholdForPagination has correct value', () {

View File

@@ -28,12 +28,14 @@ void main() {
expect(model.profilePhotoUrl, isNull); expect(model.profilePhotoUrl, isNull);
}); });
test('fromJson создает ChatUserModel из JSON', () { test('fromJson creates ChatUserModel from JSON', () {
final json = { final json = {
'id': 1, 'id': 1,
'name': 'User Name', 'name': 'User Name',
'slug': 'user-slug', 'slug': 'user-slug',
'profile_photo_url': 'https://example.com/photo.jpg', 'profile_photo_url': 'https://example.com/photo.jpg',
'is_online': true,
'last_seen_text': 'был(а) недавно',
}; };
final model = ChatUserModel.fromJson(json); final model = ChatUserModel.fromJson(json);
@@ -42,6 +44,8 @@ void main() {
expect(model.name, 'User Name'); expect(model.name, 'User Name');
expect(model.slug, 'user-slug'); expect(model.slug, 'user-slug');
expect(model.profilePhotoUrl, 'https://example.com/photo.jpg'); expect(model.profilePhotoUrl, 'https://example.com/photo.jpg');
expect(model.isOnline, isTrue);
expect(model.lastSeenText, 'был(а) недавно');
}); });
test('fromJson создает ChatUserModel из JSON без фото', () { test('fromJson создает ChatUserModel из JSON без фото', () {

View File

@@ -220,7 +220,7 @@ void main() {
expect(cubit.state.callStageEntered, isTrue); expect(cubit.state.callStageEntered, isTrue);
}); });
test('outgoing call keeps lobby until callee joins', () async { test('outgoing call stays on connecting lobby until callee joins', () async {
when( when(
mockManager.startCall( mockManager.startCall(
conversationUuid: anyNamed('conversationUuid'), conversationUuid: anyNamed('conversationUuid'),