forked from eatvio/eatvio_app
feat(video_calls): fixed online indicator and in app notifications for video call
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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/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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user