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

@@ -54,6 +54,8 @@ abstract class ChatUser with _$ChatUser {
@JsonKey(name: "name") required String name,
@JsonKey(name: "profile_photo_path") required String? profilePhotoPath,
@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;
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 slug,
@JsonKey(name: 'profile_photo_url') String? profilePhotoUrl,
@Default(false) @JsonKey(name: 'is_online') bool isOnline,
@Default('') @JsonKey(name: 'last_seen_text') String lastSeenText,
}) = _ChatUserModel;
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/chats_cubit/chats_cubit.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/repositories/cards_repository.dart';
import 'package:eatvio_app/features/plans/models/chosen_plan_model.dart';
@@ -34,6 +35,8 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
late int lastPage;
bool isLoading = false;
Timer? _timer;
String? _activeChatUuid;
void Function(bool isOnline)? _presenceOnlineListener;
var uuid = const Uuid();
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 {
final client = wsManager.client;
if (client == null) return;
_activeChatUuid = uuid;
await wsManager.initPrivateDirectChatChannel(chatId);
wsManager.subscribeDirectChatChannel();
await _subscribeToPresenceChannel(chatId: chatId);
messagesSubscription = wsManager.directChatStream?.listen((event) {
AudioPlayer().play((AssetSource('audio/recive_message.wav')));
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 {
try {
final messageUuid = uuid.v4();
@@ -337,6 +454,10 @@ class ChatRoomCubit extends Cubit<ChatRoomState> {
@override
Future<void> close() async {
if (_presenceOnlineListener != null) {
wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null;
}
wsManager.unsubscribeDirectChatChannel();
_timer?.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/plan_order_message_widget.dart';
import 'package:eatvio_app/core/locator_service.dart';
import 'package:eatvio_app/core/websockets/ws_manager.dart';
import 'package:eatvio_app/features/menu/presentation/blocked_user_cubit/blocked_users_cubit.dart';
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/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/utils/chat_counterpart_utils.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/plans/enums/chosen_plan_state_enum.dart';
@@ -141,6 +144,10 @@ class _ChatScreenState extends State<ChatScreen> {
}
Scaffold _buildChat(BuildContext context, ChatRoomStateLoaded state) {
final chatCounterpart = resolveChatCounterpart(
users: state.chatRoom.users,
currentUserId: sl<WsManager>().userId,
);
final chosenPlan = state.chatRoom.chosenPlan;
final amIConsultant = chosenPlan?.consultantId == state.chatRoom.users.sender.id;
final showUserDetailsButton = [
@@ -236,7 +243,12 @@ class _ChatScreenState extends State<ChatScreen> {
),
firstCurve: Curves.easeIn,
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,
duration: const Duration(milliseconds: 150),
),
@@ -255,8 +267,11 @@ class _ChatScreenState extends State<ChatScreen> {
VideoCallScreen.name,
extra: <String, dynamic>{
'conversationUuid': widget.uuid,
'counterpartName': state.chatRoom.users.receiver.name,
'counterpartAvatarUrl': state.chatRoom.users.receiver.profilePhotoUrl,
'counterpartName': chatCounterpart.name,
'counterpartAvatarUrl': chatCounterpart.profilePhotoUrl,
'counterpartUserId': chatCounterpart.id,
'counterpartIsOnline': chatCounterpart.isOnline,
'counterpartLastSeenText': chatCounterpart.lastSeenText,
'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;
final ctx = _navigatorContext;
if (ctx == null) return;
_recordingOverlayScheduled = true;
VideoCallTopOverlay.insertBanner(
final shown = VideoCallTopOverlay.insertBanner(
ctx,
message: S.of(ctx).video_call_recording_notice,
backgroundColor: appTheme.videoCallNoticeBannerBackground,
svgAsset: VideoCallTopOverlay.warningSvg,
autoRemoveAfter: const Duration(seconds: 3),
);
).mounted;
if (shown) {
_recordingOverlayScheduled = true;
}
}
String resolvedLocalDisplayName() {
@@ -804,13 +806,18 @@ class VideoCallManager {
final bad =
q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost;
if (bad) {
if (_poorConnectionEntry != null) return;
_poorConnectionEntry = VideoCallTopOverlay.insertBanner(
final existing = _poorConnectionEntry;
if (existing != null && existing.mounted) return;
_poorConnectionEntry = null;
final entry = VideoCallTopOverlay.insertBanner(
ctx,
message: S.of(ctx).video_call_poor_connection,
backgroundColor: appTheme.strongRed,
svgAsset: VideoCallTopOverlay.poorConnectionSvg,
);
if (entry.mounted) {
_poorConnectionEntry = entry;
}
} else {
final e = _poorConnectionEntry;
if (e != null && e.mounted) {

View File

@@ -67,11 +67,11 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
void _onManagerSnapshot(VideoCallManagerSnapshot snapshot) {
if (isClosed) return;
if (_isCallSessionInactive(snapshot)) {
unawaited(_systemPipCoordinator.disarm());
if (_callScreenRouteVisible) {
// [VideoCallManager.startCall] briefly emits idle while replacing the room.
return;
}
unawaited(_systemPipCoordinator.disarm());
emit(const VideoCallSessionState());
unawaited(_restoreSystemUiAfterCall());
return;
@@ -84,6 +84,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
void _syncFromManager(VideoCallManagerSnapshot snapshot) {
if (_isCallSessionInactive(snapshot)) {
unawaited(_systemPipCoordinator.disarm());
if (_callScreenRouteVisible) return;
emit(const VideoCallSessionState());
return;
@@ -210,9 +211,10 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
}
Future<void> _ensureSystemPipArmedIfInCall() async {
if (!state.callStageEntered) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
if (state.linkIssue == VideoCallLinkIssue.lost) return;
if (!_videoCallManager.isActiveCall) {
await _systemPipCoordinator.disarm();
return;
}
await _systemPipCoordinator.armAutoPipOnLeave();
}
@@ -227,6 +229,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
VideoCallSessionState sessionState,
VideoCallManagerSnapshot snapshot,
) {
if (!_callScreenRouteVisible) return;
if (sessionState.entryMode != VideoCallEntryMode.outgoing) return;
if (sessionState.callStageEntered) return;
if (sessionState.connectPhase != VideoCallConnectPhase.connected) return;
@@ -253,6 +256,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<void> _runOutgoingAutoEnterCallStage() async {
if (isClosed || _autoEnterInFlight) return;
if (!_callScreenRouteVisible) return;
if (state.entryMode != VideoCallEntryMode.outgoing) return;
if (state.callStageEntered) return;
if (state.connectPhase != VideoCallConnectPhase.connected) return;
@@ -380,7 +384,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
Future<void> restoreFullscreenFromPip() async {
if (state.uiMode != VideoCallUiMode.pip) return;
emit(state.copyWith(uiMode: VideoCallUiMode.fullscreen));
unawaited(_systemPipCoordinator.armAutoPipOnLeave());
unawaited(_ensureSystemPipArmedIfInCall());
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(_applyVideoCallSystemUi());
});
@@ -415,10 +419,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
mainStageShowsLocalVideo: false,
),
);
unawaited(_systemPipCoordinator.armAutoPipOnLeave());
await _ensureSystemPipArmedIfInCall();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (isClosed) return;
_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].
enum VideoCallEntryMode {
/// Outgoing call or push tap — lobby with manual enter.
/// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins.
outgoing,
/// 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/theme/custom_text_style.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_state.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,
this.counterpartName,
this.counterpartAvatarUrl,
this.counterpartUserId,
this.counterpartIsOnline,
this.counterpartLastSeenText,
this.localParticipantName,
this.entryMode = VideoCallEntryMode.outgoing,
this.onBackToChat,
@@ -33,6 +39,9 @@ class VideoCallScreen extends StatefulWidget {
final String conversationUuid;
final String? counterpartName;
final String? counterpartAvatarUrl;
final int? counterpartUserId;
final bool? counterpartIsOnline;
final String? counterpartLastSeenText;
final String? localParticipantName;
final VideoCallEntryMode entryMode;
final VoidCallback? onBackToChat;
@@ -54,12 +63,20 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
);
VideoCallSessionCubit get _cubit => sl<VideoCallSessionCubit>();
WsManager get _wsManager => sl<WsManager>();
bool _counterpartIsOnline = false;
String _counterpartLastSeenText = '';
void Function(bool isOnline)? _presenceOnlineListener;
@override
void initState() {
super.initState();
_counterpartIsOnline = widget.counterpartIsOnline ?? false;
_counterpartLastSeenText = widget.counterpartLastSeenText ?? '';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
unawaited(_initCounterpartOnlineStatus());
unawaited(
_cubit.onCallScreenVisible(
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
void dispose() {
if (_presenceOnlineListener != null) {
_wsManager.removePresenceDirectChatListener(_presenceOnlineListener!);
_presenceOnlineListener = null;
}
_cubit.onCallScreenDisposed();
super.dispose();
}
@@ -197,6 +286,8 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
body: VideoCallConnectingEntranceScreen(
counterpartDisplayName: remoteName,
counterpartAvatarUrl: widget.counterpartAvatarUrl,
counterpartIsOnline: _counterpartIsOnline,
counterpartLastSeenText: _counterpartLastSeenText,
micEnabled: sessionState.isMicActiveForUi,
videoEnabled: sessionState.videoEnabled,
canEnterCall: canEnterCall,

View File

@@ -1,8 +1,10 @@
import 'dart:io';
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_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_system_pip_content.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,
children: [
child ?? const SizedBox.shrink(),
Overlay(
key: VideoCallBannerOverlay.overlayKey,
initialEntries: const <OverlayEntry>[],
),
const VideoCallGlobalPipLayer(),
const VideoCallSystemPipListener(),
],
@@ -36,10 +42,7 @@ class VideoCallAppShell extends StatelessWidget {
previous.connectPhase != current.connectPhase ||
previous.linkIssue != current.linkIssue,
builder: (context, state) {
final allowSystemPipShell =
state.callStageEntered &&
state.connectPhase == VideoCallConnectPhase.connected &&
state.linkIssue != VideoCallLinkIssue.lost;
final allowSystemPipShell = sl<VideoCallManager>().isActiveCall;
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/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/generated/l10n.dart';
import 'package:flutter/material.dart';
@@ -10,6 +11,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget {
super.key,
required this.counterpartDisplayName,
this.counterpartAvatarUrl,
required this.counterpartIsOnline,
required this.counterpartLastSeenText,
required this.micEnabled,
required this.videoEnabled,
required this.canEnterCall,
@@ -24,6 +27,8 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget {
final String counterpartDisplayName;
final String? counterpartAvatarUrl;
final bool counterpartIsOnline;
final String counterpartLastSeenText;
final bool micEnabled;
final bool videoEnabled;
final bool canEnterCall;
@@ -140,33 +145,7 @@ class VideoCallConnectingEntranceScreen extends StatelessWidget {
const SizedBox(height: _topContentSpacing),
_avatar(context, counterpartDisplayName, counterpartAvatarUrl),
const SizedBox(height: 16),
if (canEnterCall)
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),
),
_buildStatusLine(context, s),
SizedBox(height: canEnterCall ? 20 : 16),
Text(
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/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_state.dart';
import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart';
@@ -21,7 +22,8 @@ class VideoCallSystemPipListener extends StatefulWidget {
_VideoCallSystemPipListenerState();
}
class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener> {
class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener>
with WidgetsBindingObserver {
static const MethodChannel _userLeaveHintChannel = MethodChannel(
'com.eatvioapp/video_call_pip',
);
@@ -34,9 +36,24 @@ class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener>
super.initState();
if (!Platform.isAndroid) return;
WidgetsBinding.instance.addObserver(this);
_userLeaveHintChannel.setMethodCallHandler(_onNativePipMethodCall);
final coordinator = sl<VideoCallSystemPipCoordinator>();
_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 {
@@ -88,6 +105,7 @@ class _VideoCallSystemPipListenerState extends State<VideoCallSystemPipListener>
@override
void dispose() {
if (Platform.isAndroid) {
WidgetsBinding.instance.removeObserver(this);
_userLeaveHintChannel.setMethodCallHandler(null);
}
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/features/video_calls/presentation/widgets/video_call_banner_overlay.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
@@ -7,6 +9,13 @@ abstract final class VideoCallTopOverlay {
'assets/icons/video_call/poor_connection.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(
BuildContext context, {
required String message,
@@ -14,7 +23,7 @@ abstract final class VideoCallTopOverlay {
required String svgAsset,
Duration? autoRemoveAfter,
}) {
final overlayState = Overlay.maybeOf(context, rootOverlay: true);
final overlayState = _resolveOverlayState(context);
late OverlayEntry entry;
entry = OverlayEntry(
builder: (ctx) => Positioned(