From 5f1413a2ac9e60a9f0442ea6afa72c619a2050c6 Mon Sep 17 00:00:00 2001 From: haroon amjad Date: Thu, 27 Aug 2020 10:05:23 +0300 Subject: [PATCH] implementing Twilio in LiveCare --- lib/locator.dart | 2 + lib/main.dart | 4 +- lib/models/LiveCare/IncomingCallData.dart | 104 ++++ lib/models/LiveCare/room_model.dart | 65 +++ lib/models/LiveCare/room_validators.dart | 6 + lib/models/LiveCare/twilio_enums.dart | 17 + lib/models/LiveCare/validators.dart | 13 + lib/pages/conference/clipped_video.dart | 37 ++ .../conference/conference_button_bar.dart | 244 ++++++++ lib/pages/conference/conference_page.dart | 388 +++++++++++++ lib/pages/conference/conference_room.dart | 530 ++++++++++++++++++ lib/pages/conference/draggable_publisher.dart | 173 ++++++ lib/pages/conference/participant_widget.dart | 197 +++++++ .../widgets/button_to_progress.dart | 106 ++++ .../conference/widgets/circle_button.dart | 103 ++++ lib/pages/conference/widgets/noise_box.dart | 144 +++++ .../widgets/platform_alert_dialog.dart | 98 ++++ .../platform_exception_alert_dialog.dart | 22 + .../conference/widgets/platform_widget.dart | 16 + .../widgets/responsive_save_area.dart | 31 + lib/pages/landing/landing_page.dart | 126 ++++- lib/pages/livecare/incoming_call.dart | 255 +++++++++ lib/routes.dart | 5 +- lib/uitl/navigation_service.dart | 10 + pubspec.yaml | 14 +- 25 files changed, 2684 insertions(+), 26 deletions(-) create mode 100644 lib/models/LiveCare/IncomingCallData.dart create mode 100644 lib/models/LiveCare/room_model.dart create mode 100644 lib/models/LiveCare/room_validators.dart create mode 100644 lib/models/LiveCare/twilio_enums.dart create mode 100644 lib/models/LiveCare/validators.dart create mode 100644 lib/pages/conference/clipped_video.dart create mode 100644 lib/pages/conference/conference_button_bar.dart create mode 100644 lib/pages/conference/conference_page.dart create mode 100644 lib/pages/conference/conference_room.dart create mode 100644 lib/pages/conference/draggable_publisher.dart create mode 100644 lib/pages/conference/participant_widget.dart create mode 100644 lib/pages/conference/widgets/button_to_progress.dart create mode 100644 lib/pages/conference/widgets/circle_button.dart create mode 100644 lib/pages/conference/widgets/noise_box.dart create mode 100644 lib/pages/conference/widgets/platform_alert_dialog.dart create mode 100644 lib/pages/conference/widgets/platform_exception_alert_dialog.dart create mode 100644 lib/pages/conference/widgets/platform_widget.dart create mode 100644 lib/pages/conference/widgets/responsive_save_area.dart create mode 100644 lib/pages/livecare/incoming_call.dart create mode 100644 lib/uitl/navigation_service.dart diff --git a/lib/locator.dart b/lib/locator.dart index 529c9e90..a83e462c 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -1,4 +1,5 @@ import 'package:diplomaticquarterapp/routes.dart'; +import 'package:diplomaticquarterapp/uitl/navigation_service.dart'; import 'package:get_it/get_it.dart'; import 'core/service/feedback/feedback_service.dart'; @@ -37,6 +38,7 @@ void setupLocator() { locator.registerLazySingleton(() => InsuranceCardService()); locator.registerLazySingleton(() => VitalSignService()); locator.registerLazySingleton(() => MedicalService()); + locator.registerLazySingleton(() => NavigationService()); /// View Model locator.registerFactory(() => HospitalViewModel()); diff --git a/lib/main.dart b/lib/main.dart index da20fbbc..8c2b7808 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,4 +1,7 @@ +import 'package:diplomaticquarterapp/pages/livecare/livecare_home.dart'; +import 'package:diplomaticquarterapp/pages/login/login.dart'; import 'package:diplomaticquarterapp/routes.dart'; +import 'package:diplomaticquarterapp/uitl/navigation_service.dart'; import 'package:diplomaticquarterapp/uitl/translations_delegate_base.dart'; import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; @@ -8,7 +11,6 @@ import 'config/size_config.dart'; import 'core/viewModels/project_view_model.dart'; import 'locator.dart'; - void main() async { setupLocator(); runApp(MyApp()); diff --git a/lib/models/LiveCare/IncomingCallData.dart b/lib/models/LiveCare/IncomingCallData.dart new file mode 100644 index 00000000..9234c876 --- /dev/null +++ b/lib/models/LiveCare/IncomingCallData.dart @@ -0,0 +1,104 @@ +class IncomingCallData { + String msgID; + String notfID; + String notificationForeground; + String count; + String message; + String appointmentNo; + String title; + String projectID; + String notificationType; + String background; + String doctorname; + String clinicname; + String speciality; + String appointmentdate; + String appointmenttime; + String type; + String sessionId; + String identity; + String name; + String videoUrl; + String picture; + String isCall; + String sound; + + IncomingCallData( + {this.msgID, + this.notfID, + this.notificationForeground, + this.count, + this.message, + this.appointmentNo, + this.title, + this.projectID, + this.notificationType, + this.background, + this.doctorname, + this.clinicname, + this.speciality, + this.appointmentdate, + this.appointmenttime, + this.type, + this.sessionId, + this.identity, + this.name, + this.videoUrl, + this.picture, + this.isCall, + this.sound}); + + IncomingCallData.fromJson(Map json) { + msgID = json['msgID']; + notfID = json['notfID']; + notificationForeground = json['notification_foreground']; + count = json['count']; + message = json['message']; + appointmentNo = json['AppointmentNo']; + title = json['title']; + projectID = json['ProjectID']; + notificationType = json['NotificationType']; + background = json['background']; + doctorname = json['doctorname']; + clinicname = json['clinicname']; + speciality = json['speciality']; + appointmentdate = json['appointmentdate']; + appointmenttime = json['appointmenttime']; + type = json['type']; + sessionId = json['session_id']; + identity = json['identity']; + name = json['name']; + videoUrl = json['videoUrl']; + picture = json['picture']; + isCall = json['is_call']; + sound = json['sound']; + } + + Map toJson() { + final Map data = new Map(); + data['msgID'] = this.msgID; + data['notfID'] = this.notfID; + data['notification_foreground'] = this.notificationForeground; + data['count'] = this.count; + data['message'] = this.message; + data['AppointmentNo'] = this.appointmentNo; + data['title'] = this.title; + data['ProjectID'] = this.projectID; + data['NotificationType'] = this.notificationType; + data['background'] = this.background; + data['doctorname'] = this.doctorname; + data['clinicname'] = this.clinicname; + data['speciality'] = this.speciality; + data['appointmentdate'] = this.appointmentdate; + data['appointmenttime'] = this.appointmenttime; + data['type'] = this.type; + data['session_id'] = this.sessionId; + data['identity'] = this.identity; + data['name'] = this.name; + data['videoUrl'] = this.videoUrl; + data['picture'] = this.picture; + data['is_call'] = this.isCall; + data['sound'] = this.sound; + return data; + } +} diff --git a/lib/models/LiveCare/room_model.dart b/lib/models/LiveCare/room_model.dart new file mode 100644 index 00000000..6ab64d1d --- /dev/null +++ b/lib/models/LiveCare/room_model.dart @@ -0,0 +1,65 @@ +import 'package:diplomaticquarterapp/models/LiveCare/room_validators.dart'; +import 'package:diplomaticquarterapp/models/LiveCare/twilio_enums.dart'; + +class RoomModel with RoomValidators { + final String name; + final bool isLoading; + final bool isSubmitted; + final String token; + final String identity; + final TwilioRoomType type; + + RoomModel({ + this.name, + this.isLoading = false, + this.isSubmitted = false, + this.token, + this.identity, + this.type = TwilioRoomType.groupSmall, + }); + + static String getTypeText(TwilioRoomType type) { + switch (type) { + case TwilioRoomType.peerToPeer: + return 'peer 2 peer'; + break; + case TwilioRoomType.group: + return 'large (max 50 participants)'; + break; + case TwilioRoomType.groupSmall: + return 'small (max 4 participants)'; + break; + } + return ''; + } + + String get nameErrorText { + return isSubmitted && !nameValidator.isValid(name) ? invalidNameErrorText : null; + } + + String get typeText { + return RoomModel.getTypeText(type); + } + + bool get canSubmit { + return nameValidator.isValid(name); + } + + RoomModel copyWith({ + String name, + bool isLoading, + bool isSubmitted, + String token, + String identity, + TwilioRoomType type, + }) { + return RoomModel( + name: name ?? this.name, + token: token ?? this.token, + identity: identity ?? this.identity, + isLoading: isLoading ?? this.isLoading, + isSubmitted: isSubmitted ?? this.isSubmitted, + type: type ?? this.type, + ); + } +} diff --git a/lib/models/LiveCare/room_validators.dart b/lib/models/LiveCare/room_validators.dart new file mode 100644 index 00000000..44fe95f0 --- /dev/null +++ b/lib/models/LiveCare/room_validators.dart @@ -0,0 +1,6 @@ +import 'package:diplomaticquarterapp/models/LiveCare/validators.dart'; + +mixin RoomValidators { + final StringValidator nameValidator = NonEmptyStringValidator(); + final String invalidNameErrorText = 'Room name can\'t be empty'; +} diff --git a/lib/models/LiveCare/twilio_enums.dart b/lib/models/LiveCare/twilio_enums.dart new file mode 100644 index 00000000..4d4f6f69 --- /dev/null +++ b/lib/models/LiveCare/twilio_enums.dart @@ -0,0 +1,17 @@ +enum TwilioRoomType { + peerToPeer, + group, + groupSmall, +} + +enum TwilioRoomStatus { + completed, + inProgress, +} + +enum TwilioStatusCallbackMethod { + GET, + POST, +} + +enum TwilioVideoCodec { VP8, H264 } diff --git a/lib/models/LiveCare/validators.dart b/lib/models/LiveCare/validators.dart new file mode 100644 index 00000000..aa42f231 --- /dev/null +++ b/lib/models/LiveCare/validators.dart @@ -0,0 +1,13 @@ +abstract class StringValidator { + bool isValid(String value); +} + +class NonEmptyStringValidator implements StringValidator { + @override + bool isValid(String value) { + if (value == null) { + return false; + } + return value.isNotEmpty; + } +} diff --git a/lib/pages/conference/clipped_video.dart b/lib/pages/conference/clipped_video.dart new file mode 100644 index 00000000..1c01c27c --- /dev/null +++ b/lib/pages/conference/clipped_video.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; + +class ClippedVideo extends StatefulWidget { + final double width; + final double height; + final Widget child; + + const ClippedVideo({ + Key key, + @required this.width, + @required this.height, + @required this.child, + }) : super(key: key); + + @override + _ClippedVideoState createState() => _ClippedVideoState(); +} + +class _ClippedVideoState extends State { + @override + Widget build(BuildContext context) { + return Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(20)), + border: Border.all( + color: Colors.white24, + ), + ), + child: ClipRRect( + child: widget.child, + borderRadius: const BorderRadius.all(Radius.circular(20)), + ), + ); + } +} diff --git a/lib/pages/conference/conference_button_bar.dart b/lib/pages/conference/conference_button_bar.dart new file mode 100644 index 00000000..720732ea --- /dev/null +++ b/lib/pages/conference/conference_button_bar.dart @@ -0,0 +1,244 @@ +import 'dart:async'; + +import 'package:after_layout/after_layout.dart'; +import 'package:diplomaticquarterapp/pages/conference/widgets/circle_button.dart'; +import 'package:flutter/material.dart'; + +class ConferenceButtonBar extends StatefulWidget { + final VoidCallback onVideoEnabled; + final VoidCallback onAudioEnabled; + final VoidCallback onHangup; + final VoidCallback onSwitchCamera; + final VoidCallback onPersonAdd; + final VoidCallback onPersonRemove; + final void Function(double) onHeight; + final VoidCallback onHide; + final VoidCallback onShow; + final Stream videoEnabled; + final Stream audioEnabled; + + const ConferenceButtonBar({ + Key key, + this.onVideoEnabled, + this.onAudioEnabled, + this.onHangup, + this.onSwitchCamera, + this.onPersonAdd, + this.onPersonRemove, + @required this.videoEnabled, + @required this.audioEnabled, + this.onHeight, + this.onHide, + this.onShow, + }) : assert(videoEnabled != null), + assert(audioEnabled != null), + super(key: key); + + @override + _ConferenceButtonBarState createState() => _ConferenceButtonBarState(); +} + +class _ConferenceButtonBarState extends State with AfterLayoutMixin { + var _bottom = -100.0; + Timer _timer; + int _remaining; + var _videoEnabled = true; + var _audioEnabled = true; + double _hidden; + double _visible; + final _keyButtonBarHeight = GlobalKey(); + + final Duration timeout = const Duration(seconds: 5); + final Duration ms = const Duration(milliseconds: 1); + final Duration periodicDuration = const Duration(milliseconds: 100); + + Timer startTimeout([int milliseconds]) { + final duration = milliseconds == null ? timeout : ms * milliseconds; + _remaining = duration.inMilliseconds; + return Timer.periodic(periodicDuration, (Timer timer) { + _remaining -= periodicDuration.inMilliseconds; + if (_remaining <= 0) { + timer.cancel(); + _toggleBar(); + } + }); + } + + void _pauseTimer() { + if (_timer == null) { + return; + } + _timer.cancel(); + _timer = null; + } + + void _resumeTimer() { + // resume the timer only when there is no timer active or when + // the bar is not already hidden. + if ((_timer != null && _timer.isActive) || _bottom == _hidden) { + return; + } + _timer = startTimeout(_remaining); + } + + void _toggleBar() { + setState(() { + _bottom = _bottom == _visible ? _hidden : _visible; + if (_bottom == _visible && widget.onShow != null) { + widget.onShow(); + } + if (_bottom == _hidden && widget.onHide != null) { + widget.onHide(); + } + }); + } + + void _toggleBarOnEnd() { + if (_timer != null) { + if (_timer.isActive) { + _timer.cancel(); + } + _timer = null; + } + if (_bottom == 0) { + _timer = startTimeout(); + } + } + + @override + void initState() { + super.initState(); + _timer = startTimeout(); + } + + @override + void didChangeDependencies() { + _visible = MediaQuery.of(context).viewPadding.bottom; + super.didChangeDependencies(); + } + + @override + void afterFirstLayout(BuildContext context) { + final RenderBox renderBoxButtonBar = _keyButtonBarHeight.currentContext.findRenderObject(); + final heightButtonBar = renderBoxButtonBar.size.height; + // Because the `didChangeDependencies` fires before the `afterFirstLayout`, we can use the `_visible` property here. + _hidden = -(heightButtonBar + _visible); + widget.onHeight(heightButtonBar); + _toggleBar(); + } + + @override + void dispose() { + super.dispose(); + if (_timer != null && _timer.isActive) { + _timer.cancel(); + _timer = null; + } + } + + @override + Widget build(BuildContext context) { + return Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: GestureDetector( + key: Key('show-hide-button-bar-gesture'), + behavior: HitTestBehavior.translucent, + onTapDown: (_) => _pauseTimer(), + onTapUp: (_) => _toggleBar(), + onTapCancel: () => _resumeTimer(), + child: Stack( + children: [ + AnimatedPositioned( + key: Key('button-bar'), + bottom: _bottom, + left: 0, + right: 0, + duration: const Duration(milliseconds: 300), + curve: Curves.linear, + child: _buildRow(context), + onEnd: _toggleBarOnEnd, + ), + ], + ), + ), + ); + } + + void _onPressed(VoidCallback callback) { + if (callback != null) { + callback(); + } + if (_timer != null && _timer.isActive) { + _timer.cancel(); + } + _timer = startTimeout(); + } + + Widget _buildRow(BuildContext context) { + return Padding( + key: _keyButtonBarHeight, + padding: const EdgeInsets.only(bottom: 8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + CircleButton( + child: StreamBuilder( + stream: widget.videoEnabled, + initialData: _videoEnabled, + builder: (context, snapshot) { + _videoEnabled = snapshot.data; + return Icon( + _videoEnabled ? Icons.videocam : Icons.videocam_off, + color: Colors.white, + ); + }), + key: Key('camera-button'), + onPressed: () => _onPressed(widget.onVideoEnabled), + ), + CircleButton( + child: StreamBuilder( + stream: widget.audioEnabled, + initialData: _audioEnabled, + builder: (context, snapshot) { + _audioEnabled = snapshot.data; + return Icon( + _audioEnabled ? Icons.mic : Icons.mic_off, + color: Colors.white, + ); + }), + key: Key('microphone-button'), + onPressed: () => _onPressed(widget.onAudioEnabled), + ), + CircleButton( + radius: 35, + child: const RotationTransition( + turns: AlwaysStoppedAnimation(135 / 360), + child: Icon( + Icons.phone, + color: Colors.white, + size: 40, + ), + ), + color: Colors.red.withAlpha(200), + key: Key('hangup-button'), + onPressed: () => _onPressed(widget.onHangup), + ), + CircleButton( + child: const Icon(Icons.switch_camera, color: Colors.white), + key: Key('switch-camera-button'), + onPressed: () => _onPressed(widget.onSwitchCamera), + ), + CircleButton( + child: const Icon(Icons.person_add, color: Colors.white), + key: Key('add-person-button'), + onPressed: () => _onPressed(widget.onPersonAdd), + onLongPress: () => _onPressed(widget.onPersonRemove), + ), + ], + ), + ); + } +} diff --git a/lib/pages/conference/conference_page.dart b/lib/pages/conference/conference_page.dart new file mode 100644 index 00000000..3ae404c5 --- /dev/null +++ b/lib/pages/conference/conference_page.dart @@ -0,0 +1,388 @@ +import 'dart:async'; + +import 'package:diplomaticquarterapp/models/LiveCare/room_model.dart'; +import 'package:diplomaticquarterapp/pages/conference/conference_button_bar.dart'; +import 'package:diplomaticquarterapp/pages/conference/conference_room.dart'; +import 'package:diplomaticquarterapp/pages/conference/draggable_publisher.dart'; +import 'package:diplomaticquarterapp/pages/conference/participant_widget.dart'; +import 'package:diplomaticquarterapp/pages/conference/widgets/noise_box.dart'; +import 'package:diplomaticquarterapp/pages/conference/widgets/platform_alert_dialog.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:wakelock/wakelock.dart'; + +class ConferencePage extends StatefulWidget { + final RoomModel roomModel; + + const ConferencePage({Key key, this.roomModel}) : super(key: key); + + @override + _ConferencePageState createState() => _ConferencePageState(); +} + +class _ConferencePageState extends State { + final StreamController _onButtonBarVisibleStreamController = StreamController.broadcast(); + final StreamController _onButtonBarHeightStreamController = StreamController.broadcast(); + ConferenceRoom _conferenceRoom; + StreamSubscription _onConferenceRoomException; + + @override + void initState() { + super.initState(); + _lockInPortrait(); + _connectToRoom(); + _wakeLock(true); + } + + void _connectToRoom() async { + try { + final conferenceRoom = ConferenceRoom( + name: widget.roomModel.name, + token: widget.roomModel.token, + identity: widget.roomModel.identity, + ); + await conferenceRoom.connect(); + setState(() { + _conferenceRoom = conferenceRoom; + _onConferenceRoomException = _conferenceRoom.onException.listen((err) async { + await PlatformAlertDialog( + title: err is PlatformException ? err.message : 'An error occured', + content: err is PlatformException ? err.details : err.toString(), + defaultActionText: 'OK', + ).show(context); + }); + _conferenceRoom.addListener(_conferenceRoomUpdated); + }); + } catch (err) { + print(err); + await PlatformAlertDialog( + title: err is PlatformException ? err.message : 'An error occured', + content: err is PlatformException ? err.details : err.toString(), + defaultActionText: 'OK', + ).show(context); + + Navigator.of(context).pop(); + } + } + + Future _lockInPortrait() async { + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } + + @override + void dispose() { + _freePortraitLock(); + _wakeLock(false); + _disposeStreamsAndSubscriptions(); + if (_conferenceRoom != null) _conferenceRoom.removeListener(_conferenceRoomUpdated); + super.dispose(); + } + + Future _freePortraitLock() async { + await SystemChrome.setPreferredOrientations([ + DeviceOrientation.landscapeRight, + DeviceOrientation.landscapeLeft, + DeviceOrientation.portraitUp, + DeviceOrientation.portraitDown, + ]); + } + + Future _disposeStreamsAndSubscriptions() async { + if (_onButtonBarVisibleStreamController != null) await _onButtonBarVisibleStreamController.close(); + if (_onButtonBarHeightStreamController != null) await _onButtonBarHeightStreamController.close(); + if (_onConferenceRoomException != null) await _onConferenceRoomException.cancel(); + } + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () async => false, + child: Scaffold( + backgroundColor: Colors.black, + body: _conferenceRoom == null ? showProgress() : buildLayout(), + ), + ); + } + + LayoutBuilder buildLayout() { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Stack( + children: [ + _buildParticipants(context, constraints.biggest, _conferenceRoom), + ConferenceButtonBar( + audioEnabled: _conferenceRoom.onAudioEnabled, + videoEnabled: _conferenceRoom.onVideoEnabled, + onAudioEnabled: _conferenceRoom.toggleAudioEnabled, + onVideoEnabled: _conferenceRoom.toggleVideoEnabled, + onHangup: _onHangup, + onSwitchCamera: _conferenceRoom.switchCamera, + onPersonAdd: _onPersonAdd, + onPersonRemove: _onPersonRemove, + onHeight: _onHeightBar, + onShow: _onShowBar, + onHide: _onHideBar, + ), + ], + ); + }, + ); + } + + Widget showProgress() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Center(child: CircularProgressIndicator()), + SizedBox( + height: 10, + ), + Text( + 'Connecting to the room...', + style: TextStyle(color: Colors.white), + ), + ], + ); + } + + Future _onHangup() async { + print('onHangup'); + await _conferenceRoom.disconnect(); + Navigator.of(context).pop(); + } + + void _onPersonAdd() { + print('onPersonAdd'); + try { + _conferenceRoom.addDummy( + child: Stack( + children: [ + const Placeholder(), + Center( + child: Text( + (_conferenceRoom.participants.length + 1).toString(), + style: const TextStyle( + shadows: [ + Shadow( + blurRadius: 3.0, + color: Color.fromARGB(255, 0, 0, 0), + ), + Shadow( + blurRadius: 8.0, + color: Color.fromARGB(255, 255, 255, 255), + ), + ], + fontSize: 80, + ), + ), + ), + ], + ), + ); + } on PlatformException catch (err) { + PlatformAlertDialog( + title: err.message, + content: err.details, + defaultActionText: 'OK', + ).show(context); + } + } + + void _onPersonRemove() { + print('onPersonRemove'); + _conferenceRoom.removeDummy(); + } + + Widget _buildParticipants(BuildContext context, Size size, ConferenceRoom conferenceRoom) { + final children = []; + final length = conferenceRoom.participants.length; + + if (length <= 2) { + _buildOverlayLayout(context, size, children); + return Stack(children: children); + } + + void buildInCols(bool removeLocalBeforeChunking, bool moveLastOfEachRowToNextRow, int columns) { + _buildLayoutInGrid( + context, + size, + children, + removeLocalBeforeChunking: removeLocalBeforeChunking, + moveLastOfEachRowToNextRow: moveLastOfEachRowToNextRow, + columns: columns, + ); + } + + if (length <= 3) { + buildInCols(true, false, 1); + } else if (length == 5) { + buildInCols(false, true, 2); + } else if (length <= 6 || length == 8) { + buildInCols(false, false, 2); + } else if (length == 7 || length == 9) { + buildInCols(true, false, 2); + } else if (length == 10) { + buildInCols(false, true, 3); + } else if (length == 13 || length == 16) { + buildInCols(true, false, 3); + } else if (length <= 18) { + buildInCols(false, false, 3); + } + + return Column( + children: children, + ); + } + + void _buildOverlayLayout(BuildContext context, Size size, List children) { + final participants = _conferenceRoom.participants; + if (participants.length == 1) { + children.add(_buildNoiseBox()); + } else { + final remoteParticipant = participants.firstWhere((ParticipantWidget participant) => participant.isRemote, orElse: () => null); + if (remoteParticipant != null) { + children.add(remoteParticipant); + } + } + + final localParticipant = participants.firstWhere((ParticipantWidget participant) => !participant.isRemote, orElse: () => null); + if (localParticipant != null) { + children.add(DraggablePublisher( + key: Key('publisher'), + child: localParticipant, + availableScreenSize: size, + onButtonBarVisible: _onButtonBarVisibleStreamController.stream, + onButtonBarHeight: _onButtonBarHeightStreamController.stream, + )); + } + } + + void _buildLayoutInGrid( + BuildContext context, + Size size, + List children, { + bool removeLocalBeforeChunking = false, + bool moveLastOfEachRowToNextRow = false, + int columns = 2, + }) { + final participants = _conferenceRoom.participants; + ParticipantWidget localParticipant; + if (removeLocalBeforeChunking) { + localParticipant = participants.firstWhere((ParticipantWidget participant) => !participant.isRemote, orElse: () => null); + if (localParticipant != null) { + participants.remove(localParticipant); + } + } + final chunkedParticipants = chunk(array: participants, size: columns); + if (localParticipant != null) { + chunkedParticipants.last.add(localParticipant); + participants.add(localParticipant); + } + + if (moveLastOfEachRowToNextRow) { + for (var i = 0; i < chunkedParticipants.length - 1; i++) { + var participant = chunkedParticipants[i].removeLast(); + chunkedParticipants[i + 1].insert(0, participant); + } + } + + for (final participantChunk in chunkedParticipants) { + final rowChildren = []; + for (final participant in participantChunk) { + rowChildren.add( + Container( + width: size.width / participantChunk.length, + height: size.height / chunkedParticipants.length, + child: participant, + ), + ); + } + children.add( + Container( + height: size.height / chunkedParticipants.length, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: rowChildren, + ), + ), + ); + } + } + + NoiseBox _buildNoiseBox() { + return NoiseBox( + density: NoiseBoxDensity.xLow, + backgroundColor: Colors.grey.shade900, + child: Center( + child: Container( + color: Colors.black54, + width: double.infinity, + height: 40, + child: Center( + child: Text( + 'Waiting for another participant to connect to the room...', + key: Key('text-wait'), + textAlign: TextAlign.center, + style: TextStyle(color: Colors.white), + ), + ), + ), + ), + ); + } + + List> chunk({@required List array, @required int size}) { + final result = >[]; + if (array.isEmpty || size <= 0) { + return result; + } + var first = 0; + var last = size; + final totalLoop = array.length % size == 0 ? array.length ~/ size : array.length ~/ size + 1; + for (var i = 0; i < totalLoop; i++) { + if (last > array.length) { + result.add(array.sublist(first, array.length)); + } else { + result.add(array.sublist(first, last)); + } + first = last; + last = last + size; + } + return result; + } + + void _onHeightBar(double height) { + _onButtonBarHeightStreamController.add(height); + } + + void _onShowBar() { + setState(() { + SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom, SystemUiOverlay.top]); + }); + _onButtonBarVisibleStreamController.add(true); + } + + void _onHideBar() { + setState(() { + SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom]); + }); + _onButtonBarVisibleStreamController.add(false); + } + + Future _wakeLock(bool enable) async { + try { + return await (enable ? Wakelock.enable() : Wakelock.disable()); + } catch (err) { + print('Unable to change the Wakelock and set it to $enable'); + print(err); + } + } + + void _conferenceRoomUpdated() { + setState(() {}); + } +} diff --git a/lib/pages/conference/conference_room.dart b/lib/pages/conference/conference_room.dart new file mode 100644 index 00000000..9c8c1c9a --- /dev/null +++ b/lib/pages/conference/conference_room.dart @@ -0,0 +1,530 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:diplomaticquarterapp/pages/conference/participant_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:twilio_programmable_video/twilio_programmable_video.dart'; + +class ConferenceRoom with ChangeNotifier { + final String name; + final String token; + final String identity; + + final StreamController _onAudioEnabledStreamController = StreamController.broadcast(); + Stream onAudioEnabled; + final StreamController _onVideoEnabledStreamController = StreamController.broadcast(); + Stream onVideoEnabled; + final StreamController _onExceptionStreamController = StreamController.broadcast(); + Stream onException; + + final Completer _completer = Completer(); + + final List _participants = []; + final List _participantBuffer = []; + final List _streamSubscriptions = []; + final List _dataTracks = []; + final List _messages = []; + + CameraCapturer _cameraCapturer; + Room _room; + Timer _timer; + + ConferenceRoom({ + @required this.name, + @required this.token, + @required this.identity, + }) { + onAudioEnabled = _onAudioEnabledStreamController.stream; + onVideoEnabled = _onVideoEnabledStreamController.stream; + onException = _onExceptionStreamController.stream; + } + + List get participants { + return [..._participants]; + } + + Future connect() async { + print('ConferenceRoom.connect()'); + try { + await TwilioProgrammableVideo.debug(dart: true, native: true); + await TwilioProgrammableVideo.setSpeakerphoneOn(true); + + _cameraCapturer = CameraCapturer(CameraSource.FRONT_CAMERA); + var connectOptions = ConnectOptions( + token, + roomName: name, + preferredAudioCodecs: [OpusCodec()], + audioTracks: [LocalAudioTrack(true)], + dataTracks: [LocalDataTrack()], + videoTracks: [LocalVideoTrack(true, _cameraCapturer)], + enableDominantSpeaker: true, + ); + + _room = await TwilioProgrammableVideo.connect(connectOptions); + + _streamSubscriptions.add(_room.onConnected.listen(_onConnected)); + _streamSubscriptions.add(_room.onConnectFailure.listen(_onConnectFailure)); + + return _completer.future; + } catch (err) { + print(err); + rethrow; + } + } + + Future disconnect() async { + print('ConferenceRoom.disconnect()'); + if (_timer != null) { + _timer.cancel(); + } + await _room.disconnect(); + } + + @override + void dispose() { + print('ConferenceRoom.dispose()'); + _disposeStreamsAndSubscriptions(); + super.dispose(); + } + + Future _disposeStreamsAndSubscriptions() async { + await _onAudioEnabledStreamController.close(); + await _onVideoEnabledStreamController.close(); + await _onExceptionStreamController.close(); + for (var streamSubscription in _streamSubscriptions) { + await streamSubscription.cancel(); + } + } + + Future sendMessage(String message) async { + final tracks = _room.localParticipant.localDataTracks; + final localDataTrack = tracks.isEmpty ? null : tracks[0].localDataTrack; + if (localDataTrack == null || _messages.isNotEmpty) { + print('ConferenceRoom.sendMessage => Track is not available yet, buffering message.'); + _messages.add(message); + return; + } + await localDataTrack.send(message); + } + + Future sendBufferMessage(ByteBuffer message) async { + final tracks = _room.localParticipant.localDataTracks; + final localDataTrack = tracks.isEmpty ? null : tracks[0].localDataTrack; + if (localDataTrack == null) { + return; + } + await localDataTrack.sendBuffer(message); + } + + Future toggleVideoEnabled() async { + final tracks = _room.localParticipant.localVideoTracks; + final localVideoTrack = tracks.isEmpty ? null : tracks[0].localVideoTrack; + if (localVideoTrack == null) { + print('ConferenceRoom.toggleVideoEnabled() => Track is not available yet!'); + return; + } + await localVideoTrack.enable(!localVideoTrack.isEnabled); + + var index = _participants.indexWhere((ParticipantWidget participant) => !participant.isRemote); + if (index < 0) { + return; + } + var participant = _participants[index]; + _participants.replaceRange( + index, + index + 1, + [ + participant.copyWith(videoEnabled: localVideoTrack.isEnabled), + ], + ); + print('ConferenceRoom.toggleVideoEnabled() => ${localVideoTrack.isEnabled}'); + _onVideoEnabledStreamController.add(localVideoTrack.isEnabled); + notifyListeners(); + } + + Future toggleAudioEnabled() async { + final tracks = _room.localParticipant.localAudioTracks; + final localAudioTrack = tracks.isEmpty ? null : tracks[0].localAudioTrack; + if (localAudioTrack == null) { + print('ConferenceRoom.toggleAudioEnabled() => Track is not available yet!'); + return; + } + await localAudioTrack.enable(!localAudioTrack.isEnabled); + + var index = _participants.indexWhere((ParticipantWidget participant) => !participant.isRemote); + if (index < 0) { + return; + } + var participant = _participants[index]; + _participants.replaceRange( + index, + index + 1, + [ + participant.copyWith(audioEnabled: localAudioTrack.isEnabled), + ], + ); + print('ConferenceRoom.toggleAudioEnabled() => ${localAudioTrack.isEnabled}'); + _onAudioEnabledStreamController.add(localAudioTrack.isEnabled); + notifyListeners(); + } + + Future switchCamera() async { + print('ConferenceRoom.switchCamera()'); + try { + await _cameraCapturer.switchCamera(); + } on FormatException catch (e) { + print( + 'ConferenceRoom.switchCamera() failed because of FormatException with message: ${e.message}', + ); + } + } + + void addDummy({Widget child}) { + print('ConferenceRoom.addDummy()'); + if (_participants.length >= 18) { + throw PlatformException( + code: 'ConferenceRoom.maximumReached', + message: 'Maximum reached', + details: 'Currently the lay-out can only render a maximum of 18 participants', + ); + } + _participants.insert( + 0, + ParticipantWidget( + id: (_participants.length + 1).toString(), + child: child, + isRemote: true, + audioEnabled: true, + videoEnabled: true, + isDummy: true, + ), + ); + notifyListeners(); + } + + void removeDummy() { + print('ConferenceRoom.removeDummy()'); + var dummy = _participants.firstWhere((participant) => participant.isDummy, orElse: () => null); + if (dummy != null) { + _participants.remove(dummy); + notifyListeners(); + } + } + + void _onConnected(Room room) { + print('ConferenceRoom._onConnected => state: ${room.state}'); + + // When connected for the first time, add remote participant listeners + _streamSubscriptions.add(_room.onParticipantConnected.listen(_onParticipantConnected)); + _streamSubscriptions.add(_room.onParticipantDisconnected.listen(_onParticipantDisconnected)); + _streamSubscriptions.add(_room.onDominantSpeakerChange.listen(_onDominantSpeakerChanged)); + // Only add ourselves when connected for the first time too. + _participants.add( + _buildParticipant( + child: room.localParticipant.localVideoTracks[0].localVideoTrack.widget(), + id: identity, + audioEnabled: true, + videoEnabled: true, + ), + ); + for (final remoteParticipant in room.remoteParticipants) { + var participant = _participants.firstWhere((participant) => participant.id == remoteParticipant.sid, orElse: () => null); + if (participant == null) { + print('Adding participant that was already present in the room ${remoteParticipant.sid}, before I connected'); + _addRemoteParticipantListeners(remoteParticipant); + } + } + // We have to listen for the [onDataTrackPublished] event on the [LocalParticipant] in + // order to be able to use the [send] method. + _streamSubscriptions.add(room.localParticipant.onDataTrackPublished.listen(_onLocalDataTrackPublished)); + notifyListeners(); + _completer.complete(room); + + _timer = Timer.periodic(const Duration(minutes: 1), (_) { + // Let's see if we can send some data over the DataTrack API + sendMessage('And another minute has passed since I connected...'); + // Also try the ByteBuffer way of sending data + final list = 'This data has been sent over the ByteBuffer channel of the DataTrack API'.codeUnits; + var bytes = Uint8List.fromList(list); + sendBufferMessage(bytes.buffer); + }); + } + + void _onLocalDataTrackPublished(LocalDataTrackPublishedEvent event) { + // Send buffered messages, if any... + while (_messages.isNotEmpty) { + var message = _messages.removeAt(0); + print('Sending buffered message: $message'); + event.localDataTrackPublication.localDataTrack.send(message); + } + } + + void _onConnectFailure(RoomConnectFailureEvent event) { + print('ConferenceRoom._onConnectFailure: ${event.exception}'); + _completer.completeError(event.exception); + } + + void _onDominantSpeakerChanged(DominantSpeakerChangedEvent event) { + print('ConferenceRoom._onDominantSpeakerChanged: ${event.remoteParticipant.identity}'); + var oldDominantParticipant = _participants.firstWhere((p) => p.isDominant, orElse: () => null); + if (oldDominantParticipant != null) { + var oldDominantParticipantIndex = _participants.indexOf(oldDominantParticipant); + _participants.replaceRange(oldDominantParticipantIndex, oldDominantParticipantIndex + 1, [oldDominantParticipant.copyWith(isDominant: false)]); + } + + var newDominantParticipant = _participants.firstWhere((p) => p.id == event.remoteParticipant.sid); + var newDominantParticipantIndex = _participants.indexOf(newDominantParticipant); + _participants.replaceRange(newDominantParticipantIndex, newDominantParticipantIndex + 1, [newDominantParticipant.copyWith(isDominant: true)]); + notifyListeners(); + } + + void _onParticipantConnected(RoomParticipantConnectedEvent event) { + print('ConferenceRoom._onParticipantConnected, ${event.remoteParticipant.sid}'); + _addRemoteParticipantListeners(event.remoteParticipant); + } + + void _onParticipantDisconnected(RoomParticipantDisconnectedEvent event) { + print('ConferenceRoom._onParticipantDisconnected: ${event.remoteParticipant.sid}'); + _participants.removeWhere((ParticipantWidget p) => p.id == event.remoteParticipant.sid); + notifyListeners(); + } + + ParticipantWidget _buildParticipant({ + @required Widget child, + @required String id, + @required bool audioEnabled, + @required bool videoEnabled, + RemoteParticipant remoteParticipant, + }) { + return ParticipantWidget( + id: remoteParticipant?.sid, + isRemote: remoteParticipant != null, + child: child, + audioEnabled: audioEnabled, + videoEnabled: videoEnabled, + ); + } + + void _addRemoteParticipantListeners(RemoteParticipant remoteParticipant) { + print('ConferenceRoom._addRemoteParticipantListeners() => Adding listeners to remoteParticipant ${remoteParticipant.sid}'); + _streamSubscriptions.add(remoteParticipant.onAudioTrackDisabled.listen(_onAudioTrackDisabled)); + _streamSubscriptions.add(remoteParticipant.onAudioTrackEnabled.listen(_onAudioTrackEnabled)); + _streamSubscriptions.add(remoteParticipant.onAudioTrackPublished.listen(_onAudioTrackPublished)); + _streamSubscriptions.add(remoteParticipant.onAudioTrackSubscribed.listen(_onAudioTrackSubscribed)); + _streamSubscriptions.add(remoteParticipant.onAudioTrackSubscriptionFailed.listen(_onAudioTrackSubscriptionFailed)); + _streamSubscriptions.add(remoteParticipant.onAudioTrackUnpublished.listen(_onAudioTrackUnpublished)); + _streamSubscriptions.add(remoteParticipant.onAudioTrackUnsubscribed.listen(_onAudioTrackUnsubscribed)); + + _streamSubscriptions.add(remoteParticipant.onDataTrackPublished.listen(_onDataTrackPublished)); + _streamSubscriptions.add(remoteParticipant.onDataTrackSubscribed.listen(_onDataTrackSubscribed)); + _streamSubscriptions.add(remoteParticipant.onDataTrackSubscriptionFailed.listen(_onDataTrackSubscriptionFailed)); + _streamSubscriptions.add(remoteParticipant.onDataTrackUnpublished.listen(_onDataTrackUnpublished)); + _streamSubscriptions.add(remoteParticipant.onDataTrackUnsubscribed.listen(_onDataTrackUnsubscribed)); + + _streamSubscriptions.add(remoteParticipant.onVideoTrackDisabled.listen(_onVideoTrackDisabled)); + _streamSubscriptions.add(remoteParticipant.onVideoTrackEnabled.listen(_onVideoTrackEnabled)); + _streamSubscriptions.add(remoteParticipant.onVideoTrackPublished.listen(_onVideoTrackPublished)); + _streamSubscriptions.add(remoteParticipant.onVideoTrackSubscribed.listen(_onVideoTrackSubscribed)); + _streamSubscriptions.add(remoteParticipant.onVideoTrackSubscriptionFailed.listen(_onVideoTrackSubscriptionFailed)); + _streamSubscriptions.add(remoteParticipant.onVideoTrackUnpublished.listen(_onVideoTrackUnpublished)); + _streamSubscriptions.add(remoteParticipant.onVideoTrackUnsubscribed.listen(_onVideoTrackUnsubscribed)); + } + + void _onAudioTrackDisabled(RemoteAudioTrackEvent event) { + print('ConferenceRoom._onAudioTrackDisabled(), ${event.remoteParticipant.sid}, ${event.remoteAudioTrackPublication.trackSid}, isEnabled: ${event.remoteAudioTrackPublication.isTrackEnabled}'); + _setRemoteAudioEnabled(event); + } + + void _onAudioTrackEnabled(RemoteAudioTrackEvent event) { + print('ConferenceRoom._onAudioTrackEnabled(), ${event.remoteParticipant.sid}, ${event.remoteAudioTrackPublication.trackSid}, isEnabled: ${event.remoteAudioTrackPublication.isTrackEnabled}'); + _setRemoteAudioEnabled(event); + } + + void _onAudioTrackPublished(RemoteAudioTrackEvent event) { + print('ConferenceRoom._onAudioTrackPublished(), ${event.remoteParticipant.sid}}'); + } + + void _onAudioTrackSubscribed(RemoteAudioTrackSubscriptionEvent event) { + print('ConferenceRoom._onAudioTrackSubscribed(), ${event.remoteParticipant.sid}, ${event.remoteAudioTrackPublication.trackSid}'); + _addOrUpdateParticipant(event); + } + + void _onAudioTrackSubscriptionFailed(RemoteAudioTrackSubscriptionFailedEvent event) { + print('ConferenceRoom._onAudioTrackSubscriptionFailed(), ${event.remoteParticipant.sid}, ${event.remoteAudioTrackPublication.trackSid}'); + _onExceptionStreamController.add( + PlatformException( + code: 'ConferenceRoom.audioTrackSubscriptionFailed', + message: 'AudioTrack Subscription Failed', + details: event.exception.toString(), + ), + ); + } + + void _onAudioTrackUnpublished(RemoteAudioTrackEvent event) { + print('ConferenceRoom._onAudioTrackUnpublished(), ${event.remoteParticipant.sid}, ${event.remoteAudioTrackPublication.trackSid}'); + } + + void _onAudioTrackUnsubscribed(RemoteAudioTrackSubscriptionEvent event) { + print('ConferenceRoom._onAudioTrackUnsubscribed(), ${event.remoteParticipant.sid}, ${event.remoteAudioTrack.sid}'); + } + + void _onDataTrackPublished(RemoteDataTrackEvent event) { + print('ConferenceRoom._onDataTrackPublished(), ${event.remoteParticipant.sid}}'); + } + + void _onDataTrackSubscribed(RemoteDataTrackSubscriptionEvent event) { + print('ConferenceRoom._onDataTrackSubscribed(), ${event.remoteParticipant.sid}, ${event.remoteDataTrackPublication.trackSid}'); + final dataTrack = event.remoteDataTrackPublication.remoteDataTrack; + _dataTracks.add(dataTrack); + _streamSubscriptions.add(dataTrack.onMessage.listen(_onMessage)); + _streamSubscriptions.add(dataTrack.onBufferMessage.listen(_onBufferMessage)); + } + + void _onDataTrackSubscriptionFailed(RemoteDataTrackSubscriptionFailedEvent event) { + print('ConferenceRoom._onDataTrackSubscriptionFailed(), ${event.remoteParticipant.sid}, ${event.remoteDataTrackPublication.trackSid}'); + _onExceptionStreamController.add( + PlatformException( + code: 'ConferenceRoom.dataTrackSubscriptionFailed', + message: 'DataTrack Subscription Failed', + details: event.exception.toString(), + ), + ); + } + + void _onDataTrackUnpublished(RemoteDataTrackEvent event) { + print('ConferenceRoom._onDataTrackUnpublished(), ${event.remoteParticipant.sid}, ${event.remoteDataTrackPublication.trackSid}'); + } + + void _onDataTrackUnsubscribed(RemoteDataTrackSubscriptionEvent event) { + print('ConferenceRoom._onDataTrackUnsubscribed(), ${event.remoteParticipant.sid}, ${event.remoteDataTrack.sid}'); + } + + void _onVideoTrackDisabled(RemoteVideoTrackEvent event) { + print('ConferenceRoom._onVideoTrackDisabled(), ${event.remoteParticipant.sid}, ${event.remoteVideoTrackPublication.trackSid}, isEnabled: ${event.remoteVideoTrackPublication.isTrackEnabled}'); + _setRemoteVideoEnabled(event); + } + + void _onVideoTrackEnabled(RemoteVideoTrackEvent event) { + print('ConferenceRoom._onVideoTrackEnabled(), ${event.remoteParticipant.sid}, ${event.remoteVideoTrackPublication.trackSid}, isEnabled: ${event.remoteVideoTrackPublication.isTrackEnabled}'); + _setRemoteVideoEnabled(event); + } + + void _onVideoTrackPublished(RemoteVideoTrackEvent event) { + print('ConferenceRoom._onVideoTrackPublished(), ${event.remoteParticipant.sid}, ${event.remoteVideoTrackPublication.trackSid}'); + } + + void _onVideoTrackSubscribed(RemoteVideoTrackSubscriptionEvent event) { + print('ConferenceRoom._onVideoTrackSubscribed(), ${event.remoteParticipant.sid}, ${event.remoteVideoTrack.sid}'); + _addOrUpdateParticipant(event); + } + + void _onVideoTrackSubscriptionFailed(RemoteVideoTrackSubscriptionFailedEvent event) { + print('ConferenceRoom._onVideoTrackSubscriptionFailed(), ${event.remoteParticipant.sid}, ${event.remoteVideoTrackPublication.trackSid}'); + _onExceptionStreamController.add( + PlatformException( + code: 'ConferenceRoom.videoTrackSubscriptionFailed', + message: 'VideoTrack Subscription Failed', + details: event.exception.toString(), + ), + ); + } + + void _onVideoTrackUnpublished(RemoteVideoTrackEvent event) { + print('ConferenceRoom._onVideoTrackUnpublished(), ${event.remoteParticipant.sid}, ${event.remoteVideoTrackPublication.trackSid}'); + } + + void _onVideoTrackUnsubscribed(RemoteVideoTrackSubscriptionEvent event) { + print('ConferenceRoom._onVideoTrackUnsubscribed(), ${event.remoteParticipant.sid}, ${event.remoteVideoTrack.sid}'); + } + + void _onMessage(RemoteDataTrackStringMessageEvent event) { + print('onMessage => ${event.remoteDataTrack.sid}, ${event.message}'); + } + + void _onBufferMessage(RemoteDataTrackBufferMessageEvent event) { + print('onBufferMessage => ${event.remoteDataTrack.sid}, ${String.fromCharCodes(event.message.asUint8List())}'); + } + + void _setRemoteAudioEnabled(RemoteAudioTrackEvent event) { + if (event.remoteAudioTrackPublication == null) { + return; + } + var index = _participants.indexWhere((ParticipantWidget participant) => participant.id == event.remoteParticipant.sid); + if (index < 0) { + return; + } + var participant = _participants[index]; + _participants.replaceRange( + index, + index + 1, + [ + participant.copyWith(audioEnabled: event.remoteAudioTrackPublication.isTrackEnabled), + ], + ); + notifyListeners(); + } + + void _setRemoteVideoEnabled(RemoteVideoTrackEvent event) { + if (event.remoteVideoTrackPublication == null) { + return; + } + var index = _participants.indexWhere((ParticipantWidget participant) => participant.id == event.remoteParticipant.sid); + if (index < 0) { + return; + } + var participant = _participants[index]; + _participants.replaceRange( + index, + index + 1, + [ + participant.copyWith(videoEnabled: event.remoteVideoTrackPublication.isTrackEnabled), + ], + ); + notifyListeners(); + } + + void _addOrUpdateParticipant(RemoteParticipantEvent event) { + print('ConferenceRoom._addOrUpdateParticipant(), ${event.remoteParticipant.sid}'); + final participant = _participants.firstWhere( + (ParticipantWidget participant) => participant.id == event.remoteParticipant.sid, + orElse: () => null, + ); + if (participant != null) { + print('Participant found: ${participant.id}, updating A/V enabled values'); + _setRemoteVideoEnabled(event); + _setRemoteAudioEnabled(event); + } else { + final bufferedParticipant = _participantBuffer.firstWhere( + (ParticipantBuffer participant) => participant.id == event.remoteParticipant.sid, + orElse: () => null, + ); + if (bufferedParticipant != null) { + _participantBuffer.remove(bufferedParticipant); + } else if (event is RemoteAudioTrackEvent) { + print('Audio subscription came first, waiting for the video subscription...'); + _participantBuffer.add( + ParticipantBuffer( + id: event.remoteParticipant.sid, + audioEnabled: event.remoteAudioTrackPublication?.remoteAudioTrack?.isEnabled ?? true, + ), + ); + return; + } + if (event is RemoteVideoTrackSubscriptionEvent) { + print('New participant, adding: ${event.remoteParticipant.sid}'); + _participants.insert( + 0, + _buildParticipant( + child: event.remoteVideoTrack.widget(), + id: event.remoteParticipant.sid, + remoteParticipant: event.remoteParticipant, + audioEnabled: bufferedParticipant?.audioEnabled ?? true, + videoEnabled: event.remoteVideoTrackPublication?.remoteVideoTrack?.isEnabled ?? true, + ), + ); + } + notifyListeners(); + } + } +} diff --git a/lib/pages/conference/draggable_publisher.dart b/lib/pages/conference/draggable_publisher.dart new file mode 100644 index 00000000..a1a0b142 --- /dev/null +++ b/lib/pages/conference/draggable_publisher.dart @@ -0,0 +1,173 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:diplomaticquarterapp/pages/conference/clipped_video.dart'; +import 'package:flutter/material.dart'; + +class DraggablePublisher extends StatefulWidget { + final Size availableScreenSize; + final Widget child; + final double scaleFactor; + final Stream onButtonBarVisible; + final Stream onButtonBarHeight; + + const DraggablePublisher({ + Key key, + @required this.availableScreenSize, + this.child, + @required this.onButtonBarVisible, + @required this.onButtonBarHeight, + + /// The portion of the screen the DraggableWidget should use. + this.scaleFactor = .25, + }) : assert(scaleFactor != null && scaleFactor > 0 && scaleFactor <= .4), + assert(availableScreenSize != null), + assert(onButtonBarVisible != null), + assert(onButtonBarHeight != null), + super(key: key); + + @override + _DraggablePublisherState createState() => _DraggablePublisherState(); +} + +class _DraggablePublisherState extends State { + bool _isButtonBarVisible = true; + double _buttonBarHeight = 0; + double _width; + double _height; + double _top; + double _left; + double _viewPaddingTop; + double _viewPaddingBottom; + final double _padding = 8.0; + final Duration _duration300ms = const Duration(milliseconds: 300); + final Duration _duration0ms = const Duration(milliseconds: 0); + Duration _duration; + StreamSubscription _streamSubscription; + StreamSubscription _streamHeightSubscription; + + @override + void initState() { + super.initState(); + _duration = _duration300ms; + _width = widget.availableScreenSize.width * widget.scaleFactor; + _height = _width * (widget.availableScreenSize.height / widget.availableScreenSize.width); + _top = widget.availableScreenSize.height - (_buttonBarHeight + _padding) - _height; + _left = widget.availableScreenSize.width - _padding - _width; + + _streamSubscription = widget.onButtonBarVisible.listen(_buttonBarVisible); + _streamHeightSubscription = widget.onButtonBarHeight.listen(_getButtonBarHeight); + } + + @override + void didChangeDependencies() { + final mediaQuery = MediaQuery.of(context); + _viewPaddingTop = mediaQuery.viewPadding.top; + _viewPaddingBottom = mediaQuery.viewPadding.bottom; + super.didChangeDependencies(); + } + + @override + void dispose() { + _streamSubscription.cancel(); + _streamHeightSubscription.cancel(); + super.dispose(); + } + + void _getButtonBarHeight(double height) { + setState(() { + _buttonBarHeight = height; + _positionWidget(); + }); + } + + void _buttonBarVisible(bool visible) { + if (!mounted) { + return; + } + setState(() { + _isButtonBarVisible = visible; + if (_duration == _duration300ms) { + // only position the widget when we are not currently dragging it around + _positionWidget(); + } + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedPositioned( + top: _top, + left: _left, + width: _width, + height: _height, + duration: _duration, + child: Listener( + onPointerDown: (_) => _duration = _duration0ms, + onPointerMove: (PointerMoveEvent event) { + setState(() { + _left = (_left + event.delta.dx).roundToDouble(); + _top = (_top + event.delta.dy).roundToDouble(); + }); + }, + onPointerUp: (_) => _positionWidget(), + onPointerCancel: (_) => _positionWidget(), + child: ClippedVideo( + height: _height, + width: _width, + child: widget.child, + ), + ), + ); + } + + double _getCurrentStatusBarHeight() { + if (_isButtonBarVisible) { + return _viewPaddingTop; + } + final _defaultViewPaddingTop = Platform.isIOS ? 20.0 : Platform.isAndroid ? 24.0 : 0.0; + if (_viewPaddingTop > _defaultViewPaddingTop) { + // There must be a hardware notch in the display. + return _viewPaddingTop; + } + return 0.0; + } + + double _getCurrentButtonBarHeight() { + if (_isButtonBarVisible) { + return _buttonBarHeight + _viewPaddingBottom; + } + return _viewPaddingBottom; + } + + void _positionWidget() { + // Determine the center of the object being dragged so we can decide + // in which corner the object should be placed. + var dx = (_width / 2) + _left; + dx = dx < 0 ? 0 : dx >= widget.availableScreenSize.width ? widget.availableScreenSize.width - 1 : dx; + var dy = (_height / 2) + _top; + dy = dy < 0 ? 0 : dy >= widget.availableScreenSize.height ? widget.availableScreenSize.height - 1 : dy; + final draggableCenter = Offset(dx, dy); + + setState(() { + _duration = _duration300ms; + if (Rect.fromLTRB(0, 0, widget.availableScreenSize.width / 2, widget.availableScreenSize.height / 2).contains(draggableCenter)) { + // Top-left + _top = _getCurrentStatusBarHeight() + _padding; + _left = _padding; + } else if (Rect.fromLTRB(widget.availableScreenSize.width / 2, 0, widget.availableScreenSize.width, widget.availableScreenSize.height / 2).contains(draggableCenter)) { + // Top-right + _top = _getCurrentStatusBarHeight() + _padding; + _left = widget.availableScreenSize.width - _padding - _width; + } else if (Rect.fromLTRB(0, widget.availableScreenSize.height / 2, widget.availableScreenSize.width / 2, widget.availableScreenSize.height).contains(draggableCenter)) { + // Bottom-left + _top = widget.availableScreenSize.height - (_getCurrentButtonBarHeight() + _padding) - _height; + _left = _padding; + } else if (Rect.fromLTRB(widget.availableScreenSize.width / 2, widget.availableScreenSize.height / 2, widget.availableScreenSize.width, widget.availableScreenSize.height).contains(draggableCenter)) { + // Bottom-right + _top = widget.availableScreenSize.height - (_getCurrentButtonBarHeight() + _padding) - _height; + _left = widget.availableScreenSize.width - _padding - _width; + } + }); + } +} diff --git a/lib/pages/conference/participant_widget.dart b/lib/pages/conference/participant_widget.dart new file mode 100644 index 00000000..5b8d1ff2 --- /dev/null +++ b/lib/pages/conference/participant_widget.dart @@ -0,0 +1,197 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +class ParticipantBuffer { + final bool audioEnabled; + final String id; + + ParticipantBuffer({ + @required this.audioEnabled, + @required this.id, + }) : assert(audioEnabled != null), + assert(id != null); +} + +class ParticipantWidget extends StatelessWidget { + final Widget child; + final String id; + final bool audioEnabled; + final bool videoEnabled; + final bool isRemote; + final bool isDummy; + final bool isDominant; + + const ParticipantWidget({ + Key key, + @required this.child, + @required this.audioEnabled, + @required this.videoEnabled, + @required this.id, + @required this.isRemote, + this.isDominant = false, + this.isDummy = false, + }) : assert(child != null), + assert(audioEnabled != null), + assert(videoEnabled != null), + assert(isRemote != null), + assert(isDominant != null), + assert(isDummy != null), + super(key: key); + + ParticipantWidget copyWith({ + Widget child, + bool audioEnabled, + bool videoEnabled, + bool isDominant, + }) { + return ParticipantWidget( + id: id, + child: child ?? this.child, + audioEnabled: audioEnabled ?? this.audioEnabled, + videoEnabled: videoEnabled ?? this.videoEnabled, + isDominant: isDominant ?? this.isDominant, + isRemote: isRemote, + ); + } + + @override + Widget build(BuildContext context) { + final children = []; + final icons = []; + if (!videoEnabled) { + icons.add(_buildVideoEnabledIcon()); + children.add( + ClipRect( + // Need to clip this BackdropFilter, otherwise it will blur the entire screen + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + decoration: BoxDecoration(color: Colors.black.withOpacity(.1)), + child: child, + ), + ), + ), + ); + } else { + children.add(child); + } + children.add(Padding( + padding: const EdgeInsets.all(8.0), + child: AnimatedOpacity( + duration: Duration(milliseconds: 500), + opacity: isDominant ? 1 : 0, + child: Icon( + Icons.volume_up, + color: Colors.white, + ), + ), + )); + if (!audioEnabled) { + icons.add(_buildAudioEnabledIcon()); + } + if (icons.isNotEmpty) { + if (isRemote) { + final rows = []; + rows.add(_buildRow(icons)); + if (!audioEnabled && !videoEnabled) { + rows.add(_buildRow(_fitText('The camera and microphone are off', Colors.white24))); + } else if (!audioEnabled) { + rows.add(_buildRow(_fitText('The microphone is off', Colors.black26))); + } else if (!videoEnabled) { + rows.add(_buildRow(_fitText('The camera is off', Colors.white24))); + } + children.add( + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: rows, + ), + ); + } else { + children.add(Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: icons, + )); + } + } + + return Stack( + children: children, + ); + } + + List _fitText(String text, Color color) { + return [ + Flexible( + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.only(left: 8.0, right: 8.0), + child: Text(text, maxLines: 1, style: _buildTextStyle(color)), + ), + ), + ), + ]; + } + + TextStyle _buildTextStyle(Color color) { + return TextStyle( + color: color, + shadows: [ + Shadow( + blurRadius: 1.0, + color: Color.fromARGB(255, 0, 0, 0), + ), + Shadow( + blurRadius: 1.0, + color: Color.fromARGB(24, 255, 255, 255), + ), + ], + fontSize: 15, + ); + } + + Widget _buildRow(List children) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: children, + ); + } + + Widget _buildAudioEnabledIcon() { + return Padding( + padding: const EdgeInsets.all(8), + child: CircleAvatar( + maxRadius: 15, + child: FittedBox( + child: Icon( + Icons.mic_off, + color: Colors.black, + key: Key('microphone-off-icon'), + ), + ), + backgroundColor: Colors.white24, + ), + ); + } + + Widget _buildVideoEnabledIcon() { + return Padding( + padding: const EdgeInsets.all(8), + child: CircleAvatar( + maxRadius: 15, + child: FittedBox( + child: Icon( + Icons.videocam_off, + color: Colors.black, + key: Key('videocam-off-icon'), + ), + ), + backgroundColor: Colors.white24, + ), + ); + } +} diff --git a/lib/pages/conference/widgets/button_to_progress.dart b/lib/pages/conference/widgets/button_to_progress.dart new file mode 100644 index 00000000..2879dc60 --- /dev/null +++ b/lib/pages/conference/widgets/button_to_progress.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +class ButtonToProgress extends StatefulWidget { + final double height; + final double progressHeight; + final String loadingText; + final Duration duration; + final TextStyle loadingTextStyle; + final VoidCallback onPressed; + final Stream onLoading; + final Widget child; + + const ButtonToProgress({ + Key key, + this.height = 40.0, + this.progressHeight = 5.0, + this.loadingText, + this.duration = const Duration(milliseconds: 300), + this.loadingTextStyle, + this.onPressed, + this.onLoading, + @required this.child, + }) : assert(child != null), + assert(height != null && height > 0), + assert(progressHeight != null && progressHeight > 0 && progressHeight <= height), + super(key: key); + + @override + _ButtonToProgressState createState() => _ButtonToProgressState(); +} + +class _ButtonToProgressState extends State { + double _height; + double _opacity = 0; + bool _isLoading = false; + + StreamSubscription _subscription; + + @override + void initState() { + super.initState(); + _height = widget.height; + if (widget.onLoading != null) { + _subscription = widget.onLoading.listen((bool isLoading) { + setState(() { + _isLoading = isLoading; + _height = isLoading ? widget.progressHeight : widget.height; + _opacity = isLoading ? 1 : 0; + }); + }); + } + } + + @override + void dispose() { + if (_subscription != null) { + _subscription.cancel(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + height: widget.height, + child: Stack( + children: [ + if (widget.loadingText == null) + Container() + else + Padding( + padding: EdgeInsets.only(bottom: widget.progressHeight), + child: AnimatedOpacity( + child: Center( + child: FittedBox( + child: Text( + widget.loadingText, + style: widget.loadingTextStyle, + ), + ), + ), + opacity: _opacity, + duration: Duration(milliseconds: widget.duration.inMilliseconds + 200), + curve: Curves.easeInCubic, + ), + ), + AnimatedPadding( + duration: widget.duration, + padding: EdgeInsets.only( + top: math.max(widget.height - _height, 0), + ), + child: AnimatedContainer( + duration: widget.duration, + height: _height, + width: double.infinity, + child: _isLoading ? const LinearProgressIndicator() : widget.child, + ), + ), + ], + ), + ); + } +} diff --git a/lib/pages/conference/widgets/circle_button.dart b/lib/pages/conference/widgets/circle_button.dart new file mode 100644 index 00000000..cc4248df --- /dev/null +++ b/lib/pages/conference/widgets/circle_button.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:native_device_orientation/native_device_orientation.dart'; + +class CircleButton extends StatefulWidget { + final VoidCallback onLongPress; + final VoidCallback onPressed; + final GestureTapDownCallback onTapDown; + final VoidCallback onTapCancel; + final Widget child; + final Color color; + final double radius; + + const CircleButton({ + Key key, + this.onLongPress, + this.onPressed, + this.child, + this.color, + this.radius = 25.0, + this.onTapCancel, + this.onTapDown, + }) : assert(radius != null), + super(key: key); + + @override + _CircleButtonState createState() => _CircleButtonState(); +} + +class _CircleButtonState extends State { + double _rotationAngle = 0.0; + + final Stream _orientationStream = NativeDeviceOrientationCommunicator().onOrientationChanged(useSensor: true); + StreamSubscription _orientationSubscription; + + void _handleOrientationChange(NativeDeviceOrientation orientation) { + var targetAngle = 0.0; + switch (orientation) { + case NativeDeviceOrientation.unknown: + case NativeDeviceOrientation.portraitUp: + targetAngle = 0.0; + break; + case NativeDeviceOrientation.portraitDown: + targetAngle = 180.0; + break; + case NativeDeviceOrientation.landscapeLeft: + targetAngle = 90.0; + break; + case NativeDeviceOrientation.landscapeRight: + targetAngle = 270.0; + break; + } + setState(() { + _rotationAngle = targetAngle; + }); + } + + @override + void initState() { + super.initState(); + _orientationSubscription = _orientationStream.listen( + _handleOrientationChange, + onError: (dynamic err) => print(err), + ); + } + + @override + void dispose() { + super.dispose(); + _orientationSubscription.cancel(); + } + + @override + Widget build(BuildContext context) { + final size = 2 * widget.radius; + + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: (widget.color ?? Theme.of(context).primaryColor).withAlpha(200), + borderRadius: BorderRadius.all( + Radius.circular(widget.radius), + ), + ), + child: GestureDetector( + onLongPress: widget.onLongPress, + onTapDown: widget.onTapDown, + onTapCancel: widget.onTapCancel, + child: RawMaterialButton( + onPressed: widget.onPressed, + child: RotationTransition( + child: widget.child, + turns: AlwaysStoppedAnimation(_rotationAngle / 360), + ), + elevation: 0, + shape: const CircleBorder(), + ), + ), + ); + } +} diff --git a/lib/pages/conference/widgets/noise_box.dart b/lib/pages/conference/widgets/noise_box.dart new file mode 100644 index 00000000..5f638127 --- /dev/null +++ b/lib/pages/conference/widgets/noise_box.dart @@ -0,0 +1,144 @@ +import 'dart:math' as math; +import 'dart:ui'; +import 'package:flutter/material.dart'; + +enum NoiseBoxDensity { + high, + medium, + low, + xHigh, + xLow, +} + +class NoiseBox extends StatefulWidget { + final NoiseBoxDensity density; + final Color backgroundColor; + final Widget child; + + const NoiseBox({ + Key key, + this.backgroundColor, + this.child, + this.density = NoiseBoxDensity.medium, + }) : assert(density != null), + super(key: key); + + @override + _NoiseBoxState createState() => _NoiseBoxState(); +} + +class _NoiseBoxState extends State with SingleTickerProviderStateMixin { + AnimationController _animationController; + int _density; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(seconds: 60), + ); + _animationController.repeat(); + switch (widget.density) { + case NoiseBoxDensity.high: + _density = 5; + break; + case NoiseBoxDensity.medium: + _density = 7; + break; + case NoiseBoxDensity.low: + _density = 10; + break; + case NoiseBoxDensity.xHigh: + _density = 3; + break; + case NoiseBoxDensity.xLow: + _density = 12; + break; + } + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) => Container( + color: widget.backgroundColor, + width: constraints.biggest.width, + height: constraints.biggest.height, + child: AnimatedBuilder( + animation: _animationController, + builder: (BuildContext context, Widget w) { + final children = [ + CustomPaint( + painter: NoisePainter( + width: constraints.biggest.width, + height: constraints.biggest.height, + density: _density, + ), + ), + ]; + if (widget.child != null) { + children.add(widget.child); + } + return Stack( + children: children, + ); + }, + ), + ), + ); + } +} + +class NoisePainter extends CustomPainter { + final double width; + final double height; + final int density; + + NoisePainter({ + @required this.width, + @required this.height, + @required this.density, + }) : assert(width != null), + assert(height != null), + assert(density != null && density >= 3 && density < math.min(width, height)); + + List colors = [ + Colors.black, + Colors.grey, + Colors.blueGrey, + Colors.red, + Colors.green, + Colors.blue, + Colors.white, + ]; + + @override + void paint(Canvas canvas, Size size) { + final random = math.Random(); + for (var w = 0; w < width; w += density) { + for (var h = 0; h < height; h += density) { + final offset = Offset( + random.nextDouble() * width, + random.nextDouble() * height, + ); + final paint = Paint(); + paint.color = colors[random.nextInt(colors.length)]; + paint.strokeWidth = random.nextDouble() * 2; + + canvas.drawPoints(PointMode.points, [offset], paint); + } + } + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } +} diff --git a/lib/pages/conference/widgets/platform_alert_dialog.dart b/lib/pages/conference/widgets/platform_alert_dialog.dart new file mode 100644 index 00000000..1adaa223 --- /dev/null +++ b/lib/pages/conference/widgets/platform_alert_dialog.dart @@ -0,0 +1,98 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/cupertino.dart'; + +import './platform_widget.dart'; + +class PlatformAlertDialog extends PlatformWidget { + PlatformAlertDialog({@required this.title, @required this.content, @required this.defaultActionText, this.cancelActionText}) + : assert(title != null), + assert(content != null), + assert(defaultActionText != null); + + final String title; + final String content; + final String defaultActionText; + final String cancelActionText; + + Future show(BuildContext context) async { + return Platform.isIOS + ? await showCupertinoDialog( + context: context, + builder: (BuildContext context) => this, + ) + : await showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) => this, + ); + } + + @override + Widget buildCupertinoWidget(BuildContext context) { + return CupertinoAlertDialog( + title: Text(title), + content: Text(content), + actions: _buildActions(context), + ); + } + + @override + Widget buildMaterialWidget(BuildContext context) { + return AlertDialog( + title: Text(title), + content: Text(content), + actions: _buildActions(context), + ); + } + + List _buildActions(BuildContext context) { + final actions = []; + if (cancelActionText != null) { + actions.add( + PlatformAlertDialogAction( + child: Text(cancelActionText), + onPressed: () { + Navigator.of(context).pop(false); + }, + ), + ); + } + actions.add( + PlatformAlertDialogAction( + child: Text(defaultActionText), + onPressed: () { + Navigator.of(context).pop(true); + }, + ), + ); + return actions; + } +} + +class PlatformAlertDialogAction extends PlatformWidget { + PlatformAlertDialogAction({ + this.child, + this.onPressed, + }); + + final Widget child; + final VoidCallback onPressed; + + @override + Widget buildCupertinoWidget(BuildContext context) { + return CupertinoDialogAction( + child: child, + onPressed: onPressed, + ); + } + + @override + Widget buildMaterialWidget(BuildContext context) { + return FlatButton( + child: child, + onPressed: onPressed, + ); + } +} diff --git a/lib/pages/conference/widgets/platform_exception_alert_dialog.dart b/lib/pages/conference/widgets/platform_exception_alert_dialog.dart new file mode 100644 index 00000000..931a6e10 --- /dev/null +++ b/lib/pages/conference/widgets/platform_exception_alert_dialog.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import './platform_alert_dialog.dart'; + +class PlatformExceptionAlertDialog extends PlatformAlertDialog { + PlatformExceptionAlertDialog({ + String title = 'An error occurred', + @required Exception exception, + }) : super( + title: title, + content: exception is PlatformException ? _message(exception) : exception.toString(), + defaultActionText: 'OK', + ); + + static String _message(PlatformException exception) { + return _errors[exception.code] ?? (exception.details != null ? (exception.details['message'] ?? exception.message) : exception.message); + } + + static final Map _errors = { + 'ERROR_CODE': 'Error description...', + }; +} diff --git a/lib/pages/conference/widgets/platform_widget.dart b/lib/pages/conference/widgets/platform_widget.dart new file mode 100644 index 00000000..5773190f --- /dev/null +++ b/lib/pages/conference/widgets/platform_widget.dart @@ -0,0 +1,16 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; + +abstract class PlatformWidget extends StatelessWidget { + Widget buildCupertinoWidget(BuildContext context); + + Widget buildMaterialWidget(BuildContext context); + + @override + Widget build(BuildContext context) { + if (Platform.isIOS) { + return buildCupertinoWidget(context); + } + return buildMaterialWidget(context); + } +} diff --git a/lib/pages/conference/widgets/responsive_save_area.dart b/lib/pages/conference/widgets/responsive_save_area.dart new file mode 100644 index 00000000..f9a85467 --- /dev/null +++ b/lib/pages/conference/widgets/responsive_save_area.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +typedef ResponsiveBuilder = Widget Function( + BuildContext context, + Size size, +); + +class ResponsiveSafeArea extends StatelessWidget { + const ResponsiveSafeArea({ + @required ResponsiveBuilder builder, + Key key, + }) : responsiveBuilder = builder, + assert(builder != null), + super(key: key); + + final ResponsiveBuilder responsiveBuilder; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return responsiveBuilder( + context, + constraints.biggest, + ); + }, + ), + ); + } +} diff --git a/lib/pages/landing/landing_page.dart b/lib/pages/landing/landing_page.dart index ba1d67ba..0e3ff95c 100644 --- a/lib/pages/landing/landing_page.dart +++ b/lib/pages/landing/landing_page.dart @@ -1,10 +1,13 @@ import 'dart:io'; +import 'package:diplomaticquarterapp/config/config.dart'; import 'package:diplomaticquarterapp/config/shared_pref_kay.dart'; import 'package:diplomaticquarterapp/models/Authentication/authenticated_user.dart'; import 'package:diplomaticquarterapp/models/Authentication/select_device_imei_res.dart'; +import 'package:diplomaticquarterapp/models/LiveCare/IncomingCallData.dart'; import 'package:diplomaticquarterapp/pages/BookAppointment/BookingOptions.dart'; import 'package:diplomaticquarterapp/pages/ToDoList/ToDo.dart'; +import 'package:diplomaticquarterapp/pages/livecare/incoming_call.dart'; import 'package:diplomaticquarterapp/pages/medical/medical_profile_page.dart'; import 'package:diplomaticquarterapp/pages/medical/my_admissions_page.dart'; import 'package:diplomaticquarterapp/services/authentication/auth_provider.dart'; @@ -18,34 +21,23 @@ import 'package:permission_handler/permission_handler.dart'; import 'home_page.dart'; -Future myBackgroundMessageHandler( - Map message) async { - if (message.containsKey('data')) { - // Handle data message - final dynamic data = message['data']; - print(data); - } - - if (message.containsKey('notification')) { - // Handle notification message - final dynamic notification = message['notification']; - print(notification); - } +class LandingPage extends StatefulWidget { + static bool isOpenCallPage = false; - // Or do other work. -} + static IncomingCallData incomingCallData = new IncomingCallData(); -class LandingPage extends StatefulWidget { @override _LandingPageState createState() => _LandingPageState(); } -class _LandingPageState extends State { +class _LandingPageState extends State with WidgetsBindingObserver { int currentTab = 0; PageController pageController; final FirebaseMessaging _firebaseMessaging = FirebaseMessaging(); final authService = new AuthProvider(); + bool isPageNavigated = false; + _changeCurrentTab(int tab) { setState(() { currentTab = tab; @@ -53,34 +45,105 @@ class _LandingPageState extends State { }); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + var route = ModalRoute.of(context); + + if (route != null) { + print(route.settings.name); + } + + setState(() { + print("didChangeAppLifecycleState"); + print('state = $state'); + AppGlobal.context = context; + if (state == AppLifecycleState.resumed) { + print(LandingPage.isOpenCallPage); + if (LandingPage.isOpenCallPage) { + if (!isPageNavigated) { + isPageNavigated = true; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => IncomingCall( + incomingCallData: LandingPage.incomingCallData))) + .then((value) { + isPageNavigated = false; + }); + } + } + } + + if (state == AppLifecycleState.paused) { + isPageNavigated = false; + } + + if (state == AppLifecycleState.inactive) { + isPageNavigated = false; + } + }); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); + setState(() { + AppGlobal.context = context; + }); pageController = PageController(keepPage: true); + _firebaseMessaging.setAutoInitEnabled(true); _firebaseMessaging.getToken().then((String token) { - print("Token: " + token); sharedPref.setString(PUSH_TOKEN, token); if (token != null) { checkUserStatus(token); } requestPermissions(); - //assert(token != null); }); //_firebase Background message handler _firebaseMessaging.configure( onMessage: (Map message) async { print("onMessage: $message"); -// _showItemDialog(message); + + if (message['data'].containsKey("is_call")) { + var route = ModalRoute.of(context); + + if (route != null) { + print(route.settings.name); + } + + Map myMap = + new Map.from(message['data']); + print(myMap); + LandingPage.isOpenCallPage = true; + LandingPage.incomingCallData = IncomingCallData.fromJson(myMap); + if (!isPageNavigated) { + isPageNavigated = true; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => IncomingCall( + incomingCallData: LandingPage.incomingCallData))); + } + } else { + print("Is Call Not Found"); + } }, onBackgroundMessage: Platform.isIOS ? null : myBackgroundMessageHandler, onLaunch: (Map message) async { print("onLaunch: $message"); -// _navigateToItemDetail(message); }, onResume: (Map message) async { print("onResume: $message"); -// _navigateToItemDetail(message); }, ); } @@ -164,6 +227,25 @@ class _LandingPageState extends State { } } + static Future myBackgroundMessageHandler( + Map message) async { + Map myMap = new Map.from(message['data']); + print(myMap); + + print("myBackgroundMessageHandler Out"); + if (message.containsKey('data')) { + print("myBackgroundMessageHandler Inside"); + LandingPage.incomingCallData = IncomingCallData.fromJson(myMap); + print(LandingPage.incomingCallData.doctorname); + LandingPage.isOpenCallPage = true; + } + + if (message.containsKey('notification')) { + final dynamic notification = message['notification']; + print(notification); + } + } + void setUserValues(value) async { sharedPref.setObject(IMEI_USER_DATA, value); } diff --git a/lib/pages/livecare/incoming_call.dart b/lib/pages/livecare/incoming_call.dart new file mode 100644 index 00000000..ef6c3ac3 --- /dev/null +++ b/lib/pages/livecare/incoming_call.dart @@ -0,0 +1,255 @@ +import 'package:diplomaticquarterapp/models/LiveCare/IncomingCallData.dart'; +import 'package:diplomaticquarterapp/models/LiveCare/room_model.dart'; +import 'package:diplomaticquarterapp/pages/conference/conference_page.dart'; +import 'package:diplomaticquarterapp/pages/conference/widgets/platform_exception_alert_dialog.dart'; +import 'package:diplomaticquarterapp/widgets/others/app_scaffold_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:just_audio/just_audio.dart'; + +class IncomingCall extends StatefulWidget { + IncomingCallData incomingCallData; + + IncomingCall({@required this.incomingCallData}); + + @override + _IncomingCallState createState() => _IncomingCallState(); +} + +class _IncomingCallState extends State + with SingleTickerProviderStateMixin { + AnimationController _animationController; + + final player = AudioPlayer(); + + @override + void initState() { + _animationController = + AnimationController(vsync: this, duration: Duration(milliseconds: 500)); + + WidgetsBinding.instance.addPostFrameCallback((_) => _runAnimation()); + + print(widget.incomingCallData.doctorname); + print(widget.incomingCallData.clinicname); + print(widget.incomingCallData.speciality); + + super.initState(); + } + + @override + void dispose() { + _animationController.dispose(); + player.stop(); + disposeAudioResources(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + isShowAppBar: false, + body: SafeArea( + child: Container( + decoration: BoxDecoration(color: Colors.grey[700]), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Container( + margin: EdgeInsets.only(top: 30.0), + alignment: Alignment.center, + child: Text("Incoming Video Call", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 26.0, + color: Colors.white, + letterSpacing: 1.0)), + ), + Container( + alignment: Alignment.center, + margin: EdgeInsets.fromLTRB(50.0, 30.0, 50.0, 20.0), + child: Image.asset( + 'assets/images/new-design/hmg_full_logo_hd_white.png'), + ), + Container( + margin: EdgeInsets.fromLTRB(30.0, 10.0, 30.0, 0.0), + child: Divider( + color: Colors.white, + thickness: 1.0, + ), + ), + Container( + margin: EdgeInsets.only(top: 20.0), + alignment: Alignment.center, + child: Text("Dr Eyad Ismail Abu Jayab", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22.0, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + color: Colors.white)), + ), + Container( + margin: EdgeInsets.only(top: 10.0), + alignment: Alignment.center, + child: Text("ENT Clinic", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22.0, + letterSpacing: 0.8, + color: Colors.white)), + ), + Container( + margin: EdgeInsets.only(top: 10.0), + alignment: Alignment.center, + child: Text("Speciality", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 22.0, + letterSpacing: 0.8, + color: Colors.white)), + ), + Container( + decoration: BoxDecoration( + color: Colors.grey[900].withOpacity(0.8), + borderRadius: BorderRadius.all(Radius.circular(10.0)), + ), + padding: EdgeInsets.all(20.0), + margin: EdgeInsets.only(top: 20.0), + child: Column( + children: [ + Text("Appointment Information", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.bold, + letterSpacing: 1.0, + color: Colors.white)), + Container( + margin: EdgeInsets.only(top: 20.0), + child: Text("Sun, 15th Dec, 2019, 09:00", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20.0, + letterSpacing: 1.0, + color: Colors.white)), + ), + Container( + margin: EdgeInsets.only(top: 20.0), + child: Text("ENT Clinic", + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 20.0, + letterSpacing: 1.0, + color: Colors.white)), + ), + ], + ), + ), + Container( + margin: EdgeInsets.only(top: 100.0), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + RotationTransition( + turns: Tween(begin: 0.0, end: -.1) + .chain(CurveTween(curve: Curves.elasticIn)) + .animate(_animationController), + child: Container( + child: RawMaterialButton( + onPressed: () { + _submit(); + }, + elevation: 2.0, + fillColor: Colors.green, + child: Icon( + Icons.call, + color: Colors.white, + size: 35.0, + ), + padding: EdgeInsets.all(15.0), + shape: CircleBorder(), + ), + )), + Container( + child: RawMaterialButton( + onPressed: () { + backToHome(); + }, + elevation: 2.0, + fillColor: Colors.red, + child: Icon( + Icons.call_end, + color: Colors.white, + size: 35.0, + ), + padding: EdgeInsets.all(15.0), + shape: CircleBorder(), + ), + ), + ], + ), + ), + ], + )), + ), + ); + } + + void _runAnimation() async { + setAudioFile(); + for (int i = 0; i < 100; i++) { + await _animationController.forward(); + await _animationController.reverse(); + } + } + + Future _submit() async { + backToHome(); + try { + final roomModel = RoomModel( + name: widget.incomingCallData.name, + token: widget.incomingCallData.sessionId, + identity: widget.incomingCallData.identity); + + await Navigator.of(context).push( + MaterialPageRoute( + fullscreenDialog: true, + builder: (BuildContext context) => + ConferencePage(roomModel: roomModel), + ), + ); + } catch (err) { + print(err); + await PlatformExceptionAlertDialog( + exception: err, + ).show(context); + } + } + + void backToHome() { + player.stop(); + disposeAudioResources(); + Navigator.of(context).pop(); + } + + disposeAudioResources() async { + await player.dispose(); + } + + void setAudioFile() async { + player.stop(); + await player.setVolume(1.0); // full volume + try { + await player.setAsset('assets/sounds/ring_60Sec.mp3').then((value) { + player.setLoopMode(LoopMode.one); // loop ring sound + player.play(); + }).catchError((err) { + print("Error: $err"); + }); + } catch (e) { + print("Error: $e"); + } + } +} diff --git a/lib/routes.dart b/lib/routes.dart index d4e38a9a..eb9721df 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -1,5 +1,6 @@ import 'package:diplomaticquarterapp/pages/family/my-family.dart'; import 'package:diplomaticquarterapp/pages/landing/landing_page.dart'; +import 'package:diplomaticquarterapp/pages/livecare/livecare_home.dart'; import 'package:diplomaticquarterapp/pages/login/confirm-login.dart'; import 'package:diplomaticquarterapp/pages/login/forgot-password.dart'; import 'package:diplomaticquarterapp/pages/login/register-info.dart'; @@ -21,6 +22,7 @@ const String REGISTER = 'register'; const String CONFIRM_LOGIN = 'confrim-login'; const String REGISTER_INFO = 'register-info'; const String MY_FAMILIY = 'my-family'; +const String LIVE_CARE = 'live-care'; var routes = { // ROOT: (_) => RootPage(), HOME: (_) => LandingPage(), @@ -31,5 +33,6 @@ var routes = { REGISTER: (_) => Register(), CONFIRM_LOGIN: (_) => ConfirmLogin(), REGISTER_INFO: (_) => RegisterInfo(), - MY_FAMILIY: (_) => MyFamily() + MY_FAMILIY: (_) => MyFamily(), + LIVE_CARE: (_) => LiveCareHome() }; diff --git a/lib/uitl/navigation_service.dart b/lib/uitl/navigation_service.dart new file mode 100644 index 00000000..d4cd426b --- /dev/null +++ b/lib/uitl/navigation_service.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class NavigationService { + final GlobalKey navigatorKey = + new GlobalKey(); + + Future navigateTo(String routeName) { + return navigatorKey.currentState.pushNamed(routeName); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index e91e58a6..ca6963a8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -38,7 +38,7 @@ dependencies: url_launcher: ^5.5.0 shared_preferences: ^0.5.8 flutter_flexible_toast: ^0.1.4 - firebase_messaging: ^6.0.16 + firebase_messaging: 6.0.12 # Progress bar progress_hud_v2: ^2.0.0 @@ -92,10 +92,19 @@ dependencies: smart_progress_bar: ^0.1.6 + #Just Audio to play ringing for incoming video call + just_audio: ^0.3.4 + #hijri hijri: ^2.0.3 - + #Dependencies for video call implementation + native_device_orientation: ^0.3.0 + enum_to_string: ^1.0.9 +# recase: ^3.0.0 + wakelock: ^0.1.4 + after_layout: ^1.0.7 + twilio_programmable_video: ^0.5.0+3 dev_dependencies: flutter_test: @@ -114,6 +123,7 @@ flutter: - assets/images/new-design/ - assets/images/login/ - assets/json/ + - assets/sounds/ fonts: