feat(videocall): integrate videocall feature with architecture refactor

Merges videocall stash, resolves conflicts with installed_apps routes,
and refactors UI to match current legacy patterns:
- Replace themePortProvider with context.sfColors and Theme.of(context)
- Replace showTopSnackbar with feedback dialogs
- Replace hardcoded colors with theme-aware colorScheme
- Wrap test login button in kDebugMode
- Rename error enum values to be more descriptive
This commit is contained in:
2026-04-26 09:23:19 +02:00
parent 8ff94a1e92
commit 9f23ecb42e
27 changed files with 2428 additions and 0 deletions

View File

@@ -225,6 +225,11 @@ void configureAppRouter() {
),
],
),
GoRoute(
path: 'videocall',
name: 'videocall',
pageBuilder: const VideocallBuilder().buildPage,
),
],
),
],

View File

@@ -14,3 +14,4 @@ export 'src/features/call_history/call_history_builder.dart';
export 'src/features/background_image/background_image_builder.dart';
export 'src/features/installed_apps/installed_apps_builder.dart';
export 'src/features/app_usage_schedules/app_usage_schedules_builder.dart';
export 'src/features/videocall/videocall_builder.dart';

View File

@@ -0,0 +1,22 @@
abstract class VideocallSignalingDatasource {
Future<void> initiateCall({
required String deviceId,
required String appAccount,
required String roomNumber,
});
Future<void> cancelCall({required String deviceId});
Future<void> refuseCall({
required String deviceId,
required String roomNumber,
});
Future<int> getRoomParticipantCount({required String roomNumber});
Future<void> reportParticipantCount({
required String roomNumber,
required int count,
required int type,
});
}

View File

@@ -0,0 +1,56 @@
import 'package:sf_infrastructure/sf_infrastructure.dart';
import 'videocall_signaling_datasource.dart';
class VideocallSignalingDatasourceImpl implements VideocallSignalingDatasource {
VideocallSignalingDatasourceImpl(this._repository);
final SaveFamilyRepository _repository;
@override
Future<void> initiateCall({
required String deviceId,
required String appAccount,
required String roomNumber,
}) async {
// TODO: Implement when backend API spec is available
// await _repository.post('/devices/$deviceId/videocall/initiate', body: {...});
throw UnimplementedError(
'Backend signaling API not yet available. Waiting for endpoint spec.');
}
@override
Future<void> cancelCall({required String deviceId}) async {
// TODO: Implement when backend API spec is available
throw UnimplementedError(
'Backend signaling API not yet available. Waiting for endpoint spec.');
}
@override
Future<void> refuseCall({
required String deviceId,
required String roomNumber,
}) async {
// TODO: Implement when backend API spec is available
throw UnimplementedError(
'Backend signaling API not yet available. Waiting for endpoint spec.');
}
@override
Future<int> getRoomParticipantCount({required String roomNumber}) async {
// TODO: Implement when backend API spec is available
throw UnimplementedError(
'Backend signaling API not yet available. Waiting for endpoint spec.');
}
@override
Future<void> reportParticipantCount({
required String roomNumber,
required int count,
required int type,
}) async {
// TODO: Implement when backend API spec is available
throw UnimplementedError(
'Backend signaling API not yet available. Waiting for endpoint spec.');
}
}

View File

@@ -0,0 +1,52 @@
import '../../domain/repositories/videocall_signaling_repository.dart';
import '../datasources/videocall_signaling_datasource.dart';
class VideocallSignalingRepositoryImpl implements VideocallSignalingRepository {
VideocallSignalingRepositoryImpl(this._datasource);
final VideocallSignalingDatasource _datasource;
@override
Future<void> initiateCall({
required String deviceId,
required String appAccount,
required String roomNumber,
}) {
return _datasource.initiateCall(
deviceId: deviceId,
appAccount: appAccount,
roomNumber: roomNumber,
);
}
@override
Future<void> cancelCall({required String deviceId}) {
return _datasource.cancelCall(deviceId: deviceId);
}
@override
Future<void> refuseCall({
required String deviceId,
required String roomNumber,
}) {
return _datasource.refuseCall(deviceId: deviceId, roomNumber: roomNumber);
}
@override
Future<int> getRoomParticipantCount({required String roomNumber}) {
return _datasource.getRoomParticipantCount(roomNumber: roomNumber);
}
@override
Future<void> reportParticipantCount({
required String roomNumber,
required int count,
required int type,
}) {
return _datasource.reportParticipantCount(
roomNumber: roomNumber,
count: count,
type: type,
);
}
}

View File

@@ -0,0 +1,22 @@
enum VideocallErrorEvent {
sdkInitialization,
authentication,
callStart,
callAnswer,
network,
cameraPermission,
microphonePermission,
generic,
}
enum VideocallSuccessEvent {
connected,
callEnded,
}
enum VideocallScreenMode {
idle,
outgoing,
incoming,
inCall,
}

View File

@@ -0,0 +1,16 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:videocall_sdk/videocall_sdk.dart';
part 'videocall_participant.freezed.dart';
@freezed
abstract class VideocallParticipant with _$VideocallParticipant {
const factory VideocallParticipant({
required String participantId,
required String userId,
@Default(false) bool isAudio,
@Default(false) bool isVideo,
@Default(false) bool isSelf,
JCMediaDeviceVideoCanvas? videoCanvas,
}) = _VideocallParticipant;
}

View File

@@ -0,0 +1,286 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'videocall_participant.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$VideocallParticipant {
String get participantId; String get userId; bool get isAudio; bool get isVideo; bool get isSelf; JCMediaDeviceVideoCanvas? get videoCanvas;
/// Create a copy of VideocallParticipant
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$VideocallParticipantCopyWith<VideocallParticipant> get copyWith => _$VideocallParticipantCopyWithImpl<VideocallParticipant>(this as VideocallParticipant, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is VideocallParticipant&&(identical(other.participantId, participantId) || other.participantId == participantId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.isAudio, isAudio) || other.isAudio == isAudio)&&(identical(other.isVideo, isVideo) || other.isVideo == isVideo)&&(identical(other.isSelf, isSelf) || other.isSelf == isSelf)&&(identical(other.videoCanvas, videoCanvas) || other.videoCanvas == videoCanvas));
}
@override
int get hashCode => Object.hash(runtimeType,participantId,userId,isAudio,isVideo,isSelf,videoCanvas);
@override
String toString() {
return 'VideocallParticipant(participantId: $participantId, userId: $userId, isAudio: $isAudio, isVideo: $isVideo, isSelf: $isSelf, videoCanvas: $videoCanvas)';
}
}
/// @nodoc
abstract mixin class $VideocallParticipantCopyWith<$Res> {
factory $VideocallParticipantCopyWith(VideocallParticipant value, $Res Function(VideocallParticipant) _then) = _$VideocallParticipantCopyWithImpl;
@useResult
$Res call({
String participantId, String userId, bool isAudio, bool isVideo, bool isSelf, JCMediaDeviceVideoCanvas? videoCanvas
});
}
/// @nodoc
class _$VideocallParticipantCopyWithImpl<$Res>
implements $VideocallParticipantCopyWith<$Res> {
_$VideocallParticipantCopyWithImpl(this._self, this._then);
final VideocallParticipant _self;
final $Res Function(VideocallParticipant) _then;
/// Create a copy of VideocallParticipant
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? participantId = null,Object? userId = null,Object? isAudio = null,Object? isVideo = null,Object? isSelf = null,Object? videoCanvas = freezed,}) {
return _then(_self.copyWith(
participantId: null == participantId ? _self.participantId : participantId // ignore: cast_nullable_to_non_nullable
as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String,isAudio: null == isAudio ? _self.isAudio : isAudio // ignore: cast_nullable_to_non_nullable
as bool,isVideo: null == isVideo ? _self.isVideo : isVideo // ignore: cast_nullable_to_non_nullable
as bool,isSelf: null == isSelf ? _self.isSelf : isSelf // ignore: cast_nullable_to_non_nullable
as bool,videoCanvas: freezed == videoCanvas ? _self.videoCanvas : videoCanvas // ignore: cast_nullable_to_non_nullable
as JCMediaDeviceVideoCanvas?,
));
}
}
/// Adds pattern-matching-related methods to [VideocallParticipant].
extension VideocallParticipantPatterns on VideocallParticipant {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _VideocallParticipant value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _VideocallParticipant() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _VideocallParticipant value) $default,){
final _that = this;
switch (_that) {
case _VideocallParticipant():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _VideocallParticipant value)? $default,){
final _that = this;
switch (_that) {
case _VideocallParticipant() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String participantId, String userId, bool isAudio, bool isVideo, bool isSelf, JCMediaDeviceVideoCanvas? videoCanvas)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _VideocallParticipant() when $default != null:
return $default(_that.participantId,_that.userId,_that.isAudio,_that.isVideo,_that.isSelf,_that.videoCanvas);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String participantId, String userId, bool isAudio, bool isVideo, bool isSelf, JCMediaDeviceVideoCanvas? videoCanvas) $default,) {final _that = this;
switch (_that) {
case _VideocallParticipant():
return $default(_that.participantId,_that.userId,_that.isAudio,_that.isVideo,_that.isSelf,_that.videoCanvas);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String participantId, String userId, bool isAudio, bool isVideo, bool isSelf, JCMediaDeviceVideoCanvas? videoCanvas)? $default,) {final _that = this;
switch (_that) {
case _VideocallParticipant() when $default != null:
return $default(_that.participantId,_that.userId,_that.isAudio,_that.isVideo,_that.isSelf,_that.videoCanvas);case _:
return null;
}
}
}
/// @nodoc
class _VideocallParticipant implements VideocallParticipant {
const _VideocallParticipant({required this.participantId, required this.userId, this.isAudio = false, this.isVideo = false, this.isSelf = false, this.videoCanvas});
@override final String participantId;
@override final String userId;
@override@JsonKey() final bool isAudio;
@override@JsonKey() final bool isVideo;
@override@JsonKey() final bool isSelf;
@override final JCMediaDeviceVideoCanvas? videoCanvas;
/// Create a copy of VideocallParticipant
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$VideocallParticipantCopyWith<_VideocallParticipant> get copyWith => __$VideocallParticipantCopyWithImpl<_VideocallParticipant>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VideocallParticipant&&(identical(other.participantId, participantId) || other.participantId == participantId)&&(identical(other.userId, userId) || other.userId == userId)&&(identical(other.isAudio, isAudio) || other.isAudio == isAudio)&&(identical(other.isVideo, isVideo) || other.isVideo == isVideo)&&(identical(other.isSelf, isSelf) || other.isSelf == isSelf)&&(identical(other.videoCanvas, videoCanvas) || other.videoCanvas == videoCanvas));
}
@override
int get hashCode => Object.hash(runtimeType,participantId,userId,isAudio,isVideo,isSelf,videoCanvas);
@override
String toString() {
return 'VideocallParticipant(participantId: $participantId, userId: $userId, isAudio: $isAudio, isVideo: $isVideo, isSelf: $isSelf, videoCanvas: $videoCanvas)';
}
}
/// @nodoc
abstract mixin class _$VideocallParticipantCopyWith<$Res> implements $VideocallParticipantCopyWith<$Res> {
factory _$VideocallParticipantCopyWith(_VideocallParticipant value, $Res Function(_VideocallParticipant) _then) = __$VideocallParticipantCopyWithImpl;
@override @useResult
$Res call({
String participantId, String userId, bool isAudio, bool isVideo, bool isSelf, JCMediaDeviceVideoCanvas? videoCanvas
});
}
/// @nodoc
class __$VideocallParticipantCopyWithImpl<$Res>
implements _$VideocallParticipantCopyWith<$Res> {
__$VideocallParticipantCopyWithImpl(this._self, this._then);
final _VideocallParticipant _self;
final $Res Function(_VideocallParticipant) _then;
/// Create a copy of VideocallParticipant
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? participantId = null,Object? userId = null,Object? isAudio = null,Object? isVideo = null,Object? isSelf = null,Object? videoCanvas = freezed,}) {
return _then(_VideocallParticipant(
participantId: null == participantId ? _self.participantId : participantId // ignore: cast_nullable_to_non_nullable
as String,userId: null == userId ? _self.userId : userId // ignore: cast_nullable_to_non_nullable
as String,isAudio: null == isAudio ? _self.isAudio : isAudio // ignore: cast_nullable_to_non_nullable
as bool,isVideo: null == isVideo ? _self.isVideo : isVideo // ignore: cast_nullable_to_non_nullable
as bool,isSelf: null == isSelf ? _self.isSelf : isSelf // ignore: cast_nullable_to_non_nullable
as bool,videoCanvas: freezed == videoCanvas ? _self.videoCanvas : videoCanvas // ignore: cast_nullable_to_non_nullable
as JCMediaDeviceVideoCanvas?,
));
}
}
// dart format on

View File

@@ -0,0 +1,22 @@
abstract class VideocallSignalingRepository {
Future<void> initiateCall({
required String deviceId,
required String appAccount,
required String roomNumber,
});
Future<void> cancelCall({required String deviceId});
Future<void> refuseCall({
required String deviceId,
required String roomNumber,
});
Future<int> getRoomParticipantCount({required String roomNumber});
Future<void> reportParticipantCount({
required String roomNumber,
required int count,
required int type,
});
}

View File

@@ -0,0 +1,199 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get_it/get_it.dart';
import 'package:videocall_sdk/videocall_sdk.dart';
import '../../domain/entities/videocall_error.dart';
import '../../domain/entities/videocall_participant.dart';
import 'group_call_view_state.dart';
final groupCallViewModelProvider =
NotifierProvider.autoDispose<GroupCallViewModel, GroupCallViewState>(
GroupCallViewModel.new,
);
class GroupCallViewModel extends Notifier<GroupCallViewState> {
late final VideocallChannelService _channelService;
late final VideocallDeviceService _deviceService;
StreamSubscription<({bool result, int reason, String channelId})>? _joinSub;
StreamSubscription<({int reason, String channelId})>? _leaveSub;
StreamSubscription<JCMediaChannelParticipant>? _participantJoinSub;
StreamSubscription<JCMediaChannelParticipant>? _participantLeftSub;
StreamSubscription<
({
JCMediaChannelParticipant participant,
ChannelChangeParam changeParam,
})>?
_participantUpdateSub;
@override
GroupCallViewState build() {
_channelService = GetIt.I<VideocallChannelService>();
_deviceService = GetIt.I<VideocallDeviceService>();
ref.onDispose(_disposeSubscriptions);
_subscribeToStreams();
return const GroupCallViewState();
}
void _subscribeToStreams() {
_joinSub = _channelService.joinStream.listen(_onJoin);
_leaveSub = _channelService.leaveStream.listen(_onLeave);
_participantJoinSub =
_channelService.participantJoinStream.listen(_onParticipantJoin);
_participantLeftSub =
_channelService.participantLeftStream.listen(_onParticipantLeft);
_participantUpdateSub =
_channelService.participantUpdateStream.listen(_onParticipantUpdate);
}
// -- Channel actions --
Future<void> joinChannel(String channelId) async {
await _channelService.enableUploadAudioStream(true);
await _channelService.enableUploadVideoStream(true);
final ok = await _channelService.join(channelId);
if (!ref.mounted) return;
if (!ok) {
state = state.copyWith(errorEvent: VideocallErrorEvent.callStart);
return;
}
state = state.copyWith(channelId: channelId);
}
Future<void> leaveChannel() async {
await _channelService.leave();
}
Future<void> stopChannel() async {
await _channelService.stop();
}
// -- Media controls --
Future<void> toggleMic() async {
final newValue = !state.isMicEnabled;
await _channelService.enableUploadAudioStream(newValue);
if (!ref.mounted) return;
state = state.copyWith(isMicEnabled: newValue);
}
Future<void> toggleCamera() async {
final newValue = !state.isCameraEnabled;
await _channelService.enableUploadVideoStream(newValue);
if (!ref.mounted) return;
state = state.copyWith(isCameraEnabled: newValue);
}
Future<void> switchCamera() async {
await _deviceService.switchCamera();
}
// -- Stream callbacks --
Future<void> _onJoin(
({bool result, int reason, String channelId}) event) async {
if (!ref.mounted) return;
if (!event.result) {
state = state.copyWith(errorEvent: VideocallErrorEvent.callStart);
return;
}
state = state.copyWith(isJoined: true, channelId: event.channelId);
// Start local video
final selfParticipant = await _channelService.getSelfParticipant();
if (selfParticipant == null || !ref.mounted) return;
final localCanvas = await selfParticipant.startVideo(
JCMediaDevice.RENDER_FULL_CONTENT,
JCMediaChannel.PICTURESIZE_LARGE,
);
if (!ref.mounted) return;
state = state.copyWith(localCanvas: localCanvas);
}
void _onLeave(({int reason, String channelId}) event) {
if (!ref.mounted) return;
state = state.copyWith(
isJoined: false,
participants: [],
localCanvas: null,
successEvent: VideocallSuccessEvent.callEnded,
);
}
Future<void> _onParticipantJoin(
JCMediaChannelParticipant participant) async {
if (!ref.mounted) return;
final isSelf = await participant.isSelf();
final userId = await participant.getUserId();
final isAudio = await participant.isAudio();
final isVideo = await participant.isVideo();
JCMediaDeviceVideoCanvas? canvas;
if (isVideo) {
canvas = await participant.startVideo(
JCMediaDevice.RENDER_FULL_CONTENT,
JCMediaChannel.PICTURESIZE_LARGE,
);
await _channelService.requestVideo(
participant, JCMediaChannel.PICTURESIZE_LARGE);
}
if (!ref.mounted) return;
final newParticipant = VideocallParticipant(
participantId: participant.participantId,
userId: userId,
isAudio: isAudio,
isVideo: isVideo,
isSelf: isSelf,
videoCanvas: canvas,
);
state = state.copyWith(
participants: [...state.participants, newParticipant],
);
}
void _onParticipantLeft(JCMediaChannelParticipant participant) {
if (!ref.mounted) return;
state = state.copyWith(
participants: state.participants
.where((p) => p.participantId != participant.participantId)
.toList(),
);
}
void _onParticipantUpdate(
({
JCMediaChannelParticipant participant,
ChannelChangeParam changeParam,
}) event) {
if (!ref.mounted) return;
// Update participant in list if needed
}
// -- Helpers --
void clearError() {
state = state.copyWith(errorEvent: null);
}
void _disposeSubscriptions() {
_joinSub?.cancel();
_leaveSub?.cancel();
_participantJoinSub?.cancel();
_participantLeftSub?.cancel();
_participantUpdateSub?.cancel();
}
}

View File

@@ -0,0 +1,21 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:videocall_sdk/videocall_sdk.dart';
import '../../domain/entities/videocall_error.dart';
import '../../domain/entities/videocall_participant.dart';
part 'group_call_view_state.freezed.dart';
@freezed
abstract class GroupCallViewState with _$GroupCallViewState {
const factory GroupCallViewState({
@Default('') String channelId,
@Default(false) bool isJoined,
@Default(true) bool isMicEnabled,
@Default(true) bool isCameraEnabled,
@Default([]) List<VideocallParticipant> participants,
JCMediaDeviceVideoCanvas? localCanvas,
VideocallErrorEvent? errorEvent,
VideocallSuccessEvent? successEvent,
}) = _GroupCallViewState;
}

View File

@@ -0,0 +1,298 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'group_call_view_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$GroupCallViewState {
String get channelId; bool get isJoined; bool get isMicEnabled; bool get isCameraEnabled; List<VideocallParticipant> get participants; JCMediaDeviceVideoCanvas? get localCanvas; VideocallErrorEvent? get errorEvent; VideocallSuccessEvent? get successEvent;
/// Create a copy of GroupCallViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$GroupCallViewStateCopyWith<GroupCallViewState> get copyWith => _$GroupCallViewStateCopyWithImpl<GroupCallViewState>(this as GroupCallViewState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is GroupCallViewState&&(identical(other.channelId, channelId) || other.channelId == channelId)&&(identical(other.isJoined, isJoined) || other.isJoined == isJoined)&&(identical(other.isMicEnabled, isMicEnabled) || other.isMicEnabled == isMicEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&const DeepCollectionEquality().equals(other.participants, participants)&&(identical(other.localCanvas, localCanvas) || other.localCanvas == localCanvas)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent));
}
@override
int get hashCode => Object.hash(runtimeType,channelId,isJoined,isMicEnabled,isCameraEnabled,const DeepCollectionEquality().hash(participants),localCanvas,errorEvent,successEvent);
@override
String toString() {
return 'GroupCallViewState(channelId: $channelId, isJoined: $isJoined, isMicEnabled: $isMicEnabled, isCameraEnabled: $isCameraEnabled, participants: $participants, localCanvas: $localCanvas, errorEvent: $errorEvent, successEvent: $successEvent)';
}
}
/// @nodoc
abstract mixin class $GroupCallViewStateCopyWith<$Res> {
factory $GroupCallViewStateCopyWith(GroupCallViewState value, $Res Function(GroupCallViewState) _then) = _$GroupCallViewStateCopyWithImpl;
@useResult
$Res call({
String channelId, bool isJoined, bool isMicEnabled, bool isCameraEnabled, List<VideocallParticipant> participants, JCMediaDeviceVideoCanvas? localCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent
});
}
/// @nodoc
class _$GroupCallViewStateCopyWithImpl<$Res>
implements $GroupCallViewStateCopyWith<$Res> {
_$GroupCallViewStateCopyWithImpl(this._self, this._then);
final GroupCallViewState _self;
final $Res Function(GroupCallViewState) _then;
/// Create a copy of GroupCallViewState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? channelId = null,Object? isJoined = null,Object? isMicEnabled = null,Object? isCameraEnabled = null,Object? participants = null,Object? localCanvas = freezed,Object? errorEvent = freezed,Object? successEvent = freezed,}) {
return _then(_self.copyWith(
channelId: null == channelId ? _self.channelId : channelId // ignore: cast_nullable_to_non_nullable
as String,isJoined: null == isJoined ? _self.isJoined : isJoined // ignore: cast_nullable_to_non_nullable
as bool,isMicEnabled: null == isMicEnabled ? _self.isMicEnabled : isMicEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,participants: null == participants ? _self.participants : participants // ignore: cast_nullable_to_non_nullable
as List<VideocallParticipant>,localCanvas: freezed == localCanvas ? _self.localCanvas : localCanvas // ignore: cast_nullable_to_non_nullable
as JCMediaDeviceVideoCanvas?,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
as VideocallErrorEvent?,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
as VideocallSuccessEvent?,
));
}
}
/// Adds pattern-matching-related methods to [GroupCallViewState].
extension GroupCallViewStatePatterns on GroupCallViewState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _GroupCallViewState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _GroupCallViewState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _GroupCallViewState value) $default,){
final _that = this;
switch (_that) {
case _GroupCallViewState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _GroupCallViewState value)? $default,){
final _that = this;
switch (_that) {
case _GroupCallViewState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String channelId, bool isJoined, bool isMicEnabled, bool isCameraEnabled, List<VideocallParticipant> participants, JCMediaDeviceVideoCanvas? localCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _GroupCallViewState() when $default != null:
return $default(_that.channelId,_that.isJoined,_that.isMicEnabled,_that.isCameraEnabled,_that.participants,_that.localCanvas,_that.errorEvent,_that.successEvent);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String channelId, bool isJoined, bool isMicEnabled, bool isCameraEnabled, List<VideocallParticipant> participants, JCMediaDeviceVideoCanvas? localCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent) $default,) {final _that = this;
switch (_that) {
case _GroupCallViewState():
return $default(_that.channelId,_that.isJoined,_that.isMicEnabled,_that.isCameraEnabled,_that.participants,_that.localCanvas,_that.errorEvent,_that.successEvent);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String channelId, bool isJoined, bool isMicEnabled, bool isCameraEnabled, List<VideocallParticipant> participants, JCMediaDeviceVideoCanvas? localCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent)? $default,) {final _that = this;
switch (_that) {
case _GroupCallViewState() when $default != null:
return $default(_that.channelId,_that.isJoined,_that.isMicEnabled,_that.isCameraEnabled,_that.participants,_that.localCanvas,_that.errorEvent,_that.successEvent);case _:
return null;
}
}
}
/// @nodoc
class _GroupCallViewState implements GroupCallViewState {
const _GroupCallViewState({this.channelId = '', this.isJoined = false, this.isMicEnabled = true, this.isCameraEnabled = true, final List<VideocallParticipant> participants = const [], this.localCanvas, this.errorEvent, this.successEvent}): _participants = participants;
@override@JsonKey() final String channelId;
@override@JsonKey() final bool isJoined;
@override@JsonKey() final bool isMicEnabled;
@override@JsonKey() final bool isCameraEnabled;
final List<VideocallParticipant> _participants;
@override@JsonKey() List<VideocallParticipant> get participants {
if (_participants is EqualUnmodifiableListView) return _participants;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_participants);
}
@override final JCMediaDeviceVideoCanvas? localCanvas;
@override final VideocallErrorEvent? errorEvent;
@override final VideocallSuccessEvent? successEvent;
/// Create a copy of GroupCallViewState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$GroupCallViewStateCopyWith<_GroupCallViewState> get copyWith => __$GroupCallViewStateCopyWithImpl<_GroupCallViewState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _GroupCallViewState&&(identical(other.channelId, channelId) || other.channelId == channelId)&&(identical(other.isJoined, isJoined) || other.isJoined == isJoined)&&(identical(other.isMicEnabled, isMicEnabled) || other.isMicEnabled == isMicEnabled)&&(identical(other.isCameraEnabled, isCameraEnabled) || other.isCameraEnabled == isCameraEnabled)&&const DeepCollectionEquality().equals(other._participants, _participants)&&(identical(other.localCanvas, localCanvas) || other.localCanvas == localCanvas)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent));
}
@override
int get hashCode => Object.hash(runtimeType,channelId,isJoined,isMicEnabled,isCameraEnabled,const DeepCollectionEquality().hash(_participants),localCanvas,errorEvent,successEvent);
@override
String toString() {
return 'GroupCallViewState(channelId: $channelId, isJoined: $isJoined, isMicEnabled: $isMicEnabled, isCameraEnabled: $isCameraEnabled, participants: $participants, localCanvas: $localCanvas, errorEvent: $errorEvent, successEvent: $successEvent)';
}
}
/// @nodoc
abstract mixin class _$GroupCallViewStateCopyWith<$Res> implements $GroupCallViewStateCopyWith<$Res> {
factory _$GroupCallViewStateCopyWith(_GroupCallViewState value, $Res Function(_GroupCallViewState) _then) = __$GroupCallViewStateCopyWithImpl;
@override @useResult
$Res call({
String channelId, bool isJoined, bool isMicEnabled, bool isCameraEnabled, List<VideocallParticipant> participants, JCMediaDeviceVideoCanvas? localCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent
});
}
/// @nodoc
class __$GroupCallViewStateCopyWithImpl<$Res>
implements _$GroupCallViewStateCopyWith<$Res> {
__$GroupCallViewStateCopyWithImpl(this._self, this._then);
final _GroupCallViewState _self;
final $Res Function(_GroupCallViewState) _then;
/// Create a copy of GroupCallViewState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? channelId = null,Object? isJoined = null,Object? isMicEnabled = null,Object? isCameraEnabled = null,Object? participants = null,Object? localCanvas = freezed,Object? errorEvent = freezed,Object? successEvent = freezed,}) {
return _then(_GroupCallViewState(
channelId: null == channelId ? _self.channelId : channelId // ignore: cast_nullable_to_non_nullable
as String,isJoined: null == isJoined ? _self.isJoined : isJoined // ignore: cast_nullable_to_non_nullable
as bool,isMicEnabled: null == isMicEnabled ? _self.isMicEnabled : isMicEnabled // ignore: cast_nullable_to_non_nullable
as bool,isCameraEnabled: null == isCameraEnabled ? _self.isCameraEnabled : isCameraEnabled // ignore: cast_nullable_to_non_nullable
as bool,participants: null == participants ? _self._participants : participants // ignore: cast_nullable_to_non_nullable
as List<VideocallParticipant>,localCanvas: freezed == localCanvas ? _self.localCanvas : localCanvas // ignore: cast_nullable_to_non_nullable
as JCMediaDeviceVideoCanvas?,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
as VideocallErrorEvent?,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
as VideocallSuccessEvent?,
));
}
}
// dart format on

View File

@@ -0,0 +1,270 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get_it/get_it.dart';
import 'package:sf_shared/sf_shared.dart';
import 'package:videocall_sdk/videocall_sdk.dart';
import '../../domain/entities/videocall_error.dart';
import 'videocall_view_state.dart';
final videocallViewModelProvider =
NotifierProvider.autoDispose<VideocallViewModel, VideocallViewState>(
VideocallViewModel.new,
);
class VideocallViewModel extends Notifier<VideocallViewState> {
late final VideocallSdkManager _manager;
late final VideocallCallService _callService;
late final VideocallDeviceService _deviceService;
late final VideocallClient _client;
StreamSubscription<VideocallItem>? _callAddSub;
StreamSubscription<VideocallItem>? _callUpdateSub;
StreamSubscription<({int reason, String description})>? _callRemoveSub;
StreamSubscription<VideocallItem>? _missedCallSub;
StreamSubscription<VideocallClientState>? _clientStateSub;
@override
VideocallViewState build() {
_manager = GetIt.I<VideocallSdkManager>();
_callService = GetIt.I<VideocallCallService>();
_deviceService = GetIt.I<VideocallDeviceService>();
_client = GetIt.I<VideocallClient>();
final device = ref.read(selectedDeviceProvider).value;
final deviceId = device?.identificator ?? '';
ref.onDispose(_disposeSubscriptions);
Future.microtask(_initSdk);
return VideocallViewState(deviceId: deviceId);
}
Future<void> _initSdk() async {
if (_manager.isInitialized) {
_subscribeToStreams();
state = state.copyWith(isSdkReady: true);
return;
}
final ok = await _manager.initialize();
if (!ref.mounted) return;
if (!ok) {
state = state.copyWith(errorEvent: VideocallErrorEvent.sdkInitialization);
return;
}
_subscribeToStreams();
state = state.copyWith(isSdkReady: true);
}
void _subscribeToStreams() {
_callAddSub = _callService.callItemAddStream.listen(_onCallItemAdd);
_callUpdateSub =
_callService.callItemUpdateStream.listen(_onCallItemUpdate);
_callRemoveSub =
_callService.callItemRemoveStream.listen(_onCallItemRemove);
_missedCallSub = _callService.missedCallStream.listen(_onMissedCall);
_clientStateSub = _client.stateStream.listen(_onClientStateChange);
}
// -- Auth --
Future<bool> login({
required String userId,
required String password,
}) async {
final ok = await _client.login(userId: userId, password: password);
if (!ref.mounted) return false;
if (!ok) {
state = state.copyWith(errorEvent: VideocallErrorEvent.authentication);
return false;
}
state = state.copyWith(localUserId: userId);
return true;
}
// -- Call actions --
Future<void> startCall(String remoteUserId) async {
state = state.copyWith(
remoteUserId: remoteUserId,
screenMode: VideocallScreenMode.outgoing,
errorEvent: null,
);
final ok = await _callService.startCall(
userId: remoteUserId,
isVideo: true,
);
if (!ref.mounted) return;
if (!ok) {
state = state.copyWith(
screenMode: VideocallScreenMode.idle,
errorEvent: VideocallErrorEvent.callStart,
);
}
}
Future<void> answerCall() async {
final ok = await _callService.answerCall(isVideo: true);
if (!ref.mounted) return;
if (!ok) {
state = state.copyWith(
screenMode: VideocallScreenMode.idle,
errorEvent: VideocallErrorEvent.callAnswer,
);
}
}
Future<void> hangUp() async {
await _callService.hangUp();
if (!ref.mounted) return;
await _stopVideoViews();
state = state.copyWith(
screenMode: VideocallScreenMode.idle,
currentCall: null,
successEvent: VideocallSuccessEvent.callEnded,
);
}
Future<void> rejectCall() async {
await _callService.hangUp();
if (!ref.mounted) return;
state = state.copyWith(
screenMode: VideocallScreenMode.idle,
currentCall: null,
);
}
// -- Media controls --
Future<void> toggleMic() async {
final item = await _callService.getActiveCallItem();
if (item == null) return;
final newValue = !state.isMicEnabled;
await _callService.muteMicrophone(item, !newValue);
if (!ref.mounted) return;
state = state.copyWith(isMicEnabled: newValue);
}
Future<void> toggleSpeaker() async {
final newValue = !state.isSpeakerEnabled;
await _deviceService.enableSpeaker(newValue);
if (!ref.mounted) return;
state = state.copyWith(isSpeakerEnabled: newValue);
}
Future<void> switchCamera() async {
await _deviceService.switchCamera();
if (!ref.mounted) return;
state = state.copyWith(isFrontCamera: !state.isFrontCamera);
}
// -- Video views --
Future<void> _startVideoViews() async {
final localCanvas = await _callService.startLocalVideo();
if (!ref.mounted) return;
state = state.copyWith(localCanvas: localCanvas);
final remoteCanvas = await _callService.startRemoteVideo();
if (!ref.mounted) return;
state = state.copyWith(
remoteCanvas: remoteCanvas,
isRemoteVideoAvailable: remoteCanvas != null,
);
}
Future<void> _stopVideoViews() async {
await _callService.stopLocalVideo();
await _callService.stopRemoteVideo();
if (!ref.mounted) return;
state = state.copyWith(
localCanvas: null,
remoteCanvas: null,
isRemoteVideoAvailable: false,
);
}
// -- Stream callbacks --
void _onCallItemAdd(VideocallItem item) {
if (!ref.mounted) return;
if (item.direction == CallDirection.incoming) {
state = state.copyWith(
screenMode: VideocallScreenMode.incoming,
currentCall: item,
remoteUserId: item.userId,
);
} else {
state = state.copyWith(
screenMode: VideocallScreenMode.outgoing,
currentCall: item,
);
}
}
void _onCallItemUpdate(VideocallItem item) {
if (!ref.mounted) return;
state = state.copyWith(currentCall: item);
if (item.state == VideocallState.talking &&
state.screenMode != VideocallScreenMode.inCall) {
state = state.copyWith(
screenMode: VideocallScreenMode.inCall,
successEvent: VideocallSuccessEvent.connected,
);
_startVideoViews();
}
}
void _onCallItemRemove(({int reason, String description}) event) {
if (!ref.mounted) return;
_stopVideoViews();
state = state.copyWith(
screenMode: VideocallScreenMode.idle,
currentCall: null,
localCanvas: null,
remoteCanvas: null,
isRemoteVideoAvailable: false,
successEvent: VideocallSuccessEvent.callEnded,
);
}
void _onMissedCall(VideocallItem item) {
if (!ref.mounted) return;
state = state.copyWith(
errorEvent: VideocallErrorEvent.callStart,
);
}
void _onClientStateChange(VideocallClientState clientState) {
if (!ref.mounted) return;
if (clientState == VideocallClientState.loggedIn) {
state = state.copyWith(isSdkReady: true);
}
}
// -- Helpers --
void clearError() {
state = state.copyWith(errorEvent: null);
}
void clearSuccess() {
state = state.copyWith(successEvent: null);
}
void _disposeSubscriptions() {
_callAddSub?.cancel();
_callUpdateSub?.cancel();
_callRemoveSub?.cancel();
_missedCallSub?.cancel();
_clientStateSub?.cancel();
}
}

View File

@@ -0,0 +1,26 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:videocall_sdk/videocall_sdk.dart';
import '../../domain/entities/videocall_error.dart';
part 'videocall_view_state.freezed.dart';
@freezed
abstract class VideocallViewState with _$VideocallViewState {
const factory VideocallViewState({
@Default('') String deviceId,
@Default('') String localUserId,
@Default('') String remoteUserId,
@Default(VideocallScreenMode.idle) VideocallScreenMode screenMode,
@Default(false) bool isSdkReady,
@Default(true) bool isMicEnabled,
@Default(true) bool isSpeakerEnabled,
@Default(true) bool isFrontCamera,
@Default(false) bool isRemoteVideoAvailable,
VideocallItem? currentCall,
JCMediaDeviceVideoCanvas? localCanvas,
JCMediaDeviceVideoCanvas? remoteCanvas,
VideocallErrorEvent? errorEvent,
VideocallSuccessEvent? successEvent,
}) = _VideocallViewState;
}

View File

@@ -0,0 +1,310 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'videocall_view_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$VideocallViewState {
String get deviceId; String get localUserId; String get remoteUserId; VideocallScreenMode get screenMode; bool get isSdkReady; bool get isMicEnabled; bool get isSpeakerEnabled; bool get isFrontCamera; bool get isRemoteVideoAvailable; VideocallItem? get currentCall; JCMediaDeviceVideoCanvas? get localCanvas; JCMediaDeviceVideoCanvas? get remoteCanvas; VideocallErrorEvent? get errorEvent; VideocallSuccessEvent? get successEvent;
/// Create a copy of VideocallViewState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$VideocallViewStateCopyWith<VideocallViewState> get copyWith => _$VideocallViewStateCopyWithImpl<VideocallViewState>(this as VideocallViewState, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is VideocallViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.localUserId, localUserId) || other.localUserId == localUserId)&&(identical(other.remoteUserId, remoteUserId) || other.remoteUserId == remoteUserId)&&(identical(other.screenMode, screenMode) || other.screenMode == screenMode)&&(identical(other.isSdkReady, isSdkReady) || other.isSdkReady == isSdkReady)&&(identical(other.isMicEnabled, isMicEnabled) || other.isMicEnabled == isMicEnabled)&&(identical(other.isSpeakerEnabled, isSpeakerEnabled) || other.isSpeakerEnabled == isSpeakerEnabled)&&(identical(other.isFrontCamera, isFrontCamera) || other.isFrontCamera == isFrontCamera)&&(identical(other.isRemoteVideoAvailable, isRemoteVideoAvailable) || other.isRemoteVideoAvailable == isRemoteVideoAvailable)&&(identical(other.currentCall, currentCall) || other.currentCall == currentCall)&&(identical(other.localCanvas, localCanvas) || other.localCanvas == localCanvas)&&(identical(other.remoteCanvas, remoteCanvas) || other.remoteCanvas == remoteCanvas)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent));
}
@override
int get hashCode => Object.hash(runtimeType,deviceId,localUserId,remoteUserId,screenMode,isSdkReady,isMicEnabled,isSpeakerEnabled,isFrontCamera,isRemoteVideoAvailable,currentCall,localCanvas,remoteCanvas,errorEvent,successEvent);
@override
String toString() {
return 'VideocallViewState(deviceId: $deviceId, localUserId: $localUserId, remoteUserId: $remoteUserId, screenMode: $screenMode, isSdkReady: $isSdkReady, isMicEnabled: $isMicEnabled, isSpeakerEnabled: $isSpeakerEnabled, isFrontCamera: $isFrontCamera, isRemoteVideoAvailable: $isRemoteVideoAvailable, currentCall: $currentCall, localCanvas: $localCanvas, remoteCanvas: $remoteCanvas, errorEvent: $errorEvent, successEvent: $successEvent)';
}
}
/// @nodoc
abstract mixin class $VideocallViewStateCopyWith<$Res> {
factory $VideocallViewStateCopyWith(VideocallViewState value, $Res Function(VideocallViewState) _then) = _$VideocallViewStateCopyWithImpl;
@useResult
$Res call({
String deviceId, String localUserId, String remoteUserId, VideocallScreenMode screenMode, bool isSdkReady, bool isMicEnabled, bool isSpeakerEnabled, bool isFrontCamera, bool isRemoteVideoAvailable, VideocallItem? currentCall, JCMediaDeviceVideoCanvas? localCanvas, JCMediaDeviceVideoCanvas? remoteCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent
});
}
/// @nodoc
class _$VideocallViewStateCopyWithImpl<$Res>
implements $VideocallViewStateCopyWith<$Res> {
_$VideocallViewStateCopyWithImpl(this._self, this._then);
final VideocallViewState _self;
final $Res Function(VideocallViewState) _then;
/// Create a copy of VideocallViewState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? deviceId = null,Object? localUserId = null,Object? remoteUserId = null,Object? screenMode = null,Object? isSdkReady = null,Object? isMicEnabled = null,Object? isSpeakerEnabled = null,Object? isFrontCamera = null,Object? isRemoteVideoAvailable = null,Object? currentCall = freezed,Object? localCanvas = freezed,Object? remoteCanvas = freezed,Object? errorEvent = freezed,Object? successEvent = freezed,}) {
return _then(_self.copyWith(
deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,localUserId: null == localUserId ? _self.localUserId : localUserId // ignore: cast_nullable_to_non_nullable
as String,remoteUserId: null == remoteUserId ? _self.remoteUserId : remoteUserId // ignore: cast_nullable_to_non_nullable
as String,screenMode: null == screenMode ? _self.screenMode : screenMode // ignore: cast_nullable_to_non_nullable
as VideocallScreenMode,isSdkReady: null == isSdkReady ? _self.isSdkReady : isSdkReady // ignore: cast_nullable_to_non_nullable
as bool,isMicEnabled: null == isMicEnabled ? _self.isMicEnabled : isMicEnabled // ignore: cast_nullable_to_non_nullable
as bool,isSpeakerEnabled: null == isSpeakerEnabled ? _self.isSpeakerEnabled : isSpeakerEnabled // ignore: cast_nullable_to_non_nullable
as bool,isFrontCamera: null == isFrontCamera ? _self.isFrontCamera : isFrontCamera // ignore: cast_nullable_to_non_nullable
as bool,isRemoteVideoAvailable: null == isRemoteVideoAvailable ? _self.isRemoteVideoAvailable : isRemoteVideoAvailable // ignore: cast_nullable_to_non_nullable
as bool,currentCall: freezed == currentCall ? _self.currentCall : currentCall // ignore: cast_nullable_to_non_nullable
as VideocallItem?,localCanvas: freezed == localCanvas ? _self.localCanvas : localCanvas // ignore: cast_nullable_to_non_nullable
as JCMediaDeviceVideoCanvas?,remoteCanvas: freezed == remoteCanvas ? _self.remoteCanvas : remoteCanvas // ignore: cast_nullable_to_non_nullable
as JCMediaDeviceVideoCanvas?,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
as VideocallErrorEvent?,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
as VideocallSuccessEvent?,
));
}
}
/// Adds pattern-matching-related methods to [VideocallViewState].
extension VideocallViewStatePatterns on VideocallViewState {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _VideocallViewState value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _VideocallViewState() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _VideocallViewState value) $default,){
final _that = this;
switch (_that) {
case _VideocallViewState():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _VideocallViewState value)? $default,){
final _that = this;
switch (_that) {
case _VideocallViewState() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String deviceId, String localUserId, String remoteUserId, VideocallScreenMode screenMode, bool isSdkReady, bool isMicEnabled, bool isSpeakerEnabled, bool isFrontCamera, bool isRemoteVideoAvailable, VideocallItem? currentCall, JCMediaDeviceVideoCanvas? localCanvas, JCMediaDeviceVideoCanvas? remoteCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _VideocallViewState() when $default != null:
return $default(_that.deviceId,_that.localUserId,_that.remoteUserId,_that.screenMode,_that.isSdkReady,_that.isMicEnabled,_that.isSpeakerEnabled,_that.isFrontCamera,_that.isRemoteVideoAvailable,_that.currentCall,_that.localCanvas,_that.remoteCanvas,_that.errorEvent,_that.successEvent);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String deviceId, String localUserId, String remoteUserId, VideocallScreenMode screenMode, bool isSdkReady, bool isMicEnabled, bool isSpeakerEnabled, bool isFrontCamera, bool isRemoteVideoAvailable, VideocallItem? currentCall, JCMediaDeviceVideoCanvas? localCanvas, JCMediaDeviceVideoCanvas? remoteCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent) $default,) {final _that = this;
switch (_that) {
case _VideocallViewState():
return $default(_that.deviceId,_that.localUserId,_that.remoteUserId,_that.screenMode,_that.isSdkReady,_that.isMicEnabled,_that.isSpeakerEnabled,_that.isFrontCamera,_that.isRemoteVideoAvailable,_that.currentCall,_that.localCanvas,_that.remoteCanvas,_that.errorEvent,_that.successEvent);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String deviceId, String localUserId, String remoteUserId, VideocallScreenMode screenMode, bool isSdkReady, bool isMicEnabled, bool isSpeakerEnabled, bool isFrontCamera, bool isRemoteVideoAvailable, VideocallItem? currentCall, JCMediaDeviceVideoCanvas? localCanvas, JCMediaDeviceVideoCanvas? remoteCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent)? $default,) {final _that = this;
switch (_that) {
case _VideocallViewState() when $default != null:
return $default(_that.deviceId,_that.localUserId,_that.remoteUserId,_that.screenMode,_that.isSdkReady,_that.isMicEnabled,_that.isSpeakerEnabled,_that.isFrontCamera,_that.isRemoteVideoAvailable,_that.currentCall,_that.localCanvas,_that.remoteCanvas,_that.errorEvent,_that.successEvent);case _:
return null;
}
}
}
/// @nodoc
class _VideocallViewState implements VideocallViewState {
const _VideocallViewState({this.deviceId = '', this.localUserId = '', this.remoteUserId = '', this.screenMode = VideocallScreenMode.idle, this.isSdkReady = false, this.isMicEnabled = true, this.isSpeakerEnabled = true, this.isFrontCamera = true, this.isRemoteVideoAvailable = false, this.currentCall, this.localCanvas, this.remoteCanvas, this.errorEvent, this.successEvent});
@override@JsonKey() final String deviceId;
@override@JsonKey() final String localUserId;
@override@JsonKey() final String remoteUserId;
@override@JsonKey() final VideocallScreenMode screenMode;
@override@JsonKey() final bool isSdkReady;
@override@JsonKey() final bool isMicEnabled;
@override@JsonKey() final bool isSpeakerEnabled;
@override@JsonKey() final bool isFrontCamera;
@override@JsonKey() final bool isRemoteVideoAvailable;
@override final VideocallItem? currentCall;
@override final JCMediaDeviceVideoCanvas? localCanvas;
@override final JCMediaDeviceVideoCanvas? remoteCanvas;
@override final VideocallErrorEvent? errorEvent;
@override final VideocallSuccessEvent? successEvent;
/// Create a copy of VideocallViewState
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$VideocallViewStateCopyWith<_VideocallViewState> get copyWith => __$VideocallViewStateCopyWithImpl<_VideocallViewState>(this, _$identity);
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _VideocallViewState&&(identical(other.deviceId, deviceId) || other.deviceId == deviceId)&&(identical(other.localUserId, localUserId) || other.localUserId == localUserId)&&(identical(other.remoteUserId, remoteUserId) || other.remoteUserId == remoteUserId)&&(identical(other.screenMode, screenMode) || other.screenMode == screenMode)&&(identical(other.isSdkReady, isSdkReady) || other.isSdkReady == isSdkReady)&&(identical(other.isMicEnabled, isMicEnabled) || other.isMicEnabled == isMicEnabled)&&(identical(other.isSpeakerEnabled, isSpeakerEnabled) || other.isSpeakerEnabled == isSpeakerEnabled)&&(identical(other.isFrontCamera, isFrontCamera) || other.isFrontCamera == isFrontCamera)&&(identical(other.isRemoteVideoAvailable, isRemoteVideoAvailable) || other.isRemoteVideoAvailable == isRemoteVideoAvailable)&&(identical(other.currentCall, currentCall) || other.currentCall == currentCall)&&(identical(other.localCanvas, localCanvas) || other.localCanvas == localCanvas)&&(identical(other.remoteCanvas, remoteCanvas) || other.remoteCanvas == remoteCanvas)&&(identical(other.errorEvent, errorEvent) || other.errorEvent == errorEvent)&&(identical(other.successEvent, successEvent) || other.successEvent == successEvent));
}
@override
int get hashCode => Object.hash(runtimeType,deviceId,localUserId,remoteUserId,screenMode,isSdkReady,isMicEnabled,isSpeakerEnabled,isFrontCamera,isRemoteVideoAvailable,currentCall,localCanvas,remoteCanvas,errorEvent,successEvent);
@override
String toString() {
return 'VideocallViewState(deviceId: $deviceId, localUserId: $localUserId, remoteUserId: $remoteUserId, screenMode: $screenMode, isSdkReady: $isSdkReady, isMicEnabled: $isMicEnabled, isSpeakerEnabled: $isSpeakerEnabled, isFrontCamera: $isFrontCamera, isRemoteVideoAvailable: $isRemoteVideoAvailable, currentCall: $currentCall, localCanvas: $localCanvas, remoteCanvas: $remoteCanvas, errorEvent: $errorEvent, successEvent: $successEvent)';
}
}
/// @nodoc
abstract mixin class _$VideocallViewStateCopyWith<$Res> implements $VideocallViewStateCopyWith<$Res> {
factory _$VideocallViewStateCopyWith(_VideocallViewState value, $Res Function(_VideocallViewState) _then) = __$VideocallViewStateCopyWithImpl;
@override @useResult
$Res call({
String deviceId, String localUserId, String remoteUserId, VideocallScreenMode screenMode, bool isSdkReady, bool isMicEnabled, bool isSpeakerEnabled, bool isFrontCamera, bool isRemoteVideoAvailable, VideocallItem? currentCall, JCMediaDeviceVideoCanvas? localCanvas, JCMediaDeviceVideoCanvas? remoteCanvas, VideocallErrorEvent? errorEvent, VideocallSuccessEvent? successEvent
});
}
/// @nodoc
class __$VideocallViewStateCopyWithImpl<$Res>
implements _$VideocallViewStateCopyWith<$Res> {
__$VideocallViewStateCopyWithImpl(this._self, this._then);
final _VideocallViewState _self;
final $Res Function(_VideocallViewState) _then;
/// Create a copy of VideocallViewState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? deviceId = null,Object? localUserId = null,Object? remoteUserId = null,Object? screenMode = null,Object? isSdkReady = null,Object? isMicEnabled = null,Object? isSpeakerEnabled = null,Object? isFrontCamera = null,Object? isRemoteVideoAvailable = null,Object? currentCall = freezed,Object? localCanvas = freezed,Object? remoteCanvas = freezed,Object? errorEvent = freezed,Object? successEvent = freezed,}) {
return _then(_VideocallViewState(
deviceId: null == deviceId ? _self.deviceId : deviceId // ignore: cast_nullable_to_non_nullable
as String,localUserId: null == localUserId ? _self.localUserId : localUserId // ignore: cast_nullable_to_non_nullable
as String,remoteUserId: null == remoteUserId ? _self.remoteUserId : remoteUserId // ignore: cast_nullable_to_non_nullable
as String,screenMode: null == screenMode ? _self.screenMode : screenMode // ignore: cast_nullable_to_non_nullable
as VideocallScreenMode,isSdkReady: null == isSdkReady ? _self.isSdkReady : isSdkReady // ignore: cast_nullable_to_non_nullable
as bool,isMicEnabled: null == isMicEnabled ? _self.isMicEnabled : isMicEnabled // ignore: cast_nullable_to_non_nullable
as bool,isSpeakerEnabled: null == isSpeakerEnabled ? _self.isSpeakerEnabled : isSpeakerEnabled // ignore: cast_nullable_to_non_nullable
as bool,isFrontCamera: null == isFrontCamera ? _self.isFrontCamera : isFrontCamera // ignore: cast_nullable_to_non_nullable
as bool,isRemoteVideoAvailable: null == isRemoteVideoAvailable ? _self.isRemoteVideoAvailable : isRemoteVideoAvailable // ignore: cast_nullable_to_non_nullable
as bool,currentCall: freezed == currentCall ? _self.currentCall : currentCall // ignore: cast_nullable_to_non_nullable
as VideocallItem?,localCanvas: freezed == localCanvas ? _self.localCanvas : localCanvas // ignore: cast_nullable_to_non_nullable
as JCMediaDeviceVideoCanvas?,remoteCanvas: freezed == remoteCanvas ? _self.remoteCanvas : remoteCanvas // ignore: cast_nullable_to_non_nullable
as JCMediaDeviceVideoCanvas?,errorEvent: freezed == errorEvent ? _self.errorEvent : errorEvent // ignore: cast_nullable_to_non_nullable
as VideocallErrorEvent?,successEvent: freezed == successEvent ? _self.successEvent : successEvent // ignore: cast_nullable_to_non_nullable
as VideocallSuccessEvent?,
));
}
}
// dart format on

View File

@@ -0,0 +1,311 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:navigation/navigation.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:sf_shared/sf_shared.dart';
import '../domain/entities/videocall_error.dart';
import 'state/videocall_view_model.dart';
import 'state/videocall_view_state.dart';
import 'widgets/call_controls_widget.dart';
import 'widgets/call_status_indicator.dart';
import 'widgets/incoming_call_overlay.dart';
import 'widgets/video_view_widget.dart';
class VideocallScreen extends ConsumerStatefulWidget {
const VideocallScreen({super.key, required this.navigationContract});
final NavigationContract navigationContract;
@override
ConsumerState<VideocallScreen> createState() => _VideocallScreenState();
}
class _VideocallScreenState extends ConsumerState<VideocallScreen> {
final _userIdController = TextEditingController();
@override
void dispose() {
_userIdController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(videocallViewModelProvider);
final vm = ref.read(videocallViewModelProvider.notifier);
ref.listen(
videocallViewModelProvider.select((s) => s.errorEvent),
(_, next) {
if (next == null) return;
final key = switch (next) {
VideocallErrorEvent.sdkInitialization ||
VideocallErrorEvent.authentication ||
VideocallErrorEvent.callStart ||
VideocallErrorEvent.callAnswer ||
VideocallErrorEvent.cameraPermission ||
VideocallErrorEvent.microphonePermission ||
VideocallErrorEvent.network ||
VideocallErrorEvent.generic =>
I18n.errorGeneric,
};
showErrorDialog(context, key);
vm.clearError();
},
);
ref.listen(
videocallViewModelProvider.select((s) => s.successEvent),
(_, next) {
if (next == null) return;
if (next == VideocallSuccessEvent.callEnded) {
showInfoDialog(context, I18n.videoCall);
}
vm.clearSuccess();
},
);
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: SafeArea(
child: switch (state.screenMode) {
VideocallScreenMode.idle => _IdleView(
state: state,
controller: _userIdController,
vm: vm,
),
VideocallScreenMode.outgoing => _OutgoingView(state: state, vm: vm),
VideocallScreenMode.incoming => _IncomingView(state: state, vm: vm),
VideocallScreenMode.inCall => _InCallView(state: state, vm: vm),
},
),
);
}
}
class _IdleView extends StatelessWidget {
const _IdleView({
required this.state,
required this.controller,
required this.vm,
});
final VideocallViewState state;
final TextEditingController controller;
final VideocallViewModel vm;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
if (!state.isSdkReady) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: colorScheme.onSurface),
const SizedBox(height: 16),
Text(
'Inicializando SDK...',
style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.7)),
),
],
),
);
}
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.videocam, color: colorScheme.onSurface, size: 64),
const SizedBox(height: 24),
Text(
'Videollamada',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 32),
TextField(
controller: controller,
style: TextStyle(color: colorScheme.onSurface),
decoration: InputDecoration(
hintText: 'User ID del destinatario',
hintStyle: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.38)),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: colorScheme.onSurface.withValues(alpha: 0.24)),
borderRadius: BorderRadius.circular(12),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(
color: context.sfColors.legacyPrimary,
),
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 56,
child: ElevatedButton.icon(
onPressed: () {
final userId = controller.text.trim();
if (userId.isEmpty) return;
vm.startCall(userId);
},
icon: const Icon(Icons.videocam),
label: const Text('Iniciar videollamada'),
style: ElevatedButton.styleFrom(
backgroundColor: context.sfColors.legacyPrimary,
foregroundColor: colorScheme.onPrimary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 16),
if (kDebugMode && state.localUserId.isEmpty)
TextButton(
onPressed: () async {
await vm.login(userId: 'p_test1', password: 'test123');
},
child: Text(
'Login como p_test1 (testing)',
style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.38)),
),
),
if (state.localUserId.isNotEmpty)
Text(
'Logged in as: ${state.localUserId}',
style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.38), fontSize: 12),
),
],
),
);
}
}
class _OutgoingView extends StatelessWidget {
const _OutgoingView({required this.state, required this.vm});
final VideocallViewState state;
final VideocallViewModel vm;
@override
Widget build(BuildContext context) {
return Center(
child: CallStatusIndicator(
screenMode: state.screenMode,
remoteUserId: state.remoteUserId,
onCancel: vm.hangUp,
),
);
}
}
class _IncomingView extends StatelessWidget {
const _IncomingView({required this.state, required this.vm});
final VideocallViewState state;
final VideocallViewModel vm;
@override
Widget build(BuildContext context) {
return IncomingCallOverlay(
callerUserId: state.remoteUserId,
isVideo: state.currentCall?.isVideo ?? true,
onAccept: vm.answerCall,
onReject: vm.rejectCall,
);
}
}
class _InCallView extends StatelessWidget {
const _InCallView({required this.state, required this.vm});
final VideocallViewState state;
final VideocallViewModel vm;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Stack(
fit: StackFit.expand,
children: [
if (state.remoteCanvas != null)
VideoViewWidget(canvas: state.remoteCanvas!)
else
Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.person, color: colorScheme.onSurface.withValues(alpha: 0.24), size: 96),
const SizedBox(height: 8),
Text(
'Esperando video remoto...',
style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.38)),
),
],
),
),
if (state.localCanvas != null)
Positioned(
top: 16,
right: 16,
child: Container(
width: 120,
height: 160,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(color: colorScheme.onSurface.withValues(alpha: 0.24), width: 1),
),
clipBehavior: Clip.antiAlias,
child: VideoViewWidget(
canvas: state.localCanvas!,
width: 120,
height: 160,
),
),
),
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.8),
Colors.transparent,
],
),
),
child: CallControlsWidget(
isMicEnabled: state.isMicEnabled,
isSpeakerEnabled: state.isSpeakerEnabled,
isFrontCamera: state.isFrontCamera,
onToggleMic: vm.toggleMic,
onToggleSpeaker: vm.toggleSpeaker,
onSwitchCamera: vm.switchCamera,
onHangUp: vm.hangUp,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
class CallControlsWidget extends StatelessWidget {
const CallControlsWidget({
super.key,
required this.isMicEnabled,
required this.isSpeakerEnabled,
required this.isFrontCamera,
required this.onToggleMic,
required this.onToggleSpeaker,
required this.onSwitchCamera,
required this.onHangUp,
});
final bool isMicEnabled;
final bool isSpeakerEnabled;
final bool isFrontCamera;
final VoidCallback onToggleMic;
final VoidCallback onToggleSpeaker;
final VoidCallback onSwitchCamera;
final VoidCallback onHangUp;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ControlButton(
icon: isMicEnabled ? Icons.mic : Icons.mic_off,
label: isMicEnabled ? 'Mic On' : 'Mic Off',
isActive: isMicEnabled,
onPressed: onToggleMic,
),
_ControlButton(
icon: isSpeakerEnabled
? Icons.volume_up
: Icons.volume_off,
label: isSpeakerEnabled ? 'Speaker' : 'Earpiece',
isActive: isSpeakerEnabled,
onPressed: onToggleSpeaker,
),
_ControlButton(
icon: Icons.cameraswitch,
label: isFrontCamera ? 'Front' : 'Back',
isActive: true,
onPressed: onSwitchCamera,
),
_ControlButton(
icon: Icons.call_end,
label: 'Hang Up',
isActive: false,
isDestructive: true,
onPressed: onHangUp,
),
],
),
);
}
}
class _ControlButton extends StatelessWidget {
const _ControlButton({
required this.icon,
required this.label,
required this.isActive,
this.isDestructive = false,
required this.onPressed,
});
final IconData icon;
final String label;
final bool isActive;
final bool isDestructive;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final backgroundColor = isDestructive
? colorScheme.error
: isActive
? colorScheme.onSurface.withValues(alpha: 0.2)
: colorScheme.onSurface.withValues(alpha: 0.4);
final iconColor = isDestructive
? Colors.white
: isActive
? colorScheme.onSurface
: colorScheme.onSurface.withValues(alpha: 0.7);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: backgroundColor,
shape: const CircleBorder(),
child: InkWell(
onTap: onPressed,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(16),
child: Icon(icon, color: iconColor, size: 28),
),
),
),
const SizedBox(height: 4),
Text(
label,
style: TextStyle(color: colorScheme.onSurface.withValues(alpha: 0.7), fontSize: 11),
),
],
);
}
}

View File

@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../../domain/entities/videocall_error.dart';
class CallStatusIndicator extends StatelessWidget {
const CallStatusIndicator({
super.key,
required this.screenMode,
required this.remoteUserId,
required this.onCancel,
});
final VideocallScreenMode screenMode;
final String remoteUserId;
final VoidCallback onCancel;
@override
Widget build(BuildContext context) {
final (text, showCancel) = switch (screenMode) {
VideocallScreenMode.outgoing => ('Llamando a $remoteUserId...', true),
VideocallScreenMode.incoming => ('$remoteUserId te está llamando', false),
_ => ('', false),
};
if (text.isEmpty) return const SizedBox.shrink();
final colorScheme = Theme.of(context).colorScheme;
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(color: colorScheme.onSurface),
const SizedBox(height: 24),
Text(
text,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 20,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
if (showCancel) ...[
const SizedBox(height: 48),
Material(
color: colorScheme.error,
shape: const CircleBorder(),
child: InkWell(
onTap: onCancel,
customBorder: const CircleBorder(),
child: const Padding(
padding: EdgeInsets.all(20),
child: Icon(Icons.call_end, color: Colors.white, size: 32),
),
),
),
],
],
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
class IncomingCallOverlay extends StatelessWidget {
const IncomingCallOverlay({
super.key,
required this.callerUserId,
required this.isVideo,
required this.onAccept,
required this.onReject,
});
final String callerUserId;
final bool isVideo;
final VoidCallback onAccept;
final VoidCallback onReject;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
color: colorScheme.surface,
child: SafeArea(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(),
Icon(
isVideo ? Icons.videocam : Icons.call,
color: colorScheme.onSurface,
size: 64,
),
const SizedBox(height: 24),
Text(
isVideo ? 'Videollamada entrante' : 'Llamada entrante',
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.7),
fontSize: 16,
),
),
const SizedBox(height: 8),
Text(
callerUserId,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 28,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
Padding(
padding: const EdgeInsets.only(bottom: 64),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_ActionButton(
icon: Icons.call_end,
color: colorScheme.error,
label: 'Rechazar',
onPressed: onReject,
),
_ActionButton(
icon: isVideo ? Icons.videocam : Icons.call,
color: Colors.green,
label: 'Aceptar',
onPressed: onAccept,
),
],
),
),
],
),
),
);
}
}
class _ActionButton extends StatelessWidget {
const _ActionButton({
required this.icon,
required this.color,
required this.label,
required this.onPressed,
});
final IconData icon;
final Color color;
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Material(
color: color,
shape: const CircleBorder(),
child: InkWell(
onTap: onPressed,
customBorder: const CircleBorder(),
child: Padding(
padding: const EdgeInsets.all(20),
child: Icon(icon, color: Colors.white, size: 36),
),
),
),
const SizedBox(height: 8),
Text(label, style: TextStyle(color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7))),
],
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import '../../domain/entities/videocall_participant.dart';
import 'participant_tile_widget.dart';
class ParticipantGridWidget extends StatelessWidget {
const ParticipantGridWidget({
super.key,
required this.participants,
});
final List<VideocallParticipant> participants;
@override
Widget build(BuildContext context) {
if (participants.isEmpty) {
return const Center(
child: Text(
'Esperando participantes...',
style: TextStyle(color: Colors.white70),
),
);
}
final crossAxisCount = participants.length <= 2 ? 1 : 2;
return GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
childAspectRatio: 3 / 4,
),
itemCount: participants.length,
itemBuilder: (context, index) {
return ParticipantTileWidget(participant: participants[index]);
},
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import '../../domain/entities/videocall_participant.dart';
import 'video_view_widget.dart';
class ParticipantTileWidget extends StatelessWidget {
const ParticipantTileWidget({
super.key,
required this.participant,
});
final VideocallParticipant participant;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
clipBehavior: Clip.antiAlias,
child: Stack(
fit: StackFit.expand,
children: [
if (participant.videoCanvas != null && participant.isVideo)
VideoViewWidget(canvas: participant.videoCanvas!)
else
Center(
child: Icon(
Icons.person,
color: colorScheme.onSurface.withValues(alpha: 0.38),
size: 48,
),
),
Positioned(
left: 8,
bottom: 8,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: colorScheme.surface.withValues(alpha: 0.54),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!participant.isAudio)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(Icons.mic_off, color: colorScheme.error, size: 14),
),
Text(
participant.isSelf ? '' : participant.userId,
style: TextStyle(color: colorScheme.onSurface, fontSize: 12),
),
],
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:videocall_sdk/videocall_sdk.dart';
class VideoViewWidget extends StatefulWidget {
const VideoViewWidget({
super.key,
required this.canvas,
this.width = double.infinity,
this.height = double.infinity,
this.fit = BoxFit.cover,
});
final JCMediaDeviceVideoCanvas canvas;
final double width;
final double height;
final BoxFit fit;
@override
State<VideoViewWidget> createState() => _VideoViewWidgetState();
}
class _VideoViewWidgetState extends State<VideoViewWidget> {
Widget? _videoWidget;
@override
void initState() {
super.initState();
_initVideoView();
}
@override
void didUpdateWidget(VideoViewWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.canvas.videoCanvasId != widget.canvas.videoCanvasId) {
_initVideoView();
}
}
Future<void> _initVideoView() async {
final Widget videoWidget;
if (Platform.isIOS) {
videoWidget = await widget.canvas.getIOSVideoView(
(viewId) {},
widget.width,
widget.height,
);
} else {
videoWidget = await widget.canvas.getAndroidVideoView();
}
if (mounted) {
setState(() => _videoWidget = videoWidget);
}
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: widget.width,
height: widget.height,
child: _videoWidget ??
const Center(
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
}

View File

@@ -0,0 +1,12 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:get_it/get_it.dart';
import 'package:sf_infrastructure/sf_infrastructure.dart';
import '../data/datasources/videocall_signaling_datasource.dart';
import '../data/datasources/videocall_signaling_datasource_impl.dart';
final videocallSignalingDatasourceProvider =
Provider<VideocallSignalingDatasource>((ref) {
final repository = GetIt.I<SaveFamilyRepository>();
return VideocallSignalingDatasourceImpl(repository);
});

View File

@@ -0,0 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../data/repositories/videocall_signaling_repository_impl.dart';
import '../domain/repositories/videocall_signaling_repository.dart';
import 'videocall_signaling_datasource_provider.dart';
final videocallSignalingRepositoryProvider =
Provider<VideocallSignalingRepository>((ref) {
final datasource = ref.read(videocallSignalingDatasourceProvider);
return VideocallSignalingRepositoryImpl(datasource);
});

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:go_router/go_router.dart';
import 'package:navigation/navigation.dart';
import 'presentation/videocall_screen.dart';
class VideocallBuilder {
const VideocallBuilder();
Page<void> buildPage(BuildContext context, GoRouterState state) {
final NavigationContract navigationContract = GetIt.I<NavigationContract>();
return MaterialPage<void>(
key: state.pageKey,
child: VideocallScreen(navigationContract: navigationContract),
);
}
}

View File

@@ -50,6 +50,8 @@ dependencies:
path: ../../packages/legacy_ui
sf_shared:
path: ../../../../packages/sf_shared
videocall_sdk:
path: ../../../../packages/videocall_sdk
#dependencies go here
flutter_svg: ^2.2.2
get_it: ^9.0.5

View File

@@ -76,6 +76,7 @@ class AppRoutes {
static const doNotDisturb = '$deviceManagement/do_not_disturb';
static const callHistory = '$deviceManagement/call_history';
static const backgroundImage = '$deviceManagement/background_image';
static const videocall = '$deviceManagement/videocall';
static const legacyLogin = '$legacy/login';
static const legacySignup = '$legacy/signup';