forked from eatvio/eatvio_app
feat(video_calls): first impl for main video calls screen
This commit is contained in:
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))),
|
||||
|
||||
37
lib/features/video_calls/models/video_call_models.dart
Normal file
37
lib/features/video_calls/models/video_call_models.dart
Normal 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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
lib/features/video_calls/services/video_call_service.dart
Normal file
26
lib/features/video_calls/services/video_call_service.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user