remote_page.dart 45 KB

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