forked from eatvio/eatvio_app
feat(video_calls): fixed recinnecting logic, added consultant bage
This commit is contained in:
@@ -92,6 +92,12 @@ object VideoCallPushBridge {
|
||||
?: ""
|
||||
val localParticipantName = data["localParticipantName"]
|
||||
?: data["local_participant_name"]
|
||||
val counterpartIsConsultant = isTruthy(
|
||||
data["counterpartIsConsultant"]
|
||||
?: data["counterpart_is_consultant"]
|
||||
?: data["is_consultant"]
|
||||
?: data["isConsultant"]
|
||||
)
|
||||
|
||||
val extra = HashMap<String, Any?>()
|
||||
extra["conversationUuid"] = conversationUuid
|
||||
@@ -102,6 +108,9 @@ object VideoCallPushBridge {
|
||||
if (!localParticipantName.isNullOrBlank()) {
|
||||
extra["localParticipantName"] = localParticipantName
|
||||
}
|
||||
if (counterpartIsConsultant) {
|
||||
extra["counterpartIsConsultant"] = true
|
||||
}
|
||||
|
||||
return Bundle().apply {
|
||||
putString(CallkitConstants.EXTRA_CALLKIT_ID, conversationUuid)
|
||||
|
||||
@@ -129,7 +129,7 @@ import flutter_foreground_task
|
||||
}
|
||||
|
||||
let queryItems = components.queryItems ?? []
|
||||
for key in ["uuid", "conversationUuid", "conversation_uuid"] {
|
||||
for key: String in ["uuid", "conversationUuid", "conversation_uuid"] {
|
||||
if let value = queryItems.first(where: { $0.name == key })?.value,
|
||||
!value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
{
|
||||
|
||||
@@ -69,6 +69,7 @@ class PushNavigationHandler {
|
||||
counterpartName: videoCallPayload.counterpartName,
|
||||
counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl,
|
||||
localParticipantName: videoCallPayload.localParticipantName,
|
||||
counterpartIsConsultant: videoCallPayload.counterpartIsConsultant,
|
||||
entryMode: VideoCallEntryMode.incomingAccepted,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -239,6 +239,7 @@ class OtherRoutes {
|
||||
counterpartUserId: extra['counterpartUserId'] as int?,
|
||||
counterpartIsOnline: extra['counterpartIsOnline'] as bool?,
|
||||
counterpartLastSeenText: extra['counterpartLastSeenText'] as String?,
|
||||
counterpartIsConsultant: extra['counterpartIsConsultant'] as bool?,
|
||||
localParticipantName: extra['localParticipantName'] as String?,
|
||||
entryMode: _videoCallEntryModeFromRouteExtra(extra['entryMode']),
|
||||
onBackToChat: extra['onBackToChat'] as void Function()?,
|
||||
|
||||
@@ -72,6 +72,7 @@ class VideoCallManager {
|
||||
String? _preparedToken;
|
||||
bool _videoEnabledBeforeBackground = false;
|
||||
bool _videoPausedForBackground = false;
|
||||
bool _abortingLiveKitAutoReconnect = false;
|
||||
Future<String?>? _myProfileNameFuture;
|
||||
|
||||
VideoCallManagerSnapshot _snapshot = VideoCallManagerSnapshot.initial;
|
||||
@@ -86,7 +87,7 @@ class VideoCallManager {
|
||||
bool get isActiveCall =>
|
||||
_snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||
_callStageEntered &&
|
||||
_snapshot.linkIssue != VideoCallLinkIssue.lost &&
|
||||
!_snapshot.linkIssue.needsManualReconnect &&
|
||||
!_liveKitDisposed &&
|
||||
_room != null;
|
||||
|
||||
@@ -764,17 +765,37 @@ class VideoCallManager {
|
||||
});
|
||||
}
|
||||
|
||||
/// Stops LiveKit's built-in auto-reconnect and surfaces [VideoCallLinkIssue.lost]
|
||||
/// so the user can retry manually from [VideoCallConnectionLostScreen].
|
||||
Future<void> _abortLiveKitAutoReconnect() async {
|
||||
if (_liveKitDisposed || _abortingLiveKitAutoReconnect) return;
|
||||
if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
if (_snapshot.linkIssue == VideoCallLinkIssue.lost) return;
|
||||
|
||||
_abortingLiveKitAutoReconnect = true;
|
||||
try {
|
||||
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.lost));
|
||||
final room = _room;
|
||||
if (room != null &&
|
||||
room.connectionState != lk.ConnectionState.disconnected) {
|
||||
try {
|
||||
await room.disconnect();
|
||||
} catch (_) {}
|
||||
}
|
||||
} finally {
|
||||
_abortingLiveKitAutoReconnect = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _attachRoomEventListeners() {
|
||||
final room = _room;
|
||||
if (room == null) return;
|
||||
_roomEventsListener = room.createListener()
|
||||
..on<lk.RoomReconnectingEvent>((_) {
|
||||
if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
_emitSnapshot(
|
||||
_snapshot.copyWith(linkIssue: VideoCallLinkIssue.reconnecting),
|
||||
);
|
||||
unawaited(_abortLiveKitAutoReconnect());
|
||||
})
|
||||
..on<lk.RoomReconnectedEvent>((_) {
|
||||
if (_snapshot.linkIssue.needsManualReconnect) return;
|
||||
_emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none));
|
||||
})
|
||||
..on<lk.RoomDisconnectedEvent>((_) {
|
||||
|
||||
@@ -42,7 +42,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
) {
|
||||
final hideStaleConnectError =
|
||||
snapshot.connectPhase == VideoCallConnectPhase.connected &&
|
||||
snapshot.linkIssue != VideoCallLinkIssue.lost;
|
||||
!snapshot.linkIssue.needsManualReconnect;
|
||||
final resetCallStage =
|
||||
_isCallSessionInactive(snapshot) ||
|
||||
snapshot.connectPhase == VideoCallConnectPhase.connecting;
|
||||
@@ -123,6 +123,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
required String conversationUuid,
|
||||
String? counterpartName,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
|
||||
}) async {
|
||||
_callScreenRouteVisible = true;
|
||||
@@ -153,6 +154,8 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
counterpartName: counterpartName ?? state.counterpartName,
|
||||
localParticipantName:
|
||||
localParticipantName ?? state.localParticipantName,
|
||||
counterpartIsConsultant:
|
||||
counterpartIsConsultant ?? state.counterpartIsConsultant,
|
||||
entryMode: effectiveEntryMode,
|
||||
),
|
||||
);
|
||||
@@ -175,12 +178,16 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
micEnabled: state.micEnabled,
|
||||
videoEnabled: state.videoEnabled,
|
||||
);
|
||||
if (counterpartIsConsultant != null) {
|
||||
emit(state.copyWith(counterpartIsConsultant: counterpartIsConsultant));
|
||||
}
|
||||
} else {
|
||||
emit(
|
||||
state.copyWith(
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant ?? false,
|
||||
micEnabled: false,
|
||||
videoEnabled: false,
|
||||
callStageEntered: false,
|
||||
@@ -332,7 +339,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
return;
|
||||
}
|
||||
if (state.connectPhase == VideoCallConnectPhase.connected &&
|
||||
state.linkIssue != VideoCallLinkIssue.lost) {
|
||||
!state.linkIssue.needsManualReconnect) {
|
||||
if (state.callStageEntered) {
|
||||
unawaited(minimizeToPip());
|
||||
} else {
|
||||
@@ -340,14 +347,14 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
}
|
||||
} else if (state.connectPhase == VideoCallConnectPhase.connecting) {
|
||||
unawaited(endCall());
|
||||
} else if (state.linkIssue == VideoCallLinkIssue.lost) {
|
||||
} else if (state.linkIssue.needsManualReconnect) {
|
||||
unawaited(endCall());
|
||||
}
|
||||
}
|
||||
|
||||
bool get _canMinimizeToPip =>
|
||||
state.connectPhase == VideoCallConnectPhase.connected &&
|
||||
state.linkIssue != VideoCallLinkIssue.lost &&
|
||||
!state.linkIssue.needsManualReconnect &&
|
||||
state.callStageEntered;
|
||||
|
||||
Future<void> _applyPipUiState() async {
|
||||
@@ -392,7 +399,7 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
|
||||
Future<void> leaveCallRoute() async {
|
||||
if (state.connectPhase == VideoCallConnectPhase.connected &&
|
||||
state.linkIssue != VideoCallLinkIssue.lost) {
|
||||
!state.linkIssue.needsManualReconnect) {
|
||||
if (state.callStageEntered) {
|
||||
_callScreenRouteVisible = false;
|
||||
await minimizeToPip();
|
||||
@@ -404,6 +411,11 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
}
|
||||
}
|
||||
|
||||
void updateCounterpartIsConsultant(bool isConsultant) {
|
||||
if (isClosed || state.counterpartIsConsultant == isConsultant) return;
|
||||
emit(state.copyWith(counterpartIsConsultant: isConsultant));
|
||||
}
|
||||
|
||||
Future<void> enterCallStage() async {
|
||||
if (isClosed) return;
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected) return;
|
||||
@@ -432,8 +444,15 @@ class VideoCallSessionCubit extends BaseCubit<VideoCallSessionState> {
|
||||
|
||||
Future<void> retryAfterConnectionLoss() async {
|
||||
final entryMode = state.entryMode;
|
||||
final wasInCallStage = state.callStageEntered;
|
||||
await _videoCallManager.retryAfterConnectionLoss();
|
||||
if (isClosed) return;
|
||||
emit(state.copyWith(callStageEntered: false, entryMode: entryMode));
|
||||
if (wasInCallStage &&
|
||||
state.connectPhase == VideoCallConnectPhase.connected &&
|
||||
!state.linkIssue.needsManualReconnect) {
|
||||
await enterCallStage();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> endCall() async {
|
||||
|
||||
@@ -12,6 +12,12 @@ enum VideoCallConnectPhase { idle, connecting, connected }
|
||||
|
||||
enum VideoCallLinkIssue { none, reconnecting, lost }
|
||||
|
||||
extension VideoCallLinkIssueX on VideoCallLinkIssue {
|
||||
/// True when the user must manually retry (lost link or LiveKit auto-reconnect).
|
||||
bool get needsManualReconnect =>
|
||||
this == VideoCallLinkIssue.lost || this == VideoCallLinkIssue.reconnecting;
|
||||
}
|
||||
|
||||
/// How the user reached [VideoCallScreen].
|
||||
enum VideoCallEntryMode {
|
||||
/// Outgoing call — connecting lobby; manual enter or auto-enter when callee joins.
|
||||
@@ -28,6 +34,7 @@ abstract class VideoCallSessionState with _$VideoCallSessionState {
|
||||
const factory VideoCallSessionState({
|
||||
@Default('') String conversationUuid,
|
||||
String? counterpartName,
|
||||
@Default(false) bool counterpartIsConsultant,
|
||||
String? localParticipantName,
|
||||
@Default(false) bool micEnabled,
|
||||
@Default(false) bool videoEnabled,
|
||||
|
||||
@@ -6,6 +6,7 @@ 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/profile/repositories/profile_repository.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';
|
||||
@@ -27,6 +28,7 @@ class VideoCallScreen extends StatefulWidget {
|
||||
this.counterpartUserId,
|
||||
this.counterpartIsOnline,
|
||||
this.counterpartLastSeenText,
|
||||
this.counterpartIsConsultant,
|
||||
this.localParticipantName,
|
||||
this.entryMode = VideoCallEntryMode.outgoing,
|
||||
this.onBackToChat,
|
||||
@@ -42,6 +44,7 @@ class VideoCallScreen extends StatefulWidget {
|
||||
final int? counterpartUserId;
|
||||
final bool? counterpartIsOnline;
|
||||
final String? counterpartLastSeenText;
|
||||
final bool? counterpartIsConsultant;
|
||||
final String? localParticipantName;
|
||||
final VideoCallEntryMode entryMode;
|
||||
final VoidCallback? onBackToChat;
|
||||
@@ -76,29 +79,32 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
_counterpartLastSeenText = widget.counterpartLastSeenText ?? '';
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
unawaited(_initCounterpartOnlineStatus());
|
||||
unawaited(_initCounterpartMetadata());
|
||||
unawaited(
|
||||
_cubit.onCallScreenVisible(
|
||||
conversationUuid: widget.conversationUuid,
|
||||
counterpartName: widget.counterpartName,
|
||||
localParticipantName: widget.localParticipantName,
|
||||
counterpartIsConsultant: widget.counterpartIsConsultant,
|
||||
entryMode: widget.entryMode,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _initCounterpartOnlineStatus() async {
|
||||
Future<void> _initCounterpartMetadata() async {
|
||||
var isOnline = widget.counterpartIsOnline ?? false;
|
||||
var lastSeenText = widget.counterpartLastSeenText ?? '';
|
||||
var counterpartUserId = widget.counterpartUserId;
|
||||
var counterpartIsConsultant = widget.counterpartIsConsultant;
|
||||
|
||||
final shouldFetchFromApi =
|
||||
final shouldFetchChatRoom =
|
||||
counterpartUserId == null ||
|
||||
widget.counterpartIsOnline == null ||
|
||||
widget.counterpartLastSeenText == null;
|
||||
widget.counterpartLastSeenText == null ||
|
||||
counterpartIsConsultant == null;
|
||||
|
||||
if (shouldFetchFromApi) {
|
||||
if (shouldFetchChatRoom) {
|
||||
try {
|
||||
final chatRoom = await sl<ChatRepository>().getChatRoom(
|
||||
widget.conversationUuid,
|
||||
@@ -110,6 +116,17 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
isOnline = counterpart.isOnline;
|
||||
lastSeenText = counterpart.lastSeenText;
|
||||
counterpartUserId = counterpart.id;
|
||||
|
||||
if (counterpartIsConsultant == null) {
|
||||
try {
|
||||
final profile = await sl<ProfileRepository>().getProfileBySlug(
|
||||
counterpart.slug,
|
||||
);
|
||||
counterpartIsConsultant = profile.roles.contains('consultant');
|
||||
} catch (_) {
|
||||
counterpartIsConsultant = false;
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -118,6 +135,9 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
_counterpartIsOnline = isOnline;
|
||||
_counterpartLastSeenText = lastSeenText;
|
||||
});
|
||||
if (counterpartIsConsultant != null) {
|
||||
_cubit.updateCounterpartIsConsultant(counterpartIsConsultant);
|
||||
}
|
||||
|
||||
if (counterpartUserId == null) return;
|
||||
|
||||
@@ -207,6 +227,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
a.connectError != b.connectError ||
|
||||
a.linkIssue != b.linkIssue ||
|
||||
a.counterpartName != b.counterpartName ||
|
||||
a.counterpartIsConsultant != b.counterpartIsConsultant ||
|
||||
a.localParticipantName != b.localParticipantName ||
|
||||
a.callStageEntered != b.callStageEntered ||
|
||||
a.micEnabled != b.micEnabled ||
|
||||
@@ -219,7 +240,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
final room = _cubit.liveKitRoom;
|
||||
|
||||
final showLost =
|
||||
sessionState.linkIssue == VideoCallLinkIssue.lost ||
|
||||
sessionState.linkIssue.needsManualReconnect ||
|
||||
(sessionState.connectPhase == VideoCallConnectPhase.idle &&
|
||||
sessionState.connectError != null);
|
||||
|
||||
@@ -237,20 +258,6 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
final showReconnecting =
|
||||
sessionState.connectPhase == VideoCallConnectPhase.connected &&
|
||||
sessionState.linkIssue == VideoCallLinkIssue.reconnecting;
|
||||
|
||||
if (showReconnecting) {
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: _videoCallOverlayStyle,
|
||||
child: Scaffold(
|
||||
backgroundColor: appTheme.whiteA700,
|
||||
body: const VideoCallConnectingScreen(isReconnecting: true),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final showEntranceLobby =
|
||||
sessionState.entryMode == VideoCallEntryMode.outgoing &&
|
||||
!sessionState.callStageEntered &&
|
||||
@@ -347,6 +354,7 @@ class _VideoCallScreenState extends State<VideoCallScreen> {
|
||||
connectError: sessionState.connectError,
|
||||
localDisplayName: _resolveLocalDisplayName(sessionState),
|
||||
remoteDisplayName: _resolveRemoteDisplayName(sessionState),
|
||||
remoteIsConsultant: sessionState.counterpartIsConsultant,
|
||||
micEnabled: sessionState.micEnabled,
|
||||
videoEnabled: sessionState.videoEnabled,
|
||||
splitParticipantView: sessionState.splitParticipantView,
|
||||
|
||||
@@ -2,40 +2,74 @@ import 'dart:ui';
|
||||
|
||||
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 VideoCallParticipantNameLabel extends StatelessWidget {
|
||||
const VideoCallParticipantNameLabel({super.key, required this.name});
|
||||
const VideoCallParticipantNameLabel({
|
||||
super.key,
|
||||
required this.name,
|
||||
this.isConsultant = false,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final bool isConsultant;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final roleLabel = isConsultant ? S.of(context).video_call_role_consultant : null;
|
||||
|
||||
return Positioned(
|
||||
left: 12,
|
||||
bottom: 12,
|
||||
right: 12,
|
||||
child: Align(
|
||||
alignment: Alignment.bottomLeft,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.transparentBlack,
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (roleLabel != null) ...[
|
||||
Container(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.whiteA700,
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
child: Text(
|
||||
roleLabel,
|
||||
style: TextStyles.textXSSemibold.copyWith(
|
||||
color: appTheme.blueA400,
|
||||
height: 14 / 12,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 5.5, sigmaY: 5.5),
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 240),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: appTheme.transparentBlack,
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
child: Text(
|
||||
name,
|
||||
style: TextStyles.textMMedium.copyWith(color: appTheme.whiteA700),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ class VideoCallStageLayout extends StatefulWidget {
|
||||
required this.connectError,
|
||||
required this.localDisplayName,
|
||||
required this.remoteDisplayName,
|
||||
this.remoteIsConsultant = false,
|
||||
required this.micEnabled,
|
||||
required this.videoEnabled,
|
||||
required this.splitParticipantView,
|
||||
@@ -49,6 +50,7 @@ class VideoCallStageLayout extends StatefulWidget {
|
||||
final String? connectError;
|
||||
final String localDisplayName;
|
||||
final String remoteDisplayName;
|
||||
final bool remoteIsConsultant;
|
||||
final bool micEnabled;
|
||||
final bool videoEnabled;
|
||||
final bool splitParticipantView;
|
||||
@@ -149,6 +151,7 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
||||
child: _SplitParticipantPane(
|
||||
room: widget.room,
|
||||
displayName: widget.remoteDisplayName,
|
||||
isConsultant: widget.remoteIsConsultant,
|
||||
isLocal: false,
|
||||
connectError: widget.connectError,
|
||||
videoEnabled: widget.videoEnabled,
|
||||
@@ -236,6 +239,7 @@ class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
|
||||
name: mainShowsLocal
|
||||
? widget.localDisplayName
|
||||
: widget.remoteDisplayName,
|
||||
isConsultant: !mainShowsLocal && widget.remoteIsConsultant,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -368,6 +372,7 @@ class _SplitParticipantPane extends StatelessWidget {
|
||||
const _SplitParticipantPane({
|
||||
required this.room,
|
||||
required this.displayName,
|
||||
this.isConsultant = false,
|
||||
required this.isLocal,
|
||||
required this.connectError,
|
||||
required this.videoEnabled,
|
||||
@@ -379,6 +384,7 @@ class _SplitParticipantPane extends StatelessWidget {
|
||||
|
||||
final lk.Room room;
|
||||
final String displayName;
|
||||
final bool isConsultant;
|
||||
final bool isLocal;
|
||||
final String? connectError;
|
||||
final bool videoEnabled;
|
||||
@@ -421,7 +427,10 @@ class _SplitParticipantPane extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
VideoCallParticipantNameLabel(name: displayName),
|
||||
VideoCallParticipantNameLabel(
|
||||
name: displayName,
|
||||
isConsultant: !isLocal && isConsultant,
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: _VideoCallStageLayoutState._splitExpandButtonTop,
|
||||
|
||||
@@ -22,11 +22,11 @@ class VideoCallSystemPipContent extends StatelessWidget {
|
||||
a.videoEnabled != b.videoEnabled,
|
||||
builder: (context, state) {
|
||||
if (state.connectPhase != VideoCallConnectPhase.connected ||
|
||||
state.linkIssue == VideoCallLinkIssue.lost ||
|
||||
state.linkIssue.needsManualReconnect ||
|
||||
!state.callStageEntered) {
|
||||
if (state.conversationUuid.isNotEmpty &&
|
||||
state.connectPhase != VideoCallConnectPhase.idle &&
|
||||
state.linkIssue != VideoCallLinkIssue.lost) {
|
||||
!state.linkIssue.needsManualReconnect) {
|
||||
return ColoredBox(
|
||||
color: appTheme.gray100,
|
||||
child: Center(
|
||||
|
||||
@@ -340,6 +340,12 @@ class VideoCallCallKitCoordinator {
|
||||
'localParticipantName',
|
||||
'local_participant_name',
|
||||
]),
|
||||
counterpartIsConsultant: _parseBool(extra, const [
|
||||
'counterpartIsConsultant',
|
||||
'counterpart_is_consultant',
|
||||
'is_consultant',
|
||||
'isConsultant',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -391,6 +397,12 @@ class VideoCallCallKitCoordinator {
|
||||
'localParticipantName',
|
||||
'local_participant_name',
|
||||
]),
|
||||
counterpartIsConsultant: _parseBool(extra, const [
|
||||
'counterpartIsConsultant',
|
||||
'counterpart_is_consultant',
|
||||
'is_consultant',
|
||||
'isConsultant',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -435,6 +447,12 @@ class VideoCallCallKitCoordinator {
|
||||
'localParticipantName',
|
||||
'local_participant_name',
|
||||
]),
|
||||
counterpartIsConsultant: _parseBool(extra, const [
|
||||
'counterpartIsConsultant',
|
||||
'counterpart_is_consultant',
|
||||
'is_consultant',
|
||||
'isConsultant',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -444,6 +462,7 @@ class VideoCallCallKitCoordinator {
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
}) async {
|
||||
if (_acceptHandledCallKitIds.contains(callKitId)) return;
|
||||
_acceptHandledCallKitIds.add(callKitId);
|
||||
@@ -463,6 +482,7 @@ class VideoCallCallKitCoordinator {
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
).whenComplete(completer.complete),
|
||||
);
|
||||
});
|
||||
@@ -486,6 +506,7 @@ class VideoCallCallKitCoordinator {
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
}) async {
|
||||
if (Platform.isIOS) {
|
||||
FlutterNativeSplash.remove();
|
||||
@@ -496,6 +517,7 @@ class VideoCallCallKitCoordinator {
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
entryMode: VideoCallEntryMode.incomingAccepted,
|
||||
);
|
||||
}
|
||||
@@ -571,6 +593,25 @@ class VideoCallCallKitCoordinator {
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool? _parseBool(Map<String, Object?> map, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
if (!map.containsKey(key)) continue;
|
||||
final value = map[key];
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
if (value is String) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (normalized == '1' || normalized == 'true' || normalized == 'yes') {
|
||||
return true;
|
||||
}
|
||||
if (normalized == '0' || normalized == 'false' || normalized == 'no') {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, Object?> _stringKeyedMap(Object? value) {
|
||||
if (value is! Map) return <String, Object?>{};
|
||||
return value.map((key, value) => MapEntry(key.toString(), value));
|
||||
|
||||
@@ -27,6 +27,8 @@ abstract final class VideoCallIncomingPushPresenter {
|
||||
'counterpartAvatarUrl': payload.counterpartAvatarUrl,
|
||||
if (payload.localParticipantName != null)
|
||||
'localParticipantName': payload.localParticipantName,
|
||||
if (payload.counterpartIsConsultant != null)
|
||||
'counterpartIsConsultant': payload.counterpartIsConsultant,
|
||||
},
|
||||
callingNotification: const NotificationParams(showNotification: false),
|
||||
android: const AndroidParams(
|
||||
|
||||
@@ -32,6 +32,7 @@ class VideoCallNotificationNavigator {
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
|
||||
}) async {
|
||||
if (conversationUuid.isEmpty) return;
|
||||
@@ -41,6 +42,7 @@ class VideoCallNotificationNavigator {
|
||||
counterpartName: counterpartName,
|
||||
counterpartAvatarUrl: counterpartAvatarUrl,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
entryMode: entryMode,
|
||||
);
|
||||
|
||||
@@ -49,6 +51,7 @@ class VideoCallNotificationNavigator {
|
||||
conversationUuid: conversationUuid,
|
||||
counterpartName: counterpartName,
|
||||
localParticipantName: localParticipantName,
|
||||
counterpartIsConsultant: counterpartIsConsultant,
|
||||
entryMode: entryMode,
|
||||
);
|
||||
}
|
||||
@@ -59,6 +62,7 @@ class VideoCallNotificationNavigator {
|
||||
String? counterpartName,
|
||||
String? counterpartAvatarUrl,
|
||||
String? localParticipantName,
|
||||
bool? counterpartIsConsultant,
|
||||
VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing,
|
||||
}) async {
|
||||
for (var attempt = 0; attempt < 100; attempt++) {
|
||||
@@ -77,6 +81,8 @@ class VideoCallNotificationNavigator {
|
||||
'counterpartName': counterpartName,
|
||||
'counterpartAvatarUrl': counterpartAvatarUrl,
|
||||
'localParticipantName': localParticipantName,
|
||||
if (counterpartIsConsultant != null)
|
||||
'counterpartIsConsultant': counterpartIsConsultant,
|
||||
'entryMode': entryMode.name,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -4,12 +4,14 @@ class VideoCallPushPayload {
|
||||
this.counterpartName,
|
||||
this.counterpartAvatarUrl,
|
||||
this.localParticipantName,
|
||||
this.counterpartIsConsultant,
|
||||
});
|
||||
|
||||
final String conversationUuid;
|
||||
final String? counterpartName;
|
||||
final String? counterpartAvatarUrl;
|
||||
final String? localParticipantName;
|
||||
final bool? counterpartIsConsultant;
|
||||
|
||||
static VideoCallPushPayload? tryParse(Map<String, dynamic> data) {
|
||||
if (!isIncomingVideoCall(data)) return null;
|
||||
@@ -41,6 +43,12 @@ class VideoCallPushPayload {
|
||||
'localParticipantName',
|
||||
'local_participant_name',
|
||||
]),
|
||||
counterpartIsConsultant: _bool(data, const [
|
||||
'counterpartIsConsultant',
|
||||
'counterpart_is_consultant',
|
||||
'is_consultant',
|
||||
'isConsultant',
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,9 +134,18 @@ class VideoCallPushPayload {
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool? _bool(Map<String, dynamic> map, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
if (!map.containsKey(key)) continue;
|
||||
return _isTruthy(map[key]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool _isTruthy(Object? value) {
|
||||
if (value == null) return false;
|
||||
if (value is bool) return value;
|
||||
if (value is num) return value != 0;
|
||||
final normalized = value.toString().trim().toLowerCase();
|
||||
return normalized == '1' ||
|
||||
normalized == 'true' ||
|
||||
|
||||
@@ -877,7 +877,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"video_call_chat": MessageLookupByLibrary.simpleMessage("Chat"),
|
||||
"video_call_close": MessageLookupByLibrary.simpleMessage("Close"),
|
||||
"video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage(
|
||||
"It looks like there is an internet issue. We\'re trying to restore the connection...",
|
||||
"It looks like there is an internet issue. Tap Retry to reconnect.",
|
||||
),
|
||||
"video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage(
|
||||
"Connection lost",
|
||||
|
||||
@@ -357,7 +357,7 @@ class MessageLookup extends MessageLookupByLibrary {
|
||||
"video_call_chat": MessageLookupByLibrary.simpleMessage("Чат"),
|
||||
"video_call_close": MessageLookupByLibrary.simpleMessage("Закрыть"),
|
||||
"video_call_connection_lost_message": MessageLookupByLibrary.simpleMessage(
|
||||
"Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...",
|
||||
"Похоже, возникли проблемы с интернетом. Нажмите «Повторить», чтобы переподключиться.",
|
||||
),
|
||||
"video_call_connection_lost_title": MessageLookupByLibrary.simpleMessage(
|
||||
"Связь прервалась",
|
||||
|
||||
@@ -4239,10 +4239,10 @@ class S {
|
||||
);
|
||||
}
|
||||
|
||||
/// `It looks like there is an internet issue. We're trying to restore the connection...`
|
||||
/// `It looks like there is an internet issue. Tap Retry to reconnect.`
|
||||
String get video_call_connection_lost_message {
|
||||
return Intl.message(
|
||||
'It looks like there is an internet issue. We\'re trying to restore the connection...',
|
||||
'It looks like there is an internet issue. Tap Retry to reconnect.',
|
||||
name: 'video_call_connection_lost_message',
|
||||
desc: '',
|
||||
args: [],
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
"video_call_screen_reconnecting_title": "Reconnecting",
|
||||
"video_call_screen_reconnecting_subtitle": "Check your internet and wait",
|
||||
"video_call_connection_lost_title": "Connection lost",
|
||||
"video_call_connection_lost_message": "It looks like there is an internet issue. We're trying to restore the connection...",
|
||||
"video_call_connection_lost_message": "It looks like there is an internet issue. Tap Retry to reconnect.",
|
||||
"video_call_try_again": "Retry",
|
||||
"video_call_enter": "Enter",
|
||||
"video_call_close": "Close",
|
||||
|
||||
@@ -243,7 +243,7 @@
|
||||
"video_call_screen_reconnecting_title": "Восстанавливаем соединение",
|
||||
"video_call_screen_reconnecting_subtitle": "Проверьте интернет и подождите",
|
||||
"video_call_connection_lost_title": "Связь прервалась",
|
||||
"video_call_connection_lost_message": "Похоже, возникли проблемы с интернетом. Мы пытаемся восстановить соединение...",
|
||||
"video_call_connection_lost_message": "Похоже, возникли проблемы с интернетом. Нажмите «Повторить», чтобы переподключиться.",
|
||||
"video_call_try_again": "Повторить",
|
||||
"video_call_enter": "Войти",
|
||||
"video_call_close": "Закрыть",
|
||||
|
||||
@@ -17,6 +17,18 @@ void main() {
|
||||
expect(payload.counterpartAvatarUrl, 'https://cdn.example/avatar.jpg');
|
||||
});
|
||||
|
||||
test('parses is_consultant flag from push payload', () {
|
||||
final payload = VideoCallPushPayload.tryParse({
|
||||
'type': 'video_call_incoming',
|
||||
'conversation_uuid': 'abc-123',
|
||||
'caller_name': 'Anna',
|
||||
'is_consultant': 'true',
|
||||
});
|
||||
|
||||
expect(payload, isNotNull);
|
||||
expect(payload!.counterpartIsConsultant, isTrue);
|
||||
});
|
||||
|
||||
test('parses action_url with video-call path', () {
|
||||
final payload = VideoCallPushPayload.tryParse({
|
||||
'action_url': 'https://eatvio.ru/video-call-screen?uuid=room-42',
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart';
|
||||
import 'package:eatvio_app/generated/l10n.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
void main() {
|
||||
@@ -18,4 +20,30 @@ void main() {
|
||||
|
||||
expect(find.text('Alex'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('VideoCallParticipantNameLabel renders consultant role above name', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
localizationsDelegates: const [
|
||||
S.delegate,
|
||||
...GlobalMaterialLocalizations.delegates,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: S.delegate.supportedLocales,
|
||||
home: const Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
VideoCallParticipantNameLabel(
|
||||
name: 'Alex',
|
||||
isConsultant: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(find.text('Alex'), findsOneWidget);
|
||||
expect(find.text('Eatvio consultant'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user