feat(video_calls): fixed recinnecting logic, added consultant bage

This commit is contained in:
misha
2026-06-26 08:56:57 +03:00
parent 59f42c9b1e
commit bde4eea7d1
22 changed files with 272 additions and 57 deletions

View File

@@ -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)

View File

@@ -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
{

View File

@@ -69,6 +69,7 @@ class PushNavigationHandler {
counterpartName: videoCallPayload.counterpartName,
counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl,
localParticipantName: videoCallPayload.localParticipantName,
counterpartIsConsultant: videoCallPayload.counterpartIsConsultant,
entryMode: VideoCallEntryMode.incomingAccepted,
),
);

View File

@@ -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()?,

View File

@@ -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>((_) {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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,
),
),
),
),
),
],
),
),
);

View File

@@ -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,

View File

@@ -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(

View File

@@ -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));

View File

@@ -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(

View File

@@ -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,
},
);

View File

@@ -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' ||

View File

@@ -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",

View File

@@ -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(
"Связь прервалась",

View File

@@ -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: [],

View File

@@ -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",

View File

@@ -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": "Закрыть",

View File

@@ -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',

View File

@@ -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);
});
}