feat(video_calls): first impl for main video calls screen

This commit is contained in:
misha
2026-05-05 08:09:24 +03:00
parent 50aefde842
commit 91ad51d340
49 changed files with 2179 additions and 6 deletions

View File

@@ -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<ChatScreen> {
],
),
),
actions: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 8),
child: _ChatHeaderVideoCallButton(
onPressed: () {
GoRouter.of(context).pushNamed(
VideoCallScreen.name,
extra: <String, dynamic>{
'conversationUuid': widget.uuid,
'counterpartName': state.chatRoom.users.receiver.name,
'localParticipantName': state.chatRoom.users.sender.name,
},
);
},
),
),
],
),
body: SafeArea(
child: Stack(
@@ -741,3 +759,33 @@ class _ChatScreenState extends State<ChatScreen> {
);
}
}
class _ChatHeaderVideoCallButton extends StatelessWidget {
const _ChatHeaderVideoCallButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Tooltip(
message: S.of(context).video_call,
child: 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),
),
),
),
),
);
}
}

View File

@@ -106,9 +106,7 @@ class _AboutDietScreenState extends State<AboutDietScreen> {
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))),

View File

@@ -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<String, Object?> 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<String, Object?> json) =>
_$VideoCallAdminTokenResponseFromJson(json);
}
@freezed
abstract class VideoCallEndedResponse with _$VideoCallEndedResponse {
const factory VideoCallEndedResponse({required bool sent}) =
_VideoCallEndedResponse;
factory VideoCallEndedResponse.fromJson(Map<String, Object?> json) =>
_$VideoCallEndedResponseFromJson(json);
}

View File

@@ -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<VideoCallScreen> createState() => _VideoCallScreenState();
}
class _VideoCallScreenState extends State<VideoCallScreen> {
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<lk.RoomEvent>? _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<lk.ParticipantConnectionQualityUpdatedEvent>((e) {
if (e.participant != _room.localParticipant) return;
if (!mounted) return;
_syncPoorConnectionOverlay(e.connectionQuality);
})
..on<lk.ActiveSpeakersChangedEvent>((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<void> _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<void> _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<void> _restoreSystemUiAfterCall() async {
try {
await SystemChrome.setPreferredOrientations(const <DeviceOrientation>[]);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values);
} on PlatformException catch (_) {}
}
Future<void> _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<void> _disconnectLiveKitOnly() async {
if (_liveKitDisposed) return;
_liveKitDisposed = true;
try {
if (_room.connectionState != lk.ConnectionState.disconnected) {
await _room.disconnect();
}
} catch (_) {}
try {
await _room.dispose();
} catch (_) {}
}
Future<void> _notifyCallEndedOnce() async {
if (_callEndedNotified) return;
_callEndedNotified = true;
try {
await _videoCallRepository.notifyCallEnded(widget.conversationUuid);
} catch (_) {}
}
Future<void> _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<void> _handleBackToChat() async {
widget.onBackToChat?.call();
await _cleanupLiveKitAndNotifyBackend();
if (!mounted) return;
if (context.canPop()) context.pop();
}
Future<void> _handleHangUp() async {
widget.onHangUp?.call();
await _cleanupLiveKitAndNotifyBackend();
if (!mounted) return;
if (context.canPop()) context.pop();
}
Future<void> _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<void> _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<void> _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<bool> _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<bool> _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<SystemUiOverlayStyle>(
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,
);
},
),
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<VideoCallStageLayout> createState() => _VideoCallStageLayoutState();
}
class _VideoCallStageLayoutState extends State<VideoCallStageLayout> {
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<Widget> _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<Widget> _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)),
),
),
],
);
}
}

View File

@@ -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<void>.delayed(autoRemoveAfter, () {
if (entry.mounted) {
entry.remove();
}
});
}
return entry;
}
}

View File

@@ -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<String, dynamic> _emptyJsonBody = <String, dynamic>{};
Future<VideoCallTokenResponse> getParticipantToken(String conversationUuid) {
return videoCallService.getParticipantToken(
conversationUuid: conversationUuid,
);
}
Future<VideoCallAdminTokenResponse> getAdminToken(String conversationUuid) {
return videoCallService.getAdminToken(conversationUuid: conversationUuid);
}
Future<VideoCallEndedResponse> notifyCallEnded(String conversationUuid) {
return videoCallService.notifyCallEnded(
conversationUuid: conversationUuid,
body: _emptyJsonBody,
);
}
}

View File

@@ -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<VideoCallTokenResponse> getParticipantToken({
@Path('conversation') required String conversationUuid,
});
@GET('/api/v2/chat/{conversation}/video-call/admin-token')
Future<VideoCallAdminTokenResponse> getAdminToken({
@Path('conversation') required String conversationUuid,
});
@POST('/api/v2/chat/{conversation}/video-call/ended')
Future<VideoCallEndedResponse> notifyCallEnded({
@Path('conversation') required String conversationUuid,
@Body() required Map<String, dynamic> body,
});
}