✨ Optimized UI of call actions
This commit is contained in:
		| @@ -101,8 +101,9 @@ class NotifyProvider extends ChangeNotifier { | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void clearNonRealtime() { | ||||
|   void clearRealtime() { | ||||
|     notifications = notifications.where((x) => !x.isRealtime).toList(); | ||||
|     notifyListeners(); | ||||
|   } | ||||
|  | ||||
|   void allRead() { | ||||
|   | ||||
| @@ -11,6 +11,7 @@ import 'package:solian/utils/service_url.dart'; | ||||
| import 'package:solian/widgets/chat/call/controls.dart'; | ||||
| import 'package:solian/widgets/chat/call/exts.dart'; | ||||
| import 'package:solian/widgets/chat/call/participant.dart'; | ||||
| import 'package:solian/widgets/chat/call/participant_menu.dart'; | ||||
| import 'package:solian/widgets/indent_wrapper.dart'; | ||||
| import 'package:permission_handler/permission_handler.dart'; | ||||
| import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||
| @@ -49,9 +50,10 @@ class _ChatCallState extends State<ChatCall> { | ||||
|   late EventsListener<RoomEvent> _callListener; | ||||
|  | ||||
|   List<ParticipantTrack> _participantTracks = []; | ||||
|   ParticipantTrack? _focusParticipant; | ||||
|  | ||||
|   Future<void> checkPermissions() async { | ||||
|     if(lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return; | ||||
|     if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return; | ||||
|  | ||||
|     await Permission.camera.request(); | ||||
|     await Permission.microphone.request(); | ||||
| @@ -275,6 +277,7 @@ class _ChatCallState extends State<ChatCall> { | ||||
|  | ||||
|     setState(() { | ||||
|       _participantTracks = [...screenTracks, localTrack, ...userMediaTrackList]; | ||||
|       _focusParticipant ??= _participantTracks.first; | ||||
|     }); | ||||
|   } | ||||
|  | ||||
| @@ -386,8 +389,11 @@ class _ChatCallState extends State<ChatCall> { | ||||
|                   Expanded( | ||||
|                     child: Container( | ||||
|                       color: Theme.of(context).colorScheme.surfaceVariant, | ||||
|                       child: _participantTracks.isNotEmpty | ||||
|                           ? ParticipantWidget.widgetFor(_participantTracks.first) | ||||
|                       child: _focusParticipant != null | ||||
|                           ? InteractiveParticipantWidget( | ||||
|                               participant: _focusParticipant!, | ||||
|                               onTap: () {}, | ||||
|                             ) | ||||
|                           : Container(), | ||||
|                     ), | ||||
|                   ), | ||||
| @@ -399,22 +405,34 @@ class _ChatCallState extends State<ChatCall> { | ||||
|                 right: 0, | ||||
|                 top: 0, | ||||
|                 child: SizedBox( | ||||
|                   height: 120 + 16, | ||||
|                   height: 128, | ||||
|                   child: ListView.builder( | ||||
|                     scrollDirection: Axis.horizontal, | ||||
|                     itemCount: math.max(0, _participantTracks.length - 1), | ||||
|                     itemBuilder: (BuildContext context, int index) => Padding( | ||||
|                       padding: const EdgeInsets.all(8.0), | ||||
|                       child: ClipRRect( | ||||
|                         borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                         child: Container( | ||||
|                           width: 120, | ||||
|                           height: 120, | ||||
|                           color: Theme.of(context).cardColor, | ||||
|                           child: ParticipantWidget.widgetFor(_participantTracks[index + 1]), | ||||
|                     itemCount: math.max(0, _participantTracks.length), | ||||
|                     itemBuilder: (BuildContext context, int index) { | ||||
|                       final track = _participantTracks[index]; | ||||
|                       if (track.participant.sid == _focusParticipant?.participant.sid) { | ||||
|                         return Container(); | ||||
|                       } | ||||
|  | ||||
|                       return Padding( | ||||
|                         padding: const EdgeInsets.only(top: 8, left: 8), | ||||
|                         child: ClipRRect( | ||||
|                           borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||
|                           child: InteractiveParticipantWidget( | ||||
|                             width: 120, | ||||
|                             height: 120, | ||||
|                             color: Theme.of(context).cardColor, | ||||
|                             participant: track, | ||||
|                             onTap: () { | ||||
|                               if (track.participant.sid != _focusParticipant?.participant.sid) { | ||||
|                                 setState(() => _focusParticipant = track); | ||||
|                               } | ||||
|                             }, | ||||
|                           ), | ||||
|                         ), | ||||
|                       ), | ||||
|                     ), | ||||
|                       ); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|               ), | ||||
| @@ -443,3 +461,44 @@ class _ChatCallState extends State<ChatCall> { | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class InteractiveParticipantWidget extends StatelessWidget { | ||||
|   final double? width; | ||||
|   final double? height; | ||||
|   final Color? color; | ||||
|   final ParticipantTrack participant; | ||||
|   final Function() onTap; | ||||
|  | ||||
|   const InteractiveParticipantWidget({ | ||||
|     super.key, | ||||
|     this.width, | ||||
|     this.height, | ||||
|     this.color, | ||||
|     required this.participant, | ||||
|     required this.onTap, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return InkWell( | ||||
|       child: Container( | ||||
|         width: width, | ||||
|         height: height, | ||||
|         color: color, | ||||
|         child: ParticipantWidget.widgetFor(participant), | ||||
|       ), | ||||
|       onTap: () => onTap(), | ||||
|       onLongPress: () { | ||||
|         if (participant.participant is LocalParticipant) return; | ||||
|         showModalBottomSheet( | ||||
|           context: context, | ||||
|           builder: (context) => ParticipantMenu( | ||||
|             participant: participant.participant as RemoteParticipant, | ||||
|             videoTrack: participant.videoTrack, | ||||
|             isScreenShare: participant.isScreenShare, | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -21,8 +21,6 @@ class _NotificationScreenState extends State<NotificationScreen> { | ||||
|     final auth = context.read<AuthProvider>(); | ||||
|     final nty = context.watch<NotifyProvider>(); | ||||
|  | ||||
|     WidgetsBinding.instance.addPostFrameCallback((_) => nty.allRead()); | ||||
|  | ||||
|     return IndentWrapper( | ||||
|       noSafeArea: true, | ||||
|       hideDrawer: true, | ||||
| @@ -71,6 +69,14 @@ class _NotificationScreenState extends State<NotificationScreen> { | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void dispose() { | ||||
|     final nty = context.read<NotifyProvider>(); | ||||
|     nty.allRead(); | ||||
|     nty.clearRealtime(); | ||||
|     super.dispose(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| class NotificationItem extends StatelessWidget { | ||||
|   | ||||
| @@ -60,6 +60,7 @@ class _NoContentWidgetState extends State<NoContentWidget> with SingleTickerProv | ||||
|             ], | ||||
|             child: AccountAvatar( | ||||
|               source: widget.userinfo!.avatar, | ||||
|               backgroundColor: Colors.transparent, | ||||
|               radius: radius, | ||||
|               direct: true, | ||||
|             )), | ||||
|   | ||||
| @@ -78,8 +78,6 @@ class RemoteParticipantWidget extends ParticipantWidget { | ||||
| } | ||||
|  | ||||
| abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends State<T> { | ||||
|   bool _visible = true; | ||||
|  | ||||
|   VideoTrack? get _activeVideoTrack; | ||||
|  | ||||
|   TrackPublication? get _videoPublication; | ||||
| @@ -117,24 +115,19 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   List<Widget> extraWidgets(bool isScreenShare) => []; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext ctx) { | ||||
|     return Stack( | ||||
|       children: [ | ||||
|         InkWell( | ||||
|           onTap: () => setState(() => _visible = !_visible), | ||||
|           child: _activeVideoTrack != null && !_activeVideoTrack!.muted | ||||
|               ? VideoTrackRenderer( | ||||
|                   _activeVideoTrack!, | ||||
|                   fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, | ||||
|                 ) | ||||
|               : NoContentWidget( | ||||
|                   userinfo: _userinfoMetadata, | ||||
|                   isSpeaking: widget.participant.isSpeaking, | ||||
|                 ), | ||||
|         ), | ||||
|         _activeVideoTrack != null && !_activeVideoTrack!.muted | ||||
|             ? VideoTrackRenderer( | ||||
|                 _activeVideoTrack!, | ||||
|                 fit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, | ||||
|               ) | ||||
|             : NoContentWidget( | ||||
|                 userinfo: _userinfoMetadata, | ||||
|                 isSpeaking: widget.participant.isSpeaking, | ||||
|               ), | ||||
|         if (widget.showStatsLayer) | ||||
|           Positioned( | ||||
|             top: 30, | ||||
| @@ -149,10 +142,9 @@ abstract class _ParticipantWidgetState<T extends ParticipantWidget> extends Stat | ||||
|             crossAxisAlignment: CrossAxisAlignment.stretch, | ||||
|             mainAxisSize: MainAxisSize.min, | ||||
|             children: [ | ||||
|               ...extraWidgets(widget.isScreenShare), | ||||
|               ParticipantInfoWidget( | ||||
|                 title: widget.participant.name.isNotEmpty | ||||
|                     ? '${widget.participant.name} (${widget.participant.identity})' | ||||
|                     ? widget.participant.name | ||||
|                     : widget.participant.identity, | ||||
|                 audioAvailable: _firstAudioPublication?.muted == false && _firstAudioPublication?.subscribed == true, | ||||
|                 connectionQuality: widget.participant.connectionQuality, | ||||
| @@ -190,143 +182,4 @@ class _RemoteParticipantWidgetState extends _ParticipantWidgetState<RemotePartic | ||||
|  | ||||
|   @override | ||||
|   VideoTrack? get _activeVideoTrack => widget.videoTrack; | ||||
|  | ||||
|   @override | ||||
|   List<Widget> extraWidgets(bool isScreenShare) => [ | ||||
|         Row( | ||||
|           mainAxisSize: MainAxisSize.max, | ||||
|           mainAxisAlignment: MainAxisAlignment.end, | ||||
|           children: [ | ||||
|             // Menu for RemoteTrackPublication<RemoteAudioTrack> | ||||
|             if (_firstAudioPublication != null && !isScreenShare) | ||||
|               RemoteTrackPublicationMenuWidget( | ||||
|                 pub: _firstAudioPublication!, | ||||
|                 icon: Icons.volume_up, | ||||
|               ), | ||||
|             if (_videoPublication != null) | ||||
|               RemoteTrackPublicationMenuWidget( | ||||
|                 pub: _videoPublication!, | ||||
|                 icon: isScreenShare ? Icons.monitor : Icons.videocam, | ||||
|               ), | ||||
|             if (_videoPublication != null) | ||||
|               RemoteTrackFPSMenuWidget( | ||||
|                 pub: _videoPublication!, | ||||
|                 icon: Icons.menu, | ||||
|               ), | ||||
|             if (_videoPublication != null) | ||||
|               RemoteTrackQualityMenuWidget( | ||||
|                 pub: _videoPublication!, | ||||
|                 icon: Icons.monitor_outlined, | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|       ]; | ||||
| } | ||||
|  | ||||
| class RemoteTrackPublicationMenuWidget extends StatelessWidget { | ||||
|   final IconData icon; | ||||
|   final RemoteTrackPublication pub; | ||||
|  | ||||
|   const RemoteTrackPublicationMenuWidget({ | ||||
|     required this.pub, | ||||
|     required this.icon, | ||||
|     super.key, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) => Material( | ||||
|         color: Theme.of(context).colorScheme.onSurface.withOpacity(0.75), | ||||
|         child: PopupMenuButton<Function>( | ||||
|           tooltip: 'Subscribe menu', | ||||
|           icon: Icon(icon, | ||||
|               color: { | ||||
|                 TrackSubscriptionState.notAllowed: Colors.red, | ||||
|                 TrackSubscriptionState.unsubscribed: Colors.grey, | ||||
|                 TrackSubscriptionState.subscribed: Colors.green, | ||||
|               }[pub.subscriptionState]), | ||||
|           onSelected: (value) => value(), | ||||
|           itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[ | ||||
|             if (pub.subscribed == false) | ||||
|               PopupMenuItem( | ||||
|                 child: const Text('Subscribe'), | ||||
|                 value: () => pub.subscribe(), | ||||
|               ) | ||||
|             else if (pub.subscribed == true) | ||||
|               PopupMenuItem( | ||||
|                 child: const Text('Un-subscribe'), | ||||
|                 value: () => pub.unsubscribe(), | ||||
|               ), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
| } | ||||
|  | ||||
| class RemoteTrackFPSMenuWidget extends StatelessWidget { | ||||
|   final IconData icon; | ||||
|   final RemoteTrackPublication pub; | ||||
|  | ||||
|   const RemoteTrackFPSMenuWidget({ | ||||
|     required this.pub, | ||||
|     required this.icon, | ||||
|     super.key, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) => Material( | ||||
|         color: Colors.black.withOpacity(0.3), | ||||
|         child: PopupMenuButton<Function>( | ||||
|           tooltip: 'Preferred FPS', | ||||
|           icon: Icon(icon, color: Colors.white), | ||||
|           onSelected: (value) => value(), | ||||
|           itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[ | ||||
|             PopupMenuItem( | ||||
|               child: const Text('30'), | ||||
|               value: () => pub.setVideoFPS(30), | ||||
|             ), | ||||
|             PopupMenuItem( | ||||
|               child: const Text('15'), | ||||
|               value: () => pub.setVideoFPS(15), | ||||
|             ), | ||||
|             PopupMenuItem( | ||||
|               child: const Text('8'), | ||||
|               value: () => pub.setVideoFPS(8), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
| } | ||||
|  | ||||
| class RemoteTrackQualityMenuWidget extends StatelessWidget { | ||||
|   final IconData icon; | ||||
|   final RemoteTrackPublication pub; | ||||
|  | ||||
|   const RemoteTrackQualityMenuWidget({ | ||||
|     required this.pub, | ||||
|     required this.icon, | ||||
|     super.key, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) => Material( | ||||
|         color: Colors.black.withOpacity(0.3), | ||||
|         child: PopupMenuButton<Function>( | ||||
|           tooltip: 'Preferred Quality', | ||||
|           icon: Icon(icon, color: Colors.white), | ||||
|           onSelected: (value) => value(), | ||||
|           itemBuilder: (BuildContext context) => <PopupMenuEntry<Function>>[ | ||||
|             PopupMenuItem( | ||||
|               child: const Text('HIGH'), | ||||
|               value: () => pub.setVideoQuality(VideoQuality.HIGH), | ||||
|             ), | ||||
|             PopupMenuItem( | ||||
|               child: const Text('MEDIUM'), | ||||
|               value: () => pub.setVideoQuality(VideoQuality.MEDIUM), | ||||
|             ), | ||||
|             PopupMenuItem( | ||||
|               child: const Text('LOW'), | ||||
|               value: () => pub.setVideoQuality(VideoQuality.LOW), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										148
									
								
								lib/widgets/chat/call/participant_menu.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										148
									
								
								lib/widgets/chat/call/participant_menu.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,148 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:livekit_client/livekit_client.dart'; | ||||
| import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||
|  | ||||
| class ParticipantMenu extends StatefulWidget { | ||||
|   final RemoteParticipant participant; | ||||
|   final VideoTrack? videoTrack; | ||||
|   final bool isScreenShare; | ||||
|   final bool showStatsLayer; | ||||
|  | ||||
|   const ParticipantMenu({ | ||||
|     super.key, | ||||
|     required this.participant, | ||||
|     this.videoTrack, | ||||
|     this.isScreenShare = false, | ||||
|     this.showStatsLayer = false, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   State<ParticipantMenu> createState() => _ParticipantMenuState(); | ||||
| } | ||||
|  | ||||
| class _ParticipantMenuState extends State<ParticipantMenu> { | ||||
|   @override | ||||
|   RemoteTrackPublication<RemoteVideoTrack>? get _videoPublication => | ||||
|       widget.participant.videoTrackPublications.where((element) => element.sid == widget.videoTrack?.sid).firstOrNull; | ||||
|  | ||||
|   @override | ||||
|   RemoteTrackPublication<RemoteAudioTrack>? get _firstAudioPublication => | ||||
|       widget.participant.audioTrackPublications.firstOrNull; | ||||
|  | ||||
|   @override | ||||
|   VideoTrack? get _activeVideoTrack => widget.videoTrack; | ||||
|  | ||||
|   void tookAction() { | ||||
|     if (Navigator.canPop(context)) { | ||||
|       Navigator.pop(context); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Column( | ||||
|       crossAxisAlignment: CrossAxisAlignment.start, | ||||
|       children: [ | ||||
|         Container( | ||||
|           padding: const EdgeInsets.only(left: 8, right: 8, top: 20, bottom: 12), | ||||
|           child: Padding( | ||||
|             padding: const EdgeInsets.symmetric( | ||||
|               horizontal: 8, | ||||
|               vertical: 12, | ||||
|             ), | ||||
|             child: Text( | ||||
|               AppLocalizations.of(context)!.action, | ||||
|               style: Theme.of(context).textTheme.headlineSmall, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         Expanded( | ||||
|           child: ListView( | ||||
|             children: [ | ||||
|               if (_firstAudioPublication != null && !widget.isScreenShare) | ||||
|                 ListTile( | ||||
|                   leading: Icon( | ||||
|                     Icons.volume_up, | ||||
|                     color: { | ||||
|                       TrackSubscriptionState.notAllowed: Theme.of(context).colorScheme.error, | ||||
|                       TrackSubscriptionState.unsubscribed: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), | ||||
|                       TrackSubscriptionState.subscribed: Theme.of(context).colorScheme.primary, | ||||
|                     }[_firstAudioPublication!.subscriptionState], | ||||
|                   ), | ||||
|                   title: Text( | ||||
|                     _firstAudioPublication!.subscribed | ||||
|                         ? AppLocalizations.of(context)!.chatCallMute | ||||
|                         : AppLocalizations.of(context)!.chatCallUnMute, | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     if (_firstAudioPublication!.subscribed) { | ||||
|                       _firstAudioPublication!.unsubscribe(); | ||||
|                     } else { | ||||
|                       _firstAudioPublication!.subscribe(); | ||||
|                     } | ||||
|                     tookAction(); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (_videoPublication != null) | ||||
|                 ListTile( | ||||
|                   leading: Icon( | ||||
|                     widget.isScreenShare ? Icons.monitor : Icons.videocam, | ||||
|                     color: { | ||||
|                       TrackSubscriptionState.notAllowed: Theme.of(context).colorScheme.error, | ||||
|                       TrackSubscriptionState.unsubscribed: Theme.of(context).colorScheme.onSurface.withOpacity(0.6), | ||||
|                       TrackSubscriptionState.subscribed: Theme.of(context).colorScheme.primary, | ||||
|                     }[_videoPublication!.subscriptionState], | ||||
|                   ), | ||||
|                   title: Text( | ||||
|                     _videoPublication!.subscribed | ||||
|                         ? AppLocalizations.of(context)!.chatCallVideoOff | ||||
|                         : AppLocalizations.of(context)!.chatCallVideoOn, | ||||
|                   ), | ||||
|                   onTap: () { | ||||
|                     if (_videoPublication!.subscribed) { | ||||
|                       _videoPublication!.unsubscribe(); | ||||
|                     } else { | ||||
|                       _videoPublication!.subscribe(); | ||||
|                     } | ||||
|                     tookAction(); | ||||
|                   }, | ||||
|                 ), | ||||
|               if (_videoPublication != null) const Divider(thickness: 0.3), | ||||
|               if (_videoPublication != null) | ||||
|                 ...[30, 15, 8].map( | ||||
|                   (x) => ListTile( | ||||
|                     leading: Icon( | ||||
|                       _videoPublication?.fps == x ? Icons.check_box_outlined : Icons.check_box_outline_blank, | ||||
|                     ), | ||||
|                     title: Text('Set preferred frame-per-second to $x'), | ||||
|                     onTap: () { | ||||
|                       _videoPublication!.setVideoFPS(x); | ||||
|                       tookAction(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|               if (_videoPublication != null) const Divider(thickness: 0.3), | ||||
|               if (_videoPublication != null) | ||||
|                 ...[ | ||||
|                   ('High', VideoQuality.HIGH), | ||||
|                   ('Medium', VideoQuality.MEDIUM), | ||||
|                   ('Low', VideoQuality.LOW), | ||||
|                 ].map( | ||||
|                   (x) => ListTile( | ||||
|                     leading: Icon( | ||||
|                       _videoPublication?.videoQuality == x.$2 ? Icons.check_box_outlined : Icons.check_box_outline_blank, | ||||
|                     ), | ||||
|                     title: Text('Set preferred quality to ${x.$1}'), | ||||
|                     onTap: () { | ||||
|                       _videoPublication!.setVideoQuality(x.$2); | ||||
|                       tookAction(); | ||||
|                     }, | ||||
|                   ), | ||||
|                 ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user