forked from eatvio/eatvio_app
feat(video_calls): fixed online indicator and in app notifications for video call
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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()?,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
lib/features/chat/utils/chat_counterpart_utils.dart
Normal file
15
lib/features/chat/utils/chat_counterpart_utils.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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', () {
|
||||||
|
|||||||
@@ -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 без фото', () {
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user