From bde4eea7d125901f3810f61fb9ceb4754c3779cd Mon Sep 17 00:00:00 2001 From: misha Date: Fri, 26 Jun 2026 08:56:57 +0300 Subject: [PATCH] feat(video_calls): fixed recinnecting logic, added consultant bage --- .../com/eatvioapp/app/VideoCallPushBridge.kt | 9 +++ ios/Runner/AppDelegate.swift | 2 +- .../push_navigation_handler.dart | 1 + lib/core/routing/other_routes.dart | 1 + .../managers/video_call_manager.dart | 31 +++++++-- .../video_call_session_cubit.dart | 29 ++++++-- .../video_call_session_state.dart | 7 ++ .../presentation/pages/video_call_screen.dart | 48 +++++++------ .../widgets/video_call_name_chips.dart | 68 ++++++++++++++----- .../widgets/video_call_stage_layout.dart | 11 ++- .../video_call_system_pip_content.dart | 4 +- .../video_call_callkit_coordinator.dart | 41 +++++++++++ .../video_call_incoming_push_presenter.dart | 2 + .../video_call_notification_navigator.dart | 6 ++ .../services/video_call_push_payload.dart | 17 +++++ lib/generated/intl/messages_en.dart | 2 +- lib/generated/intl/messages_ru.dart | 2 +- lib/generated/l10n.dart | 4 +- lib/l10n/intl_en.arb | 2 +- lib/l10n/intl_ru.arb | 2 +- .../video_call_push_payload_test.dart | 12 ++++ .../widgets/video_call_name_label_test.dart | 28 ++++++++ 22 files changed, 272 insertions(+), 57 deletions(-) diff --git a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt index b4272684..358f0540 100644 --- a/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt +++ b/android/app/src/main/kotlin/com/eatvioapp/app/VideoCallPushBridge.kt @@ -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() 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) diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index e1b521eb..15dfb671 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -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 { diff --git a/lib/core/notifications/push_navigation_handler.dart b/lib/core/notifications/push_navigation_handler.dart index ad0286a1..e44c32ab 100644 --- a/lib/core/notifications/push_navigation_handler.dart +++ b/lib/core/notifications/push_navigation_handler.dart @@ -69,6 +69,7 @@ class PushNavigationHandler { counterpartName: videoCallPayload.counterpartName, counterpartAvatarUrl: videoCallPayload.counterpartAvatarUrl, localParticipantName: videoCallPayload.localParticipantName, + counterpartIsConsultant: videoCallPayload.counterpartIsConsultant, entryMode: VideoCallEntryMode.incomingAccepted, ), ); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index d650445d..db8db78b 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -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()?, diff --git a/lib/features/video_calls/managers/video_call_manager.dart b/lib/features/video_calls/managers/video_call_manager.dart index 64834455..6bd2294b 100644 --- a/lib/features/video_calls/managers/video_call_manager.dart +++ b/lib/features/video_calls/managers/video_call_manager.dart @@ -72,6 +72,7 @@ class VideoCallManager { String? _preparedToken; bool _videoEnabledBeforeBackground = false; bool _videoPausedForBackground = false; + bool _abortingLiveKitAutoReconnect = false; Future? _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 _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((_) { - if (_snapshot.connectPhase != VideoCallConnectPhase.connected) return; - _emitSnapshot( - _snapshot.copyWith(linkIssue: VideoCallLinkIssue.reconnecting), - ); + unawaited(_abortLiveKitAutoReconnect()); }) ..on((_) { + if (_snapshot.linkIssue.needsManualReconnect) return; _emitSnapshot(_snapshot.copyWith(linkIssue: VideoCallLinkIssue.none)); }) ..on((_) { diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart index e1a37e71..2ad362d1 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart @@ -42,7 +42,7 @@ class VideoCallSessionCubit extends BaseCubit { ) { 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 { required String conversationUuid, String? counterpartName, String? localParticipantName, + bool? counterpartIsConsultant, VideoCallEntryMode entryMode = VideoCallEntryMode.outgoing, }) async { _callScreenRouteVisible = true; @@ -153,6 +154,8 @@ class VideoCallSessionCubit extends BaseCubit { counterpartName: counterpartName ?? state.counterpartName, localParticipantName: localParticipantName ?? state.localParticipantName, + counterpartIsConsultant: + counterpartIsConsultant ?? state.counterpartIsConsultant, entryMode: effectiveEntryMode, ), ); @@ -175,12 +178,16 @@ class VideoCallSessionCubit extends BaseCubit { 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 { 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 { } } 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 _applyPipUiState() async { @@ -392,7 +399,7 @@ class VideoCallSessionCubit extends BaseCubit { Future 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 { } } + void updateCounterpartIsConsultant(bool isConsultant) { + if (isClosed || state.counterpartIsConsultant == isConsultant) return; + emit(state.copyWith(counterpartIsConsultant: isConsultant)); + } + Future enterCallStage() async { if (isClosed) return; if (state.connectPhase != VideoCallConnectPhase.connected) return; @@ -432,8 +444,15 @@ class VideoCallSessionCubit extends BaseCubit { Future 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 endCall() async { diff --git a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart index cc204991..28b7fabc 100644 --- a/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart +++ b/lib/features/video_calls/presentation/bloc/video_call_session/video_call_session_state.dart @@ -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, diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart index 459b85c2..76b3934d 100644 --- a/lib/features/video_calls/presentation/pages/video_call_screen.dart +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -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 { _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 _initCounterpartOnlineStatus() async { + Future _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().getChatRoom( widget.conversationUuid, @@ -110,6 +116,17 @@ class _VideoCallScreenState extends State { isOnline = counterpart.isOnline; lastSeenText = counterpart.lastSeenText; counterpartUserId = counterpart.id; + + if (counterpartIsConsultant == null) { + try { + final profile = await sl().getProfileBySlug( + counterpart.slug, + ); + counterpartIsConsultant = profile.roles.contains('consultant'); + } catch (_) { + counterpartIsConsultant = false; + } + } } catch (_) {} } @@ -118,6 +135,9 @@ class _VideoCallScreenState extends State { _counterpartIsOnline = isOnline; _counterpartLastSeenText = lastSeenText; }); + if (counterpartIsConsultant != null) { + _cubit.updateCounterpartIsConsultant(counterpartIsConsultant); + } if (counterpartUserId == null) return; @@ -207,6 +227,7 @@ class _VideoCallScreenState extends State { 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 { 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 { ); } - final showReconnecting = - sessionState.connectPhase == VideoCallConnectPhase.connected && - sessionState.linkIssue == VideoCallLinkIssue.reconnecting; - - if (showReconnecting) { - return AnnotatedRegion( - 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 { connectError: sessionState.connectError, localDisplayName: _resolveLocalDisplayName(sessionState), remoteDisplayName: _resolveRemoteDisplayName(sessionState), + remoteIsConsultant: sessionState.counterpartIsConsultant, micEnabled: sessionState.micEnabled, videoEnabled: sessionState.videoEnabled, splitParticipantView: sessionState.splitParticipantView, diff --git a/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart b/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart index ff4829eb..389af26e 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart @@ -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, + ), + ), ), ), - ), + ], ), ), ); diff --git a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart index feac9376..131c7a55 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart @@ -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 { 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 { 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, diff --git a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart index fbcc16fd..3abcb415 100644 --- a/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart +++ b/lib/features/video_calls/presentation/widgets/video_call_system_pip_content.dart @@ -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( diff --git a/lib/features/video_calls/services/video_call_callkit_coordinator.dart b/lib/features/video_calls/services/video_call_callkit_coordinator.dart index 036103ba..3c5b1ad0 100644 --- a/lib/features/video_calls/services/video_call_callkit_coordinator.dart +++ b/lib/features/video_calls/services/video_call_callkit_coordinator.dart @@ -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 map, List 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 _stringKeyedMap(Object? value) { if (value is! Map) return {}; return value.map((key, value) => MapEntry(key.toString(), value)); diff --git a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart index 4fb6bf2e..9136a26d 100644 --- a/lib/features/video_calls/services/video_call_incoming_push_presenter.dart +++ b/lib/features/video_calls/services/video_call_incoming_push_presenter.dart @@ -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( diff --git a/lib/features/video_calls/services/video_call_notification_navigator.dart b/lib/features/video_calls/services/video_call_notification_navigator.dart index 612e032f..a9efde95 100644 --- a/lib/features/video_calls/services/video_call_notification_navigator.dart +++ b/lib/features/video_calls/services/video_call_notification_navigator.dart @@ -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, }, ); diff --git a/lib/features/video_calls/services/video_call_push_payload.dart b/lib/features/video_calls/services/video_call_push_payload.dart index 3b0decac..ceb0767a 100644 --- a/lib/features/video_calls/services/video_call_push_payload.dart +++ b/lib/features/video_calls/services/video_call_push_payload.dart @@ -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 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 map, List 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' || diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index aca4b29f..37ffb25c 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -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", diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index a0e9f849..0f59a0dc 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -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( "Связь прервалась", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index acaa66ec..5fdc1ea4 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -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: [], diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 276785c5..8dba037c 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -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", diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index f854b851..9b4fefd4 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -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": "Закрыть", diff --git a/test/features/video_calls/services/video_call_push_payload_test.dart b/test/features/video_calls/services/video_call_push_payload_test.dart index 9ddae36e..91b3e8d6 100644 --- a/test/features/video_calls/services/video_call_push_payload_test.dart +++ b/test/features/video_calls/services/video_call_push_payload_test.dart @@ -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', diff --git a/test/features/video_calls/widgets/video_call_name_label_test.dart b/test/features/video_calls/widgets/video_call_name_label_test.dart index bf759ad6..6332785e 100644 --- a/test/features/video_calls/widgets/video_call_name_label_test.dart +++ b/test/features/video_calls/widgets/video_call_name_label_test.dart @@ -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); + }); }