✨ Floating call widgets
This commit is contained in:
		| @@ -78,6 +78,7 @@ | |||||||
|   "chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!", |   "chatChannelDeleteConfirm": "Are you sure you want to delete this channel? All messages in this channel will be gone forever. This operation cannot be revert!", | ||||||
|   "chatCall": "Call", |   "chatCall": "Call", | ||||||
|   "chatCallOngoing": "A call is ongoing", |   "chatCallOngoing": "A call is ongoing", | ||||||
|  |   "chatCallOngoingShort": "Ongoing", | ||||||
|   "chatCallJoin": "Join", |   "chatCallJoin": "Join", | ||||||
|   "chatCallMute": "Mute", |   "chatCallMute": "Mute", | ||||||
|   "chatCallUnMute": "Un-mute", |   "chatCallUnMute": "Un-mute", | ||||||
| @@ -86,6 +87,8 @@ | |||||||
|   "chatCallVideoFlip": "Flip Camera", |   "chatCallVideoFlip": "Flip Camera", | ||||||
|   "chatCallScreenOn": "Start Screen Share", |   "chatCallScreenOn": "Start Screen Share", | ||||||
|   "chatCallScreenOff": "Stop Screen Share", |   "chatCallScreenOff": "Stop Screen Share", | ||||||
|  |   "chatCallDisconnect": "Disconnect", | ||||||
|  |   "chatCallDisconnectConfirm": "Are you sure you want to disconnect? You can reconnect after this if you want.", | ||||||
|   "chatCallChangeSpeaker": "Change Speaker", |   "chatCallChangeSpeaker": "Change Speaker", | ||||||
|   "chatMessagePlaceholder": "Write a message...", |   "chatMessagePlaceholder": "Write a message...", | ||||||
|   "chatMessageEditNotify": "You are about editing a message.", |   "chatMessageEditNotify": "You are about editing a message.", | ||||||
|   | |||||||
| @@ -86,7 +86,10 @@ | |||||||
|   "chatCallScreenOff": "停止屏幕分享", |   "chatCallScreenOff": "停止屏幕分享", | ||||||
|   "chatCallChangeSpeaker": "切换扬声器", |   "chatCallChangeSpeaker": "切换扬声器", | ||||||
|   "chatCallOngoing": "一则通话正在进行中", |   "chatCallOngoing": "一则通话正在进行中", | ||||||
|  |   "chatCallOngoingShort": "进行中", | ||||||
|   "chatCallJoin": "加入", |   "chatCallJoin": "加入", | ||||||
|  |   "chatCallDisconnect": "断开连接", | ||||||
|  |   "chatCallDisconnectConfirm": "你确定你要断开连接吗?你可以之后在任何时候重新连接。", | ||||||
|   "chatMessagePlaceholder": "发条消息……", |   "chatMessagePlaceholder": "发条消息……", | ||||||
|   "chatMessageEditNotify": "你正在编辑信息中……", |   "chatMessageEditNotify": "你正在编辑信息中……", | ||||||
|   "chatMessageReplyNotify": "你正在回复消息中……", |   "chatMessageReplyNotify": "你正在回复消息中……", | ||||||
|   | |||||||
| @@ -9,6 +9,7 @@ import 'package:solian/router.dart'; | |||||||
| import 'package:solian/utils/timeago.dart'; | import 'package:solian/utils/timeago.dart'; | ||||||
| import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||||
| import 'package:solian/utils/video_player.dart'; | import 'package:solian/utils/video_player.dart'; | ||||||
|  | import 'package:solian/widgets/chat/call/call_overlay.dart'; | ||||||
| import 'package:solian/widgets/notification_notifier.dart'; | import 'package:solian/widgets/notification_notifier.dart'; | ||||||
|  |  | ||||||
| void main() { | void main() { | ||||||
| @@ -36,21 +37,22 @@ class SolianApp extends StatelessWidget { | |||||||
|       supportedLocales: AppLocalizations.supportedLocales, |       supportedLocales: AppLocalizations.supportedLocales, | ||||||
|       routerConfig: router, |       routerConfig: router, | ||||||
|       builder: (context, child) { |       builder: (context, child) { | ||||||
|         return Overlay( |         return MultiProvider( | ||||||
|           initialEntries: [ |           providers: [ | ||||||
|             OverlayEntry(builder: (context) { |             Provider(create: (_) => NavigationProvider()), | ||||||
|               return MultiProvider( |             ChangeNotifierProvider(create: (_) => AuthProvider()), | ||||||
|                 providers: [ |             ChangeNotifierProvider(create: (_) => ChatProvider()), | ||||||
|                   Provider(create: (_) => NavigationProvider()), |             ChangeNotifierProvider(create: (_) => NotifyProvider()), | ||||||
|                   Provider(create: (_) => AuthProvider()), |             ChangeNotifierProvider(create: (_) => FriendProvider()), | ||||||
|                   Provider(create: (_) => ChatProvider()), |  | ||||||
|                   ChangeNotifierProvider(create: (_) => NotifyProvider()), |  | ||||||
|                   ChangeNotifierProvider(create: (_) => FriendProvider()), |  | ||||||
|                 ], |  | ||||||
|                 child: NotificationNotifier(child: child ?? Container()), |  | ||||||
|               ); |  | ||||||
|             }) |  | ||||||
|           ], |           ], | ||||||
|  |           child: Overlay( | ||||||
|  |             initialEntries: [ | ||||||
|  |               OverlayEntry(builder: (context) { | ||||||
|  |                 return NotificationNotifier(child: child ?? Container()); | ||||||
|  |               }), | ||||||
|  |               OverlayEntry(builder: (context) => const CallOverlay()), | ||||||
|  |             ], | ||||||
|  |           ), | ||||||
|         ); |         ); | ||||||
|       }, |       }, | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; | |||||||
| import 'package:oauth2/oauth2.dart' as oauth2; | import 'package:oauth2/oauth2.dart' as oauth2; | ||||||
| import 'package:solian/utils/service_url.dart'; | import 'package:solian/utils/service_url.dart'; | ||||||
|  |  | ||||||
| class AuthProvider { | class AuthProvider extends ChangeNotifier { | ||||||
|   AuthProvider(); |   AuthProvider(); | ||||||
|  |  | ||||||
|   final deviceEndpoint = getRequestUri('passport', '/api/notifications/subscribe'); |   final deviceEndpoint = getRequestUri('passport', '/api/notifications/subscribe'); | ||||||
| @@ -63,6 +63,7 @@ class AuthProvider { | |||||||
|       var userinfo = await client!.get(userinfoEndpoint); |       var userinfo = await client!.get(userinfoEndpoint); | ||||||
|       storage.write(key: profileKey, value: utf8.decode(userinfo.bodyBytes)); |       storage.write(key: profileKey, value: utf8.decode(userinfo.bodyBytes)); | ||||||
|     } |     } | ||||||
|  |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> refreshToken() async { |   Future<void> refreshToken() async { | ||||||
| @@ -72,6 +73,7 @@ class AuthProvider { | |||||||
|       client = oauth2.Client(credentials, identifier: clientId, secret: clientSecret); |       client = oauth2.Client(credentials, identifier: clientId, secret: clientSecret); | ||||||
|       storage.write(key: storageKey, value: credentials.toJson()); |       storage.write(key: storageKey, value: credentials.toJson()); | ||||||
|     } |     } | ||||||
|  |     notifyListeners(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> signin(BuildContext context, String username, String password) async { |   Future<void> signin(BuildContext context, String username, String password) async { | ||||||
|   | |||||||
| @@ -1,11 +1,25 @@ | |||||||
| import 'dart:async'; | import 'dart:async'; | ||||||
|  | import 'dart:convert'; | ||||||
|  |  | ||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:livekit_client/livekit_client.dart'; | ||||||
|  | import 'package:permission_handler/permission_handler.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:solian/models/call.dart'; | ||||||
|  | import 'package:solian/models/channel.dart'; | ||||||
| import 'package:solian/providers/auth.dart'; | import 'package:solian/providers/auth.dart'; | ||||||
| import 'package:solian/utils/service_url.dart'; | import 'package:solian/utils/service_url.dart'; | ||||||
|  | import 'package:solian/widgets/chat/call/exts.dart'; | ||||||
|  | import 'package:solian/widgets/exts.dart'; | ||||||
|  | import 'package:wakelock_plus/wakelock_plus.dart'; | ||||||
| import 'package:web_socket_channel/web_socket_channel.dart'; | import 'package:web_socket_channel/web_socket_channel.dart'; | ||||||
|  | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||||
|  |  | ||||||
| class ChatProvider { | class ChatProvider extends ChangeNotifier { | ||||||
|   bool isOpened = false; |   bool isOpened = false; | ||||||
|  |   bool isShown = false; | ||||||
|  |  | ||||||
|  |   ChatCallInstance? call; | ||||||
|  |  | ||||||
|   Future<WebSocketChannel?> connect(AuthProvider auth) async { |   Future<WebSocketChannel?> connect(AuthProvider auth) async { | ||||||
|     if (auth.client == null) await auth.pickClient(); |     if (auth.client == null) await auth.pickClient(); | ||||||
| @@ -26,4 +40,388 @@ class ChatProvider { | |||||||
|  |  | ||||||
|     return channel; |     return channel; | ||||||
|   } |   } | ||||||
|  |  | ||||||
|  |   bool handleCall(Call call, Channel channel, {Function? onUpdate, Function? onDispose}) { | ||||||
|  |     if (this.call != null) return false; | ||||||
|  |  | ||||||
|  |     this.call = ChatCallInstance( | ||||||
|  |       onUpdate: () { | ||||||
|  |         notifyListeners(); | ||||||
|  |         if (onUpdate != null) onUpdate(); | ||||||
|  |       }, | ||||||
|  |       onDispose: () { | ||||||
|  |         this.call = null; | ||||||
|  |         notifyListeners(); | ||||||
|  |         if (onDispose != null) onDispose(); | ||||||
|  |       }, | ||||||
|  |       channel: channel, | ||||||
|  |       info: call, | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void setShown(bool state) { | ||||||
|  |     isShown = state; | ||||||
|  |     notifyListeners(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class ChatCallInstance { | ||||||
|  |   final Function onUpdate; | ||||||
|  |   final Function onDispose; | ||||||
|  |  | ||||||
|  |   final Call info; | ||||||
|  |   final Channel channel; | ||||||
|  |  | ||||||
|  |   bool isMounted = false; | ||||||
|  |  | ||||||
|  |   String? token; | ||||||
|  |   String? endpoint; | ||||||
|  |  | ||||||
|  |   StreamSubscription? subscription; | ||||||
|  |   List<MediaDevice> audioInputs = []; | ||||||
|  |   List<MediaDevice> videoInputs = []; | ||||||
|  |  | ||||||
|  |   bool enableAudio = true; | ||||||
|  |   bool enableVideo = false; | ||||||
|  |   LocalAudioTrack? audioTrack; | ||||||
|  |   LocalVideoTrack? videoTrack; | ||||||
|  |   MediaDevice? videoDevice; | ||||||
|  |   MediaDevice? audioDevice; | ||||||
|  |  | ||||||
|  |   final VideoParameters videoParameters = VideoParametersPresets.h720_169; | ||||||
|  |  | ||||||
|  |   late Room room; | ||||||
|  |   late EventsListener<RoomEvent> listener; | ||||||
|  |  | ||||||
|  |   List<ParticipantTrack> participantTracks = []; | ||||||
|  |   ParticipantTrack? focusTrack; | ||||||
|  |  | ||||||
|  |   ChatCallInstance({ | ||||||
|  |     required this.onUpdate, | ||||||
|  |     required this.onDispose, | ||||||
|  |     required this.channel, | ||||||
|  |     required this.info, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   void init() { | ||||||
|  |     subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices); | ||||||
|  |     room = Room(); | ||||||
|  |     listener = room.createListener(); | ||||||
|  |     Hardware.instance.enumerateDevices().then(revertDevices); | ||||||
|  |     WakelockPlus.enable(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> checkPermissions() async { | ||||||
|  |     if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return; | ||||||
|  |  | ||||||
|  |     await Permission.camera.request(); | ||||||
|  |     await Permission.microphone.request(); | ||||||
|  |     await Permission.bluetooth.request(); | ||||||
|  |     await Permission.bluetoothConnect.request(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<(String, String)> exchangeToken(BuildContext context) async { | ||||||
|  |     await checkPermissions(); | ||||||
|  |  | ||||||
|  |     final auth = context.read<AuthProvider>(); | ||||||
|  |     if (!await auth.isAuthorized()) { | ||||||
|  |       onDispose(); | ||||||
|  |       throw Exception("unauthorized"); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     var uri = getRequestUri('messaging', '/api/channels/${channel.alias}/calls/ongoing/token'); | ||||||
|  |  | ||||||
|  |     var res = await auth.client!.post(uri); | ||||||
|  |     if (res.statusCode == 200) { | ||||||
|  |       final result = jsonDecode(utf8.decode(res.bodyBytes)); | ||||||
|  |       token = result['token']; | ||||||
|  |       endpoint = 'wss://${result['endpoint']}'; | ||||||
|  |       joinRoom(context, endpoint!, token!); | ||||||
|  |       return (token!, endpoint!); | ||||||
|  |     } else { | ||||||
|  |       var message = utf8.decode(res.bodyBytes); | ||||||
|  |       context.showErrorDialog(message); | ||||||
|  |       throw Exception(message); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void joinRoom(BuildContext context, String url, String token) async { | ||||||
|  |     if (isMounted) { | ||||||
|  |       return; | ||||||
|  |     } else { | ||||||
|  |       isMounted = true; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     ScaffoldMessenger.of(context).clearSnackBars(); | ||||||
|  |  | ||||||
|  |     final notify = ScaffoldMessenger.of(context).showSnackBar( | ||||||
|  |       SnackBar( | ||||||
|  |         content: Text(AppLocalizations.of(context)!.connectingServer), | ||||||
|  |         duration: const Duration(minutes: 1), | ||||||
|  |       ), | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     try { | ||||||
|  |       await room.connect( | ||||||
|  |         url, | ||||||
|  |         token, | ||||||
|  |         roomOptions: RoomOptions( | ||||||
|  |           dynacast: true, | ||||||
|  |           adaptiveStream: true, | ||||||
|  |           defaultAudioPublishOptions: const AudioPublishOptions( | ||||||
|  |             name: 'call_voice', | ||||||
|  |             stream: 'call_stream', | ||||||
|  |           ), | ||||||
|  |           defaultVideoPublishOptions: const VideoPublishOptions( | ||||||
|  |             name: 'callvideo', | ||||||
|  |             stream: 'call_stream', | ||||||
|  |             simulcast: true, | ||||||
|  |             backupVideoCodec: BackupVideoCodec(enabled: true), | ||||||
|  |           ), | ||||||
|  |           defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions( | ||||||
|  |             useiOSBroadcastExtension: true, | ||||||
|  |             params: VideoParameters( | ||||||
|  |               dimensions: VideoDimensionsPresets.h1080_169, | ||||||
|  |               encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |           defaultCameraCaptureOptions: CameraCaptureOptions(maxFrameRate: 30, params: videoParameters), | ||||||
|  |         ), | ||||||
|  |         fastConnectOptions: FastConnectOptions( | ||||||
|  |           microphone: TrackOption(track: audioTrack), | ||||||
|  |           camera: TrackOption(track: videoTrack), | ||||||
|  |         ), | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       setupRoom(context); | ||||||
|  |     } catch (e) { | ||||||
|  |       context.showErrorDialog(e); | ||||||
|  |     } finally { | ||||||
|  |       notify.close(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void autoPublish(BuildContext context) async { | ||||||
|  |     try { | ||||||
|  |       if (enableVideo) await room.localParticipant?.setCameraEnabled(true); | ||||||
|  |     } catch (error) { | ||||||
|  |       await context.showErrorDialog(error); | ||||||
|  |     } | ||||||
|  |     try { | ||||||
|  |       if (enableAudio) await room.localParticipant?.setMicrophoneEnabled(true); | ||||||
|  |     } catch (error) { | ||||||
|  |       await context.showErrorDialog(error); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void setupRoom(BuildContext context) { | ||||||
|  |     room.addListener(onRoomDidUpdate); | ||||||
|  |     setupRoomListeners(context); | ||||||
|  |     sortParticipants(); | ||||||
|  |     WidgetsBindingCompatible.instance?.addPostFrameCallback((_) => autoPublish(context)); | ||||||
|  |  | ||||||
|  |     if (lkPlatformIsMobile()) { | ||||||
|  |       Hardware.instance.setSpeakerphoneOn(true); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void setupRoomListeners(BuildContext context) { | ||||||
|  |     listener | ||||||
|  |       ..on<RoomDisconnectedEvent>((event) async { | ||||||
|  |         if (event.reason != null) { | ||||||
|  |           ScaffoldMessenger.of(context).showSnackBar(SnackBar( | ||||||
|  |             content: Text('Call disconnected... ${event.reason}'), | ||||||
|  |           )); | ||||||
|  |         } | ||||||
|  |         onDispose(); | ||||||
|  |       }) | ||||||
|  |       ..on<ParticipantEvent>((event) => sortParticipants()) | ||||||
|  |       ..on<LocalTrackPublishedEvent>((_) => sortParticipants()) | ||||||
|  |       ..on<LocalTrackUnpublishedEvent>((_) => sortParticipants()) | ||||||
|  |       ..on<TrackSubscribedEvent>((_) => sortParticipants()) | ||||||
|  |       ..on<TrackUnsubscribedEvent>((_) => sortParticipants()) | ||||||
|  |       ..on<ParticipantNameUpdatedEvent>((event) { | ||||||
|  |         sortParticipants(); | ||||||
|  |       }) | ||||||
|  |       ..on<AudioPlaybackStatusChanged>((event) async { | ||||||
|  |         if (!room.canPlaybackAudio) { | ||||||
|  |           bool? yesno = await context.showPlayAudioManuallyDialog(); | ||||||
|  |           if (yesno == true) { | ||||||
|  |             await room.startAudio(); | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void sortParticipants() { | ||||||
|  |     Map<String, ParticipantTrack> mediaTracks = {}; | ||||||
|  |     for (var participant in room.remoteParticipants.values) { | ||||||
|  |       mediaTracks[participant.sid] = ParticipantTrack( | ||||||
|  |         participant: participant, | ||||||
|  |         videoTrack: null, | ||||||
|  |         isScreenShare: false, | ||||||
|  |       ); | ||||||
|  |  | ||||||
|  |       for (var t in participant.videoTrackPublications) { | ||||||
|  |         mediaTracks[participant.sid]?.videoTrack = t.track; | ||||||
|  |         mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare; | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     final mediaTrackList = mediaTracks.values.toList(); | ||||||
|  |     mediaTrackList.sort((a, b) { | ||||||
|  |       // Loudest people first | ||||||
|  |       if (a.participant.isSpeaking && b.participant.isSpeaking) { | ||||||
|  |         if (a.participant.audioLevel > b.participant.audioLevel) { | ||||||
|  |           return -1; | ||||||
|  |         } else { | ||||||
|  |           return 1; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Last spoke first | ||||||
|  |       final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; | ||||||
|  |       final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; | ||||||
|  |  | ||||||
|  |       if (aSpokeAt != bSpokeAt) { | ||||||
|  |         return aSpokeAt > bSpokeAt ? -1 : 1; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // Has video first | ||||||
|  |       if (a.participant.hasVideo != b.participant.hasVideo) { | ||||||
|  |         return a.participant.hasVideo ? -1 : 1; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       // First joined people first | ||||||
|  |       return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     ParticipantTrack localTrack = ParticipantTrack( | ||||||
|  |       participant: room.localParticipant!, | ||||||
|  |       videoTrack: null, | ||||||
|  |       isScreenShare: false, | ||||||
|  |     ); | ||||||
|  |     if (room.localParticipant != null) { | ||||||
|  |       final localParticipantTracks = room.localParticipant?.videoTrackPublications; | ||||||
|  |       if (localParticipantTracks != null) { | ||||||
|  |         for (var t in localParticipantTracks) { | ||||||
|  |           localTrack.videoTrack = t.track; | ||||||
|  |           localTrack.isScreenShare = t.isScreenShare; | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     participantTracks = [localTrack, ...mediaTrackList]; | ||||||
|  |     if (focusTrack == null) { | ||||||
|  |       focusTrack = participantTracks.first; | ||||||
|  |     } else { | ||||||
|  |       final idx = participantTracks.indexWhere((x) => focusTrack!.participant.sid == x.participant.sid); | ||||||
|  |       focusTrack = participantTracks[idx]; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     onUpdate(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void revertDevices(List<MediaDevice> devices) async { | ||||||
|  |     audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); | ||||||
|  |     videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); | ||||||
|  |  | ||||||
|  |     if (audioInputs.isNotEmpty) { | ||||||
|  |       if (audioDevice == null && enableAudio) { | ||||||
|  |         audioDevice = audioInputs.first; | ||||||
|  |         Future.delayed(const Duration(milliseconds: 100), () async { | ||||||
|  |           await changeLocalAudioTrack(); | ||||||
|  |           onUpdate(); | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (videoInputs.isNotEmpty) { | ||||||
|  |       if (videoDevice == null && enableVideo) { | ||||||
|  |         videoDevice = videoInputs.first; | ||||||
|  |         Future.delayed(const Duration(milliseconds: 100), () async { | ||||||
|  |           await changeLocalVideoTrack(); | ||||||
|  |           onUpdate(); | ||||||
|  |         }); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     onUpdate(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> setEnableVideo(value) async { | ||||||
|  |     enableVideo = value; | ||||||
|  |     if (!enableVideo) { | ||||||
|  |       await videoTrack?.stop(); | ||||||
|  |       videoTrack = null; | ||||||
|  |     } else { | ||||||
|  |       await changeLocalVideoTrack(); | ||||||
|  |     } | ||||||
|  |     onUpdate(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> setEnableAudio(value) async { | ||||||
|  |     enableAudio = value; | ||||||
|  |     if (!enableAudio) { | ||||||
|  |       await audioTrack?.stop(); | ||||||
|  |       audioTrack = null; | ||||||
|  |     } else { | ||||||
|  |       await changeLocalAudioTrack(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     onUpdate(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> changeLocalAudioTrack() async { | ||||||
|  |     if (audioTrack != null) { | ||||||
|  |       await audioTrack!.stop(); | ||||||
|  |       audioTrack = null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (audioDevice != null) { | ||||||
|  |       audioTrack = await LocalAudioTrack.create(AudioCaptureOptions( | ||||||
|  |         deviceId: audioDevice!.deviceId, | ||||||
|  |       )); | ||||||
|  |       await audioTrack!.start(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   Future<void> changeLocalVideoTrack() async { | ||||||
|  |     if (videoTrack != null) { | ||||||
|  |       await videoTrack!.stop(); | ||||||
|  |       videoTrack = null; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (videoDevice != null) { | ||||||
|  |       videoTrack = await LocalVideoTrack.createCameraTrack(CameraCaptureOptions( | ||||||
|  |         deviceId: videoDevice!.deviceId, | ||||||
|  |         params: videoParameters, | ||||||
|  |       )); | ||||||
|  |       await videoTrack!.start(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void changeFocusTrack(ParticipantTrack track) { | ||||||
|  |     focusTrack = track; | ||||||
|  |     onUpdate(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void onRoomDidUpdate() => sortParticipants(); | ||||||
|  |  | ||||||
|  |   void deactivate() { | ||||||
|  |     subscription?.cancel(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   void dispose() { | ||||||
|  |     room.removeListener(onRoomDidUpdate); | ||||||
|  |     (() async { | ||||||
|  |       await listener.dispose(); | ||||||
|  |       await room.disconnect(); | ||||||
|  |       await room.dispose(); | ||||||
|  |     })(); | ||||||
|  |     WakelockPlus.disable(); | ||||||
|  |     onDispose(); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,22 +1,13 @@ | |||||||
| import 'dart:async'; |  | ||||||
| import 'dart:convert'; |  | ||||||
|  |  | ||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
| import 'package:livekit_client/livekit_client.dart'; | import 'package:livekit_client/livekit_client.dart'; | ||||||
| import 'package:provider/provider.dart'; | import 'package:provider/provider.dart'; | ||||||
| import 'package:solian/models/call.dart'; | import 'package:solian/models/call.dart'; | ||||||
| import 'package:solian/providers/auth.dart'; | import 'package:solian/providers/chat.dart'; | ||||||
| import 'package:solian/router.dart'; |  | ||||||
| import 'package:solian/utils/service_url.dart'; |  | ||||||
| import 'package:solian/widgets/chat/call/controls.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.dart'; | ||||||
| import 'package:solian/widgets/chat/call/participant_menu.dart'; | import 'package:solian/widgets/chat/call/participant_menu.dart'; | ||||||
| import 'package:solian/widgets/exts.dart'; |  | ||||||
| import 'package:solian/widgets/indent_wrapper.dart'; | import 'package:solian/widgets/indent_wrapper.dart'; | ||||||
| import 'package:permission_handler/permission_handler.dart'; |  | ||||||
| import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||||
| import 'package:wakelock_plus/wakelock_plus.dart'; |  | ||||||
| import 'dart:math' as math; | import 'dart:math' as math; | ||||||
|  |  | ||||||
| class ChatCall extends StatefulWidget { | class ChatCall extends StatefulWidget { | ||||||
| @@ -29,337 +20,39 @@ class ChatCall extends StatefulWidget { | |||||||
| } | } | ||||||
|  |  | ||||||
| class _ChatCallState extends State<ChatCall> { | class _ChatCallState extends State<ChatCall> { | ||||||
|   String? _token; |   bool _isHandled = false; | ||||||
|   String? _endpoint; |  | ||||||
|  |  | ||||||
|   bool _isMounted = false; |   late ChatProvider _chat; | ||||||
|  |  | ||||||
|   StreamSubscription? _subscription; |   ChatCallInstance get _call => _chat.call!; | ||||||
|   List<MediaDevice> _audioInputs = []; |  | ||||||
|   List<MediaDevice> _videoInputs = []; |  | ||||||
|  |  | ||||||
|   bool _enableAudio = true; |  | ||||||
|   bool _enableVideo = false; |  | ||||||
|   LocalAudioTrack? _audioTrack; |  | ||||||
|   LocalVideoTrack? _videoTrack; |  | ||||||
|   MediaDevice? _videoDevice; |  | ||||||
|   MediaDevice? _audioDevice; |  | ||||||
|  |  | ||||||
|   final VideoParameters _videoParameters = VideoParametersPresets.h720_169; |  | ||||||
|  |  | ||||||
|   late Room _callRoom; |  | ||||||
|   late EventsListener<RoomEvent> _callListener; |  | ||||||
|  |  | ||||||
|   List<ParticipantTrack> _participantTracks = []; |  | ||||||
|   ParticipantTrack? _focusParticipant; |  | ||||||
|  |  | ||||||
|   Future<void> checkPermissions() async { |  | ||||||
|     if (lkPlatformIs(PlatformType.macOS) || lkPlatformIs(PlatformType.linux)) return; |  | ||||||
|  |  | ||||||
|     await Permission.camera.request(); |  | ||||||
|     await Permission.microphone.request(); |  | ||||||
|     await Permission.bluetooth.request(); |  | ||||||
|     await Permission.bluetoothConnect.request(); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<(String, String)> exchangeToken() async { |  | ||||||
|     await checkPermissions(); |  | ||||||
|  |  | ||||||
|     final auth = context.read<AuthProvider>(); |  | ||||||
|     if (!await auth.isAuthorized()) { |  | ||||||
|       router.pop(); |  | ||||||
|       throw Error(); |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     var uri = getRequestUri('messaging', '/api/channels/${widget.call.channel.alias}/calls/ongoing/token'); |  | ||||||
|  |  | ||||||
|     var res = await auth.client!.post(uri); |  | ||||||
|     if (res.statusCode == 200) { |  | ||||||
|       final result = jsonDecode(utf8.decode(res.bodyBytes)); |  | ||||||
|       _token = result['token']; |  | ||||||
|       _endpoint = 'wss://${result['endpoint']}'; |  | ||||||
|       joinRoom(_endpoint!, _token!); |  | ||||||
|       return (_token!, _endpoint!); |  | ||||||
|     } else { |  | ||||||
|       var message = utf8.decode(res.bodyBytes); |  | ||||||
|       context.showErrorDialog(message); |  | ||||||
|       throw Exception(message); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void joinRoom(String url, String token) async { |  | ||||||
|     if (_isMounted) { |  | ||||||
|       return; |  | ||||||
|     } else { |  | ||||||
|       _isMounted = true; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     ScaffoldMessenger.of(context).clearSnackBars(); |  | ||||||
|  |  | ||||||
|     final notify = ScaffoldMessenger.of(context).showSnackBar( |  | ||||||
|       SnackBar( |  | ||||||
|         content: Text(AppLocalizations.of(context)!.connectingServer), |  | ||||||
|         duration: const Duration(minutes: 1), |  | ||||||
|       ), |  | ||||||
|     ); |  | ||||||
|  |  | ||||||
|     try { |  | ||||||
|       await _callRoom.connect( |  | ||||||
|         url, |  | ||||||
|         token, |  | ||||||
|         roomOptions: RoomOptions( |  | ||||||
|           dynacast: true, |  | ||||||
|           adaptiveStream: true, |  | ||||||
|           defaultAudioPublishOptions: const AudioPublishOptions( |  | ||||||
|             name: 'call_voice', |  | ||||||
|             stream: 'call_stream', |  | ||||||
|           ), |  | ||||||
|           defaultVideoPublishOptions: const VideoPublishOptions( |  | ||||||
|             name: 'call_video', |  | ||||||
|             stream: 'call_stream', |  | ||||||
|             simulcast: true, |  | ||||||
|             backupVideoCodec: BackupVideoCodec(enabled: true), |  | ||||||
|           ), |  | ||||||
|           defaultScreenShareCaptureOptions: const ScreenShareCaptureOptions( |  | ||||||
|             useiOSBroadcastExtension: true, |  | ||||||
|             params: VideoParameters( |  | ||||||
|               dimensions: VideoDimensionsPresets.h1080_169, |  | ||||||
|               encoding: VideoEncoding(maxBitrate: 3 * 1000 * 1000, maxFramerate: 30), |  | ||||||
|             ), |  | ||||||
|           ), |  | ||||||
|           defaultCameraCaptureOptions: CameraCaptureOptions(maxFrameRate: 30, params: _videoParameters), |  | ||||||
|         ), |  | ||||||
|         fastConnectOptions: FastConnectOptions( |  | ||||||
|           microphone: TrackOption(track: _audioTrack), |  | ||||||
|           camera: TrackOption(track: _videoTrack), |  | ||||||
|         ), |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       setupRoom(); |  | ||||||
|     } catch (e) { |  | ||||||
|       context.showErrorDialog(e); |  | ||||||
|     } finally { |  | ||||||
|       notify.close(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void autoPublish() async { |  | ||||||
|     try { |  | ||||||
|       if (_enableVideo) await _callRoom.localParticipant?.setCameraEnabled(true); |  | ||||||
|     } catch (error) { |  | ||||||
|       await context.showErrorDialog(error); |  | ||||||
|     } |  | ||||||
|     try { |  | ||||||
|       if (_enableAudio) await _callRoom.localParticipant?.setMicrophoneEnabled(true); |  | ||||||
|     } catch (error) { |  | ||||||
|       await context.showErrorDialog(error); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void setupRoom() { |  | ||||||
|     _callRoom.addListener(onRoomDidUpdate); |  | ||||||
|     setupRoomListeners(); |  | ||||||
|     sortParticipants(); |  | ||||||
|     WidgetsBindingCompatible.instance?.addPostFrameCallback((_) => autoPublish()); |  | ||||||
|  |  | ||||||
|     if (lkPlatformIsMobile()) { |  | ||||||
|       Hardware.instance.setSpeakerphoneOn(true); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void setupRoomListeners() { |  | ||||||
|     _callListener |  | ||||||
|       ..on<RoomDisconnectedEvent>((event) async { |  | ||||||
|         if (event.reason != null) { |  | ||||||
|           ScaffoldMessenger.of(context).showSnackBar(SnackBar( |  | ||||||
|             content: Text('Call disconnected... ${event.reason}'), |  | ||||||
|           )); |  | ||||||
|         } |  | ||||||
|         if (router.canPop()) router.pop(); |  | ||||||
|       }) |  | ||||||
|       ..on<ParticipantEvent>((event) => sortParticipants()) |  | ||||||
|       ..on<LocalTrackPublishedEvent>((_) => sortParticipants()) |  | ||||||
|       ..on<LocalTrackUnpublishedEvent>((_) => sortParticipants()) |  | ||||||
|       ..on<TrackSubscribedEvent>((_) => sortParticipants()) |  | ||||||
|       ..on<TrackUnsubscribedEvent>((_) => sortParticipants()) |  | ||||||
|       ..on<ParticipantNameUpdatedEvent>((event) { |  | ||||||
|         sortParticipants(); |  | ||||||
|       }) |  | ||||||
|       ..on<AudioPlaybackStatusChanged>((event) async { |  | ||||||
|         if (!_callRoom.canPlaybackAudio) { |  | ||||||
|           bool? yesno = await context.showPlayAudioManuallyDialog(); |  | ||||||
|           if (yesno == true) { |  | ||||||
|             await _callRoom.startAudio(); |  | ||||||
|           } |  | ||||||
|         } |  | ||||||
|       }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void sortParticipants() { |  | ||||||
|     Map<String, ParticipantTrack> mediaTracks = {}; |  | ||||||
|     for (var participant in _callRoom.remoteParticipants.values) { |  | ||||||
|       mediaTracks[participant.sid] = ParticipantTrack( |  | ||||||
|         participant: participant, |  | ||||||
|         videoTrack: null, |  | ||||||
|         isScreenShare: false, |  | ||||||
|       ); |  | ||||||
|  |  | ||||||
|       for (var t in participant.videoTrackPublications) { |  | ||||||
|         mediaTracks[participant.sid]?.videoTrack = t.track; |  | ||||||
|         mediaTracks[participant.sid]?.isScreenShare = t.isScreenShare; |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     final mediaTrackList = mediaTracks.values.toList(); |  | ||||||
|     mediaTrackList.sort((a, b) { |  | ||||||
|       // Loudest people first |  | ||||||
|       if (a.participant.isSpeaking && b.participant.isSpeaking) { |  | ||||||
|         if (a.participant.audioLevel > b.participant.audioLevel) { |  | ||||||
|           return -1; |  | ||||||
|         } else { |  | ||||||
|           return 1; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // Last spoke first |  | ||||||
|       final aSpokeAt = a.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; |  | ||||||
|       final bSpokeAt = b.participant.lastSpokeAt?.millisecondsSinceEpoch ?? 0; |  | ||||||
|  |  | ||||||
|       if (aSpokeAt != bSpokeAt) { |  | ||||||
|         return aSpokeAt > bSpokeAt ? -1 : 1; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // Has video first |  | ||||||
|       if (a.participant.hasVideo != b.participant.hasVideo) { |  | ||||||
|         return a.participant.hasVideo ? -1 : 1; |  | ||||||
|       } |  | ||||||
|  |  | ||||||
|       // First joined people first |  | ||||||
|       return a.participant.joinedAt.millisecondsSinceEpoch - b.participant.joinedAt.millisecondsSinceEpoch; |  | ||||||
|     }); |  | ||||||
|  |  | ||||||
|     ParticipantTrack localTrack = ParticipantTrack( |  | ||||||
|       participant: _callRoom.localParticipant!, |  | ||||||
|       videoTrack: null, |  | ||||||
|       isScreenShare: false, |  | ||||||
|     ); |  | ||||||
|     if (_callRoom.localParticipant != null) { |  | ||||||
|       final localParticipantTracks = _callRoom.localParticipant?.videoTrackPublications; |  | ||||||
|       if (localParticipantTracks != null) { |  | ||||||
|         for (var t in localParticipantTracks) { |  | ||||||
|           localTrack.videoTrack = t.track; |  | ||||||
|           localTrack.isScreenShare = t.isScreenShare; |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     setState(() { |  | ||||||
|       _participantTracks = [localTrack, ...mediaTrackList]; |  | ||||||
|       if (_focusParticipant == null) { |  | ||||||
|         _focusParticipant = _participantTracks.first; |  | ||||||
|       } else { |  | ||||||
|         final idx = _participantTracks.indexWhere((x) => _focusParticipant!.participant.sid == x.participant.sid); |  | ||||||
|         _focusParticipant = _participantTracks[idx]; |  | ||||||
|       } |  | ||||||
|     }); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   void onRoomDidUpdate() => sortParticipants(); |  | ||||||
|  |  | ||||||
|   void revertDevices(List<MediaDevice> devices) async { |  | ||||||
|     _audioInputs = devices.where((d) => d.kind == 'audioinput').toList(); |  | ||||||
|     _videoInputs = devices.where((d) => d.kind == 'videoinput').toList(); |  | ||||||
|  |  | ||||||
|     if (_audioInputs.isNotEmpty) { |  | ||||||
|       if (_audioDevice == null && _enableAudio) { |  | ||||||
|         _audioDevice = _audioInputs.first; |  | ||||||
|         Future.delayed(const Duration(milliseconds: 100), () async { |  | ||||||
|           await changeLocalAudioTrack(); |  | ||||||
|           setState(() {}); |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (_videoInputs.isNotEmpty) { |  | ||||||
|       if (_videoDevice == null && _enableVideo) { |  | ||||||
|         _videoDevice = _videoInputs.first; |  | ||||||
|         Future.delayed(const Duration(milliseconds: 100), () async { |  | ||||||
|           await changeLocalVideoTrack(); |  | ||||||
|           setState(() {}); |  | ||||||
|         }); |  | ||||||
|       } |  | ||||||
|     } |  | ||||||
|     setState(() {}); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> setEnableVideo(value) async { |  | ||||||
|     _enableVideo = value; |  | ||||||
|     if (!_enableVideo) { |  | ||||||
|       await _videoTrack?.stop(); |  | ||||||
|       _videoTrack = null; |  | ||||||
|     } else { |  | ||||||
|       await changeLocalVideoTrack(); |  | ||||||
|     } |  | ||||||
|     setState(() {}); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> setEnableAudio(value) async { |  | ||||||
|     _enableAudio = value; |  | ||||||
|     if (!_enableAudio) { |  | ||||||
|       await _audioTrack?.stop(); |  | ||||||
|       _audioTrack = null; |  | ||||||
|     } else { |  | ||||||
|       await changeLocalAudioTrack(); |  | ||||||
|     } |  | ||||||
|     setState(() {}); |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> changeLocalAudioTrack() async { |  | ||||||
|     if (_audioTrack != null) { |  | ||||||
|       await _audioTrack!.stop(); |  | ||||||
|       _audioTrack = null; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (_audioDevice != null) { |  | ||||||
|       _audioTrack = await LocalAudioTrack.create(AudioCaptureOptions( |  | ||||||
|         deviceId: _audioDevice!.deviceId, |  | ||||||
|       )); |  | ||||||
|       await _audioTrack!.start(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   Future<void> changeLocalVideoTrack() async { |  | ||||||
|     if (_videoTrack != null) { |  | ||||||
|       await _videoTrack!.stop(); |  | ||||||
|       _videoTrack = null; |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     if (_videoDevice != null) { |  | ||||||
|       _videoTrack = await LocalVideoTrack.createCameraTrack(CameraCaptureOptions( |  | ||||||
|         deviceId: _videoDevice!.deviceId, |  | ||||||
|         params: _videoParameters, |  | ||||||
|       )); |  | ||||||
|       await _videoTrack!.start(); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void initState() { |   void initState() { | ||||||
|     super.initState(); |     super.initState(); | ||||||
|     _subscription = Hardware.instance.onDeviceChange.stream.listen(revertDevices); |  | ||||||
|     _callRoom = Room(); |     WidgetsBinding.instance.addPostFrameCallback((_) { | ||||||
|     _callListener = _callRoom.createListener(); |       _chat.setShown(true); | ||||||
|     Hardware.instance.enumerateDevices().then(revertDevices); |     }); | ||||||
|     WakelockPlus.enable(); |  | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   Widget build(BuildContext context) { |   Widget build(BuildContext context) { | ||||||
|     return IndentWrapper( |     _chat = context.watch<ChatProvider>(); | ||||||
|       title: AppLocalizations.of(context)!.chatCall, |     if (!_isHandled) { | ||||||
|       hideDrawer: true, |       _isHandled = true; | ||||||
|       child: FutureBuilder( |       if (_chat.handleCall(widget.call, widget.call.channel)) { | ||||||
|         future: exchangeToken(), |         _chat.call?.init(); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     Widget content; | ||||||
|  |     if (_chat.call == null) { | ||||||
|  |       content = const Center( | ||||||
|  |         child: CircularProgressIndicator(), | ||||||
|  |       ); | ||||||
|  |     } else { | ||||||
|  |       content = FutureBuilder( | ||||||
|  |         future: _call.exchangeToken(context), | ||||||
|         builder: (context, snapshot) { |         builder: (context, snapshot) { | ||||||
|           if (!snapshot.hasData || snapshot.data == null) { |           if (!snapshot.hasData || snapshot.data == null) { | ||||||
|             return const Center(child: CircularProgressIndicator()); |             return const Center(child: CircularProgressIndicator()); | ||||||
| @@ -372,15 +65,20 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|                   Expanded( |                   Expanded( | ||||||
|                     child: Container( |                     child: Container( | ||||||
|                       color: Theme.of(context).colorScheme.surfaceVariant, |                       color: Theme.of(context).colorScheme.surfaceVariant, | ||||||
|                       child: _focusParticipant != null |                       child: _call.focusTrack != null | ||||||
|                           ? InteractiveParticipantWidget( |                           ? InteractiveParticipantWidget( | ||||||
|                               participant: _focusParticipant!, |                               isFixed: false, | ||||||
|  |                               participant: _call.focusTrack!, | ||||||
|                               onTap: () {}, |                               onTap: () {}, | ||||||
|                             ) |                             ) | ||||||
|                           : Container(), |                           : Container(), | ||||||
|                     ), |                     ), | ||||||
|                   ), |                   ), | ||||||
|                   if (_callRoom.localParticipant != null) ControlsWidget(_callRoom, _callRoom.localParticipant!), |                   if (_call.room.localParticipant != null) | ||||||
|  |                     ControlsWidget( | ||||||
|  |                       _call.room, | ||||||
|  |                       _call.room.localParticipant!, | ||||||
|  |                     ), | ||||||
|                 ], |                 ], | ||||||
|               ), |               ), | ||||||
|               Positioned( |               Positioned( | ||||||
| @@ -391,10 +89,10 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|                   height: 128, |                   height: 128, | ||||||
|                   child: ListView.builder( |                   child: ListView.builder( | ||||||
|                     scrollDirection: Axis.horizontal, |                     scrollDirection: Axis.horizontal, | ||||||
|                     itemCount: math.max(0, _participantTracks.length), |                     itemCount: math.max(0, _call.participantTracks.length), | ||||||
|                     itemBuilder: (BuildContext context, int index) { |                     itemBuilder: (BuildContext context, int index) { | ||||||
|                       final track = _participantTracks[index]; |                       final track = _call.participantTracks[index]; | ||||||
|                       if (track.participant.sid == _focusParticipant?.participant.sid) { |                       if (track.participant.sid == _call.focusTrack?.participant.sid) { | ||||||
|                         return Container(); |                         return Container(); | ||||||
|                       } |                       } | ||||||
|  |  | ||||||
| @@ -403,13 +101,14 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|                         child: ClipRRect( |                         child: ClipRRect( | ||||||
|                           borderRadius: const BorderRadius.all(Radius.circular(8)), |                           borderRadius: const BorderRadius.all(Radius.circular(8)), | ||||||
|                           child: InteractiveParticipantWidget( |                           child: InteractiveParticipantWidget( | ||||||
|  |                             isFixed: true, | ||||||
|                             width: 120, |                             width: 120, | ||||||
|                             height: 120, |                             height: 120, | ||||||
|                             color: Theme.of(context).cardColor, |                             color: Theme.of(context).cardColor, | ||||||
|                             participant: track, |                             participant: track, | ||||||
|                             onTap: () { |                             onTap: () { | ||||||
|                               if (track.participant.sid != _focusParticipant?.participant.sid) { |                               if (track.participant.sid != _call.focusTrack?.participant.sid) { | ||||||
|                                 setState(() => _focusParticipant = track); |                                 _call.changeFocusTrack(track); | ||||||
|                               } |                               } | ||||||
|                             }, |                             }, | ||||||
|                           ), |                           ), | ||||||
| @@ -422,27 +121,21 @@ class _ChatCallState extends State<ChatCall> { | |||||||
|             ], |             ], | ||||||
|           ); |           ); | ||||||
|         }, |         }, | ||||||
|       ), |       ); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return IndentWrapper( | ||||||
|  |       title: AppLocalizations.of(context)!.chatCall, | ||||||
|  |       hideDrawer: true, | ||||||
|  |       child: content, | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
|   void deactivate() { |   void deactivate() { | ||||||
|     _subscription?.cancel(); |     WidgetsBinding.instance.addPostFrameCallback((_) => _chat.setShown(false)); | ||||||
|     super.deactivate(); |     super.deactivate(); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   @override |  | ||||||
|   void dispose() { |  | ||||||
|     WakelockPlus.disable(); |  | ||||||
|     (() async { |  | ||||||
|       _callRoom.removeListener(onRoomDidUpdate); |  | ||||||
|       await _callListener.dispose(); |  | ||||||
|       await _callRoom.disconnect(); |  | ||||||
|       await _callRoom.dispose(); |  | ||||||
|     })(); |  | ||||||
|     super.dispose(); |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|  |  | ||||||
| class InteractiveParticipantWidget extends StatelessWidget { | class InteractiveParticipantWidget extends StatelessWidget { | ||||||
|   | |||||||
							
								
								
									
										63
									
								
								lib/widgets/chat/call/call_overlay.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								lib/widgets/chat/call/call_overlay.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | |||||||
|  | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||||
|  | import 'package:draggable_float_widget/draggable_float_widget.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:solian/providers/chat.dart'; | ||||||
|  | import 'package:solian/router.dart'; | ||||||
|  |  | ||||||
|  | class CallOverlay extends StatelessWidget { | ||||||
|  |   const CallOverlay({super.key}); | ||||||
|  |  | ||||||
|  |   @override | ||||||
|  |   Widget build(BuildContext context) { | ||||||
|  |     const radius = BorderRadius.all(Radius.circular(8)); | ||||||
|  |  | ||||||
|  |     final chat = context.watch<ChatProvider>(); | ||||||
|  |  | ||||||
|  |     if (chat.isShown || chat.call == null) { | ||||||
|  |       return Container(); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     return DraggableFloatWidget( | ||||||
|  |       config: const DraggableFloatWidgetBaseConfig( | ||||||
|  |         initPositionYInTop: false, | ||||||
|  |         initPositionYMarginBorder: 50, | ||||||
|  |         borderTopContainTopBar: true, | ||||||
|  |         borderBottom: defaultBorderWidth, | ||||||
|  |         borderLeft: 8, | ||||||
|  |       ), | ||||||
|  |       child: Material( | ||||||
|  |         elevation: 6, | ||||||
|  |         color: Colors.transparent, | ||||||
|  |         borderRadius: radius, | ||||||
|  |         child: ClipRRect( | ||||||
|  |           borderRadius: radius, | ||||||
|  |           child: Container( | ||||||
|  |             height: 80, | ||||||
|  |             width: 80, | ||||||
|  |             color: Theme.of(context).colorScheme.secondaryContainer, | ||||||
|  |             child: Column( | ||||||
|  |               mainAxisSize: MainAxisSize.max, | ||||||
|  |               mainAxisAlignment: MainAxisAlignment.center, | ||||||
|  |               children: [ | ||||||
|  |                 const Icon(Icons.call, size: 18), | ||||||
|  |                 const SizedBox(height: 4), | ||||||
|  |                 Text( | ||||||
|  |                   AppLocalizations.of(context)!.chatCallOngoingShort, | ||||||
|  |                   style: const TextStyle(fontSize: 12), | ||||||
|  |                 ) | ||||||
|  |               ], | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|  |         ), | ||||||
|  |       ), | ||||||
|  |       onTap: () { | ||||||
|  |         router.pushNamed( | ||||||
|  |           'chat.channel.call', | ||||||
|  |           extra: chat.call!.info, | ||||||
|  |           pathParameters: {'channel': chat.call!.channel.alias}, | ||||||
|  |         ); | ||||||
|  |       }, | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -6,6 +6,10 @@ import 'package:flutter_background/flutter_background.dart'; | |||||||
| import 'package:flutter_webrtc/flutter_webrtc.dart'; | import 'package:flutter_webrtc/flutter_webrtc.dart'; | ||||||
| import 'package:livekit_client/livekit_client.dart'; | import 'package:livekit_client/livekit_client.dart'; | ||||||
| import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||||
|  | import 'package:provider/provider.dart'; | ||||||
|  | import 'package:solian/providers/chat.dart'; | ||||||
|  | import 'package:solian/router.dart'; | ||||||
|  | import 'package:solian/widgets/chat/call/exts.dart'; | ||||||
| import 'package:solian/widgets/exts.dart'; | import 'package:solian/widgets/exts.dart'; | ||||||
|  |  | ||||||
| class ControlsWidget extends StatefulWidget { | class ControlsWidget extends StatefulWidget { | ||||||
| @@ -64,11 +68,22 @@ class _ControlsWidgetState extends State<ControlsWidget> { | |||||||
|  |  | ||||||
|   bool get isMuted => participant.isMuted; |   bool get isMuted => participant.isMuted; | ||||||
|  |  | ||||||
|  |   void disconnect() async { | ||||||
|  |     if (await context.showDisconnectDialog() != true) return; | ||||||
|  |  | ||||||
|  |     final chat = context.read<ChatProvider>(); | ||||||
|  |     if (chat.call != null) { | ||||||
|  |       chat.call!.deactivate(); | ||||||
|  |       chat.call!.dispose(); | ||||||
|  |       router.pop(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|   void disableAudio() async { |   void disableAudio() async { | ||||||
|     await participant.setMicrophoneEnabled(false); |     await participant.setMicrophoneEnabled(false); | ||||||
|   } |   } | ||||||
|  |  | ||||||
|   Future<void> enableAudio() async { |   void enableAudio() async { | ||||||
|     await participant.setMicrophoneEnabled(true); |     await participant.setMicrophoneEnabled(true); | ||||||
|   } |   } | ||||||
|  |  | ||||||
| @@ -207,11 +222,17 @@ class _ControlsWidgetState extends State<ControlsWidget> { | |||||||
|         spacing: 5, |         spacing: 5, | ||||||
|         runSpacing: 5, |         runSpacing: 5, | ||||||
|         children: [ |         children: [ | ||||||
|  |           IconButton( | ||||||
|  |             icon: Transform.flip(flipX: true, child: const Icon(Icons.exit_to_app)), | ||||||
|  |             color: Theme.of(context).colorScheme.onSurface, | ||||||
|  |             onPressed: disconnect, | ||||||
|  |           ), | ||||||
|           if (participant.isMicrophoneEnabled()) |           if (participant.isMicrophoneEnabled()) | ||||||
|             if (lkPlatformIs(PlatformType.android)) |             if (lkPlatformIs(PlatformType.android)) | ||||||
|               IconButton( |               IconButton( | ||||||
|                 onPressed: disableAudio, |                 onPressed: disableAudio, | ||||||
|                 icon: const Icon(Icons.mic), |                 icon: const Icon(Icons.mic), | ||||||
|  |                 color: Theme.of(context).colorScheme.onSurface, | ||||||
|                 tooltip: AppLocalizations.of(context)!.chatCallMute, |                 tooltip: AppLocalizations.of(context)!.chatCallMute, | ||||||
|               ) |               ) | ||||||
|             else |             else | ||||||
| @@ -247,43 +268,9 @@ class _ControlsWidgetState extends State<ControlsWidget> { | |||||||
|             IconButton( |             IconButton( | ||||||
|               onPressed: enableAudio, |               onPressed: enableAudio, | ||||||
|               icon: const Icon(Icons.mic_off), |               icon: const Icon(Icons.mic_off), | ||||||
|  |               color: Theme.of(context).colorScheme.onSurface, | ||||||
|               tooltip: AppLocalizations.of(context)!.chatCallUnMute, |               tooltip: AppLocalizations.of(context)!.chatCallUnMute, | ||||||
|             ), |             ), | ||||||
|           if (!lkPlatformIs(PlatformType.iOS)) |  | ||||||
|             PopupMenuButton<MediaDevice>( |  | ||||||
|               icon: const Icon(Icons.volume_up), |  | ||||||
|               itemBuilder: (BuildContext context) { |  | ||||||
|                 return [ |  | ||||||
|                   const PopupMenuItem<MediaDevice>( |  | ||||||
|                     value: null, |  | ||||||
|                     child: ListTile( |  | ||||||
|                       leading: Icon(Icons.speaker), |  | ||||||
|                       title: Text('Select Audio Output'), |  | ||||||
|                     ), |  | ||||||
|                   ), |  | ||||||
|                   if (_audioOutputs != null) |  | ||||||
|                     ..._audioOutputs!.map((device) { |  | ||||||
|                       return PopupMenuItem<MediaDevice>( |  | ||||||
|                         value: device, |  | ||||||
|                         child: ListTile( |  | ||||||
|                           leading: (device.deviceId == widget.room.selectedAudioOutputDeviceId) |  | ||||||
|                               ? const Icon(Icons.check_box_outlined) |  | ||||||
|                               : const Icon(Icons.check_box_outline_blank), |  | ||||||
|                           title: Text(device.label), |  | ||||||
|                         ), |  | ||||||
|                         onTap: () => selectAudioOutput(device), |  | ||||||
|                       ); |  | ||||||
|                     }) |  | ||||||
|                 ]; |  | ||||||
|               }, |  | ||||||
|             ), |  | ||||||
|           if (!kIsWeb && lkPlatformIs(PlatformType.iOS)) |  | ||||||
|             IconButton( |  | ||||||
|               disabledColor: Colors.grey, |  | ||||||
|               onPressed: Hardware.instance.canSwitchSpeakerphone ? setSpeakerphoneOn : null, |  | ||||||
|               icon: Icon(_speakerphoneOn ? Icons.speaker_phone : Icons.phone_android), |  | ||||||
|               tooltip: AppLocalizations.of(context)!.chatCallChangeSpeaker, |  | ||||||
|             ), |  | ||||||
|           if (participant.isCameraEnabled()) |           if (participant.isCameraEnabled()) | ||||||
|             PopupMenuButton<MediaDevice>( |             PopupMenuButton<MediaDevice>( | ||||||
|               icon: const Icon(Icons.videocam_sharp), |               icon: const Icon(Icons.videocam_sharp), | ||||||
| @@ -317,22 +304,61 @@ class _ControlsWidgetState extends State<ControlsWidget> { | |||||||
|             IconButton( |             IconButton( | ||||||
|               onPressed: enableVideo, |               onPressed: enableVideo, | ||||||
|               icon: const Icon(Icons.videocam_off), |               icon: const Icon(Icons.videocam_off), | ||||||
|  |               color: Theme.of(context).colorScheme.onSurface, | ||||||
|               tooltip: AppLocalizations.of(context)!.chatCallVideoOn, |               tooltip: AppLocalizations.of(context)!.chatCallVideoOn, | ||||||
|             ), |             ), | ||||||
|           IconButton( |           IconButton( | ||||||
|             icon: Icon(position == CameraPosition.back ? Icons.video_camera_back : Icons.video_camera_front), |             icon: Icon(position == CameraPosition.back ? Icons.video_camera_back : Icons.video_camera_front), | ||||||
|  |             color: Theme.of(context).colorScheme.onSurface, | ||||||
|             onPressed: () => toggleCamera(), |             onPressed: () => toggleCamera(), | ||||||
|             tooltip: AppLocalizations.of(context)!.chatCallVideoFlip, |             tooltip: AppLocalizations.of(context)!.chatCallVideoFlip, | ||||||
|           ), |           ), | ||||||
|  |           if (!lkPlatformIs(PlatformType.iOS)) | ||||||
|  |             PopupMenuButton<MediaDevice>( | ||||||
|  |               icon: const Icon(Icons.volume_up), | ||||||
|  |               itemBuilder: (BuildContext context) { | ||||||
|  |                 return [ | ||||||
|  |                   const PopupMenuItem<MediaDevice>( | ||||||
|  |                     value: null, | ||||||
|  |                     child: ListTile( | ||||||
|  |                       leading: Icon(Icons.speaker), | ||||||
|  |                       title: Text('Select Audio Output'), | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                   if (_audioOutputs != null) | ||||||
|  |                     ..._audioOutputs!.map((device) { | ||||||
|  |                       return PopupMenuItem<MediaDevice>( | ||||||
|  |                         value: device, | ||||||
|  |                         child: ListTile( | ||||||
|  |                           leading: (device.deviceId == widget.room.selectedAudioOutputDeviceId) | ||||||
|  |                               ? const Icon(Icons.check_box_outlined) | ||||||
|  |                               : const Icon(Icons.check_box_outline_blank), | ||||||
|  |                           title: Text(device.label), | ||||||
|  |                         ), | ||||||
|  |                         onTap: () => selectAudioOutput(device), | ||||||
|  |                       ); | ||||||
|  |                     }) | ||||||
|  |                 ]; | ||||||
|  |               }, | ||||||
|  |             ), | ||||||
|  |           if (!kIsWeb && lkPlatformIs(PlatformType.iOS)) | ||||||
|  |             IconButton( | ||||||
|  |               onPressed: Hardware.instance.canSwitchSpeakerphone ? setSpeakerphoneOn : null, | ||||||
|  |               color: Theme.of(context).colorScheme.onSurface, | ||||||
|  |               icon: Icon(_speakerphoneOn ? Icons.speaker_phone : Icons.phone_android), | ||||||
|  |               tooltip: AppLocalizations.of(context)!.chatCallChangeSpeaker, | ||||||
|  |             ), | ||||||
|           if (participant.isScreenShareEnabled()) |           if (participant.isScreenShareEnabled()) | ||||||
|             IconButton( |             IconButton( | ||||||
|               icon: const Icon(Icons.monitor_outlined), |               icon: const Icon(Icons.monitor_outlined), | ||||||
|  |               color: Theme.of(context).colorScheme.onSurface, | ||||||
|               onPressed: () => disableScreenShare(), |               onPressed: () => disableScreenShare(), | ||||||
|               tooltip: AppLocalizations.of(context)!.chatCallScreenOff, |               tooltip: AppLocalizations.of(context)!.chatCallScreenOff, | ||||||
|             ) |             ) | ||||||
|           else |           else | ||||||
|             IconButton( |             IconButton( | ||||||
|               icon: const Icon(Icons.monitor), |               icon: const Icon(Icons.monitor), | ||||||
|  |               color: Theme.of(context).colorScheme.onSurface, | ||||||
|               onPressed: () => enableScreenShare(), |               onPressed: () => enableScreenShare(), | ||||||
|               tooltip: AppLocalizations.of(context)!.chatCallScreenOn, |               tooltip: AppLocalizations.of(context)!.chatCallScreenOn, | ||||||
|             ), |             ), | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | ||||||
|  |  | ||||||
| extension SolianCallExt on BuildContext { | extension SolianCallExt on BuildContext { | ||||||
|   Future<bool?> showPlayAudioManuallyDialog() => showDialog<bool>( |   Future<bool?> showPlayAudioManuallyDialog() => showDialog<bool>( | ||||||
| @@ -24,16 +25,16 @@ extension SolianCallExt on BuildContext { | |||||||
|   Future<bool?> showDisconnectDialog() => showDialog<bool>( |   Future<bool?> showDisconnectDialog() => showDialog<bool>( | ||||||
|         context: this, |         context: this, | ||||||
|         builder: (ctx) => AlertDialog( |         builder: (ctx) => AlertDialog( | ||||||
|           title: const Text('Disconnect'), |           title: Text(AppLocalizations.of(this)!.chatCallDisconnect), | ||||||
|           content: const Text('Are you sure to disconnect?'), |           content: Text(AppLocalizations.of(this)!.chatCallDisconnectConfirm), | ||||||
|           actions: [ |           actions: [ | ||||||
|             TextButton( |             TextButton( | ||||||
|               onPressed: () => Navigator.pop(ctx, false), |               onPressed: () => Navigator.pop(ctx, false), | ||||||
|               child: const Text('Cancel'), |               child: Text(AppLocalizations.of(this)!.confirmCancel), | ||||||
|             ), |             ), | ||||||
|             TextButton( |             TextButton( | ||||||
|               onPressed: () => Navigator.pop(ctx, true), |               onPressed: () => Navigator.pop(ctx, true), | ||||||
|               child: const Text('Disconnect'), |               child: Text(AppLocalizations.of(this)!.confirmOkay), | ||||||
|             ), |             ), | ||||||
|           ], |           ], | ||||||
|         ), |         ), | ||||||
|   | |||||||
| @@ -5,7 +5,7 @@ class LayoutWrapper extends StatelessWidget { | |||||||
|   final Widget? child; |   final Widget? child; | ||||||
|   final Widget? floatingActionButton; |   final Widget? floatingActionButton; | ||||||
|   final List<Widget>? appBarActions; |   final List<Widget>? appBarActions; | ||||||
|   final bool? noSafeArea; |   final bool noSafeArea; | ||||||
|   final String title; |   final String title; | ||||||
|  |  | ||||||
|   const LayoutWrapper({ |   const LayoutWrapper({ | ||||||
| @@ -14,7 +14,7 @@ class LayoutWrapper extends StatelessWidget { | |||||||
|     required this.title, |     required this.title, | ||||||
|     this.floatingActionButton, |     this.floatingActionButton, | ||||||
|     this.appBarActions, |     this.appBarActions, | ||||||
|     this.noSafeArea, |     this.noSafeArea = false, | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -25,7 +25,7 @@ class LayoutWrapper extends StatelessWidget { | |||||||
|       appBar: AppBar(title: Text(title), actions: appBarActions), |       appBar: AppBar(title: Text(title), actions: appBarActions), | ||||||
|       floatingActionButton: floatingActionButton, |       floatingActionButton: floatingActionButton, | ||||||
|       drawer: const SolianNavigationDrawer(), |       drawer: const SolianNavigationDrawer(), | ||||||
|       body: (noSafeArea ?? false) ? content : SafeArea(child: content), |       body: noSafeArea ? content : SafeArea(child: content), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -3,16 +3,16 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart'; | |||||||
|  |  | ||||||
| extension SolianCommonExtensions on BuildContext { | extension SolianCommonExtensions on BuildContext { | ||||||
|   Future<void> showErrorDialog(dynamic exception) => showDialog<void>( |   Future<void> showErrorDialog(dynamic exception) => showDialog<void>( | ||||||
|     context: this, |         context: this, | ||||||
|     builder: (ctx) => AlertDialog( |         builder: (ctx) => AlertDialog( | ||||||
|       title: Text(AppLocalizations.of(this)!.errorHappened), |           title: Text(AppLocalizations.of(this)!.errorHappened), | ||||||
|       content: Text(exception.toString()), |           content: Text(exception.toString()), | ||||||
|       actions: [ |           actions: [ | ||||||
|         TextButton( |             TextButton( | ||||||
|           onPressed: () => Navigator.pop(ctx), |               onPressed: () => Navigator.pop(ctx), | ||||||
|           child: Text(AppLocalizations.of(this)!.confirmOkay), |               child: Text(AppLocalizations.of(this)!.confirmOkay), | ||||||
|         ) |             ) | ||||||
|       ], |           ], | ||||||
|     ), |         ), | ||||||
|   ); |       ); | ||||||
| } | } | ||||||
| @@ -1,9 +1,10 @@ | |||||||
| import 'package:flutter/material.dart'; | import 'package:flutter/material.dart'; | ||||||
|  | import 'package:solian/router.dart'; | ||||||
| import 'package:solian/widgets/common_wrapper.dart'; | import 'package:solian/widgets/common_wrapper.dart'; | ||||||
| import 'package:solian/widgets/navigation_drawer.dart'; | import 'package:solian/widgets/navigation_drawer.dart'; | ||||||
|  |  | ||||||
| class IndentWrapper extends LayoutWrapper { | class IndentWrapper extends LayoutWrapper { | ||||||
|   final bool? hideDrawer; |   final bool hideDrawer; | ||||||
|  |  | ||||||
|   const IndentWrapper({ |   const IndentWrapper({ | ||||||
|     super.key, |     super.key, | ||||||
| @@ -11,8 +12,8 @@ class IndentWrapper extends LayoutWrapper { | |||||||
|     required super.title, |     required super.title, | ||||||
|     super.floatingActionButton, |     super.floatingActionButton, | ||||||
|     super.appBarActions, |     super.appBarActions, | ||||||
|     this.hideDrawer, |     this.hideDrawer = false, | ||||||
|     super.noSafeArea, |     super.noSafeArea = false, | ||||||
|   }) : super(); |   }) : super(); | ||||||
|  |  | ||||||
|   @override |   @override | ||||||
| @@ -20,10 +21,17 @@ class IndentWrapper extends LayoutWrapper { | |||||||
|     final content = child ?? Container(); |     final content = child ?? Container(); | ||||||
|  |  | ||||||
|     return Scaffold( |     return Scaffold( | ||||||
|       appBar: AppBar(title: Text(title), actions: appBarActions), |       appBar: AppBar( | ||||||
|  |         leading: hideDrawer ? IconButton( | ||||||
|  |           icon: const Icon(Icons.arrow_back), | ||||||
|  |           onPressed: () => router.pop(), | ||||||
|  |         ) : null, | ||||||
|  |         title: Text(title), | ||||||
|  |         actions: appBarActions, | ||||||
|  |       ), | ||||||
|       floatingActionButton: floatingActionButton, |       floatingActionButton: floatingActionButton, | ||||||
|       drawer: (hideDrawer ?? false) ? null : const SolianNavigationDrawer(), |       drawer: const SolianNavigationDrawer(), | ||||||
|       body: (noSafeArea ?? false) ? content : SafeArea(child: content), |       body: noSafeArea ? content : SafeArea(child: content), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -62,7 +62,9 @@ class _AttachmentItemState extends State<AttachmentItem> { | |||||||
|                     : Positioned( |                     : Positioned( | ||||||
|                         right: 12, |                         right: 12, | ||||||
|                         bottom: 8, |                         bottom: 8, | ||||||
|                         child: Chip(label: Text(widget.badge!)), |                         child: Material( | ||||||
|  |                           child: Chip(label: Text(widget.badge!)), | ||||||
|  |                         ), | ||||||
|                       ) |                       ) | ||||||
|               ], |               ], | ||||||
|             ), |             ), | ||||||
|   | |||||||
| @@ -169,6 +169,14 @@ packages: | |||||||
|       url: "https://pub.dev" |       url: "https://pub.dev" | ||||||
|     source: hosted |     source: hosted | ||||||
|     version: "7.0.0" |     version: "7.0.0" | ||||||
|  |   draggable_float_widget: | ||||||
|  |     dependency: "direct main" | ||||||
|  |     description: | ||||||
|  |       name: draggable_float_widget | ||||||
|  |       sha256: "075675c56f6b2bfc9f972a3937dc1b59838489a312f75fe7e90ba6844a84dce4" | ||||||
|  |       url: "https://pub.dev" | ||||||
|  |     source: hosted | ||||||
|  |     version: "0.1.0" | ||||||
|   fake_async: |   fake_async: | ||||||
|     dependency: transitive |     dependency: transitive | ||||||
|     description: |     description: | ||||||
|   | |||||||
| @@ -66,6 +66,7 @@ dependencies: | |||||||
|   flutter_background: ^1.2.0 |   flutter_background: ^1.2.0 | ||||||
|   wakelock_plus: ^1.2.4 |   wakelock_plus: ^1.2.4 | ||||||
|   flutter_local_notifications: ^17.1.0 |   flutter_local_notifications: ^17.1.0 | ||||||
|  |   draggable_float_widget: ^0.1.0 | ||||||
|  |  | ||||||
| dev_dependencies: | dev_dependencies: | ||||||
|   flutter_test: |   flutter_test: | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user