remote_page.dart 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400
  1. import 'dart:async';
  2. import 'dart:ui' as ui;
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter/services.dart';
  5. import 'package:flutter_hbb/common/shared_state.dart';
  6. import 'package:flutter_hbb/common/widgets/toolbar.dart';
  7. import 'package:flutter_hbb/consts.dart';
  8. import 'package:flutter_hbb/mobile/widgets/gesture_help.dart';
  9. import 'package:flutter_hbb/models/chat_model.dart';
  10. import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart';
  11. import 'package:flutter_svg/svg.dart';
  12. import 'package:get/get.dart';
  13. import 'package:provider/provider.dart';
  14. import 'package:wakelock_plus/wakelock_plus.dart';
  15. import '../../common.dart';
  16. import '../../common/widgets/overlay.dart';
  17. import '../../common/widgets/dialog.dart';
  18. import '../../common/widgets/remote_input.dart';
  19. import '../../models/input_model.dart';
  20. import '../../models/model.dart';
  21. import '../../models/platform_model.dart';
  22. import '../../utils/image.dart';
  23. import '../widgets/dialog.dart';
  24. final initText = '1' * 1024;
  25. // Workaround for Android (default input method, Microsoft SwiftKey keyboard) when using physical keyboard.
  26. // When connecting a physical keyboard, `KeyEvent.physicalKey.usbHidUsage` are wrong is using Microsoft SwiftKey keyboard.
  27. // https://github.com/flutter/flutter/issues/159384
  28. // https://github.com/flutter/flutter/issues/159383
  29. void _disableAndroidSoftKeyboard({bool? isKeyboardVisible}) {
  30. if (isAndroid) {
  31. if (isKeyboardVisible != true) {
  32. // `enable_soft_keyboard` will be set to `true` when clicking the keyboard icon, in `openKeyboard()`.
  33. gFFI.invokeMethod("enable_soft_keyboard", false);
  34. }
  35. }
  36. }
  37. class RemotePage extends StatefulWidget {
  38. RemotePage({Key? key, required this.id, this.password, this.isSharedPassword, this.forceRelay})
  39. : super(key: key);
  40. final String id;
  41. final String? password;
  42. final bool? isSharedPassword;
  43. final bool? forceRelay;
  44. @override
  45. State<RemotePage> createState() => _RemotePageState(id);
  46. }
  47. class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
  48. Timer? _timer;
  49. bool _showBar = !isWebDesktop;
  50. bool _showGestureHelp = false;
  51. String _value = '';
  52. Orientation? _currentOrientation;
  53. double _viewInsetsBottom = 0;
  54. Timer? _timerDidChangeMetrics;
  55. final _blockableOverlayState = BlockableOverlayState();
  56. final keyboardVisibilityController = KeyboardVisibilityController();
  57. late final StreamSubscription<bool> keyboardSubscription;
  58. final FocusNode _mobileFocusNode = FocusNode();
  59. final FocusNode _physicalFocusNode = FocusNode();
  60. var _showEdit = false; // use soft keyboard
  61. InputModel get inputModel => gFFI.inputModel;
  62. SessionID get sessionId => gFFI.sessionId;
  63. final TextEditingController _textController =
  64. TextEditingController(text: initText);
  65. _RemotePageState(String id) {
  66. initSharedStates(id);
  67. gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
  68. gFFI.dialogManager.loadMobileActionsOverlayVisible();
  69. }
  70. @override
  71. void initState() {
  72. super.initState();
  73. gFFI.ffiModel.updateEventListener(sessionId, widget.id);
  74. gFFI.start(
  75. widget.id,
  76. password: widget.password,
  77. isSharedPassword: widget.isSharedPassword,
  78. forceRelay: widget.forceRelay,
  79. );
  80. WidgetsBinding.instance.addPostFrameCallback((_) {
  81. SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
  82. gFFI.dialogManager
  83. .showLoading(translate('Connecting...'), onCancel: closeConnection);
  84. });
  85. if (!isWeb) {
  86. WakelockPlus.enable();
  87. }
  88. _physicalFocusNode.requestFocus();
  89. gFFI.inputModel.listenToMouse(true);
  90. gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId);
  91. keyboardSubscription =
  92. keyboardVisibilityController.onChange.listen(onSoftKeyboardChanged);
  93. gFFI.chatModel
  94. .changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
  95. _blockableOverlayState.applyFfi(gFFI);
  96. gFFI.imageModel.addCallbackOnFirstImage((String peerId) {
  97. gFFI.recordingModel
  98. .updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId));
  99. if (gFFI.recordingModel.start) {
  100. showToast(translate('Automatically record outgoing sessions'));
  101. }
  102. _disableAndroidSoftKeyboard(
  103. isKeyboardVisible: keyboardVisibilityController.isVisible);
  104. });
  105. WidgetsBinding.instance.addObserver(this);
  106. }
  107. @override
  108. Future<void> dispose() async {
  109. WidgetsBinding.instance.removeObserver(this);
  110. // https://github.com/flutter/flutter/issues/64935
  111. super.dispose();
  112. gFFI.dialogManager.hideMobileActionsOverlay(store: false);
  113. gFFI.inputModel.listenToMouse(false);
  114. gFFI.imageModel.disposeImage();
  115. gFFI.cursorModel.disposeImages();
  116. await gFFI.invokeMethod("enable_soft_keyboard", true);
  117. _mobileFocusNode.dispose();
  118. _physicalFocusNode.dispose();
  119. await gFFI.close();
  120. _timer?.cancel();
  121. _timerDidChangeMetrics?.cancel();
  122. gFFI.dialogManager.dismissAll();
  123. await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
  124. overlays: SystemUiOverlay.values);
  125. if (!isWeb) {
  126. await WakelockPlus.disable();
  127. }
  128. await keyboardSubscription.cancel();
  129. removeSharedStates(widget.id);
  130. // `on_voice_call_closed` should be called when the connection is ended.
  131. // The inner logic of `on_voice_call_closed` will check if the voice call is active.
  132. // Only one client is considered here for now.
  133. gFFI.chatModel.onVoiceCallClosed("End connetion");
  134. }
  135. @override
  136. void didChangeAppLifecycleState(AppLifecycleState state) {
  137. if (state == AppLifecycleState.resumed) {
  138. trySyncClipboard();
  139. }
  140. }
  141. // For client side
  142. // When swithing from other app to this app, try to sync clipboard.
  143. void trySyncClipboard() {
  144. gFFI.invokeMethod("try_sync_clipboard");
  145. }
  146. @override
  147. void didChangeMetrics() {
  148. // If the soft keyboard is visible and the canvas has been changed(panned or scaled)
  149. // Don't try reset the view style and focus the cursor.
  150. if (gFFI.cursorModel.lastKeyboardIsVisible &&
  151. gFFI.canvasModel.isMobileCanvasChanged) {
  152. return;
  153. }
  154. final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
  155. _timerDidChangeMetrics?.cancel();
  156. _timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
  157. // We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
  158. if (newBottom != _viewInsetsBottom) {
  159. gFFI.canvasModel.mobileFocusCanvasCursor();
  160. _viewInsetsBottom = newBottom;
  161. }
  162. });
  163. }
  164. // to-do: It should be better to use transparent color instead of the bgColor.
  165. // But for now, the transparent color will cause the canvas to be white.
  166. // I'm sure that the white color is caused by the Overlay widget in BlockableOverlay.
  167. // But I don't know why and how to fix it.
  168. Widget emptyOverlay(Color bgColor) => BlockableOverlay(
  169. /// the Overlay key will be set with _blockableOverlayState in BlockableOverlay
  170. /// see override build() in [BlockableOverlay]
  171. state: _blockableOverlayState,
  172. underlying: Container(
  173. color: bgColor,
  174. ),
  175. );
  176. void onSoftKeyboardChanged(bool visible) {
  177. if (!visible) {
  178. SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []);
  179. // [pi.version.isNotEmpty] -> check ready or not, avoid login without soft-keyboard
  180. if (gFFI.chatModel.chatWindowOverlayEntry == null &&
  181. gFFI.ffiModel.pi.version.isNotEmpty) {
  182. gFFI.invokeMethod("enable_soft_keyboard", false);
  183. }
  184. } else {
  185. _timer?.cancel();
  186. _timer = Timer(kMobileDelaySoftKeyboardFocus, () {
  187. SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
  188. overlays: SystemUiOverlay.values);
  189. _mobileFocusNode.requestFocus();
  190. });
  191. }
  192. // update for Scaffold
  193. setState(() {});
  194. }
  195. void _handleIOSSoftKeyboardInput(String newValue) {
  196. var oldValue = _value;
  197. _value = newValue;
  198. var i = newValue.length - 1;
  199. for (; i >= 0 && newValue[i] != '1'; --i) {}
  200. var j = oldValue.length - 1;
  201. for (; j >= 0 && oldValue[j] != '1'; --j) {}
  202. if (i < j) j = i;
  203. var subNewValue = newValue.substring(j + 1);
  204. var subOldValue = oldValue.substring(j + 1);
  205. // get common prefix of subNewValue and subOldValue
  206. var common = 0;
  207. for (;
  208. common < subOldValue.length &&
  209. common < subNewValue.length &&
  210. subNewValue[common] == subOldValue[common];
  211. ++common) {}
  212. // get newStr from subNewValue
  213. var newStr = "";
  214. if (subNewValue.length > common) {
  215. newStr = subNewValue.substring(common);
  216. }
  217. // Set the value to the old value and early return if is still composing. (1 && 2)
  218. // 1. The composing range is valid
  219. // 2. The new string is shorter than the composing range.
  220. if (_textController.value.isComposingRangeValid) {
  221. final composingLength = _textController.value.composing.end -
  222. _textController.value.composing.start;
  223. if (composingLength > newStr.length) {
  224. _value = oldValue;
  225. return;
  226. }
  227. }
  228. // Delete the different part in the old value.
  229. for (i = 0; i < subOldValue.length - common; ++i) {
  230. inputModel.inputKey('VK_BACK');
  231. }
  232. // Input the new string.
  233. if (newStr.length > 1) {
  234. bind.sessionInputString(sessionId: sessionId, value: newStr);
  235. } else {
  236. inputChar(newStr);
  237. }
  238. }
  239. void _handleNonIOSSoftKeyboardInput(String newValue) {
  240. var oldValue = _value;
  241. _value = newValue;
  242. if (oldValue.isNotEmpty &&
  243. newValue.isNotEmpty &&
  244. oldValue[0] == '1' &&
  245. newValue[0] != '1') {
  246. // clipboard
  247. oldValue = '';
  248. }
  249. if (newValue.length == oldValue.length) {
  250. // ?
  251. } else if (newValue.length < oldValue.length) {
  252. final char = 'VK_BACK';
  253. inputModel.inputKey(char);
  254. } else {
  255. final content = newValue.substring(oldValue.length);
  256. if (content.length > 1) {
  257. if (oldValue != '' &&
  258. content.length == 2 &&
  259. (content == '""' ||
  260. content == '()' ||
  261. content == '[]' ||
  262. content == '<>' ||
  263. content == "{}" ||
  264. content == '”“' ||
  265. content == '《》' ||
  266. content == '()' ||
  267. content == '【】')) {
  268. // can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input
  269. bind.sessionInputString(sessionId: sessionId, value: content);
  270. openKeyboard();
  271. return;
  272. }
  273. bind.sessionInputString(sessionId: sessionId, value: content);
  274. } else {
  275. inputChar(content);
  276. }
  277. }
  278. }
  279. // handle mobile virtual keyboard
  280. void handleSoftKeyboardInput(String newValue) {
  281. if (isIOS) {
  282. _handleIOSSoftKeyboardInput(newValue);
  283. } else {
  284. _handleNonIOSSoftKeyboardInput(newValue);
  285. }
  286. }
  287. void inputChar(String char) {
  288. if (char == '\n') {
  289. char = 'VK_RETURN';
  290. } else if (char == ' ') {
  291. char = 'VK_SPACE';
  292. }
  293. inputModel.inputKey(char);
  294. }
  295. void openKeyboard() {
  296. gFFI.invokeMethod("enable_soft_keyboard", true);
  297. // destroy first, so that our _value trick can work
  298. _value = initText;
  299. _textController.text = _value;
  300. setState(() => _showEdit = false);
  301. _timer?.cancel();
  302. _timer = Timer(kMobileDelaySoftKeyboard, () {
  303. // show now, and sleep a while to requestFocus to
  304. // make sure edit ready, so that keyboard won't show/hide/show/hide happen
  305. setState(() => _showEdit = true);
  306. _timer?.cancel();
  307. _timer = Timer(kMobileDelaySoftKeyboardFocus, () {
  308. SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual,
  309. overlays: SystemUiOverlay.values);
  310. _mobileFocusNode.requestFocus();
  311. });
  312. });
  313. }
  314. Widget _bottomWidget() => _showGestureHelp
  315. ? getGestureHelp()
  316. : (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty
  317. ? getBottomAppBar()
  318. : Offstage());
  319. @override
  320. Widget build(BuildContext context) {
  321. final keyboardIsVisible =
  322. keyboardVisibilityController.isVisible && _showEdit;
  323. final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp;
  324. return WillPopScope(
  325. onWillPop: () async {
  326. clientClose(sessionId, gFFI.dialogManager);
  327. return false;
  328. },
  329. child: Scaffold(
  330. // workaround for https://github.com/rustdesk/rustdesk/issues/3131
  331. floatingActionButtonLocation: keyboardIsVisible
  332. ? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35)
  333. : null,
  334. floatingActionButton: !showActionButton
  335. ? null
  336. : FloatingActionButton(
  337. mini: !keyboardIsVisible,
  338. child: Icon(
  339. (keyboardIsVisible || _showGestureHelp)
  340. ? Icons.expand_more
  341. : Icons.expand_less,
  342. color: Colors.white,
  343. ),
  344. backgroundColor: MyTheme.accent,
  345. onPressed: () {
  346. setState(() {
  347. if (keyboardIsVisible) {
  348. _showEdit = false;
  349. gFFI.invokeMethod("enable_soft_keyboard", false);
  350. _mobileFocusNode.unfocus();
  351. _physicalFocusNode.requestFocus();
  352. } else if (_showGestureHelp) {
  353. _showGestureHelp = false;
  354. } else {
  355. _showBar = !_showBar;
  356. }
  357. });
  358. }),
  359. bottomNavigationBar: Obx(() => Stack(
  360. alignment: Alignment.bottomCenter,
  361. children: [
  362. gFFI.ffiModel.pi.isSet.isTrue &&
  363. gFFI.ffiModel.waitForFirstImage.isTrue
  364. ? emptyOverlay(MyTheme.canvasColor)
  365. : () {
  366. gFFI.ffiModel.tryShowAndroidActionsOverlay();
  367. return Offstage();
  368. }(),
  369. _bottomWidget(),
  370. gFFI.ffiModel.pi.isSet.isFalse
  371. ? emptyOverlay(MyTheme.canvasColor)
  372. : Offstage(),
  373. ],
  374. )),
  375. body: Obx(
  376. () => getRawPointerAndKeyBody(Overlay(
  377. initialEntries: [
  378. OverlayEntry(builder: (context) {
  379. return Container(
  380. color: kColorCanvas,
  381. child: isWebDesktop
  382. ? getBodyForDesktopWithListener()
  383. : SafeArea(
  384. child:
  385. OrientationBuilder(builder: (ctx, orientation) {
  386. if (_currentOrientation != orientation) {
  387. Timer(const Duration(milliseconds: 200), () {
  388. gFFI.dialogManager
  389. .resetMobileActionsOverlay(ffi: gFFI);
  390. _currentOrientation = orientation;
  391. gFFI.canvasModel.updateViewStyle();
  392. });
  393. }
  394. return Container(
  395. color: MyTheme.canvasColor,
  396. child: inputModel.isPhysicalMouse.value
  397. ? getBodyForMobile()
  398. : RawTouchGestureDetectorRegion(
  399. child: getBodyForMobile(),
  400. ffi: gFFI,
  401. ),
  402. );
  403. }),
  404. ),
  405. );
  406. })
  407. ],
  408. )),
  409. )),
  410. );
  411. }
  412. Widget getRawPointerAndKeyBody(Widget child) {
  413. final ffiModel = Provider.of<FfiModel>(context);
  414. return RawPointerMouseRegion(
  415. cursor: ffiModel.keyboard ? SystemMouseCursors.none : MouseCursor.defer,
  416. inputModel: inputModel,
  417. // Disable RawKeyFocusScope before the connecting is established.
  418. // The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog.
  419. child: gFFI.ffiModel.pi.isSet.isTrue
  420. ? RawKeyFocusScope(
  421. focusNode: _physicalFocusNode,
  422. inputModel: inputModel,
  423. child: child)
  424. : child,
  425. );
  426. }
  427. Widget getBottomAppBar() {
  428. final ffiModel = Provider.of<FfiModel>(context);
  429. return BottomAppBar(
  430. elevation: 10,
  431. color: MyTheme.accent,
  432. child: Row(
  433. mainAxisSize: MainAxisSize.max,
  434. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  435. children: <Widget>[
  436. Row(
  437. children: <Widget>[
  438. IconButton(
  439. color: Colors.white,
  440. icon: Icon(Icons.clear),
  441. onPressed: () {
  442. clientClose(sessionId, gFFI.dialogManager);
  443. },
  444. ),
  445. IconButton(
  446. color: Colors.white,
  447. icon: Icon(Icons.tv),
  448. onPressed: () {
  449. setState(() => _showEdit = false);
  450. showOptions(context, widget.id, gFFI.dialogManager);
  451. },
  452. )
  453. ] +
  454. (isWebDesktop || ffiModel.viewOnly || !ffiModel.keyboard
  455. ? []
  456. : gFFI.ffiModel.isPeerAndroid
  457. ? [
  458. IconButton(
  459. color: Colors.white,
  460. icon: Icon(Icons.keyboard),
  461. onPressed: openKeyboard),
  462. IconButton(
  463. color: Colors.white,
  464. icon: const Icon(Icons.build),
  465. onPressed: () => gFFI.dialogManager
  466. .toggleMobileActionsOverlay(ffi: gFFI),
  467. )
  468. ]
  469. : [
  470. IconButton(
  471. color: Colors.white,
  472. icon: Icon(Icons.keyboard),
  473. onPressed: openKeyboard),
  474. IconButton(
  475. color: Colors.white,
  476. icon: Icon(gFFI.ffiModel.touchMode
  477. ? Icons.touch_app
  478. : Icons.mouse),
  479. onPressed: () => setState(
  480. () => _showGestureHelp = !_showGestureHelp),
  481. ),
  482. ]) +
  483. (isWeb
  484. ? []
  485. : <Widget>[
  486. futureBuilder(
  487. future: gFFI.invokeMethod(
  488. "get_value", "KEY_IS_SUPPORT_VOICE_CALL"),
  489. hasData: (isSupportVoiceCall) => IconButton(
  490. color: Colors.white,
  491. icon: isAndroid && isSupportVoiceCall
  492. ? SvgPicture.asset('assets/chat.svg',
  493. colorFilter: ColorFilter.mode(
  494. Colors.white, BlendMode.srcIn))
  495. : Icon(Icons.message),
  496. onPressed: () =>
  497. isAndroid && isSupportVoiceCall
  498. ? showChatOptions(widget.id)
  499. : onPressedTextChat(widget.id),
  500. ))
  501. ]) +
  502. [
  503. IconButton(
  504. color: Colors.white,
  505. icon: Icon(Icons.more_vert),
  506. onPressed: () {
  507. setState(() => _showEdit = false);
  508. showActions(widget.id);
  509. },
  510. ),
  511. ]),
  512. Obx(() => IconButton(
  513. color: Colors.white,
  514. icon: Icon(Icons.expand_more),
  515. onPressed: gFFI.ffiModel.waitForFirstImage.isTrue
  516. ? null
  517. : () {
  518. setState(() => _showBar = !_showBar);
  519. },
  520. )),
  521. ],
  522. ),
  523. );
  524. }
  525. bool get showCursorPaint =>
  526. !gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded;
  527. Widget getBodyForMobile() {
  528. final keyboardIsVisible = keyboardVisibilityController.isVisible;
  529. return Container(
  530. color: MyTheme.canvasColor,
  531. child: Stack(children: () {
  532. final paints = [
  533. ImagePaint(),
  534. Positioned(
  535. top: 10,
  536. right: 10,
  537. child: QualityMonitor(gFFI.qualityMonitorModel),
  538. ),
  539. KeyHelpTools(
  540. keyboardIsVisible: keyboardIsVisible,
  541. showGestureHelp: _showGestureHelp),
  542. SizedBox(
  543. width: 0,
  544. height: 0,
  545. child: !_showEdit
  546. ? Container()
  547. : TextFormField(
  548. textInputAction: TextInputAction.newline,
  549. autocorrect: false,
  550. // Flutter 3.16.9 Android.
  551. // `enableSuggestions` causes secure keyboard to be shown.
  552. // https://github.com/flutter/flutter/issues/139143
  553. // https://github.com/flutter/flutter/issues/146540
  554. // enableSuggestions: false,
  555. autofocus: true,
  556. focusNode: _mobileFocusNode,
  557. maxLines: null,
  558. controller: _textController,
  559. // trick way to make backspace work always
  560. keyboardType: TextInputType.multiline,
  561. // `onChanged` may be called depending on the input method if this widget is wrapped in
  562. // `Focus(onKeyEvent: ..., child: ...)`
  563. // For `Backspace` button in the soft keyboard:
  564. // en/fr input method:
  565. // 1. The button will not trigger `onKeyEvent` if the text field is not empty.
  566. // 2. The button will trigger `onKeyEvent` if the text field is empty.
  567. // ko/zh/ja input method: the button will trigger `onKeyEvent`
  568. // and the event will not popup if `KeyEventResult.handled` is returned.
  569. onChanged: handleSoftKeyboardInput,
  570. ).workaroundFreezeLinuxMint(),
  571. ),
  572. ];
  573. if (showCursorPaint) {
  574. paints.add(CursorPaint(widget.id));
  575. }
  576. return paints;
  577. }()));
  578. }
  579. Widget getBodyForDesktopWithListener() {
  580. final ffiModel = Provider.of<FfiModel>(context);
  581. var paints = <Widget>[ImagePaint()];
  582. if (showCursorPaint) {
  583. final cursor = bind.sessionGetToggleOptionSync(
  584. sessionId: sessionId, arg: 'show-remote-cursor');
  585. if (ffiModel.keyboard || cursor) {
  586. paints.add(CursorPaint(widget.id));
  587. }
  588. }
  589. return Container(
  590. color: MyTheme.canvasColor, child: Stack(children: paints));
  591. }
  592. List<TTextMenu> _getMobileActionMenus() {
  593. if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid ||
  594. !gFFI.ffiModel.keyboard) {
  595. return [];
  596. }
  597. final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0;
  598. if (!enabled) return [];
  599. return [
  600. TTextMenu(
  601. child: Text(translate('Back')),
  602. onPressed: () => gFFI.inputModel.onMobileBack(),
  603. ),
  604. TTextMenu(
  605. child: Text(translate('Home')),
  606. onPressed: () => gFFI.inputModel.onMobileHome(),
  607. ),
  608. TTextMenu(
  609. child: Text(translate('Apps')),
  610. onPressed: () => gFFI.inputModel.onMobileApps(),
  611. ),
  612. TTextMenu(
  613. child: Text(translate('Volume up')),
  614. onPressed: () => gFFI.inputModel.onMobileVolumeUp(),
  615. ),
  616. TTextMenu(
  617. child: Text(translate('Volume down')),
  618. onPressed: () => gFFI.inputModel.onMobileVolumeDown(),
  619. ),
  620. TTextMenu(
  621. child: Text(translate('Power')),
  622. onPressed: () => gFFI.inputModel.onMobilePower(),
  623. ),
  624. ];
  625. }
  626. void showActions(String id) async {
  627. final size = MediaQuery.of(context).size;
  628. final x = 120.0;
  629. final y = size.height;
  630. final mobileActionMenus = _getMobileActionMenus();
  631. final menus = toolbarControls(context, id, gFFI);
  632. final List<PopupMenuEntry<int>> more = [
  633. ...mobileActionMenus
  634. .asMap()
  635. .entries
  636. .map((e) =>
  637. PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
  638. .toList(),
  639. if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
  640. ...menus
  641. .asMap()
  642. .entries
  643. .map((e) => PopupMenuItem<int>(
  644. child: e.value.getChild(),
  645. value: e.key + mobileActionMenus.length))
  646. .toList(),
  647. ];
  648. () async {
  649. var index = await showMenu(
  650. context: context,
  651. position: RelativeRect.fromLTRB(x, y, x, y),
  652. items: more,
  653. elevation: 8,
  654. );
  655. if (index != null) {
  656. if (index < mobileActionMenus.length) {
  657. mobileActionMenus[index].onPressed?.call();
  658. } else if (index < mobileActionMenus.length + more.length) {
  659. menus[index - mobileActionMenus.length].onPressed?.call();
  660. }
  661. }
  662. }();
  663. }
  664. onPressedTextChat(String id) {
  665. gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID));
  666. gFFI.chatModel.toggleChatOverlay();
  667. }
  668. showChatOptions(String id) async {
  669. onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId);
  670. onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId);
  671. makeTextMenu(String label, Widget icon, VoidCallback onPressed,
  672. {TextStyle? labelStyle}) =>
  673. TTextMenu(
  674. child: Text(translate(label), style: labelStyle),
  675. trailingIcon: Transform.scale(
  676. scale: (isDesktop || isWebDesktop) ? 0.8 : 1,
  677. child: IgnorePointer(
  678. child: IconButton(
  679. onPressed: null,
  680. icon: icon,
  681. ),
  682. ),
  683. ),
  684. onPressed: onPressed,
  685. );
  686. final isInVoice = [
  687. VoiceCallStatus.waitingForResponse,
  688. VoiceCallStatus.connected
  689. ].contains(gFFI.chatModel.voiceCallStatus.value);
  690. final menus = [
  691. makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent),
  692. () => onPressedTextChat(widget.id)),
  693. isInVoice
  694. ? makeTextMenu(
  695. 'End voice call',
  696. SvgPicture.asset(
  697. 'assets/call_wait.svg',
  698. colorFilter:
  699. ColorFilter.mode(Colors.redAccent, BlendMode.srcIn),
  700. ),
  701. onPressEndVoiceCall,
  702. labelStyle: TextStyle(color: Colors.redAccent))
  703. : makeTextMenu(
  704. 'Voice call',
  705. SvgPicture.asset(
  706. 'assets/call_wait.svg',
  707. colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn),
  708. ),
  709. onPressVoiceCall),
  710. ];
  711. final menuItems = menus
  712. .asMap()
  713. .entries
  714. .map((e) => PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
  715. .toList();
  716. Future.delayed(Duration.zero, () async {
  717. final size = MediaQuery.of(context).size;
  718. final x = 120.0;
  719. final y = size.height;
  720. var index = await showMenu(
  721. context: context,
  722. position: RelativeRect.fromLTRB(x, y, x, y),
  723. items: menuItems,
  724. elevation: 8,
  725. );
  726. if (index != null && index < menus.length) {
  727. menus[index].onPressed?.call();
  728. }
  729. });
  730. }
  731. /// aka changeTouchMode
  732. BottomAppBar getGestureHelp() {
  733. return BottomAppBar(
  734. child: SingleChildScrollView(
  735. controller: ScrollController(),
  736. padding: EdgeInsets.symmetric(vertical: 10),
  737. child: GestureHelp(
  738. touchMode: gFFI.ffiModel.touchMode,
  739. onTouchModeChange: (t) {
  740. gFFI.ffiModel.toggleTouchMode();
  741. final v = gFFI.ffiModel.touchMode ? 'Y' : '';
  742. bind.sessionPeerOption(
  743. sessionId: sessionId, name: kOptionTouchMode, value: v);
  744. })));
  745. }
  746. // * Currently mobile does not enable map mode
  747. // void changePhysicalKeyboardInputMode() async {
  748. // var current = await bind.sessionGetKeyboardMode(id: widget.id) ?? "legacy";
  749. // gFFI.dialogManager.show((setState, close) {
  750. // void setMode(String? v) async {
  751. // await bind.sessionSetKeyboardMode(id: widget.id, value: v ?? "");
  752. // setState(() => current = v ?? '');
  753. // Future.delayed(Duration(milliseconds: 300), close);
  754. // }
  755. //
  756. // return CustomAlertDialog(
  757. // title: Text(translate('Physical Keyboard Input Mode')),
  758. // content: Column(mainAxisSize: MainAxisSize.min, children: [
  759. // getRadio('Legacy mode', 'legacy', current, setMode),
  760. // getRadio('Map mode', 'map', current, setMode),
  761. // ]));
  762. // }, clickMaskDismiss: true);
  763. // }
  764. }
  765. class KeyHelpTools extends StatefulWidget {
  766. final bool keyboardIsVisible;
  767. final bool showGestureHelp;
  768. /// need to show by external request, etc [keyboardIsVisible] or [changeTouchMode]
  769. bool get requestShow => keyboardIsVisible || showGestureHelp;
  770. KeyHelpTools(
  771. {required this.keyboardIsVisible, required this.showGestureHelp});
  772. @override
  773. State<KeyHelpTools> createState() => _KeyHelpToolsState();
  774. }
  775. class _KeyHelpToolsState extends State<KeyHelpTools> {
  776. var _more = true;
  777. var _fn = false;
  778. var _pin = false;
  779. final _keyboardVisibilityController = KeyboardVisibilityController();
  780. final _key = GlobalKey();
  781. InputModel get inputModel => gFFI.inputModel;
  782. Widget wrap(String text, void Function() onPressed,
  783. {bool? active, IconData? icon}) {
  784. return TextButton(
  785. style: TextButton.styleFrom(
  786. minimumSize: Size(0, 0),
  787. padding: EdgeInsets.symmetric(vertical: 10, horizontal: 9.75),
  788. //adds padding inside the button
  789. tapTargetSize: MaterialTapTargetSize.shrinkWrap,
  790. //limits the touch area to the button area
  791. shape: RoundedRectangleBorder(
  792. borderRadius: BorderRadius.circular(5.0),
  793. ),
  794. backgroundColor: active == true ? MyTheme.accent80 : null,
  795. ),
  796. child: icon != null
  797. ? Icon(icon, size: 14, color: Colors.white)
  798. : Text(translate(text),
  799. style: TextStyle(color: Colors.white, fontSize: 11)),
  800. onPressed: onPressed);
  801. }
  802. _updateRect() {
  803. RenderObject? renderObject = _key.currentContext?.findRenderObject();
  804. if (renderObject == null) {
  805. return;
  806. }
  807. if (renderObject is RenderBox) {
  808. final size = renderObject.size;
  809. Offset pos = renderObject.localToGlobal(Offset.zero);
  810. gFFI.cursorModel.keyHelpToolsVisibilityChanged(
  811. Rect.fromLTWH(pos.dx, pos.dy, size.width, size.height),
  812. widget.keyboardIsVisible);
  813. }
  814. }
  815. @override
  816. Widget build(BuildContext context) {
  817. final hasModifierOn = inputModel.ctrl ||
  818. inputModel.alt ||
  819. inputModel.shift ||
  820. inputModel.command;
  821. if (!_pin && !hasModifierOn && !widget.requestShow) {
  822. gFFI.cursorModel
  823. .keyHelpToolsVisibilityChanged(null, widget.keyboardIsVisible);
  824. return Offstage();
  825. }
  826. final size = MediaQuery.of(context).size;
  827. final pi = gFFI.ffiModel.pi;
  828. final isMac = pi.platform == kPeerPlatformMacOS;
  829. final isWin = pi.platform == kPeerPlatformWindows;
  830. final isLinux = pi.platform == kPeerPlatformLinux;
  831. final modifiers = <Widget>[
  832. wrap('Ctrl ', () {
  833. setState(() => inputModel.ctrl = !inputModel.ctrl);
  834. }, active: inputModel.ctrl),
  835. wrap(' Alt ', () {
  836. setState(() => inputModel.alt = !inputModel.alt);
  837. }, active: inputModel.alt),
  838. wrap('Shift', () {
  839. setState(() => inputModel.shift = !inputModel.shift);
  840. }, active: inputModel.shift),
  841. wrap(isMac ? ' Cmd ' : ' Win ', () {
  842. setState(() => inputModel.command = !inputModel.command);
  843. }, active: inputModel.command),
  844. ];
  845. final keys = <Widget>[
  846. wrap(
  847. ' Fn ',
  848. () => setState(
  849. () {
  850. _fn = !_fn;
  851. if (_fn) {
  852. _more = false;
  853. }
  854. },
  855. ),
  856. active: _fn),
  857. wrap(
  858. '',
  859. () => setState(
  860. () => _pin = !_pin,
  861. ),
  862. active: _pin,
  863. icon: Icons.push_pin),
  864. wrap(
  865. ' ... ',
  866. () => setState(
  867. () {
  868. _more = !_more;
  869. if (_more) {
  870. _fn = false;
  871. }
  872. },
  873. ),
  874. active: _more),
  875. ];
  876. final fn = <Widget>[
  877. SizedBox(width: 9999),
  878. ];
  879. for (var i = 1; i <= 12; ++i) {
  880. final name = 'F$i';
  881. fn.add(wrap(name, () {
  882. inputModel.inputKey('VK_$name');
  883. }));
  884. }
  885. final more = <Widget>[
  886. SizedBox(width: 9999),
  887. wrap('Esc', () {
  888. inputModel.inputKey('VK_ESCAPE');
  889. }),
  890. wrap('Tab', () {
  891. inputModel.inputKey('VK_TAB');
  892. }),
  893. wrap('Home', () {
  894. inputModel.inputKey('VK_HOME');
  895. }),
  896. wrap('End', () {
  897. inputModel.inputKey('VK_END');
  898. }),
  899. wrap('Ins', () {
  900. inputModel.inputKey('VK_INSERT');
  901. }),
  902. wrap('Del', () {
  903. inputModel.inputKey('VK_DELETE');
  904. }),
  905. wrap('PgUp', () {
  906. inputModel.inputKey('VK_PRIOR');
  907. }),
  908. wrap('PgDn', () {
  909. inputModel.inputKey('VK_NEXT');
  910. }),
  911. // to-do: support PrtScr on Mac
  912. if (isWin || isLinux)
  913. wrap('PrtScr', () {
  914. inputModel.inputKey('VK_SNAPSHOT');
  915. }),
  916. if (isWin || isLinux)
  917. wrap('ScrollLock', () {
  918. inputModel.inputKey('VK_SCROLL');
  919. }),
  920. if (isWin || isLinux)
  921. wrap('Pause', () {
  922. inputModel.inputKey('VK_PAUSE');
  923. }),
  924. if (isWin || isLinux)
  925. // Maybe it's better to call it "Menu"
  926. // https://en.wikipedia.org/wiki/Menu_key
  927. wrap('Menu', () {
  928. inputModel.inputKey('Apps');
  929. }),
  930. wrap('Enter', () {
  931. inputModel.inputKey('VK_ENTER');
  932. }),
  933. SizedBox(width: 9999),
  934. wrap('', () {
  935. inputModel.inputKey('VK_LEFT');
  936. }, icon: Icons.keyboard_arrow_left),
  937. wrap('', () {
  938. inputModel.inputKey('VK_UP');
  939. }, icon: Icons.keyboard_arrow_up),
  940. wrap('', () {
  941. inputModel.inputKey('VK_DOWN');
  942. }, icon: Icons.keyboard_arrow_down),
  943. wrap('', () {
  944. inputModel.inputKey('VK_RIGHT');
  945. }, icon: Icons.keyboard_arrow_right),
  946. wrap(isMac ? 'Cmd+C' : 'Ctrl+C', () {
  947. sendPrompt(isMac, 'VK_C');
  948. }),
  949. wrap(isMac ? 'Cmd+V' : 'Ctrl+V', () {
  950. sendPrompt(isMac, 'VK_V');
  951. }),
  952. wrap(isMac ? 'Cmd+S' : 'Ctrl+S', () {
  953. sendPrompt(isMac, 'VK_S');
  954. }),
  955. ];
  956. final space = size.width > 320 ? 4.0 : 2.0;
  957. // 500 ms is long enough for this widget to be built!
  958. Future.delayed(Duration(milliseconds: 500), () {
  959. _updateRect();
  960. });
  961. return Container(
  962. key: _key,
  963. color: Color(0xAA000000),
  964. padding: EdgeInsets.only(
  965. top: _keyboardVisibilityController.isVisible ? 24 : 4, bottom: 8),
  966. child: Wrap(
  967. spacing: space,
  968. runSpacing: space,
  969. children: <Widget>[SizedBox(width: 9999)] +
  970. modifiers +
  971. keys +
  972. (_fn ? fn : []) +
  973. (_more ? more : []),
  974. ));
  975. }
  976. }
  977. class ImagePaint extends StatelessWidget {
  978. @override
  979. Widget build(BuildContext context) {
  980. final m = Provider.of<ImageModel>(context);
  981. final c = Provider.of<CanvasModel>(context);
  982. var s = c.scale;
  983. final adjust = c.getAdjustY();
  984. return CustomPaint(
  985. painter: ImagePainter(
  986. image: m.image, x: c.x / s, y: (c.y + adjust) / s, scale: s),
  987. );
  988. }
  989. }
  990. class CursorPaint extends StatelessWidget {
  991. late final String id;
  992. CursorPaint(this.id);
  993. @override
  994. Widget build(BuildContext context) {
  995. final m = Provider.of<CursorModel>(context);
  996. final c = Provider.of<CanvasModel>(context);
  997. final ffiModel = Provider.of<FfiModel>(context);
  998. final s = c.scale;
  999. double hotx = m.hotx;
  1000. double hoty = m.hoty;
  1001. var image = m.image;
  1002. if (image == null) {
  1003. if (preDefaultCursor.image != null) {
  1004. image = preDefaultCursor.image;
  1005. hotx = preDefaultCursor.image!.width / 2;
  1006. hoty = preDefaultCursor.image!.height / 2;
  1007. }
  1008. }
  1009. if (preForbiddenCursor.image != null &&
  1010. !ffiModel.viewOnly &&
  1011. !ffiModel.keyboard &&
  1012. !ShowRemoteCursorState.find(id).value) {
  1013. image = preForbiddenCursor.image;
  1014. hotx = preForbiddenCursor.image!.width / 2;
  1015. hoty = preForbiddenCursor.image!.height / 2;
  1016. }
  1017. if (image == null) {
  1018. return Offstage();
  1019. }
  1020. final minSize = 12.0;
  1021. double mins =
  1022. minSize / (image.width > image.height ? image.width : image.height);
  1023. double factor = 1.0;
  1024. if (s < mins) {
  1025. factor = s / mins;
  1026. }
  1027. final s2 = s < mins ? mins : s;
  1028. final adjust = c.getAdjustY();
  1029. return CustomPaint(
  1030. painter: ImagePainter(
  1031. image: image,
  1032. x: (m.x - hotx) * factor + c.x / s2,
  1033. y: (m.y - hoty) * factor + (c.y + adjust) / s2,
  1034. scale: s2),
  1035. );
  1036. }
  1037. }
  1038. void showOptions(
  1039. BuildContext context, String id, OverlayDialogManager dialogManager) async {
  1040. var displays = <Widget>[];
  1041. final pi = gFFI.ffiModel.pi;
  1042. final image = gFFI.ffiModel.getConnectionImage();
  1043. if (image != null) {
  1044. displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image));
  1045. }
  1046. if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) {
  1047. final cur = pi.currentDisplay;
  1048. final children = <Widget>[];
  1049. for (var i = 0; i < pi.displays.length; ++i) {
  1050. children.add(InkWell(
  1051. onTap: () {
  1052. if (i == cur) return;
  1053. openMonitorInTheSameTab(i, gFFI, pi);
  1054. gFFI.dialogManager.dismissAll();
  1055. },
  1056. child: Ink(
  1057. width: 40,
  1058. height: 40,
  1059. decoration: BoxDecoration(
  1060. border: Border.all(color: Theme.of(context).hintColor),
  1061. borderRadius: BorderRadius.circular(2),
  1062. color: i == cur
  1063. ? Theme.of(context).primaryColor.withOpacity(0.6)
  1064. : null),
  1065. child: Center(
  1066. child: Text((i + 1).toString(),
  1067. style: TextStyle(
  1068. color: i == cur ? Colors.white : Colors.black87,
  1069. fontWeight: FontWeight.bold))))));
  1070. }
  1071. displays.add(Padding(
  1072. padding: const EdgeInsets.only(top: 8),
  1073. child: Wrap(
  1074. alignment: WrapAlignment.center,
  1075. spacing: 8,
  1076. children: children,
  1077. )));
  1078. }
  1079. if (displays.isNotEmpty) {
  1080. displays.add(const Divider(color: MyTheme.border));
  1081. }
  1082. List<TRadioMenu<String>> viewStyleRadios =
  1083. await toolbarViewStyle(context, id, gFFI);
  1084. List<TRadioMenu<String>> imageQualityRadios =
  1085. await toolbarImageQuality(context, id, gFFI);
  1086. List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
  1087. List<TToggleMenu> cursorToggles = await toolbarCursor(context, id, gFFI);
  1088. List<TToggleMenu> displayToggles =
  1089. await toolbarDisplayToggle(context, id, gFFI);
  1090. List<TToggleMenu> privacyModeList = [];
  1091. // privacy mode
  1092. final privacyModeState = PrivacyModeState.find(id);
  1093. if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) {
  1094. privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI);
  1095. if (privacyModeList.length == 1) {
  1096. displayToggles.add(privacyModeList[0]);
  1097. }
  1098. }
  1099. dialogManager.show((setState, close, context) {
  1100. var viewStyle =
  1101. (viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs;
  1102. var imageQuality =
  1103. (imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '')
  1104. .obs;
  1105. var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs;
  1106. final radios = [
  1107. for (var e in viewStyleRadios)
  1108. Obx(() => getRadio<String>(
  1109. e.child,
  1110. e.value,
  1111. viewStyle.value,
  1112. e.onChanged != null
  1113. ? (v) {
  1114. e.onChanged?.call(v);
  1115. if (v != null) viewStyle.value = v;
  1116. }
  1117. : null)),
  1118. const Divider(color: MyTheme.border),
  1119. for (var e in imageQualityRadios)
  1120. Obx(() => getRadio<String>(
  1121. e.child,
  1122. e.value,
  1123. imageQuality.value,
  1124. e.onChanged != null
  1125. ? (v) {
  1126. e.onChanged?.call(v);
  1127. if (v != null) imageQuality.value = v;
  1128. }
  1129. : null)),
  1130. const Divider(color: MyTheme.border),
  1131. for (var e in codecRadios)
  1132. Obx(() => getRadio<String>(
  1133. e.child,
  1134. e.value,
  1135. codec.value,
  1136. e.onChanged != null
  1137. ? (v) {
  1138. e.onChanged?.call(v);
  1139. if (v != null) codec.value = v;
  1140. }
  1141. : null)),
  1142. if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border),
  1143. ];
  1144. final rxCursorToggleValues = cursorToggles.map((e) => e.value.obs).toList();
  1145. final cursorTogglesList = cursorToggles
  1146. .asMap()
  1147. .entries
  1148. .map((e) => Obx(() => CheckboxListTile(
  1149. contentPadding: EdgeInsets.zero,
  1150. visualDensity: VisualDensity.compact,
  1151. value: rxCursorToggleValues[e.key].value,
  1152. onChanged: e.value.onChanged != null
  1153. ? (v) {
  1154. e.value.onChanged?.call(v);
  1155. if (v != null) rxCursorToggleValues[e.key].value = v;
  1156. }
  1157. : null,
  1158. title: e.value.child)))
  1159. .toList();
  1160. final rxToggleValues = displayToggles.map((e) => e.value.obs).toList();
  1161. final displayTogglesList = displayToggles
  1162. .asMap()
  1163. .entries
  1164. .map((e) => Obx(() => CheckboxListTile(
  1165. contentPadding: EdgeInsets.zero,
  1166. visualDensity: VisualDensity.compact,
  1167. value: rxToggleValues[e.key].value,
  1168. onChanged: e.value.onChanged != null
  1169. ? (v) {
  1170. e.value.onChanged?.call(v);
  1171. if (v != null) rxToggleValues[e.key].value = v;
  1172. }
  1173. : null,
  1174. title: e.value.child)))
  1175. .toList();
  1176. final toggles = [
  1177. ...cursorTogglesList,
  1178. if (cursorToggles.isNotEmpty) const Divider(color: MyTheme.border),
  1179. ...displayTogglesList,
  1180. ];
  1181. Widget privacyModeWidget = Offstage();
  1182. if (privacyModeList.length > 1) {
  1183. privacyModeWidget = ListTile(
  1184. contentPadding: EdgeInsets.zero,
  1185. visualDensity: VisualDensity.compact,
  1186. title: Text(translate('Privacy mode')),
  1187. onTap: () => setPrivacyModeDialog(
  1188. dialogManager, privacyModeList, privacyModeState),
  1189. );
  1190. }
  1191. var popupDialogMenus = List<Widget>.empty(growable: true);
  1192. final resolution = getResolutionMenu(gFFI, id);
  1193. if (resolution != null) {
  1194. popupDialogMenus.add(ListTile(
  1195. contentPadding: EdgeInsets.zero,
  1196. visualDensity: VisualDensity.compact,
  1197. title: resolution.child,
  1198. onTap: () {
  1199. close();
  1200. resolution.onPressed?.call();
  1201. },
  1202. ));
  1203. }
  1204. final virtualDisplayMenu = getVirtualDisplayMenu(gFFI, id);
  1205. if (virtualDisplayMenu != null) {
  1206. popupDialogMenus.add(ListTile(
  1207. contentPadding: EdgeInsets.zero,
  1208. visualDensity: VisualDensity.compact,
  1209. title: virtualDisplayMenu.child,
  1210. onTap: () {
  1211. close();
  1212. virtualDisplayMenu.onPressed?.call();
  1213. },
  1214. ));
  1215. }
  1216. if (popupDialogMenus.isNotEmpty) {
  1217. popupDialogMenus.add(const Divider(color: MyTheme.border));
  1218. }
  1219. return CustomAlertDialog(
  1220. content: Column(
  1221. mainAxisSize: MainAxisSize.min,
  1222. children: displays +
  1223. radios +
  1224. popupDialogMenus +
  1225. toggles +
  1226. [privacyModeWidget]),
  1227. );
  1228. }, clickMaskDismiss: true, backDismiss: true).then((value) {
  1229. _disableAndroidSoftKeyboard();
  1230. });
  1231. }
  1232. TTextMenu? getVirtualDisplayMenu(FFI ffi, String id) {
  1233. if (!showVirtualDisplayMenu(ffi)) {
  1234. return null;
  1235. }
  1236. return TTextMenu(
  1237. child: Text(translate("Virtual display")),
  1238. onPressed: () {
  1239. ffi.dialogManager.show((setState, close, context) {
  1240. final children = getVirtualDisplayMenuChildren(ffi, id, close);
  1241. return CustomAlertDialog(
  1242. title: Text(translate('Virtual display')),
  1243. content: Column(
  1244. mainAxisSize: MainAxisSize.min,
  1245. children: children,
  1246. ),
  1247. );
  1248. }, clickMaskDismiss: true, backDismiss: true).then((value) {
  1249. _disableAndroidSoftKeyboard();
  1250. });
  1251. },
  1252. );
  1253. }
  1254. TTextMenu? getResolutionMenu(FFI ffi, String id) {
  1255. final ffiModel = ffi.ffiModel;
  1256. final pi = ffiModel.pi;
  1257. final resolutions = pi.resolutions;
  1258. final display = pi.tryGetDisplayIfNotAllDisplay(display: pi.currentDisplay);
  1259. final visible =
  1260. ffiModel.keyboard && (resolutions.length > 1) && display != null;
  1261. if (!visible) return null;
  1262. return TTextMenu(
  1263. child: Text(translate("Resolution")),
  1264. onPressed: () {
  1265. ffi.dialogManager.show((setState, close, context) {
  1266. final children = resolutions
  1267. .map((e) => getRadio<String>(
  1268. Text('${e.width}x${e.height}'),
  1269. '${e.width}x${e.height}',
  1270. '${display.width}x${display.height}',
  1271. (value) {
  1272. close();
  1273. bind.sessionChangeResolution(
  1274. sessionId: ffi.sessionId,
  1275. display: pi.currentDisplay,
  1276. width: e.width,
  1277. height: e.height,
  1278. );
  1279. },
  1280. ))
  1281. .toList();
  1282. return CustomAlertDialog(
  1283. title: Text(translate('Resolution')),
  1284. content: Column(
  1285. mainAxisSize: MainAxisSize.min,
  1286. children: children,
  1287. ),
  1288. );
  1289. }, clickMaskDismiss: true, backDismiss: true).then((value) {
  1290. _disableAndroidSoftKeyboard();
  1291. });
  1292. },
  1293. );
  1294. }
  1295. void sendPrompt(bool isMac, String key) {
  1296. final old = isMac ? gFFI.inputModel.command : gFFI.inputModel.ctrl;
  1297. if (isMac) {
  1298. gFFI.inputModel.command = true;
  1299. } else {
  1300. gFFI.inputModel.ctrl = true;
  1301. }
  1302. gFFI.inputModel.inputKey(key);
  1303. if (isMac) {
  1304. gFFI.inputModel.command = old;
  1305. } else {
  1306. gFFI.inputModel.ctrl = old;
  1307. }
  1308. }
  1309. class FABLocation extends FloatingActionButtonLocation {
  1310. FloatingActionButtonLocation location;
  1311. double offsetX;
  1312. double offsetY;
  1313. FABLocation(this.location, this.offsetX, this.offsetY);
  1314. @override
  1315. Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) {
  1316. final offset = location.getOffset(scaffoldGeometry);
  1317. return Offset(offset.dx + offsetX, offset.dy + offsetY);
  1318. }
  1319. }