remote_page.dart 47 KB

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