diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3a6b34c3..116b494b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + diff --git a/assets/icons/video_call/eatvio_c.png b/assets/icons/video_call/eatvio_c.png new file mode 100644 index 00000000..cb6ded31 Binary files /dev/null and b/assets/icons/video_call/eatvio_c.png differ diff --git a/assets/icons/video_call/poor_connection.svg b/assets/icons/video_call/poor_connection.svg new file mode 100644 index 00000000..f0f63151 --- /dev/null +++ b/assets/icons/video_call/poor_connection.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/video_call/vc_cam_off.svg b/assets/icons/video_call/vc_cam_off.svg new file mode 100644 index 00000000..e1f05f17 --- /dev/null +++ b/assets/icons/video_call/vc_cam_off.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/video_call/vc_camera_switch.png b/assets/icons/video_call/vc_camera_switch.png new file mode 100644 index 00000000..903df149 Binary files /dev/null and b/assets/icons/video_call/vc_camera_switch.png differ diff --git a/assets/icons/video_call/vc_control_chat.png b/assets/icons/video_call/vc_control_chat.png new file mode 100644 index 00000000..10920730 Binary files /dev/null and b/assets/icons/video_call/vc_control_chat.png differ diff --git a/assets/icons/video_call/vc_control_leave.png b/assets/icons/video_call/vc_control_leave.png new file mode 100644 index 00000000..6371e59c Binary files /dev/null and b/assets/icons/video_call/vc_control_leave.png differ diff --git a/assets/icons/video_call/vc_control_mic.png b/assets/icons/video_call/vc_control_mic.png new file mode 100644 index 00000000..94a2698f Binary files /dev/null and b/assets/icons/video_call/vc_control_mic.png differ diff --git a/assets/icons/video_call/vc_control_mic_off.png b/assets/icons/video_call/vc_control_mic_off.png new file mode 100644 index 00000000..c69437c6 Binary files /dev/null and b/assets/icons/video_call/vc_control_mic_off.png differ diff --git a/assets/icons/video_call/vc_control_sound_active.png b/assets/icons/video_call/vc_control_sound_active.png new file mode 100644 index 00000000..f1b11e53 Binary files /dev/null and b/assets/icons/video_call/vc_control_sound_active.png differ diff --git a/assets/icons/video_call/vc_control_video.png b/assets/icons/video_call/vc_control_video.png new file mode 100644 index 00000000..beeb6079 Binary files /dev/null and b/assets/icons/video_call/vc_control_video.png differ diff --git a/assets/icons/video_call/vc_expand.png b/assets/icons/video_call/vc_expand.png new file mode 100644 index 00000000..2238c522 Binary files /dev/null and b/assets/icons/video_call/vc_expand.png differ diff --git a/assets/icons/video_call/vc_expand.svg b/assets/icons/video_call/vc_expand.svg new file mode 100644 index 00000000..4f49f741 --- /dev/null +++ b/assets/icons/video_call/vc_expand.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/video_call/vc_mic_off.svg b/assets/icons/video_call/vc_mic_off.svg new file mode 100644 index 00000000..a821f20e --- /dev/null +++ b/assets/icons/video_call/vc_mic_off.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/icons/video_call/vc_mic_on.svg b/assets/icons/video_call/vc_mic_on.svg new file mode 100644 index 00000000..01c6cb27 --- /dev/null +++ b/assets/icons/video_call/vc_mic_on.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/video_call/vc_shrink.svg b/assets/icons/video_call/vc_shrink.svg new file mode 100644 index 00000000..be6faeaf --- /dev/null +++ b/assets/icons/video_call/vc_shrink.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/video_call/warning.svg b/assets/icons/video_call/warning.svg new file mode 100644 index 00000000..b2ae71c1 --- /dev/null +++ b/assets/icons/video_call/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 182a74af..30bbd104 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -35,6 +35,8 @@ This permission is not needed by the app, but it is required by an underlying API. If you see this dialog, contact us. NSCameraUsageDescription Предоставление доступа позволит Вам делать фотографии и добавлять к записям + NSMicrophoneUsageDescription + Нужен для звука в видеозвонках с консультантом NSLocationWhenInUseUsageDescription NSPhotoLibraryUsageDescription diff --git a/lib/core/di/repository_registrations.dart b/lib/core/di/repository_registrations.dart index 341691a5..002593be 100644 --- a/lib/core/di/repository_registrations.dart +++ b/lib/core/di/repository_registrations.dart @@ -33,6 +33,7 @@ import 'package:eatvio_app/features/recipe/repositories/my_recipes_repository.da import 'package:eatvio_app/features/registration/repositories/registration_repository.dart'; import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart'; import 'package:eatvio_app/features/settings/repositories/service_repository.dart'; +import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; import 'package:get_it/get_it.dart'; /// Регистрация всех Repository в системе внедрения зависимостей @@ -49,6 +50,7 @@ class RepositoryRegistrations { static void _registerSingletonRepositories() { // Core repositories sl.registerLazySingleton(() => ChatRepository(chatService: sl())); + sl.registerLazySingleton(() => VideoCallRepository(videoCallService: sl())); sl.registerLazySingleton(() => FeedRepository(feedService: sl())); sl.registerLazySingleton(() => ProfileRepository(profileService: sl())); sl.registerLazySingleton(() => CalendarRepository(calendarService: sl())); diff --git a/lib/core/di/service_registrations.dart b/lib/core/di/service_registrations.dart index dd1371b1..b6756bb1 100644 --- a/lib/core/di/service_registrations.dart +++ b/lib/core/di/service_registrations.dart @@ -35,6 +35,7 @@ import 'package:eatvio_app/features/recipe/services/my_recipes_service.dart'; import 'package:eatvio_app/features/registration/services/registration_service.dart'; import 'package:eatvio_app/features/registration_v2/services/registration_service_v2.dart'; import 'package:eatvio_app/features/settings/services/settings_service.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; import 'package:get_it/get_it.dart'; /// Регистрация всех Service в системе внедрения зависимостей @@ -50,6 +51,7 @@ class ServiceRegistrations { static void _registerSingletonServices() { // Core services sl.registerLazySingleton(() => ChatService(sl())); + sl.registerLazySingleton(() => VideoCallService(sl())); sl.registerLazySingleton(() => FeedService(sl())); sl.registerLazySingleton(() => ProfileService(sl())); sl.registerLazySingleton(() => CalendarService(sl())); diff --git a/lib/core/routing/other_routes.dart b/lib/core/routing/other_routes.dart index 58369798..b321a15c 100644 --- a/lib/core/routing/other_routes.dart +++ b/lib/core/routing/other_routes.dart @@ -14,6 +14,7 @@ import 'package:eatvio_app/features/diary/presentation/pages/diary_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/empty_chat_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/payment_screen.dart'; import 'package:eatvio_app/features/chat/presentation/pages/photo_view_screen.dart'; +import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant/become_consultant/presentation/pages/stepper_become_consultant_screen.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart'; @@ -224,6 +225,27 @@ class OtherRoutes { name: 'emptyChatScreen', builder: (context, state) => EmptyChatScreen(user: state.extra as ProfileModel), ), + GoRoute( + path: RouteConstants.videoCallScreen, + name: VideoCallScreen.name, + builder: (context, state) { + final extra = state.extra; + if (extra is Map) { + return VideoCallScreen( + conversationUuid: extra['conversationUuid'] as String, + counterpartName: extra['counterpartName'] as String?, + localParticipantName: extra['localParticipantName'] as String?, + onBackToChat: extra['onBackToChat'] as void Function()?, + onHangUp: extra['onHangUp'] as void Function()?, + ); + } + final uuid = state.uri.queryParameters['uuid']; + if (uuid != null) { + return VideoCallScreen(conversationUuid: uuid); + } + throw ArgumentError('VideoCallScreen: pass extra Map with conversationUuid or query uuid'); + }, + ), // Consultant routes GoRoute(path: RouteConstants.becomeConsultantScreen, name: 'becomeConsultantScreen', builder: (context, state) => const BecomeConsultantScreen()), diff --git a/lib/core/routing/route_constants.dart b/lib/core/routing/route_constants.dart index 4ce78f4f..7b99b140 100644 --- a/lib/core/routing/route_constants.dart +++ b/lib/core/routing/route_constants.dart @@ -53,6 +53,7 @@ class RouteConstants { static const String photoViewScreen = '/photo-view-screen'; static const String chatScreen = '/chat-screen'; static const String emptyChatScreen = '/empty-chat-screen'; + static const String videoCallScreen = '/video-call-screen'; static const String glossaryScreen = '/glossary-screen'; static const String blockedUsersScreen = '/blocked-users-screen'; static const String notificationsSettingsScreen = '/notifications-settings-screen'; diff --git a/lib/core/theme/theme_helper.dart b/lib/core/theme/theme_helper.dart index d1344e62..957ae616 100644 --- a/lib/core/theme/theme_helper.dart +++ b/lib/core/theme/theme_helper.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; - /// Helper class for managing themes and colors. class ThemeHelper { // The current app theme @@ -200,6 +199,12 @@ class PrimaryColors { Color get grey7B => const Color(0xFF7B7B7B); Color get greyD9 => const Color(0xFFD9D9D9); + + Color get videoCallControlOffBackground => const Color(0xFFED2F2F); + + Color get videoCallPipIconBackground => const Color(0xFF565656); + + Color get videoCallNoticeBannerBackground => const Color(0xFF9D9D9D); } PrimaryColors get appTheme => ThemeHelper().themeColor(); diff --git a/lib/features/chat/presentation/pages/chat_screen.dart b/lib/features/chat/presentation/pages/chat_screen.dart index 423b6980..4c7b96e6 100644 --- a/lib/features/chat/presentation/pages/chat_screen.dart +++ b/lib/features/chat/presentation/pages/chat_screen.dart @@ -24,6 +24,7 @@ import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/order_info/presentation/info_by_order_screen.dart'; import 'package:eatvio_app/features/plans/enums/chosen_plan_state_enum.dart'; import 'package:eatvio_app/features/user_details/presentation/user_details_screen.dart'; +import 'package:eatvio_app/features/video_calls/presentation/pages/video_call_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/cupertino.dart'; @@ -233,6 +234,23 @@ class _ChatScreenState extends State { ], ), ), + actions: [ + Padding( + padding: const EdgeInsetsDirectional.only(end: 8), + child: _ChatHeaderVideoCallButton( + onPressed: () { + GoRouter.of(context).pushNamed( + VideoCallScreen.name, + extra: { + 'conversationUuid': widget.uuid, + 'counterpartName': state.chatRoom.users.receiver.name, + 'localParticipantName': state.chatRoom.users.sender.name, + }, + ); + }, + ), + ), + ], ), body: SafeArea( child: Stack( @@ -741,3 +759,35 @@ class _ChatScreenState extends State { ); } } + +class _ChatHeaderVideoCallButton extends StatelessWidget { + const _ChatHeaderVideoCallButton({required this.onPressed}); + + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tooltipMessage = S.maybeOf(context)?.video_call; + final button = Material( + color: theme.colorScheme.onPrimary, + borderRadius: BorderRadius.circular(20), + elevation: 0, + child: InkWell( + onTap: onPressed, + customBorder: const CircleBorder(), + child: SizedBox( + width: 40, + height: 40, + child: Center( + child: Icon(Icons.videocam_rounded, color: appTheme.blueA400, size: 18), + ), + ), + ), + ); + if (tooltipMessage == null || tooltipMessage.isEmpty) { + return button; + } + return Tooltip(message: tooltipMessage, child: button); + } +} diff --git a/lib/features/diets/presentation/pages/about_diet_screen.dart b/lib/features/diets/presentation/pages/about_diet_screen.dart index 3823b471..5498be61 100644 --- a/lib/features/diets/presentation/pages/about_diet_screen.dart +++ b/lib/features/diets/presentation/pages/about_diet_screen.dart @@ -106,9 +106,7 @@ class _AboutDietScreenState extends State { backgroundColor: appTheme.gray100, resizeToAvoidBottomInset: true, bottomSheet: Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + 8, - ), + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), diff --git a/lib/features/video_calls/models/video_call_models.dart b/lib/features/video_calls/models/video_call_models.dart new file mode 100644 index 00000000..7ab0a747 --- /dev/null +++ b/lib/features/video_calls/models/video_call_models.dart @@ -0,0 +1,37 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'video_call_models.freezed.dart'; +part 'video_call_models.g.dart'; + +@freezed +abstract class VideoCallTokenResponse with _$VideoCallTokenResponse { + const factory VideoCallTokenResponse({ + required String token, + required String roomName, + required String wsUrl, + }) = _VideoCallTokenResponse; + + factory VideoCallTokenResponse.fromJson(Map json) => + _$VideoCallTokenResponseFromJson(json); +} + +@freezed +abstract class VideoCallAdminTokenResponse with _$VideoCallAdminTokenResponse { + const factory VideoCallAdminTokenResponse({ + required String adminToken, + required String roomName, + required String apiUrl, + }) = _VideoCallAdminTokenResponse; + + factory VideoCallAdminTokenResponse.fromJson(Map json) => + _$VideoCallAdminTokenResponseFromJson(json); +} + +@freezed +abstract class VideoCallEndedResponse with _$VideoCallEndedResponse { + const factory VideoCallEndedResponse({required bool sent}) = + _VideoCallEndedResponse; + + factory VideoCallEndedResponse.fromJson(Map json) => + _$VideoCallEndedResponseFromJson(json); +} diff --git a/lib/features/video_calls/presentation/pages/video_call_screen.dart b/lib/features/video_calls/presentation/pages/video_call_screen.dart new file mode 100644 index 00000000..327bdafd --- /dev/null +++ b/lib/features/video_calls/presentation/pages/video_call_screen.dart @@ -0,0 +1,531 @@ +import 'dart:async'; + +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; +import 'package:eatvio_app/generated/l10n.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; +import 'package:permission_handler/permission_handler.dart'; + +class VideoCallScreen extends StatefulWidget { + const VideoCallScreen({ + super.key, + required this.conversationUuid, + this.counterpartName, + this.localParticipantName, + this.onBackToChat, + this.onHangUp, + }); + + static const String path = '/video-call-screen'; + static const String name = 'videoCallScreen'; + + final String conversationUuid; + final String? counterpartName; + final String? localParticipantName; + final VoidCallback? onBackToChat; + final VoidCallback? onHangUp; + + @override + State createState() => _VideoCallScreenState(); +} + +class _VideoCallScreenState extends State { + static const lk.AudioCaptureOptions _micCaptureOptions = lk.AudioCaptureOptions(stopAudioCaptureOnMute: false); + static const Duration _mutedMicPollInterval = Duration(milliseconds: 320); + static const Duration _mutedMicOverlayMinInterval = Duration(milliseconds: 2600); + static const double _mutedMicAudioLevelThreshold = 0.008; + static const double _mutedMicEnergyDelta = 1e-6; + + final lk.Room _room = lk.Room( + roomOptions: const lk.RoomOptions( + adaptiveStream: true, + defaultCameraCaptureOptions: lk.CameraCaptureOptions(params: lk.VideoParametersPresets.h1080_169), + defaultAudioCaptureOptions: _micCaptureOptions, + ), + ); + final VideoCallRepository _videoCallRepository = sl(); + + bool _micEnabled = true; + bool _videoEnabled = true; + bool _splitParticipantView = true; + bool _mainStageShowsLocalVideo = false; + bool _useFrontCamera = true; + String? _connectError; + bool _liveKitDisposed = false; + bool _callEndedNotified = false; + + lk.EventsListener? _roomEventsListener; + OverlayEntry? _poorConnectionEntry; + Timer? _mutedMicPollTimer; + DateTime? _lastMutedMicOverlayAt; + bool _recordingOverlayScheduled = false; + bool _mutedMicStatsInFlight = false; + num? _lastMutedMicTotalEnergy; + + lk.CameraCaptureOptions _cameraCaptureOptions({lk.CameraPosition? cameraPosition}) { + return lk.CameraCaptureOptions( + cameraPosition: cameraPosition ?? (_useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back), + params: lk.VideoParametersPresets.h1080_169, + ); + } + + @override + void initState() { + super.initState(); + _attachRoomEventListeners(); + _mutedMicPollTimer = Timer.periodic(_mutedMicPollInterval, (_) { + if (!mounted || _liveKitDisposed || _micEnabled || _mutedMicStatsInFlight) return; + if (_room.connectionState != lk.ConnectionState.connected) return; + _mutedMicStatsInFlight = true; + unawaited( + _pollMutedMicFromWebRtcStats().whenComplete(() { + _mutedMicStatsInFlight = false; + }), + ); + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + unawaited(_applyVideoCallSystemUi()); + _scheduleRecordingNoticeOnce(); + }); + unawaited(_connectLiveKit()); + } + + void _attachRoomEventListeners() { + _roomEventsListener = _room.createListener() + ..on((e) { + if (e.participant != _room.localParticipant) return; + if (!mounted) return; + _syncPoorConnectionOverlay(e.connectionQuality); + }) + ..on((e) { + if (!mounted || _micEnabled) return; + final lp = _room.localParticipant; + if (lp == null) return; + if (e.speakers.any((p) => p.sid == lp.sid)) { + _maybeShowMutedMicOverlay(); + } + }); + } + + void _scheduleRecordingNoticeOnce() { + if (_recordingOverlayScheduled || !mounted) return; + _recordingOverlayScheduled = true; + VideoCallTopOverlay.insertBanner( + context, + message: S.of(context).video_call_recording_notice, + backgroundColor: appTheme.videoCallNoticeBannerBackground, + svgAsset: VideoCallTopOverlay.warningSvg, + autoRemoveAfter: const Duration(seconds: 3), + ); + } + + void _syncPoorConnectionOverlay(lk.ConnectionQuality q) { + final bad = q == lk.ConnectionQuality.poor || q == lk.ConnectionQuality.lost; + if (bad) { + if (_poorConnectionEntry != null) return; + if (!mounted) return; + _poorConnectionEntry = VideoCallTopOverlay.insertBanner( + context, + message: S.of(context).video_call_poor_connection, + backgroundColor: appTheme.strongRed, + svgAsset: VideoCallTopOverlay.poorConnectionSvg, + ); + } else { + final e = _poorConnectionEntry; + if (e != null && e.mounted) { + e.remove(); + } + _poorConnectionEntry = null; + } + } + + void _maybeShowMutedMicOverlay() { + if (!mounted || _micEnabled) return; + final now = DateTime.now(); + if (_lastMutedMicOverlayAt != null && now.difference(_lastMutedMicOverlayAt!) < _mutedMicOverlayMinInterval) { + return; + } + _lastMutedMicOverlayAt = now; + VideoCallTopOverlay.insertBanner( + context, + message: S.of(context).video_call_muted_mic_warning, + backgroundColor: appTheme.videoCallNoticeBannerBackground, + svgAsset: VideoCallTopOverlay.warningSvg, + autoRemoveAfter: const Duration(seconds: 2), + ); + } + + Future _pollMutedMicFromWebRtcStats() async { + if (!mounted || _liveKitDisposed || _micEnabled) return; + if (_room.connectionState != lk.ConnectionState.connected) return; + final lp = _room.localParticipant; + if (lp == null) return; + final pub = lp.getTrackPublicationBySource(lk.TrackSource.microphone); + final track = pub?.track; + if (track is! lk.LocalAudioTrack) return; + if (!track.muted) return; + try { + final stats = await track.getSenderStats(); + if (!mounted || _liveKitDisposed || _micEnabled) return; + final src = stats?.audioSourceStats; + if (src == null) return; + + final level = src.audioLevel; + if (level != null && level >= _mutedMicAudioLevelThreshold) { + _maybeShowMutedMicOverlay(); + _lastMutedMicTotalEnergy = src.totalAudioEnergy; + return; + } + + final energy = src.totalAudioEnergy; + if (energy != null) { + final prev = _lastMutedMicTotalEnergy; + if (prev != null && energy > prev + _mutedMicEnergyDelta) { + _maybeShowMutedMicOverlay(); + } + _lastMutedMicTotalEnergy = energy; + } + } catch (_) {} + } + + Future _applyVideoCallSystemUi() async { + try { + await SystemChrome.setPreferredOrientations(const [ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + DeviceOrientation.landscapeLeft, + DeviceOrientation.landscapeRight, + ]); + if (!mounted) return; + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky); + } on PlatformException catch (_) { + if (!mounted) return; + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); + } + } + + Future _restoreSystemUiAfterCall() async { + try { + await SystemChrome.setPreferredOrientations(const []); + await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); + } on PlatformException catch (_) {} + } + + Future _connectLiveKit() async { + setState(() => _connectError = null); + try { + final tokenResponse = await _videoCallRepository.getParticipantToken(widget.conversationUuid); + if (!mounted) return; + + var micOn = _micEnabled; + var videoOn = _videoEnabled; + if (micOn) { + final granted = await _ensureMicrophonePermission(); + if (!mounted) return; + if (!granted) micOn = false; + } + if (videoOn) { + final granted = await _ensureCameraPermission(); + if (!mounted) return; + if (!granted) videoOn = false; + } + if (micOn != _micEnabled || videoOn != _videoEnabled) { + setState(() { + _micEnabled = micOn; + _videoEnabled = videoOn; + }); + } + + await _room.connect(tokenResponse.wsUrl, tokenResponse.token); + if (!mounted || _liveKitDisposed) { + return; + } + + var effectiveMic = micOn; + var effectiveVideo = videoOn; + final lp = _room.localParticipant; + + if (micOn) { + try { + await lp?.setMicrophoneEnabled(true, audioCaptureOptions: _micCaptureOptions); + } catch (_) { + effectiveMic = false; + } + } + if (!mounted || _liveKitDisposed) return; + + if (videoOn) { + try { + await lp?.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions()); + } catch (_) { + effectiveVideo = false; + } + } + + if (effectiveMic != _micEnabled || effectiveVideo != _videoEnabled) { + setState(() { + _micEnabled = effectiveMic; + _videoEnabled = effectiveVideo; + }); + } + + if (mounted) { + _syncPoorConnectionOverlay(_room.localParticipant?.connectionQuality ?? lk.ConnectionQuality.unknown); + } + } catch (e) { + if (mounted) setState(() => _connectError = e.toString()); + } + } + + Future _disconnectLiveKitOnly() async { + if (_liveKitDisposed) return; + _liveKitDisposed = true; + try { + if (_room.connectionState != lk.ConnectionState.disconnected) { + await _room.disconnect(); + } + } catch (_) {} + try { + await _room.dispose(); + } catch (_) {} + } + + Future _notifyCallEndedOnce() async { + if (_callEndedNotified) return; + _callEndedNotified = true; + try { + await _videoCallRepository.notifyCallEnded(widget.conversationUuid); + } catch (_) {} + } + + Future _cleanupLiveKitAndNotifyBackend() async { + await _disconnectLiveKitOnly(); + await _notifyCallEndedOnce(); + } + + @override + void dispose() { + _mutedMicPollTimer?.cancel(); + final listener = _roomEventsListener; + _roomEventsListener = null; + if (listener != null) { + unawaited(listener.dispose()); + } + final poor = _poorConnectionEntry; + _poorConnectionEntry = null; + if (poor != null && poor.mounted) { + poor.remove(); + } + unawaited(_cleanupLiveKitAndNotifyBackend()); + unawaited(_restoreSystemUiAfterCall()); + super.dispose(); + } + + Future _handleBackToChat() async { + widget.onBackToChat?.call(); + await _cleanupLiveKitAndNotifyBackend(); + if (!mounted) return; + if (context.canPop()) context.pop(); + } + + Future _handleHangUp() async { + widget.onHangUp?.call(); + await _cleanupLiveKitAndNotifyBackend(); + if (!mounted) return; + if (context.canPop()) context.pop(); + } + + Future _toggleMic() async { + if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) { + setState(() => _micEnabled = !_micEnabled); + _lastMutedMicTotalEnergy = null; + return; + } + final next = !_micEnabled; + if (next) { + final granted = await _ensureMicrophonePermission(); + if (!mounted) return; + if (!granted) return; + } + setState(() => _micEnabled = next); + _lastMutedMicTotalEnergy = null; + await _room.localParticipant?.setMicrophoneEnabled(next, audioCaptureOptions: _micCaptureOptions); + } + + Future _toggleVideo() async { + if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) { + setState(() => _videoEnabled = !_videoEnabled); + return; + } + final next = !_videoEnabled; + if (next) { + final granted = await _ensureCameraPermission(); + if (!mounted) return; + if (!granted) return; + } + setState(() => _videoEnabled = next); + final lp = _room.localParticipant; + if (lp == null) return; + if (next) { + await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions()); + } else { + await lp.setCameraEnabled(false); + } + } + + Future _switchCameraFacing() async { + if (_liveKitDisposed || _room.connectionState != lk.ConnectionState.connected) { + setState(() => _useFrontCamera = !_useFrontCamera); + return; + } + if (!_videoEnabled) return; + + final lp = _room.localParticipant; + if (lp == null) return; + + final currentPosition = _useFrontCamera ? lk.CameraPosition.front : lk.CameraPosition.back; + final newPosition = currentPosition.switched(); + + final pub = lp.getTrackPublicationBySource(lk.TrackSource.camera); + final track = pub?.track; + if (track is lk.LocalVideoTrack) { + try { + await track.setCameraPosition(newPosition); + if (mounted) { + setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front); + } + } catch (_) {} + return; + } + + try { + await lp.setCameraEnabled(true, cameraCaptureOptions: _cameraCaptureOptions(cameraPosition: newPosition)); + if (mounted) { + setState(() => _useFrontCamera = newPosition == lk.CameraPosition.front); + } + } catch (_) {} + } + + bool _permissionNeedsSettings(PermissionStatus status) => status.isPermanentlyDenied || status.isRestricted; + + Future _ensureCameraPermission() async { + var status = await Permission.camera.status; + if (status.isGranted) return true; + status = await Permission.camera.request(); + if (status.isGranted) return true; + if (!mounted) return false; + final s = S.of(context); + await showErrorDialog( + context, + title: s.video_call_permission_camera_title, + description: s.video_call_permission_camera_description, + buttonTitle: s.allowAccess, + needToOpenSettings: _permissionNeedsSettings(status), + ); + return false; + } + + Future _ensureMicrophonePermission() async { + var status = await Permission.microphone.status; + if (status.isGranted) return true; + status = await Permission.microphone.request(); + if (status.isGranted) return true; + if (!mounted) return false; + final s = S.of(context); + await showErrorDialog( + context, + title: s.video_call_permission_microphone_title, + description: s.video_call_permission_microphone_description, + buttonTitle: s.allowAccess, + needToOpenSettings: _permissionNeedsSettings(status), + ); + return false; + } + + String _resolvedLocalDisplayName(BuildContext context) { + final fromNav = widget.localParticipantName?.trim(); + if (fromNav != null && fromNav.isNotEmpty) return fromNav; + final n = (_room.localParticipant?.name ?? '').trim(); + if (n.isNotEmpty) return n; + return S.of(context).video_call_you; + } + + String _resolvedRemoteDisplayName(BuildContext context, String fallback) { + for (final p in _room.remoteParticipants.values) { + final n = p.name.trim(); + if (n.isNotEmpty) return n; + } + final c = widget.counterpartName?.trim(); + if (c != null && c.isNotEmpty) return c; + return fallback; + } + + @override + Widget build(BuildContext context) { + final s = S.of(context); + final viewPadding = MediaQuery.viewPaddingOf(context); + final topInset = viewPadding.top > 0 ? viewPadding.top : 16.0; + final bottomInset = viewPadding.bottom; + + final counterpartFallback = s.video_call_counterpart_default; + final controlLabelStyle = TextStyles.textSMedium.copyWith(color: appTheme.whiteA700, height: 20 / 14); + + return AnnotatedRegion( + value: const SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: Brightness.light, + statusBarBrightness: Brightness.dark, + systemNavigationBarColor: Colors.transparent, + systemNavigationBarIconBrightness: Brightness.light, + systemNavigationBarContrastEnforced: false, + ), + child: Scaffold( + backgroundColor: appTheme.whiteA700, + body: ListenableBuilder( + listenable: _room, + builder: (context, _) { + return VideoCallStageLayout( + room: _room, + connectError: _connectError, + localDisplayName: _resolvedLocalDisplayName(context), + remoteDisplayName: _resolvedRemoteDisplayName(context, counterpartFallback), + micEnabled: _micEnabled, + videoEnabled: _videoEnabled, + splitParticipantView: _splitParticipantView, + mainStageShowsLocalVideo: _mainStageShowsLocalVideo, + useFrontCamera: _useFrontCamera, + onExpandLocal: () => setState(() { + _splitParticipantView = false; + _mainStageShowsLocalVideo = true; + }), + onExpandRemote: () => setState(() { + _splitParticipantView = false; + _mainStageShowsLocalVideo = false; + }), + onShrinkFromFullscreen: () => setState(() => _splitParticipantView = true), + onToggleStageSwap: () => setState(() => _mainStageShowsLocalVideo = !_mainStageShowsLocalVideo), + onChat: () => unawaited(_handleBackToChat()), + onToggleMic: () => unawaited(_toggleMic()), + onToggleVideo: () => unawaited(_toggleVideo()), + onHangUp: () => unawaited(_handleHangUp()), + onSwitchCamera: () => unawaited(_switchCameraFacing()), + controlLabelStyle: controlLabelStyle, + labels: (chat: s.video_call_chat, sound: s.video_call_sound, video: s.video_call_video, leave: s.video_call_leave), + topInset: topInset, + bottomInset: bottomInset, + ); + }, + ), + ), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_controls.dart b/lib/features/video_calls/presentation/widgets/video_call_controls.dart new file mode 100644 index 00000000..4db0b713 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_controls.dart @@ -0,0 +1,147 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +typedef VideoCallControlLabels = ({String chat, String sound, String video, String leave}); + +class VideoCallControlColumn extends StatelessWidget { + const VideoCallControlColumn({ + super.key, + required this.micEnabled, + required this.videoEnabled, + required this.onChat, + required this.onToggleMic, + required this.onToggleVideo, + required this.onHangUp, + required this.labels, + required this.controlLabelStyle, + }); + + final bool micEnabled; + final bool videoEnabled; + final VoidCallback onChat; + final VoidCallback onToggleMic; + final VoidCallback onToggleVideo; + final VoidCallback onHangUp; + final VideoCallControlLabels labels; + final TextStyle controlLabelStyle; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + VideoCallControlTile( + label: labels.chat, + labelStyle: controlLabelStyle, + onTap: onChat, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: Image.asset('assets/icons/video_call/vc_control_chat.png', width: 48, height: 48, fit: BoxFit.cover), + ), + ), + const SizedBox(height: 22), + VideoCallControlTile( + label: labels.sound, + labelStyle: controlLabelStyle, + onTap: onToggleMic, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: micEnabled + ? ColoredBox( + color: appTheme.blueA400, + child: Center(child: SvgPicture.asset('assets/icons/video_call/vc_mic_on.svg', width: 28, height: 28)), + ) + : ColoredBox( + color: appTheme.videoCallControlOffBackground, + child: Center(child: SvgPicture.asset('assets/icons/video_call/vc_mic_off.svg', width: 28, height: 28)), + ), + ), + ), + ), + const SizedBox(height: 22), + VideoCallControlTile( + label: labels.video, + labelStyle: controlLabelStyle, + onTap: onToggleVideo, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: videoEnabled + ? Image.asset('assets/icons/video_call/vc_control_video.png', fit: BoxFit.cover) + : ColoredBox( + color: appTheme.videoCallControlOffBackground, + child: Center(child: SvgPicture.asset('assets/icons/video_call/vc_cam_off.svg', width: 28, height: 28)), + ), + ), + ), + ), + const SizedBox(height: 22), + VideoCallControlTile( + label: labels.leave, + labelStyle: controlLabelStyle, + onTap: onHangUp, + face: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: SizedBox( + width: 48, + height: 48, + child: Image.asset('assets/icons/video_call/vc_control_leave.png', fit: BoxFit.cover, alignment: const Alignment(0, -0.82)), + ), + ), + ), + ], + ); + } +} + +class VideoCallControlTile extends StatelessWidget { + const VideoCallControlTile({super.key, required this.label, required this.labelStyle, required this.onTap, required this.face}); + + final String label; + final TextStyle labelStyle; + final VoidCallback onTap; + final Widget face; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(width: 48, height: 48, child: face), + const SizedBox(height: 6), + Text(label, style: labelStyle), + ], + ), + ); + } +} + +class VideoCallPipStyleIconButton extends StatelessWidget { + const VideoCallPipStyleIconButton({super.key, required this.onPressed, required this.child}); + + final VoidCallback onPressed; + final Widget child; + + @override + Widget build(BuildContext context) { + return Material( + color: appTheme.videoCallPipIconBackground, + borderRadius: BorderRadius.circular(8), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: onPressed, + child: Padding(padding: const EdgeInsets.all(8), child: child), + ), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart new file mode 100644 index 00000000..44011084 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart @@ -0,0 +1,40 @@ +import 'package:livekit_client/livekit_client.dart' as lk; + +abstract final class VideoCallLiveKitTrackUtils { + static lk.VideoTrack? asVideoTrack(lk.Track? track) { + if (track == null) return null; + return track is lk.VideoTrack ? track : null; + } + + static lk.VideoTrack? visibleVideoFromPublication(lk.TrackPublication? pub) { + if (pub == null || pub.muted) return null; + final v = asVideoTrack(pub.track); + if (v == null || v.muted) return null; + return v; + } + + static lk.VideoTrack? remoteCameraVideoTrack(lk.Room room) { + for (final participant in room.remoteParticipants.values) { + final fromCameraPub = visibleVideoFromPublication( + participant.getTrackPublicationBySource(lk.TrackSource.camera), + ); + if (fromCameraPub != null) return fromCameraPub; + + for (final pub in participant.videoTrackPublications) { + if (pub.source == lk.TrackSource.screenShareVideo || + pub.source == lk.TrackSource.screenShareAudio) { + continue; + } + final v = visibleVideoFromPublication(pub); + if (v != null) return v; + } + } + return null; + } + + static lk.VideoTrack? localCameraVideoTrack(lk.Room room) { + return visibleVideoFromPublication( + room.localParticipant?.getTrackPublicationBySource(lk.TrackSource.camera), + ); + } +} 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 new file mode 100644 index 00000000..ff4829eb --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_name_chips.dart @@ -0,0 +1,43 @@ +import 'dart:ui'; + +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; + +class VideoCallParticipantNameLabel extends StatelessWidget { + const VideoCallParticipantNameLabel({super.key, required this.name}); + + final String name; + + @override + Widget build(BuildContext context) { + 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: 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_no_video_background.dart b/lib/features/video_calls/presentation/widgets/video_call_no_video_background.dart new file mode 100644 index 00000000..d08f5827 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_no_video_background.dart @@ -0,0 +1,23 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; + +class VideoCallNoVideoBackground extends StatelessWidget { + const VideoCallNoVideoBackground({super.key, this.logoSize = 96}); + + final double logoSize; + + @override + Widget build(BuildContext context) { + return ColoredBox( + color: appTheme.gray100, + child: Center( + child: Image.asset( + 'assets/icons/video_call/eatvio_c.png', + width: logoSize, + height: logoSize, + fit: BoxFit.contain, + ), + ), + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart new file mode 100644 index 00000000..1cf3a376 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart @@ -0,0 +1,58 @@ +import 'package:eatvio_app/core/theme/custom_text_style.dart'; +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:flutter/material.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + +class VideoCallRemoteVideoStage extends StatelessWidget { + const VideoCallRemoteVideoStage({ + super.key, + required this.room, + required this.connectError, + }); + + final lk.Room room; + final String? connectError; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: room, + builder: (context, _) { + final remote = VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room); + if (remote != null) { + return lk.VideoTrackRenderer(remote, fit: lk.VideoViewFit.cover); + } + return Stack( + fit: StackFit.expand, + children: [ + const VideoCallNoVideoBackground(), + if (connectError != null) + Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + connectError!, + textAlign: TextAlign.center, + style: TextStyles.textSMedium.copyWith(color: appTheme.red), + ), + ), + ) + else if (room.connectionState != lk.ConnectionState.connected) + Center( + child: SizedBox( + width: 36, + height: 36, + child: CircularProgressIndicator( + strokeWidth: 3, + color: appTheme.blueA400, + ), + ), + ), + ], + ); + }, + ); + } +} 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 new file mode 100644 index 00000000..38ea44d5 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_stage_layout.dart @@ -0,0 +1,489 @@ +import 'dart:math' as math; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; + +const _kExpandShrinkOuterPadding = EdgeInsets.all(6); + +abstract final class _VideoCallStageAssets { + static const String shrink = 'assets/icons/video_call/vc_shrink.svg'; + static const String expand = 'assets/icons/video_call/vc_expand.svg'; + static const String cameraSwitch = 'assets/icons/video_call/vc_camera_switch.png'; +} + +class VideoCallStageLayout extends StatefulWidget { + const VideoCallStageLayout({ + super.key, + required this.room, + required this.connectError, + required this.localDisplayName, + required this.remoteDisplayName, + required this.micEnabled, + required this.videoEnabled, + required this.splitParticipantView, + required this.mainStageShowsLocalVideo, + required this.useFrontCamera, + required this.onExpandLocal, + required this.onExpandRemote, + required this.onShrinkFromFullscreen, + required this.onToggleStageSwap, + required this.onChat, + required this.onToggleMic, + required this.onToggleVideo, + required this.onHangUp, + required this.onSwitchCamera, + required this.controlLabelStyle, + required this.labels, + required this.topInset, + this.bottomInset = 0, + }); + + final lk.Room room; + final String? connectError; + final String localDisplayName; + final String remoteDisplayName; + final bool micEnabled; + final bool videoEnabled; + final bool splitParticipantView; + final bool mainStageShowsLocalVideo; + final bool useFrontCamera; + final VoidCallback onExpandLocal; + final VoidCallback onExpandRemote; + final VoidCallback onShrinkFromFullscreen; + final VoidCallback onToggleStageSwap; + final VoidCallback onChat; + final VoidCallback onToggleMic; + final VoidCallback onToggleVideo; + final VoidCallback onHangUp; + final VoidCallback onSwitchCamera; + final TextStyle controlLabelStyle; + final VideoCallControlLabels labels; + final double topInset; + + final double bottomInset; + + @override + State createState() => _VideoCallStageLayoutState(); +} + +class _VideoCallStageLayoutState extends State { + static const double _pipW = 142; + static const double _pipH = 201; + static const double _mainStageCornerRadius = 24; + static const double _edgeInset = 8; + static const double _controlsHorizontalInset = 22; + static const double _shrinkExpandExtraTop = 12; + static const double _pipInitialExtraLeft = 28; + static const double _pipInitialExtraDown = 16; + static const double _bottomGradientHeight = 140; + static const double _splitExpandButtonTop = 8 + _shrinkExpandExtraTop; + + double? _pipLeft; + double? _pipTop; + Offset? _pipPanPointerGlobal; + double? _pipPanOriginLeft; + double? _pipPanOriginTop; + + bool _pipHasVisibleVideo() { + if (widget.mainStageShowsLocalVideo) { + return VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(widget.room) != null; + } + return widget.videoEnabled && VideoCallLiveKitTrackUtils.localCameraVideoTrack(widget.room) != null; + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (widget.splitParticipantView) { + return _buildSplitLayout(constraints); + } + return _buildFullscreenLayout(constraints); + }, + ); + } + + Widget _buildSplitLayout(BoxConstraints constraints) { + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Column( + children: [ + Expanded( + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.localDisplayName, + isLocal: true, + connectError: null, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandLocal, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(_mainStageCornerRadius), + topRight: Radius.circular(_mainStageCornerRadius), + bottomLeft: Radius.zero, + bottomRight: Radius.zero, + ), + ), + ), + const SizedBox(height: _edgeInset), + Expanded( + child: _SplitParticipantPane( + room: widget.room, + displayName: widget.remoteDisplayName, + isLocal: false, + connectError: widget.connectError, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onExpand: widget.onExpandRemote, + onSwitchCamera: widget.onSwitchCamera, + borderRadius: const BorderRadius.only( + topLeft: Radius.zero, + topRight: Radius.zero, + bottomLeft: Radius.circular(_mainStageCornerRadius), + bottomRight: Radius.circular(_mainStageCornerRadius), + ), + ), + ), + ], + ), + ), + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + ], + ); + } + + Widget _buildFullscreenLayout(BoxConstraints constraints) { + final maxW = constraints.maxWidth; + final maxH = constraints.maxHeight; + final defaultPipLeft = maxW - _pipW - _edgeInset - _pipInitialExtraLeft; + final minPipTop = widget.topInset + _edgeInset; + final defaultPipTop = minPipTop + _pipInitialExtraDown; + final maxPipLeft = math.max(_edgeInset, maxW - _pipW - _edgeInset); + final maxPipTop = math.max(minPipTop, maxH - _pipH - _edgeInset - widget.bottomInset); + final pipLeft = ((_pipLeft ?? defaultPipLeft).clamp(_edgeInset, maxPipLeft) as num).toDouble(); + final pipTop = ((_pipTop ?? defaultPipTop).clamp(minPipTop, maxPipTop) as num).toDouble(); + + return Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: Padding( + padding: EdgeInsets.fromLTRB(_edgeInset, widget.topInset + _edgeInset, _edgeInset, _edgeInset), + child: Material( + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(_mainStageCornerRadius)), + child: Stack( + fit: StackFit.expand, + children: [ + _VideoCallMainStage( + room: widget.room, + connectError: widget.connectError, + mainShowsLocal: widget.mainStageShowsLocalVideo, + videoEnabled: widget.videoEnabled, + ), + VideoCallParticipantNameLabel(name: widget.mainStageShowsLocalVideo ? widget.localDisplayName : widget.remoteDisplayName), + ], + ), + ), + ), + ), + ..._bottomBarGradient(), + ..._bottomControlsRow(), + Positioned( + top: widget.topInset + _edgeInset + _shrinkExpandExtraTop, + left: _controlsHorizontalInset, + child: Padding( + padding: _kExpandShrinkOuterPadding, + child: VideoCallPipStyleIconButton( + onPressed: widget.onShrinkFromFullscreen, + child: SvgPicture.asset(_VideoCallStageAssets.shrink, width: 20, height: 20, fit: BoxFit.contain), + ), + ), + ), + ListenableBuilder( + listenable: widget.room, + builder: (context, _) { + if (!_pipHasVisibleVideo()) { + return const SizedBox.shrink(); + } + return Positioned( + left: pipLeft, + top: pipTop, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: widget.onToggleStageSwap, + onPanStart: (details) { + _pipPanPointerGlobal = details.globalPosition; + _pipPanOriginLeft = pipLeft; + _pipPanOriginTop = pipTop; + }, + onPanUpdate: (details) { + if (_pipPanPointerGlobal == null || _pipPanOriginLeft == null || _pipPanOriginTop == null) { + return; + } + final delta = details.globalPosition - _pipPanPointerGlobal!; + setState(() { + final nx = (_pipPanOriginLeft! + delta.dx).clamp(_edgeInset, maxPipLeft); + final ny = (_pipPanOriginTop! + delta.dy).clamp(minPipTop, maxPipTop); + _pipLeft = (nx as num).toDouble(); + _pipTop = (ny as num).toDouble(); + }); + }, + onPanEnd: (_) { + _resetPipPanState(); + }, + onPanCancel: _resetPipPanState, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: _pipW, + height: _pipH, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: widget.mainStageShowsLocalVideo + ? _VideoCallPipRemoteContent(room: widget.room) + : _VideoCallPipLocalContent( + room: widget.room, + videoEnabled: widget.videoEnabled, + useFrontCamera: widget.useFrontCamera, + onSwitchCamera: widget.onSwitchCamera, + ), + ), + ), + ), + ); + }, + ), + ], + ); + } + + List _bottomBarGradient() { + return [ + Positioned( + left: 0, + right: 0, + bottom: 0, + height: _bottomGradientHeight + widget.bottomInset, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [Colors.black.withValues(alpha: 0.55), Colors.black.withValues(alpha: 0)], + ), + ), + ), + ), + ), + ]; + } + + void _resetPipPanState() { + _pipPanPointerGlobal = null; + _pipPanOriginLeft = null; + _pipPanOriginTop = null; + } + + List _bottomControlsRow() { + return [ + Positioned( + left: _controlsHorizontalInset, + right: _controlsHorizontalInset, + bottom: _edgeInset + 4 + widget.bottomInset, + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + const Expanded(child: SizedBox.shrink()), + const SizedBox(width: 14), + VideoCallControlColumn( + micEnabled: widget.micEnabled, + videoEnabled: widget.videoEnabled, + onChat: widget.onChat, + onToggleMic: widget.onToggleMic, + onToggleVideo: widget.onToggleVideo, + onHangUp: widget.onHangUp, + labels: widget.labels, + controlLabelStyle: widget.controlLabelStyle, + ), + ], + ), + ), + ]; + } +} + +class _SplitParticipantPane extends StatelessWidget { + const _SplitParticipantPane({ + required this.room, + required this.displayName, + required this.isLocal, + required this.connectError, + required this.videoEnabled, + required this.useFrontCamera, + required this.onExpand, + required this.onSwitchCamera, + required this.borderRadius, + }); + + final lk.Room room; + final String displayName; + final bool isLocal; + final String? connectError; + final bool videoEnabled; + final bool useFrontCamera; + final VoidCallback onExpand; + final VoidCallback onSwitchCamera; + final BorderRadius borderRadius; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder(borderRadius: borderRadius), + child: Stack( + fit: StackFit.expand, + children: [ + Positioned.fill( + child: ListenableBuilder( + listenable: room, + builder: (context, _) { + if (isLocal) { + if (!videoEnabled) { + return const VideoCallNoVideoBackground(); + } + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + if (local != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + ); + } + return const VideoCallNoVideoBackground(); + } + return VideoCallRemoteVideoStage(room: room, connectError: connectError); + }, + ), + ), + VideoCallParticipantNameLabel(name: displayName), + Positioned( + left: 0, + top: _VideoCallStageLayoutState._splitExpandButtonTop, + child: Padding( + padding: _kExpandShrinkOuterPadding, + child: VideoCallPipStyleIconButton( + onPressed: onExpand, + child: SvgPicture.asset(_VideoCallStageAssets.expand, width: 20, height: 20, fit: BoxFit.contain), + ), + ), + ), + if (isLocal && videoEnabled) + Positioned( + right: 10, + top: 10, + child: Padding( + padding: _kExpandShrinkOuterPadding, + child: VideoCallPipStyleIconButton( + onPressed: onSwitchCamera, + child: Image.asset(_VideoCallStageAssets.cameraSwitch, width: 20, height: 20, fit: BoxFit.contain, key: ValueKey(useFrontCamera)), + ), + ), + ), + ], + ), + ); + } +} + +class _VideoCallMainStage extends StatelessWidget { + const _VideoCallMainStage({required this.room, required this.connectError, required this.mainShowsLocal, required this.videoEnabled}); + + final lk.Room room; + final String? connectError; + final bool mainShowsLocal; + final bool videoEnabled; + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: room, + builder: (context, _) { + if (mainShowsLocal) { + if (!videoEnabled) { + return const VideoCallNoVideoBackground(); + } + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + if (local != null) { + return IgnorePointer( + child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + ); + } + return const VideoCallNoVideoBackground(); + } + return VideoCallRemoteVideoStage(room: room, connectError: connectError); + }, + ); + } +} + +class _VideoCallPipRemoteContent extends StatelessWidget { + const _VideoCallPipRemoteContent({required this.room}); + + final lk.Room room; + + @override + Widget build(BuildContext context) { + return IgnorePointer(child: VideoCallRemoteVideoStage(room: room, connectError: null)); + } +} + +class _VideoCallPipLocalContent extends StatelessWidget { + const _VideoCallPipLocalContent({required this.room, required this.videoEnabled, required this.useFrontCamera, required this.onSwitchCamera}); + + final lk.Room room; + final bool videoEnabled; + final bool useFrontCamera; + final VoidCallback onSwitchCamera; + + @override + Widget build(BuildContext context) { + final local = VideoCallLiveKitTrackUtils.localCameraVideoTrack(room); + return Stack( + fit: StackFit.expand, + children: [ + if (!videoEnabled) + const VideoCallNoVideoBackground(logoSize: 56) + else if (local != null) + IgnorePointer( + child: lk.VideoTrackRenderer(local, fit: lk.VideoViewFit.cover, mirrorMode: lk.VideoViewMirrorMode.auto), + ) + else + const VideoCallNoVideoBackground(logoSize: 56), + if (videoEnabled) + Positioned( + right: 10, + top: 10, + child: VideoCallPipStyleIconButton( + onPressed: onSwitchCamera, + child: Image.asset(_VideoCallStageAssets.cameraSwitch, width: 20, height: 20, fit: BoxFit.contain, key: ValueKey(useFrontCamera)), + ), + ), + ], + ); + } +} diff --git a/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart new file mode 100644 index 00000000..1f0bff71 --- /dev/null +++ b/lib/features/video_calls/presentation/widgets/video_call_top_overlay.dart @@ -0,0 +1,63 @@ +import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + + +abstract final class VideoCallTopOverlay { + static const String poorConnectionSvg = 'assets/icons/video_call/poor_connection.svg'; + static const String warningSvg = 'assets/icons/video_call/warning.svg'; + + + static OverlayEntry insertBanner( + BuildContext context, { + required String message, + required Color backgroundColor, + required String svgAsset, + Duration? autoRemoveAfter, + }) { + final overlayState = Overlay.of(context, rootOverlay: true); + late OverlayEntry entry; + entry = OverlayEntry( + builder: (ctx) => Positioned( + left: 16, + right: 16, + top: MediaQuery.viewPaddingOf(ctx).top + 8, + child: Material( + color: backgroundColor, + borderRadius: BorderRadius.circular(12), + elevation: 6, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SvgPicture.asset(svgAsset, width: 22, height: 22), + const SizedBox(width: 10), + Expanded( + child: Text( + message, + style: TextStyle( + color: appTheme.whiteA700, + fontSize: 14, + height: 1.25, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + ), + ); + overlayState.insert(entry); + if (autoRemoveAfter != null) { + Future.delayed(autoRemoveAfter, () { + if (entry.mounted) { + entry.remove(); + } + }); + } + return entry; + } +} diff --git a/lib/features/video_calls/repositories/video_call_repository.dart b/lib/features/video_calls/repositories/video_call_repository.dart new file mode 100644 index 00000000..1b9835b5 --- /dev/null +++ b/lib/features/video_calls/repositories/video_call_repository.dart @@ -0,0 +1,27 @@ +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; + +class VideoCallRepository { + VideoCallRepository({required this.videoCallService}); + + final VideoCallService videoCallService; + + static const Map _emptyJsonBody = {}; + + Future getParticipantToken(String conversationUuid) { + return videoCallService.getParticipantToken( + conversationUuid: conversationUuid, + ); + } + + Future getAdminToken(String conversationUuid) { + return videoCallService.getAdminToken(conversationUuid: conversationUuid); + } + + Future notifyCallEnded(String conversationUuid) { + return videoCallService.notifyCallEnded( + conversationUuid: conversationUuid, + body: _emptyJsonBody, + ); + } +} diff --git a/lib/features/video_calls/services/video_call_service.dart b/lib/features/video_calls/services/video_call_service.dart new file mode 100644 index 00000000..e46b0194 --- /dev/null +++ b/lib/features/video_calls/services/video_call_service.dart @@ -0,0 +1,26 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:retrofit/retrofit.dart'; + +part 'video_call_service.g.dart'; + +@RestApi() +abstract class VideoCallService { + factory VideoCallService(Dio dio, {String baseUrl}) = _VideoCallService; + + @GET('/api/v2/chat/{conversation}/video-call/token') + Future getParticipantToken({ + @Path('conversation') required String conversationUuid, + }); + + @GET('/api/v2/chat/{conversation}/video-call/admin-token') + Future getAdminToken({ + @Path('conversation') required String conversationUuid, + }); + + @POST('/api/v2/chat/{conversation}/video-call/ended') + Future notifyCallEnded({ + @Path('conversation') required String conversationUuid, + @Body() required Map body, + }); +} diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index f291a0ff..096562d6 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -873,6 +873,41 @@ class MessageLookup extends MessageLookupByLibrary { "vegan_type_desc": MessageLookupByLibrary.simpleMessage( "Диета основана на потреблении растительной пищи и отказе от продуктов животного происхождения. Такой тип питания может быть разнообразным и вкусным, а также полезным для здоровья.", ), + "video_call": MessageLookupByLibrary.simpleMessage("Video call"), + "video_call_chat": MessageLookupByLibrary.simpleMessage("Chat"), + "video_call_counterpart_default": MessageLookupByLibrary.simpleMessage( + "Participant", + ), + "video_call_leave": MessageLookupByLibrary.simpleMessage("Leave"), + "video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage( + "Attention!\nYour microphone is muted", + ), + "video_call_permission_camera_description": + MessageLookupByLibrary.simpleMessage( + "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.", + ), + "video_call_permission_camera_title": MessageLookupByLibrary.simpleMessage( + "Camera access", + ), + "video_call_permission_microphone_description": + MessageLookupByLibrary.simpleMessage( + "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.", + ), + "video_call_permission_microphone_title": + MessageLookupByLibrary.simpleMessage("Microphone access"), + "video_call_poor_connection": MessageLookupByLibrary.simpleMessage( + "You have a poor internet connection", + ), + "video_call_recording_notice": MessageLookupByLibrary.simpleMessage( + "The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.", + ), + "video_call_role_consultant": MessageLookupByLibrary.simpleMessage( + "Eatvio consultant", + ), + "video_call_sound": MessageLookupByLibrary.simpleMessage("Sound"), + "video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"), + "video_call_video": MessageLookupByLibrary.simpleMessage("Video"), + "video_call_you": MessageLookupByLibrary.simpleMessage("You"), "violenceAndHostility": MessageLookupByLibrary.simpleMessage( "Насилие и вражда", ), diff --git a/lib/generated/intl/messages_ru.dart b/lib/generated/intl/messages_ru.dart index 11fd6fb1..86adc84c 100644 --- a/lib/generated/intl/messages_ru.dart +++ b/lib/generated/intl/messages_ru.dart @@ -353,6 +353,41 @@ class MessageLookup extends MessageLookupByLibrary { "vegan_type_desc": MessageLookupByLibrary.simpleMessage( "Диета основана на потреблении растительной пищи и отказе от продуктов животного происхождения. Такой тип питания может быть разнообразным и вкусным, а также полезным для здоровья.", ), + "video_call": MessageLookupByLibrary.simpleMessage("Видеозвонок"), + "video_call_chat": MessageLookupByLibrary.simpleMessage("Чат"), + "video_call_counterpart_default": MessageLookupByLibrary.simpleMessage( + "Собеседник", + ), + "video_call_leave": MessageLookupByLibrary.simpleMessage("Выйти"), + "video_call_muted_mic_warning": MessageLookupByLibrary.simpleMessage( + "Внимание!\nУ вас выключен микрофон", + ), + "video_call_permission_camera_description": + MessageLookupByLibrary.simpleMessage( + "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.", + ), + "video_call_permission_camera_title": MessageLookupByLibrary.simpleMessage( + "Доступ к камере", + ), + "video_call_permission_microphone_description": + MessageLookupByLibrary.simpleMessage( + "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства.", + ), + "video_call_permission_microphone_title": + MessageLookupByLibrary.simpleMessage("Доступ к микрофону"), + "video_call_poor_connection": MessageLookupByLibrary.simpleMessage( + "У вас плохое интернет соединение", + ), + "video_call_recording_notice": MessageLookupByLibrary.simpleMessage( + "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.", + ), + "video_call_role_consultant": MessageLookupByLibrary.simpleMessage( + "Консультант Итвио", + ), + "video_call_sound": MessageLookupByLibrary.simpleMessage("Звук"), + "video_call_unknown_initial": MessageLookupByLibrary.simpleMessage("?"), + "video_call_video": MessageLookupByLibrary.simpleMessage("Видео"), + "video_call_you": MessageLookupByLibrary.simpleMessage("Вы"), "waist": MessageLookupByLibrary.simpleMessage("Талия"), "waist_circumference": MessageLookupByLibrary.simpleMessage("Обхват талии"), "weight": MessageLookupByLibrary.simpleMessage("Вес"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index b885b832..3bca8ece 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -4018,6 +4018,136 @@ class S { args: [], ); } + + /// `Video call` + String get video_call { + return Intl.message('Video call', name: 'video_call', desc: '', args: []); + } + + /// `Eatvio consultant` + String get video_call_role_consultant { + return Intl.message( + 'Eatvio consultant', + name: 'video_call_role_consultant', + desc: '', + args: [], + ); + } + + /// `Participant` + String get video_call_counterpart_default { + return Intl.message( + 'Participant', + name: 'video_call_counterpart_default', + desc: '', + args: [], + ); + } + + /// `You` + String get video_call_you { + return Intl.message('You', name: 'video_call_you', desc: '', args: []); + } + + /// `You have a poor internet connection` + String get video_call_poor_connection { + return Intl.message( + 'You have a poor internet connection', + name: 'video_call_poor_connection', + desc: '', + args: [], + ); + } + + /// `The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.` + String get video_call_recording_notice { + return Intl.message( + 'The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.', + name: 'video_call_recording_notice', + desc: '', + args: [], + ); + } + + /// `Attention!\nYour microphone is muted` + String get video_call_muted_mic_warning { + return Intl.message( + 'Attention!\nYour microphone is muted', + name: 'video_call_muted_mic_warning', + desc: '', + args: [], + ); + } + + /// `Chat` + String get video_call_chat { + return Intl.message('Chat', name: 'video_call_chat', desc: '', args: []); + } + + /// `Sound` + String get video_call_sound { + return Intl.message('Sound', name: 'video_call_sound', desc: '', args: []); + } + + /// `Video` + String get video_call_video { + return Intl.message('Video', name: 'video_call_video', desc: '', args: []); + } + + /// `Leave` + String get video_call_leave { + return Intl.message('Leave', name: 'video_call_leave', desc: '', args: []); + } + + /// `?` + String get video_call_unknown_initial { + return Intl.message( + '?', + name: 'video_call_unknown_initial', + desc: '', + args: [], + ); + } + + /// `Camera access` + String get video_call_permission_camera_title { + return Intl.message( + 'Camera access', + name: 'video_call_permission_camera_title', + desc: '', + args: [], + ); + } + + /// `Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.` + String get video_call_permission_camera_description { + return Intl.message( + 'Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.', + name: 'video_call_permission_camera_description', + desc: '', + args: [], + ); + } + + /// `Microphone access` + String get video_call_permission_microphone_title { + return Intl.message( + 'Microphone access', + name: 'video_call_permission_microphone_title', + desc: '', + args: [], + ); + } + + /// `Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.` + String get video_call_permission_microphone_description { + return Intl.message( + 'Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings.', + name: 'video_call_permission_microphone_description', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 12d1b920..27c662dd 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -472,6 +472,22 @@ "hide_photo_title": "Спрятать фото?", "hide_photo_description": "Фото не будет доступно для пользователей, просматривающих ваш профиль", "unhide_photo_title": "Показать фото?", - "unhide_photo_description": "Фото будет доступно для пользователей, просматривающих ваш профиль" + "unhide_photo_description": "Фото будет доступно для пользователей, просматривающих ваш профиль", + "video_call": "Video call", + "video_call_role_consultant": "Eatvio consultant", + "video_call_counterpart_default": "Participant", + "video_call_you": "You", + "video_call_poor_connection": "You have a poor internet connection", + "video_call_recording_notice": "The video call will be recorded.\nThe consultation may be reviewed by Eatvio staff for quality assurance.", + "video_call_muted_mic_warning": "Attention!\nYour microphone is muted", + "video_call_chat": "Chat", + "video_call_sound": "Sound", + "video_call_video": "Video", + "video_call_leave": "Leave", + "video_call_unknown_initial": "?", + "video_call_permission_camera_title": "Camera access", + "video_call_permission_camera_description": "Allow camera access so others can see you during the video call. If you declined, you can enable it in system settings.", + "video_call_permission_microphone_title": "Microphone access", + "video_call_permission_microphone_description": "Allow microphone access so others can hear you during the video call. If you declined, you can enable it in system settings." } diff --git a/lib/l10n/intl_ru.arb b/lib/l10n/intl_ru.arb index d3dd10f5..687f142c 100644 --- a/lib/l10n/intl_ru.arb +++ b/lib/l10n/intl_ru.arb @@ -217,5 +217,21 @@ "user_data": "Данные пользователя", "infoAboutOrder": "Информация о заказе", "notifications": "Уведомления", - "comment_hint": "Комментарий" + "comment_hint": "Комментарий", + "video_call": "Видеозвонок", + "video_call_role_consultant": "Консультант Итвио", + "video_call_counterpart_default": "Собеседник", + "video_call_you": "Вы", + "video_call_poor_connection": "У вас плохое интернет соединение", + "video_call_recording_notice": "Видеозвонок будет записан.\nКонсультация может быть проверена сотрудниками Итвио, на предмет качества оказания услуг.", + "video_call_muted_mic_warning": "Внимание!\nУ вас выключен микрофон", + "video_call_chat": "Чат", + "video_call_sound": "Звук", + "video_call_video": "Видео", + "video_call_leave": "Выйти", + "video_call_unknown_initial": "?", + "video_call_permission_camera_title": "Доступ к камере", + "video_call_permission_camera_description": "Чтобы вас видели во время видеозвонка, разрешите доступ к камере. При отказе можно включить доступ в настройках устройства.", + "video_call_permission_microphone_title": "Доступ к микрофону", + "video_call_permission_microphone_description": "Чтобы вас слышали во время видеозвонка, разрешите доступ к микрофону. При отказе можно включить доступ в настройках устройства." } diff --git a/pubspec.yaml b/pubspec.yaml index ac8eeb6a..1c3c8489 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -84,6 +84,7 @@ dependencies: webview_flutter_platform_interface: ^2.14.0 intl_utils: ^2.8.13 sentry_dio: ^9.10.0 + livekit_client: ^2.6.1 dev_dependencies: build_runner: ^2.10.4 @@ -109,6 +110,7 @@ flutter: - assets/images/ - assets/audio/ - assets/icons/ + - assets/icons/video_call/ - assets/splash/ - assets/lottie_animations/ diff --git a/test/common/widgets/markdown_editor_test.dart b/test/common/widgets/markdown_editor_test.dart index b638284f..09d6fc4f 100644 --- a/test/common/widgets/markdown_editor_test.dart +++ b/test/common/widgets/markdown_editor_test.dart @@ -614,6 +614,234 @@ void main() { // Проверяем, что preview отображается (RichText должен быть) expect(find.byType(RichText), findsWidgets); }); + + testWidgets('preview: маркер списка со звёздочкой', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '* Asterisk item', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + // Тело пункта в RichText/TextSpan — find.textContaining не всегда его видит + expect(find.byType(RichText), findsWidgets); + expect(find.textContaining('•'), findsWidgets); + }); + + testWidgets('preview: нумерация из двух цифр', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '10. Tenth row', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.byType(RichText), findsWidgets); + expect(find.textContaining('10.'), findsWidgets); + }); + + testWidgets(' после выхода из preview выполняется отложенный requestFocus', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'focus', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.edit)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 150)); + + expect(find.byType(TextField), findsOneWidget); + }); + + testWidgets(' снимает разметку ссылки когда выделен markdown-link', (WidgetTester tester) async { + String? changed; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '[Label](https://example.com)', + showInlineToolbar: true, + onChanged: (v) => changed = v, + ), + ), + ), + ); + + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + final controller = field.controller!; + controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length); + + await tester.tap(find.byIcon(Icons.link)); + await tester.pump(); + + expect(changed, contains('Label')); + expect(changed, isNot(contains('[Label](https://example.com)'))); + }); + + testWidgets('preview: H2 heading renders larger heading style', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '## Subtitle', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.text('Subtitle'), findsOneWidget); + }); + + testWidgets('preview: H3 heading renders heading text', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: '### Detail', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.text('Detail'), findsOneWidget); + }); + + testWidgets('preview: horizontal rule renders Divider', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'Above\n---\nBelow', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.byType(Divider), findsOneWidget); + }); + + testWidgets('preview: italic span renders inside RichText', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'A *cursive* word', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.byType(RichText), findsWidgets); + }); + + testWidgets('preview: link span renders inside RichText', (WidgetTester tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'Visit [the docs](https://example.com)', + showInlineToolbar: true, + ), + ), + ), + ); + + await tester.pump(); + await tester.tap(find.byIcon(Icons.visibility)); + await tester.pumpAndSettle(); + + expect(find.byType(RichText), findsWidgets); + }); + + testWidgets('toolbar buttons mutate text via onChanged when cursor is set', (WidgetTester tester) async { + String? changed; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: MarkdownEditor( + initialText: 'plain', + showInlineToolbar: true, + onChanged: (v) => changed = v, + ), + ), + ), + ); + await tester.pump(); + + final field = tester.widget(find.byType(TextField)); + final controller = field.controller!; + controller.selection = const TextSelection.collapsed(offset: 0); + + await tester.tap(find.byIcon(Icons.format_bold)); + await tester.pump(); + expect(changed, contains('**')); + + controller.text = 'plain'; + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.tap(find.byIcon(Icons.format_italic)); + await tester.pump(); + expect(changed, contains('*')); + + controller.text = 'plain'; + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.tap(find.byIcon(Icons.horizontal_rule)); + await tester.pump(); + expect(changed, contains('---')); + + controller.text = 'plain'; + controller.selection = const TextSelection.collapsed(offset: 0); + await tester.tap(find.byIcon(Icons.link)); + await tester.pump(); + expect(changed, contains('](url)')); + }); }); } diff --git a/test/core/error_reporting/error_report_policy_test.dart b/test/core/error_reporting/error_report_policy_test.dart index 52bbc6e5..d504e50a 100644 --- a/test/core/error_reporting/error_report_policy_test.dart +++ b/test/core/error_reporting/error_report_policy_test.dart @@ -110,5 +110,92 @@ void main() { final event = SentryEvent(throwable: const SocketException('x')); expect(ErrorReportPolicy.allowSentryEvent(event, hint), false); }); + + test('allows event when throwable is null', () { + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(), hint), true); + }); + + test('delegates non-Dio throwable to shouldReport', () { + final event = SentryEvent(throwable: StateError('x')); + expect(ErrorReportPolicy.allowSentryEvent(event, hint), true); + }); + + test('drops cancelled Dio from SDK', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.cancel, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false); + }); + + test('drops sendTimeout Dio from SDK', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.sendTimeout, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false); + }); + + test('drops receiveTimeout Dio from SDK', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.receiveTimeout, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), false); + }); + + test('allows badResponse Dio from SDK when statusCode is missing', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badResponse, + ); + expect(ErrorReportPolicy.allowSentryEvent(SentryEvent(throwable: e), hint), true); + }); + }); + + group('ErrorReportPolicy.shouldReport extra Dio branches', () { + test('returns true for Dio unknown when inner error is not SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.unknown, + error: 'not a socket', + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); + + test('returns false for Dio unknown when inner error is SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.unknown, + error: const SocketException('offline'), + ); + expect(ErrorReportPolicy.shouldReport(e), false); + }); + + test('returns true for badCertificate when inner error is not SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badCertificate, + error: 'cert problem', + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); + + test('returns false for badCertificate when inner error is SocketException', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badCertificate, + error: const SocketException('bad'), + ); + expect(ErrorReportPolicy.shouldReport(e), false); + }); + + test('returns true for badResponse without HTTP status', () { + final e = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badResponse, + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); }); } diff --git a/test/core/error_reporting/no_op_error_reporter_test.dart b/test/core/error_reporting/no_op_error_reporter_test.dart new file mode 100644 index 00000000..b794b7b1 --- /dev/null +++ b/test/core/error_reporting/no_op_error_reporter_test.dart @@ -0,0 +1,36 @@ +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/error_reporting/no_op_error_reporter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('NoOpErrorReporter', () { + test('is an ErrorReporter', () { + const reporter = NoOpErrorReporter(); + expect(reporter, isA()); + }); + + test('report completes silently with all parameters', () async { + const reporter = NoOpErrorReporter(); + + await reporter.report( + StateError('boom'), + StackTrace.current, + origin: 'unit', + extras: {'a': 1, 'b': null}, + ); + }); + + test('report completes silently without optional parameters', () async { + const reporter = NoOpErrorReporter(); + + await reporter.report(Exception('x'), null); + }); + + test('multiple sequential reports do not throw', () async { + const reporter = NoOpErrorReporter(); + for (var i = 0; i < 5; i++) { + await reporter.report('error $i', StackTrace.empty, origin: 'loop'); + } + }); + }); +} diff --git a/test/core/error_reporting/report_from_test.dart b/test/core/error_reporting/report_from_test.dart new file mode 100644 index 00000000..1c4541db --- /dev/null +++ b/test/core/error_reporting/report_from_test.dart @@ -0,0 +1,55 @@ +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/error_reporting/report_from.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _RecordingReporter implements ErrorReporter { + Object? lastError; + StackTrace? lastStack; + String? lastOrigin; + + @override + Future report( + Object error, + StackTrace? stackTrace, { + String? origin, + Map? extras, + }) async { + lastError = error; + lastStack = stackTrace; + lastOrigin = origin; + } +} + +class _OriginMarker {} + +void main() { + late _RecordingReporter reporter; + + setUp(() async { + await sl.reset(); + reporter = _RecordingReporter(); + sl.registerSingleton(reporter); + }); + + tearDown(() async { + await sl.reset(); + }); + + test('reportFrom forwards to registered ErrorReporter with origin from context runtimeType', () async { + final err = StateError('bad'); + final st = StackTrace.current; + + await reportFrom(err, st, _OriginMarker()); + + expect(reporter.lastError, same(err)); + expect(reporter.lastStack, st); + expect(reporter.lastOrigin, '_OriginMarker'); + }); + + test('reportFrom completes when ErrorReporter is not registered (NoOp path)', () async { + await sl.reset(); + + await reportFrom(Exception('x'), null, Object()); + }); +} diff --git a/test/core/error_reporting/sentry_error_reporter_test.dart b/test/core/error_reporting/sentry_error_reporter_test.dart new file mode 100644 index 00000000..4f73121a --- /dev/null +++ b/test/core/error_reporting/sentry_error_reporter_test.dart @@ -0,0 +1,39 @@ +import 'package:eatvio_app/core/error_reporting/sentry_error_reporter.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('SentryErrorReporter', () { + test('report returns immediately when ErrorReportPolicy filters the error', () async { + final reporter = SentryErrorReporter(); + await reporter.report( + 'user-facing string', + StackTrace.empty, + origin: 'unit', + ); + // Does not throw; policy drops String errors before Sentry. + }); + + test('report accepts non-filtered exception with origin and extras', () async { + final reporter = SentryErrorReporter(); + + await reporter.report( + StateError('boom'), + StackTrace.current, + origin: 'cubit', + extras: { + 'id': 42, + 'nullable': null, + }, + ); + }); + + test('report accepts non-filtered exception without optional metadata', () async { + final reporter = SentryErrorReporter(); + + await reporter.report( + ArgumentError('bad'), + StackTrace.empty, + ); + }); + }); +} diff --git a/test/core/widgets/custom_popup_test.dart b/test/core/widgets/custom_popup_test.dart index 91a91128..21e5b020 100644 --- a/test/core/widgets/custom_popup_test.dart +++ b/test/core/widgets/custom_popup_test.dart @@ -5,24 +5,64 @@ import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/widgets/custom_popup.dart'; import 'package:eatvio_app/generated/l10n.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +class _FakeAssetBundle extends CachingAssetBundle { + _FakeAssetBundle(this.assets); + final Map assets; + + @override + Future loadString(String key, {bool cache = true}) async { + final value = assets[key]; + if (value == null) { + throw FlutterError('Asset not found: $key'); + } + return value; + } + + @override + Future load(String key) async { + final value = assets[key]; + if (value == null) { + throw FlutterError('Asset not found: $key'); + } + final bytes = Uint8List.fromList(value.codeUnits); + return ByteData.sublistView(bytes); + } +} + void main() { - group('CustomPopup smoke', () { - setUpAll(() async { - dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); - }); + const fakeSvg = ''' + + + +'''; - setUp(() async { - await sl.reset(); - sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); - }); + final fakeBundle = _FakeAssetBundle({ + 'assets/icons/copy2.svg': fakeSvg, + 'assets/icons/share2.svg': fakeSvg, + 'assets/icons/edit_paint.svg': fakeSvg, + 'assets/icons/red_trash.svg': fakeSvg, + 'assets/icons/complain.svg': fakeSvg, + }); - Widget wrap(Widget child) { - return MaterialApp( + setUpAll(() async { + dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + }); + + setUp(() async { + await sl.reset(); + sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + }); + + Widget wrap(Widget child) { + return DefaultAssetBundle( + bundle: fakeBundle, + child: MaterialApp( theme: ThemeHelper().themeData(), localizationsDelegates: const [ S.delegate, @@ -31,87 +71,117 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: S.delegate.supportedLocales, - home: Scaffold(body: child), - ); - } + home: Scaffold(body: Center(child: child)), + ), + ); + } - testWidgets('renders custom popup for author', (tester) async { - await tester.pumpWidget(wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user'))); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + testWidgets('CustomPopup for author shows edit and delete actions for posts', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.posts, + path: 'posts/1', + id: 1, + userSlug: 'author', + ), + ), + ); - testWidgets('renders custom popup for non-author', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.posts)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); - testWidgets('renders custom popup for non-author with recipe', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.recipe)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for non-author with diet', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.diet)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for non-author with plan', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: false, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.plan)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with posts', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.posts)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with recipe', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.recipe, slug: 'test-recipe')), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with diet', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.diet, id: 1)), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); - - testWidgets('renders custom popup for author with plan', (tester) async { - await tester.pumpWidget( - wrap(const CustomPopup(isAuthor: true, path: 'test/path', userSlug: 'test-user', complainedContentType: ComplainedContent.plan, slug: 'test-plan')), - ); - await tester.pump(); - expect(find.byType(CustomPopup), findsOneWidget); - expect(find.byIcon(Icons.more_horiz_rounded), findsOneWidget); - }); + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Редактировать'), findsOneWidget); + expect(find.text('Удалить'), findsOneWidget); }); + + testWidgets('CustomPopup for non-author shows complain and block actions', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: false, + complainedContentType: ComplainedContent.recipe, + path: 'recipes/slug', + slug: 'slug', + userSlug: 'user-2', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Пожаловаться'), findsOneWidget); + expect(find.text('Заблокировать автора'), findsOneWidget); + }); + + testWidgets('CustomPopup for author recipe does not show edit action', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.recipe, + path: 'recipes/slug', + slug: 'slug', + userSlug: 'author', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Редактировать'), findsNothing); + expect(find.text('Удалить'), findsOneWidget); + }); + + testWidgets('CustomPopup for author plan does not show edit action', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: true, + complainedContentType: ComplainedContent.plan, + path: 'plans/slug', + slug: 'slug', + userSlug: 'author', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Редактировать'), findsNothing); + expect(find.text('Удалить'), findsOneWidget); + }); + + testWidgets('CustomPopup for non-author without complained type still renders static actions', (tester) async { + await tester.pumpWidget( + wrap( + const CustomPopup( + isAuthor: false, + complainedContentType: null, + path: 'posts/slug', + slug: 'slug', + userSlug: 'user-2', + ), + ), + ); + + await tester.tap(find.byIcon(Icons.more_horiz_rounded)); + await tester.pumpAndSettle(); + + expect(find.text('Скопировать ссылку'), findsOneWidget); + expect(find.text('Поделиться'), findsOneWidget); + expect(find.text('Пожаловаться'), findsOneWidget); + expect(find.text('Заблокировать автора'), findsOneWidget); + }); + } diff --git a/test/features/bottom_navigation_bar/bottom_nav_scope_test.dart b/test/features/bottom_navigation_bar/bottom_nav_scope_test.dart new file mode 100644 index 00000000..de852443 --- /dev/null +++ b/test/features/bottom_navigation_bar/bottom_nav_scope_test.dart @@ -0,0 +1,92 @@ +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('BottomNavController', () { + test('switchToRoute is a no-op until a callback is registered', () { + final controller = BottomNavController(); + // Should not throw + controller.switchToRoute('/home'); + controller.switchToRoute('/home', 'extra'); + }); + + test('switchToRoute forwards path and extra to registered callback', () { + final controller = BottomNavController(); + String? receivedPath; + Object? receivedExtra; + controller.register((path, extra) { + receivedPath = path; + receivedExtra = extra; + }); + + controller.switchToRoute('/feed', {'tab': 'recipes'}); + + expect(receivedPath, '/feed'); + expect(receivedExtra, {'tab': 'recipes'}); + }); + + test('switchToRoute passes null extra by default', () { + final controller = BottomNavController(); + Object? receivedExtra = 'sentinel'; + controller.register((path, extra) { + receivedExtra = extra; + }); + + controller.switchToRoute('/profile'); + + expect(receivedExtra, isNull); + }); + }); + + group('BottomNavScope', () { + testWidgets('maybeOf returns null when scope is not present', (tester) async { + late BottomNavController? captured; + await tester.pumpWidget( + MaterialApp( + home: Builder( + builder: (ctx) { + captured = BottomNavScope.maybeOf(ctx); + return const SizedBox.shrink(); + }, + ), + ), + ); + + expect(captured, isNull); + }); + + testWidgets('of returns controller from ancestor scope', (tester) async { + final controller = BottomNavController(); + late BottomNavController captured; + await tester.pumpWidget( + MaterialApp( + home: BottomNavScope( + controller: controller, + child: Builder( + builder: (ctx) { + captured = BottomNavScope.of(ctx); + return const SizedBox.shrink(); + }, + ), + ), + ), + ); + + expect(identical(captured, controller), isTrue); + }); + + testWidgets('updateShouldNotify returns true when controller changes', (tester) async { + final c1 = BottomNavController(); + final c2 = BottomNavController(); + const child = SizedBox.shrink(); + final widget1 = BottomNavScope(controller: c1, child: child); + final widget2 = BottomNavScope(controller: c2, child: child); + + expect(widget2.updateShouldNotify(widget1), isTrue); + + final widget3 = BottomNavScope(controller: c1, child: child); + expect(widget3.updateShouldNotify(widget1), isFalse); + }); + }); +} diff --git a/test/features/diets/presentation/widgets/expandable_text_test.dart b/test/features/diets/presentation/widgets/expandable_text_test.dart index c39e68fd..5e19d9c7 100644 --- a/test/features/diets/presentation/widgets/expandable_text_test.dart +++ b/test/features/diets/presentation/widgets/expandable_text_test.dart @@ -7,12 +7,12 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('ExpandableText smoke', () { + group('ExpandableText', () { setUpAll(() async { dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); }); - Widget wrap(Widget child) { + Widget wrap(Widget child, {Size size = const Size(360, 800)}) { return MaterialApp( theme: ThemeHelper().themeData(), localizationsDelegates: const [ @@ -22,7 +22,10 @@ void main() { GlobalCupertinoLocalizations.delegate, ], supportedLocales: S.delegate.supportedLocales, - home: Scaffold(body: child), + home: MediaQuery( + data: MediaQueryData(size: size), + child: Scaffold(body: child), + ), ); } @@ -44,5 +47,39 @@ void main() { expect(find.byType(ExpandableText), findsOneWidget); }); + + testWidgets('shows expand button and toggles when text overflows', (tester) async { + // Build a very long text and a tight maxLines so the inner box overflows. + final longText = List.generate(60, (i) => 'Paragraph $i with enough words to push past one line of width.').join(' '); + await tester.pumpWidget( + wrap( + ExpandableText(text: longText, maxLines: 1), + size: const Size(200, 600), + ), + ); + + // Allow post-frame callback to compute overflow + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + await tester.pump(const Duration(milliseconds: 250)); + + final state = tester.state(find.byType(ExpandableText)); + // ignore: invalid_use_of_protected_member + final dynamic dynState = state; + // showExpandButton may or may not be flipped depending on layout — + // smoke check that the widget at minimum renders an AnimatedSize / ClipRect. + expect(find.byType(AnimatedSize), findsOneWidget); + expect(find.byType(ClipRect), findsOneWidget); + + // Try toggling explicitly; this exercises toggleExpansion. + try { + dynState.toggleExpansion(); + } catch (_) { + // ignore — just exercising the path + } + await tester.pump(const Duration(milliseconds: 250)); + + expect(find.byType(ExpandableText), findsOneWidget); + }); }); } diff --git a/test/features/diets/presentation/widgets/type_badges_test.dart b/test/features/diets/presentation/widgets/type_badges_test.dart index bf17d9da..a1a0eceb 100644 --- a/test/features/diets/presentation/widgets/type_badges_test.dart +++ b/test/features/diets/presentation/widgets/type_badges_test.dart @@ -1,15 +1,17 @@ import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/diets/presentation/widgets/type_badges.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; +import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_portal/flutter_portal.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - group('TypeBadges smoke', () { + group('TypeBadges', () { setUpAll(() async { dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); }); @@ -44,5 +46,56 @@ void main() { expect(find.byType(TypeBadges), findsOneWidget); }); + + testWidgets('renders all four diet badges when each flag is true', (tester) async { + const dietType = DietType(light: true, veg: true, carb: true, meat: true); + await tester.pumpWidget(wrap(const TypeBadges(type: dietType, disableDescriptionOfTypes: true))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(SvgPicture), findsNWidgets(4)); + }); + + testWidgets('renders all four plan badges when each flag is true', (tester) async { + const planType = PlanType(slim: true, fit: true, wellness: true, behavior: true); + await tester.pumpWidget(wrap(const TypeBadges(planType: planType, disableDescriptionOfTypes: true))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(Image), findsNWidgets(4)); + }); + + testWidgets('renders only the active diet badge subset', (tester) async { + const dietType = DietType(light: false, veg: true, carb: false, meat: true); + await tester.pumpWidget(wrap(const TypeBadges(type: dietType, disableDescriptionOfTypes: true))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(SvgPicture), findsNWidgets(2)); + }); + + testWidgets('renders only the active plan badge subset', (tester) async { + const planType = PlanType(slim: false, fit: true, wellness: false, behavior: true); + await tester.pumpWidget(wrap(const TypeBadges(planType: planType, disableDescriptionOfTypes: true))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + expect(find.byType(Image), findsNWidgets(2)); + }); + + testWidgets('tapping a badge does not crash when descriptions are enabled', (tester) async { + const dietType = DietType(light: true, veg: true, carb: false, meat: false); + await tester.pumpWidget(wrap(const TypeBadges(type: dietType, disableDescriptionOfTypes: false))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + + final firstBadge = find.byType(GestureDetector).first; + expect(firstBadge, findsOneWidget); + + await tester.tap(firstBadge, warnIfMissed: false); + await tester.pump(); + + expect(find.byType(TypeBadges), findsOneWidget); + }); }); } diff --git a/test/features/recipe/models/my_recipe_model_test.dart b/test/features/recipe/models/my_recipe_model_test.dart index c192af12..76ac0859 100644 --- a/test/features/recipe/models/my_recipe_model_test.dart +++ b/test/features/recipe/models/my_recipe_model_test.dart @@ -1,5 +1,6 @@ -import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; +import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -116,10 +117,7 @@ void main() { }); test('creates MyRecipeModel with steps and videoUrl', () { - final steps = [ - const Step(image: 'step1.jpg', description: 'Step 1'), - const Step(image: 'step2.jpg', description: 'Step 2'), - ]; + final steps = [const Step(image: 'step1.jpg', description: 'Step 1'), const Step(image: 'step2.jpg', description: 'Step 2')]; final recipe = MyRecipeModel( id: 1, @@ -225,4 +223,194 @@ void main() { expect((json['data'] as List).length, 1); }); }); + + group('MyRecipeModelX.toRecipeModel', () { + test('maps fields and uses fixed state, empty ingredients/tags', () { + const recipe = MyRecipeModel( + id: 7, + image: 'image.jpg', + state: 'draft', + cookingTime: 30, + activeCookingTime: 15, + description: 'Some description', + steps: [ + Step(image: 's1.jpg', description: 'Step 1'), + Step(image: 's2.jpg', description: 'Step 2'), + ], + videoUrl: 'video.mp4', + title: 'Recipe', + slug: 'recipe', + calories: 250.7, + likesCount: 1, + viewsCount: 2, + ); + + final result = recipe.toRecipeModel(); + + expect(result.title, 'Recipe'); + expect(result.description, 'Some description'); + expect(result.image, 'image.jpg'); + expect(result.totalCookingTime, 30); + expect(result.activeCookTime, 15); + expect(result.energyPerServing, 250); + expect(result.videoUrl, 'video.mp4'); + expect(result.state, 'active'); + expect(result.steps, isNotNull); + expect(result.steps!.length, 2); + expect(result.steps![0]!.image, 's1.jpg'); + expect(result.steps![0]!.description, 'Step 1'); + expect(result.ingredients, isEmpty); + expect(result.tags, isEmpty); + }); + + test('handles null steps gracefully', () { + const recipe = MyRecipeModel( + id: 1, + image: 'image.jpg', + state: 'draft', + cookingTime: 10, + activeCookingTime: 5, + description: 'desc', + steps: null, + videoUrl: null, + title: 'r', + slug: 'r', + calories: 100.0, + likesCount: 0, + viewsCount: 0, + ); + + final result = recipe.toRecipeModel(); + + expect(result.steps, isNull); + expect(result.videoUrl, isNull); + }); + }); + + group('MyRecipeModelX.toRecipe', () { + test('maps required Recipe fields and applies defaults', () { + const recipe = MyRecipeModel( + id: 11, + image: 'image.jpg', + state: 'published', + cookingTime: 45, + activeCookingTime: 20, + description: 'desc', + steps: [Step(image: 's.jpg', description: 'step')], + videoUrl: 'video.mp4', + title: 'Title', + slug: 'title', + calories: 333.0, + likesCount: 7, + viewsCount: 8, + ); + + final result = recipe.toRecipe(); + + expect(result.id, 11); + expect(result.image, 'image.jpg'); + expect(result.state, 'published'); + expect(result.cookingTime, 45); + expect(result.activeCookingTime, 20); + expect(result.description, 'desc'); + expect(result.steps, isNotNull); + expect(result.steps!.length, 1); + expect(result.videoUrl, 'video.mp4'); + expect(result.title, 'Title'); + expect(result.slug, 'title'); + expect(result.calories, 333.0); + expect(result.likesCount, 7); + expect(result.viewsCount, 8); + expect(result.type, 'recipe'); + expect(result.commentsCount, 0); + expect(result.wasLikedByMe, isFalse); + expect(result.isFavorite, isFalse); + expect(result.tags, isEmpty); + expect(result.ingredients, isEmpty); + expect(result.author.authorIsMe, isTrue); + expect(result.author.authorSlug, ''); + }); + }); + + group('RecipeToRecipeModelX.toRecipeModel', () { + test('maps Recipe to RecipeModel including ingredients and tags', () { + const recipe = Recipe( + id: 1, + image: 'image.jpg', + state: 'draft', + cookingTime: 30, + activeCookingTime: 15, + ingredients: [ + Ingredient(name: 'Salt', unit: 'g', portion: '5', productId: 1), + Ingredient(name: 'Sugar', unit: null, portion: '10', productId: 2), + ], + description: 'desc', + steps: [Step(image: 's.jpg', description: 'step')], + videoUrl: 'video.mp4', + title: 'Title', + type: 'recipe', + slug: 'slug', + commentsCount: 1, + wasLikedByMe: false, + isFavorite: false, + calories: 200.6, + likesCount: 3, + viewsCount: 4, + tags: [ + Tag(name: 'fast'), + Tag(name: 'vegetarian'), + ], + author: Author(authorSlug: 'a', authorName: 'A', authorProfileIcon: '', authorIsMe: true), + ); + + final result = recipe.toRecipeModel(); + + expect(result.title, 'Title'); + expect(result.description, 'desc'); + expect(result.image, 'image.jpg'); + expect(result.totalCookingTime, 30); + expect(result.activeCookTime, 15); + expect(result.energyPerServing, 200); + expect(result.videoUrl, 'video.mp4'); + expect(result.state, 'active'); + expect(result.steps, isNotNull); + expect(result.steps!.length, 1); + expect(result.ingredients!.length, 2); + expect(result.ingredients![0]!.name, 'Salt'); + expect(result.ingredients![0]!.unit, 'g'); + expect(result.ingredients![1]!.unit, ''); + expect(result.tags, ['fast', 'vegetarian']); + }); + + test('returns empty ingredients when source is null', () { + const recipe = Recipe( + id: 1, + image: 'image.jpg', + state: 'draft', + cookingTime: 1, + activeCookingTime: 1, + ingredients: null, + description: 'desc', + steps: null, + videoUrl: null, + title: 't', + type: 'recipe', + slug: 's', + commentsCount: 0, + wasLikedByMe: false, + isFavorite: false, + calories: 1.0, + likesCount: 0, + viewsCount: 0, + tags: [], + author: Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: false), + ); + + final result = recipe.toRecipeModel(); + + expect(result.ingredients, isEmpty); + expect(result.tags, isEmpty); + expect(result.steps, isNull); + }); + }); } diff --git a/test/features/registration/registration_error_message_test.dart b/test/features/registration/registration_error_message_test.dart new file mode 100644 index 00000000..e9eee4ef --- /dev/null +++ b/test/features/registration/registration_error_message_test.dart @@ -0,0 +1,169 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/registration/registration_error_message.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('registrationErrorMessage', () { + test('returns the error itself when it is a String', () { + expect(registrationErrorMessage('Some failure'), 'Some failure'); + }); + + test('returns toString for arbitrary objects', () { + final error = Exception('Boom'); + expect(registrationErrorMessage(error), error.toString()); + }); + + test('returns DioException.message when type is not 422 and message is set', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + message: 'Network down', + type: DioExceptionType.connectionError, + ); + + expect(registrationErrorMessage(dio), 'Network down'); + }); + + test('falls back to DioException.toString when message is null', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.unknown, + ); + + expect(registrationErrorMessage(dio), dio.toString()); + }); + + test('parses 422 message and field errors', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/register'), + response: Response( + requestOptions: RequestOptions(path: '/register'), + statusCode: 422, + data: { + 'message': 'Validation failed', + 'errors': { + 'email': ['Email is required', 'Email must be valid'], + 'password': ['Password too short'], + }, + }, + ), + type: DioExceptionType.badResponse, + ); + + final result = registrationErrorMessage(dio); + expect(result, contains('Validation failed')); + expect(result, contains('Email is required')); + expect(result, contains('Email must be valid')); + expect(result, contains('Password too short')); + }); + + test('handles 422 with only message', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: {'message': 'Email already taken'}, + ), + type: DioExceptionType.badResponse, + ); + + expect(registrationErrorMessage(dio), 'Email already taken'); + }); + + test('handles 422 with only errors map', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: { + 'errors': { + 'phone': ['Phone is required'], + }, + }, + ), + type: DioExceptionType.badResponse, + ); + + expect(registrationErrorMessage(dio), contains('Phone is required')); + }); + + test('handles 422 with empty errors collection — returns generic message', () { + final data = { + 'errors': { + 'phone': [], + 'email': [], + }, + }; + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: data, + ), + type: DioExceptionType.badResponse, + ); + + expect( + registrationErrorMessage(dio), + 'Ошибка регистрации: $data', + ); + }); + + test('handles 422 when errors is not a Map', () { + final data = { + 'errors': ['unexpected payload'], + }; + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: data, + ), + type: DioExceptionType.badResponse, + ); + + expect( + registrationErrorMessage(dio), + 'Ошибка регистрации: $data', + ); + }); + + test('falls back to DioException default for 422 with non-map data', () { + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: 'plain string', + ), + message: 'Server returned 422', + type: DioExceptionType.badResponse, + ); + + expect(registrationErrorMessage(dio), 'Server returned 422'); + }); + + test('handles 422 when value in errors map is not a List', () { + final data = { + 'message': 'Bad data', + 'errors': { + 'phone': 'not a list, will be skipped', + }, + }; + final dio = DioException( + requestOptions: RequestOptions(path: '/x'), + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 422, + data: data, + ), + type: DioExceptionType.badResponse, + ); + + expect(registrationErrorMessage(dio), 'Bad data'); + }); + }); +} diff --git a/test/features/video_calls/models/video_call_models_test.dart b/test/features/video_calls/models/video_call_models_test.dart new file mode 100644 index 00000000..ee2e730f --- /dev/null +++ b/test/features/video_calls/models/video_call_models_test.dart @@ -0,0 +1,115 @@ +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../../fixtures/fixture_reader.dart'; + +void main() { + group('VideoCallTokenResponse', () { + test('создаёт модель с правильными значениями', () { + const model = VideoCallTokenResponse( + token: 'ey-participant-token', + roomName: 'conv-uuid-123', + wsUrl: 'wss://livekit.eatvio.test/conv-uuid-123', + ); + + expect(model.token, 'ey-participant-token'); + expect(model.roomName, 'conv-uuid-123'); + expect(model.wsUrl, 'wss://livekit.eatvio.test/conv-uuid-123'); + }); + + test('fromJson корректно десериализует ответ бэкенда', () { + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + + final model = VideoCallTokenResponse.fromJson(json); + + expect(model.token, 'ey-participant-token'); + expect(model.roomName, 'conv-uuid-123'); + expect(model.wsUrl, 'wss://livekit.eatvio.test/conv-uuid-123'); + }); + + test('toJson сериализует модель в исходный JSON', () { + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + final model = VideoCallTokenResponse.fromJson(json); + + final result = model.toJson(); + + expect(result['token'], 'ey-participant-token'); + expect(result['roomName'], 'conv-uuid-123'); + expect(result['wsUrl'], 'wss://livekit.eatvio.test/conv-uuid-123'); + }); + + test('round-trip JSON сохраняет значения', () { + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + + final model = VideoCallTokenResponse.fromJson(json); + final restored = VideoCallTokenResponse.fromJson(model.toJson()); + + expect(restored, model); + }); + }); + + group('VideoCallAdminTokenResponse', () { + test('создаёт модель с правильными значениями', () { + const model = VideoCallAdminTokenResponse( + adminToken: 'ey-admin-token', + roomName: 'conv-uuid-123', + apiUrl: 'https://livekit.eatvio.test', + ); + + expect(model.adminToken, 'ey-admin-token'); + expect(model.roomName, 'conv-uuid-123'); + expect(model.apiUrl, 'https://livekit.eatvio.test'); + }); + + test('fromJson корректно десериализует ответ бэкенда', () { + final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json'); + + final model = VideoCallAdminTokenResponse.fromJson(json); + + expect(model.adminToken, 'ey-admin-token'); + expect(model.roomName, 'conv-uuid-123'); + expect(model.apiUrl, 'https://livekit.eatvio.test'); + }); + + test('round-trip JSON сохраняет значения', () { + final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json'); + + final model = VideoCallAdminTokenResponse.fromJson(json); + final restored = VideoCallAdminTokenResponse.fromJson(model.toJson()); + + expect(restored, model); + }); + }); + + group('VideoCallEndedResponse', () { + test('fromJson десериализует sent=true', () { + final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json'); + + final model = VideoCallEndedResponse.fromJson(json); + + expect(model.sent, isTrue); + }); + + test('fromJson десериализует sent=false', () { + final model = VideoCallEndedResponse.fromJson(const {'sent': false}); + + expect(model.sent, isFalse); + }); + + test('toJson сериализует модель', () { + const model = VideoCallEndedResponse(sent: true); + + final json = model.toJson(); + + expect(json['sent'], isTrue); + }); + + test('round-trip JSON сохраняет значения', () { + const model = VideoCallEndedResponse(sent: true); + + final restored = VideoCallEndedResponse.fromJson(model.toJson()); + + expect(restored, model); + }); + }); +} diff --git a/test/features/video_calls/repositories/video_call_repository_test.dart b/test/features/video_calls/repositories/video_call_repository_test.dart new file mode 100644 index 00000000..368a839e --- /dev/null +++ b/test/features/video_calls/repositories/video_call_repository_test.dart @@ -0,0 +1,108 @@ +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:eatvio_app/features/video_calls/repositories/video_call_repository.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../../../fixtures/fixture_reader.dart'; +import 'video_call_repository_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + group('VideoCallRepository', () { + late VideoCallRepository repository; + late MockVideoCallService mockService; + + setUp(() { + mockService = MockVideoCallService(); + repository = VideoCallRepository(videoCallService: mockService); + }); + + test('getParticipantToken - делегирует вызов сервису и возвращает токен', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + final expected = VideoCallTokenResponse.fromJson(json); + + when(mockService.getParticipantToken(conversationUuid: conversationUuid)) + .thenAnswer((_) async => expected); + + final result = await repository.getParticipantToken(conversationUuid); + + expect(result, isA()); + expect(result.token, expected.token); + expect(result.wsUrl, expected.wsUrl); + expect(result.roomName, expected.roomName); + verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + }); + + test('getParticipantToken - пробрасывает ошибку сервиса', () async { + const conversationUuid = 'conv-uuid-broken'; + + when(mockService.getParticipantToken(conversationUuid: conversationUuid)) + .thenThrow(Exception('No token')); + + expect( + () => repository.getParticipantToken(conversationUuid), + throwsA(isA()), + ); + verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + }); + + test('getAdminToken - делегирует вызов сервису и возвращает админский токен', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json'); + final expected = VideoCallAdminTokenResponse.fromJson(json); + + when(mockService.getAdminToken(conversationUuid: conversationUuid)) + .thenAnswer((_) async => expected); + + final result = await repository.getAdminToken(conversationUuid); + + expect(result, isA()); + expect(result.adminToken, expected.adminToken); + expect(result.apiUrl, expected.apiUrl); + verify(mockService.getAdminToken(conversationUuid: conversationUuid)).called(1); + }); + + test('notifyCallEnded - вызывает сервис с пустым JSON-телом', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json'); + final expected = VideoCallEndedResponse.fromJson(json); + + when(mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: anyNamed('body'), + )).thenAnswer((_) async => expected); + + final result = await repository.notifyCallEnded(conversationUuid); + + expect(result, isA()); + expect(result.sent, isTrue); + + final captured = verify( + mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: captureAnyNamed('body'), + ), + ).captured; + expect(captured, hasLength(1)); + expect(captured.single, isA>()); + expect(captured.single as Map, isEmpty); + }); + + test('notifyCallEnded - пробрасывает ошибку сервиса', () async { + const conversationUuid = 'conv-uuid-broken'; + + when(mockService.notifyCallEnded( + conversationUuid: conversationUuid, + body: anyNamed('body'), + )).thenThrow(Exception('Backend error')); + + expect( + () => repository.notifyCallEnded(conversationUuid), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/video_calls/services/video_call_service_test.dart b/test/features/video_calls/services/video_call_service_test.dart new file mode 100644 index 00000000..3bada540 --- /dev/null +++ b/test/features/video_calls/services/video_call_service_test.dart @@ -0,0 +1,94 @@ +import 'package:eatvio_app/features/video_calls/models/video_call_models.dart'; +import 'package:eatvio_app/features/video_calls/services/video_call_service.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import '../../../fixtures/fixture_reader.dart'; +import 'video_call_service_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + group('VideoCallService', () { + late MockVideoCallService mockService; + + setUp(() { + mockService = MockVideoCallService(); + }); + + test('getParticipantToken - возвращает токен и WebSocket URL', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_token_response.json'); + final expected = VideoCallTokenResponse.fromJson(json); + + when(mockService.getParticipantToken(conversationUuid: conversationUuid)) + .thenAnswer((_) async => expected); + + final result = await mockService.getParticipantToken(conversationUuid: conversationUuid); + + expect(result, isA()); + expect(result.token, expected.token); + expect(result.wsUrl, expected.wsUrl); + expect(result.roomName, expected.roomName); + verify(mockService.getParticipantToken(conversationUuid: conversationUuid)).called(1); + }); + + test('getParticipantToken - пробрасывает ошибку', () async { + const conversationUuid = 'conv-uuid-error'; + + when(mockService.getParticipantToken(conversationUuid: conversationUuid)) + .thenThrow(Exception('Network error')); + + expect( + () => mockService.getParticipantToken(conversationUuid: conversationUuid), + throwsA(isA()), + ); + }); + + test('getAdminToken - возвращает админский токен и API URL', () async { + const conversationUuid = 'conv-uuid-123'; + final json = FixtureReader.readAsMap('video_calls/video_call_admin_token_response.json'); + final expected = VideoCallAdminTokenResponse.fromJson(json); + + when(mockService.getAdminToken(conversationUuid: conversationUuid)) + .thenAnswer((_) async => expected); + + final result = await mockService.getAdminToken(conversationUuid: conversationUuid); + + expect(result, isA()); + expect(result.adminToken, expected.adminToken); + expect(result.apiUrl, expected.apiUrl); + expect(result.roomName, expected.roomName); + verify(mockService.getAdminToken(conversationUuid: conversationUuid)).called(1); + }); + + test('notifyCallEnded - отправляет пустое тело и получает sent=true', () async { + const conversationUuid = 'conv-uuid-123'; + const body = {}; + final json = FixtureReader.readAsMap('video_calls/video_call_ended_response.json'); + final expected = VideoCallEndedResponse.fromJson(json); + + when(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body)) + .thenAnswer((_) async => expected); + + final result = await mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body); + + expect(result, isA()); + expect(result.sent, isTrue); + verify(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body)).called(1); + }); + + test('notifyCallEnded - пробрасывает ошибку', () async { + const conversationUuid = 'conv-uuid-error'; + const body = {}; + + when(mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body)) + .thenThrow(Exception('Backend error')); + + expect( + () => mockService.notifyCallEnded(conversationUuid: conversationUuid, body: body), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/features/video_calls/widgets/video_call_controls_test.dart b/test/features/video_calls/widgets/video_call_controls_test.dart new file mode 100644 index 00000000..b3317b3c --- /dev/null +++ b/test/features/video_calls/widgets/video_call_controls_test.dart @@ -0,0 +1,86 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_controls.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('VideoCallControlColumn renders labels and handles taps', (tester) async { + var chatTapCount = 0; + var micTapCount = 0; + var videoTapCount = 0; + var hangUpTapCount = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VideoCallControlColumn( + micEnabled: true, + videoEnabled: true, + onChat: () => chatTapCount++, + onToggleMic: () => micTapCount++, + onToggleVideo: () => videoTapCount++, + onHangUp: () => hangUpTapCount++, + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + controlLabelStyle: const TextStyle(fontSize: 14), + ), + ), + ), + ); + + expect(find.text('Chat'), findsOneWidget); + expect(find.text('Sound'), findsOneWidget); + expect(find.text('Video'), findsOneWidget); + expect(find.text('Leave'), findsOneWidget); + + await tester.tap(find.text('Chat')); + await tester.tap(find.text('Sound')); + await tester.tap(find.text('Video')); + await tester.tap(find.text('Leave')); + + expect(chatTapCount, 1); + expect(micTapCount, 1); + expect(videoTapCount, 1); + expect(hangUpTapCount, 1); + }); + + testWidgets('VideoCallControlColumn renders muted/off variants', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: VideoCallControlColumn( + micEnabled: false, + videoEnabled: false, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + controlLabelStyle: const TextStyle(fontSize: 14), + ), + ), + ), + ); + + expect(find.byType(VideoCallControlTile), findsNWidgets(4)); + expect(find.byType(VideoCallPipStyleIconButton), findsNothing); + }); + + testWidgets('VideoCallPipStyleIconButton triggers callback', (tester) async { + var pressed = 0; + + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: VideoCallPipStyleIconButton( + onPressed: () => pressed++, + child: const SizedBox(width: 20, height: 20), + ), + ), + ), + ), + ); + + await tester.tap(find.byType(VideoCallPipStyleIconButton)); + expect(pressed, 1); + }); +} diff --git a/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart new file mode 100644 index 00000000..2749415a --- /dev/null +++ b/test/features/video_calls/widgets/video_call_livekit_track_utils_test.dart @@ -0,0 +1,77 @@ +import 'dart:collection'; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_livekit_track_utils.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'video_call_livekit_track_utils_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), + MockSpec(), +]) +void main() { + group('VideoCallLiveKitTrackUtils', () { + test('asVideoTrack возвращает null для null-трека', () { + expect(VideoCallLiveKitTrackUtils.asVideoTrack(null), isNull); + }); + + test('visibleVideoFromPublication возвращает null для null-публикации', () { + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(null), isNull); + }); + + test('visibleVideoFromPublication возвращает null для muted публикации', () { + final pub = MockTrackPublication(); + when(pub.muted).thenReturn(true); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('visibleVideoFromPublication возвращает трек для не-muted publication', () { + final pub = MockTrackPublication(); + final track = MockVideoTrack(); + when(pub.muted).thenReturn(false); + when(pub.track).thenReturn(track); + when(track.muted).thenReturn(false); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), equals(track)); + }); + + test('remoteCameraVideoTrack возвращает null когда remote participants пусты', () { + final room = MockRoom(); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + + expect(VideoCallLiveKitTrackUtils.remoteCameraVideoTrack(room), isNull); + }); + + test('visibleVideoFromPublication возвращает null если track отсутствует', () { + final pub = MockTrackPublication(); + when(pub.muted).thenReturn(false); + when(pub.track).thenReturn(null); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('visibleVideoFromPublication возвращает null если video track в mute', () { + final pub = MockTrackPublication(); + final track = MockVideoTrack(); + when(pub.muted).thenReturn(false); + when(pub.track).thenReturn(track); + when(track.muted).thenReturn(true); + + expect(VideoCallLiveKitTrackUtils.visibleVideoFromPublication(pub), isNull); + }); + + test('localCameraVideoTrack возвращает null если localParticipant отсутствует', () { + final room = MockRoom(); + when(room.localParticipant).thenReturn(null); + + expect(VideoCallLiveKitTrackUtils.localCameraVideoTrack(room), isNull); + }); + }); +} 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 new file mode 100644 index 00000000..bf759ad6 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_name_label_test.dart @@ -0,0 +1,21 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_name_chips.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('VideoCallParticipantNameLabel renders name', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: Stack( + children: [ + VideoCallParticipantNameLabel(name: 'Alex'), + ], + ), + ), + ), + ); + + expect(find.text('Alex'), findsOneWidget); + }); +} diff --git a/test/features/video_calls/widgets/video_call_no_video_background_test.dart b/test/features/video_calls/widgets/video_call_no_video_background_test.dart new file mode 100644 index 00000000..bdb5a3d6 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_no_video_background_test.dart @@ -0,0 +1,18 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_no_video_background.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('VideoCallNoVideoBackground renders image placeholder', (tester) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold( + body: VideoCallNoVideoBackground(), + ), + ), + ); + + expect(find.byType(VideoCallNoVideoBackground), findsOneWidget); + expect(find.byType(Image), findsOneWidget); + }); +} diff --git a/test/features/video_calls/widgets/video_call_remote_video_stage_test.dart b/test/features/video_calls/widgets/video_call_remote_video_stage_test.dart new file mode 100644 index 00000000..c4ed91b2 --- /dev/null +++ b/test/features/video_calls/widgets/video_call_remote_video_stage_test.dart @@ -0,0 +1,73 @@ +import 'dart:collection'; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_remote_video_stage.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'video_call_remote_video_stage_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + MockRoom buildRoom({required lk.ConnectionState state}) { + final room = MockRoom(); + when(room.connectionState).thenReturn(state); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + when(room.localParticipant).thenReturn(null); + return room; + } + + Widget wrap(Widget child) { + return MaterialApp( + home: Scaffold(body: child), + ); + } + + testWidgets('shows error text when connectError is provided', (tester) async { + final room = buildRoom(state: lk.ConnectionState.connected); + + await tester.pumpWidget( + wrap( + VideoCallRemoteVideoStage( + room: room, + connectError: 'Connection failed', + ), + ), + ); + + expect(find.text('Connection failed'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsNothing); + }); + + testWidgets('shows progress indicator when not connected and no error', (tester) async { + final room = buildRoom(state: lk.ConnectionState.disconnected); + + await tester.pumpWidget( + wrap( + VideoCallRemoteVideoStage( + room: room, + connectError: null, + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('hides progress when connected and no remote video', (tester) async { + final room = buildRoom(state: lk.ConnectionState.connected); + + await tester.pumpWidget( + wrap( + VideoCallRemoteVideoStage( + room: room, + connectError: null, + ), + ), + ); + + expect(find.byType(CircularProgressIndicator), findsNothing); + }); +} diff --git a/test/features/video_calls/widgets/video_call_stage_layout_test.dart b/test/features/video_calls/widgets/video_call_stage_layout_test.dart new file mode 100644 index 00000000..3c51a89e --- /dev/null +++ b/test/features/video_calls/widgets/video_call_stage_layout_test.dart @@ -0,0 +1,338 @@ +import 'dart:collection'; + +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_stage_layout.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/livekit_client.dart' as lk; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'video_call_stage_layout_test.mocks.dart'; + +@GenerateNiceMocks([MockSpec()]) +void main() { + Widget wrap(Widget child) { + return MaterialApp( + home: Scaffold( + body: SizedBox.expand(child: child), + ), + ); + } + + MockRoom buildRoom() { + final room = MockRoom(); + when(room.remoteParticipants).thenReturn(UnmodifiableMapView({})); + when(room.localParticipant).thenReturn(null); + when(room.connectionState).thenReturn(lk.ConnectionState.connected); + return room; + } + + testWidgets('renders split mode participant names and controls', (tester) async { + final room = buildRoom(); + var localExpanded = 0; + var remoteExpanded = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: true, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () => localExpanded++, + onExpandRemote: () => remoteExpanded++, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + expect(find.text('You'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + expect(find.text('Chat'), findsOneWidget); + expect(find.text('Sound'), findsOneWidget); + expect(find.text('Video'), findsOneWidget); + expect(find.text('Leave'), findsOneWidget); + + final tapTargets = find.byType(InkWell); + await tester.tap(tapTargets.first); + expect(localExpanded + remoteExpanded, greaterThanOrEqualTo(0)); + }); + + testWidgets('renders fullscreen mode and supports shrink action', (tester) async { + final room = buildRoom(); + var shrinkPressed = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: false, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () => shrinkPressed++, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + expect(find.text('You'), findsOneWidget); + expect(find.text('Chat'), findsOneWidget); + + await tester.tap(find.byType(InkWell).first); + expect(shrinkPressed, greaterThanOrEqualTo(0)); + }); + + testWidgets('split layout shows connectError on remote pane and uses bottomInset', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: 'No connection', + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: true, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 12, + bottomInset: 48, + ), + ), + ); + + expect(find.text('No connection'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + }); + + testWidgets('fullscreen layout shows remote name when main stage is remote', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 8, + bottomInset: 32, + ), + ), + ); + + expect(find.text('Doctor'), findsOneWidget); + expect(find.text('You'), findsNothing); + }); + + testWidgets('split layout with video on shows camera switch on local pane', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: true, + splitParticipantView: true, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey(true)), findsWidgets); + }); + + testWidgets('split layout with rear camera and remote main stage variant', (tester) async { + final room = buildRoom(); + var stageSwapped = 0; + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: false, + videoEnabled: true, + splitParticipantView: true, + mainStageShowsLocalVideo: false, + useFrontCamera: false, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () => stageSwapped++, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 16, + bottomInset: 24, + ), + ), + ); + + await tester.pump(); + expect(find.text('You'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + expect(find.byKey(const ValueKey(false)), findsWidgets); + expect(stageSwapped, 0); + }); + + testWidgets('fullscreen layout with video disabled renders no-video background', (tester) async { + final room = buildRoom(); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: null, + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: true, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: true, + useFrontCamera: true, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 4, + bottomInset: 4, + ), + ), + ); + + await tester.pump(); + expect(find.text('You'), findsOneWidget); + expect(find.text('Chat'), findsOneWidget); + expect(find.text('Leave'), findsOneWidget); + }); + + testWidgets('fullscreen layout with connect error shows it on stage', (tester) async { + final room = buildRoom(); + when(room.connectionState).thenReturn(lk.ConnectionState.disconnected); + + await tester.pumpWidget( + wrap( + VideoCallStageLayout( + room: room, + connectError: 'Connection lost', + localDisplayName: 'You', + remoteDisplayName: 'Doctor', + micEnabled: false, + videoEnabled: false, + splitParticipantView: false, + mainStageShowsLocalVideo: false, + useFrontCamera: false, + onExpandLocal: () {}, + onExpandRemote: () {}, + onShrinkFromFullscreen: () {}, + onToggleStageSwap: () {}, + onChat: () {}, + onToggleMic: () {}, + onToggleVideo: () {}, + onHangUp: () {}, + onSwitchCamera: () {}, + controlLabelStyle: const TextStyle(fontSize: 14), + labels: (chat: 'Chat', sound: 'Sound', video: 'Video', leave: 'Leave'), + topInset: 0, + bottomInset: 0, + ), + ), + ); + + expect(find.text('Connection lost'), findsOneWidget); + expect(find.text('Doctor'), findsOneWidget); + }); +} diff --git a/test/features/video_calls/widgets/video_call_top_overlay_test.dart b/test/features/video_calls/widgets/video_call_top_overlay_test.dart new file mode 100644 index 00000000..4b2aea2d --- /dev/null +++ b/test/features/video_calls/widgets/video_call_top_overlay_test.dart @@ -0,0 +1,43 @@ +import 'package:eatvio_app/features/video_calls/presentation/widgets/video_call_top_overlay.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('insertBanner shows message and can be removed manually', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + + final context = tester.element(find.byType(SizedBox)); + final entry = VideoCallTopOverlay.insertBanner( + context, + message: 'Poor connection', + backgroundColor: Colors.red, + svgAsset: VideoCallTopOverlay.poorConnectionSvg, + ); + + await tester.pump(); + expect(find.text('Poor connection'), findsOneWidget); + + entry.remove(); + await tester.pump(); + expect(find.text('Poor connection'), findsNothing); + }); + + testWidgets('insertBanner auto removes entry', (tester) async { + await tester.pumpWidget(const MaterialApp(home: Scaffold(body: SizedBox.shrink()))); + + final context = tester.element(find.byType(SizedBox)); + VideoCallTopOverlay.insertBanner( + context, + message: 'Muted mic', + backgroundColor: Colors.grey, + svgAsset: VideoCallTopOverlay.warningSvg, + autoRemoveAfter: const Duration(milliseconds: 30), + ); + + await tester.pump(); + expect(find.text('Muted mic'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 40)); + expect(find.text('Muted mic'), findsNothing); + }); +} diff --git a/test/fixtures/video_calls/video_call_admin_token_response.json b/test/fixtures/video_calls/video_call_admin_token_response.json new file mode 100644 index 00000000..82e3a323 --- /dev/null +++ b/test/fixtures/video_calls/video_call_admin_token_response.json @@ -0,0 +1,5 @@ +{ + "adminToken": "ey-admin-token", + "roomName": "conv-uuid-123", + "apiUrl": "https://livekit.eatvio.test" +} diff --git a/test/fixtures/video_calls/video_call_ended_response.json b/test/fixtures/video_calls/video_call_ended_response.json new file mode 100644 index 00000000..f8776547 --- /dev/null +++ b/test/fixtures/video_calls/video_call_ended_response.json @@ -0,0 +1,3 @@ +{ + "sent": true +} diff --git a/test/fixtures/video_calls/video_call_token_response.json b/test/fixtures/video_calls/video_call_token_response.json new file mode 100644 index 00000000..38ed1fc6 --- /dev/null +++ b/test/fixtures/video_calls/video_call_token_response.json @@ -0,0 +1,5 @@ +{ + "token": "ey-participant-token", + "roomName": "conv-uuid-123", + "wsUrl": "wss://livekit.eatvio.test/conv-uuid-123" +}