import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common/shared_state.dart'; import 'package:flutter_hbb/common/widgets/toolbar.dart'; import 'package:flutter_hbb/consts.dart'; import 'package:flutter_hbb/mobile/widgets/gesture_help.dart'; import 'package:flutter_hbb/models/chat_model.dart'; import 'package:flutter_keyboard_visibility/flutter_keyboard_visibility.dart'; import 'package:flutter_svg/svg.dart'; import 'package:get/get.dart'; import 'package:provider/provider.dart'; import 'package:wakelock_plus/wakelock_plus.dart'; import '../../common.dart'; import '../../common/widgets/overlay.dart'; import '../../common/widgets/dialog.dart'; import '../../common/widgets/remote_input.dart'; import '../../models/input_model.dart'; import '../../models/model.dart'; import '../../models/platform_model.dart'; import '../../utils/image.dart'; import '../widgets/dialog.dart'; final initText = '1' * 1024; class RemotePage extends StatefulWidget { RemotePage({Key? key, required this.id, this.password, this.isSharedPassword}) : super(key: key); final String id; final String? password; final bool? isSharedPassword; @override State createState() => _RemotePageState(); } class _RemotePageState extends State { Timer? _timer; bool _showBar = !isWebDesktop; bool _showGestureHelp = false; String _value = ''; Orientation? _currentOrientation; final _blockableOverlayState = BlockableOverlayState(); final keyboardVisibilityController = KeyboardVisibilityController(); late final StreamSubscription keyboardSubscription; final FocusNode _mobileFocusNode = FocusNode(); final FocusNode _physicalFocusNode = FocusNode(); var _showEdit = false; // use soft keyboard InputModel get inputModel => gFFI.inputModel; SessionID get sessionId => gFFI.sessionId; final TextEditingController _textController = TextEditingController(text: initText); @override void initState() { super.initState(); gFFI.ffiModel.updateEventListener(sessionId, widget.id); gFFI.start( widget.id, password: widget.password, isSharedPassword: widget.isSharedPassword, ); WidgetsBinding.instance.addPostFrameCallback((_) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); gFFI.dialogManager .showLoading(translate('Connecting...'), onCancel: closeConnection); }); if (!isWeb) { WakelockPlus.enable(); } _physicalFocusNode.requestFocus(); gFFI.inputModel.listenToMouse(true); gFFI.qualityMonitorModel.checkShowQualityMonitor(sessionId); keyboardSubscription = keyboardVisibilityController.onChange.listen(onSoftKeyboardChanged); initSharedStates(widget.id); gFFI.chatModel .changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID)); gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted; _blockableOverlayState.applyFfi(gFFI); gFFI.dialogManager.loadMobileActionsOverlayVisible(); } @override Future dispose() async { // https://github.com/flutter/flutter/issues/64935 super.dispose(); gFFI.dialogManager.hideMobileActionsOverlay(store: false); gFFI.inputModel.listenToMouse(false); gFFI.imageModel.disposeImage(); gFFI.cursorModel.disposeImages(); await gFFI.invokeMethod("enable_soft_keyboard", true); _mobileFocusNode.dispose(); _physicalFocusNode.dispose(); await gFFI.close(); _timer?.cancel(); gFFI.dialogManager.dismissAll(); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); if (!isWeb) { await WakelockPlus.disable(); } await keyboardSubscription.cancel(); removeSharedStates(widget.id); // `on_voice_call_closed` should be called when the connection is ended. // The inner logic of `on_voice_call_closed` will check if the voice call is active. // Only one client is considered here for now. gFFI.chatModel.onVoiceCallClosed("End connetion"); } // to-do: It should be better to use transparent color instead of the bgColor. // But for now, the transparent color will cause the canvas to be white. // I'm sure that the white color is caused by the Overlay widget in BlockableOverlay. // But I don't know why and how to fix it. Widget emptyOverlay(Color bgColor) => BlockableOverlay( /// the Overlay key will be set with _blockableOverlayState in BlockableOverlay /// see override build() in [BlockableOverlay] state: _blockableOverlayState, underlying: Container( color: bgColor, ), ); void onSoftKeyboardChanged(bool visible) { if (!visible) { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: []); // [pi.version.isNotEmpty] -> check ready or not, avoid login without soft-keyboard if (gFFI.chatModel.chatWindowOverlayEntry == null && gFFI.ffiModel.pi.version.isNotEmpty) { gFFI.invokeMethod("enable_soft_keyboard", false); } } else { _timer?.cancel(); _timer = Timer(kMobileDelaySoftKeyboardFocus, () { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); _mobileFocusNode.requestFocus(); }); } // update for Scaffold setState(() {}); } void _handleIOSSoftKeyboardInput(String newValue) { var oldValue = _value; _value = newValue; var i = newValue.length - 1; for (; i >= 0 && newValue[i] != '\1'; --i) {} var j = oldValue.length - 1; for (; j >= 0 && oldValue[j] != '\1'; --j) {} if (i < j) j = i; var subNewValue = newValue.substring(j + 1); var subOldValue = oldValue.substring(j + 1); // get common prefix of subNewValue and subOldValue var common = 0; for (; common < subOldValue.length && common < subNewValue.length && subNewValue[common] == subOldValue[common]; ++common) {} // get newStr from subNewValue var newStr = ""; if (subNewValue.length > common) { newStr = subNewValue.substring(common); } // Set the value to the old value and early return if is still composing. (1 && 2) // 1. The composing range is valid // 2. The new string is shorter than the composing range. if (_textController.value.isComposingRangeValid) { final composingLength = _textController.value.composing.end - _textController.value.composing.start; if (composingLength > newStr.length) { _value = oldValue; return; } } // Delete the different part in the old value. for (i = 0; i < subOldValue.length - common; ++i) { inputModel.inputKey('VK_BACK'); } // Input the new string. if (newStr.length > 1) { bind.sessionInputString(sessionId: sessionId, value: newStr); } else { inputChar(newStr); } } void _handleNonIOSSoftKeyboardInput(String newValue) { var oldValue = _value; _value = newValue; if (oldValue.isNotEmpty && newValue.isNotEmpty && oldValue[0] == '\1' && newValue[0] != '\1') { // clipboard oldValue = ''; } if (newValue.length == oldValue.length) { // ? } else if (newValue.length < oldValue.length) { final char = 'VK_BACK'; inputModel.inputKey(char); } else { final content = newValue.substring(oldValue.length); if (content.length > 1) { if (oldValue != '' && content.length == 2 && (content == '""' || content == '()' || content == '[]' || content == '<>' || content == "{}" || content == '”“' || content == '《》' || content == '()' || content == '【】')) { // can not only input content[0], because when input ], [ are also auo insert, which cause ] never be input bind.sessionInputString(sessionId: sessionId, value: content); openKeyboard(); return; } bind.sessionInputString(sessionId: sessionId, value: content); } else { inputChar(content); } } } // handle mobile virtual keyboard void handleSoftKeyboardInput(String newValue) { if (isIOS) { _handleIOSSoftKeyboardInput(newValue); } else { _handleNonIOSSoftKeyboardInput(newValue); } } void inputChar(String char) { if (char == '\n') { char = 'VK_RETURN'; } else if (char == ' ') { char = 'VK_SPACE'; } inputModel.inputKey(char); } void openKeyboard() { gFFI.invokeMethod("enable_soft_keyboard", true); // destroy first, so that our _value trick can work _value = initText; _textController.text = _value; setState(() => _showEdit = false); _timer?.cancel(); _timer = Timer(kMobileDelaySoftKeyboard, () { // show now, and sleep a while to requestFocus to // make sure edit ready, so that keyboard won't show/hide/show/hide happen setState(() => _showEdit = true); _timer?.cancel(); _timer = Timer(kMobileDelaySoftKeyboardFocus, () { SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values); _mobileFocusNode.requestFocus(); }); }); } Widget _bottomWidget() => _showGestureHelp ? getGestureHelp() : (_showBar && gFFI.ffiModel.pi.displays.isNotEmpty ? getBottomAppBar() : Offstage()); @override Widget build(BuildContext context) { final keyboardIsVisible = keyboardVisibilityController.isVisible && _showEdit; final showActionButton = !_showBar || keyboardIsVisible || _showGestureHelp; return WillPopScope( onWillPop: () async { clientClose(sessionId, gFFI.dialogManager); return false; }, child: Scaffold( // workaround for https://github.com/rustdesk/rustdesk/issues/3131 floatingActionButtonLocation: keyboardIsVisible ? FABLocation(FloatingActionButtonLocation.endFloat, 0, -35) : null, floatingActionButton: !showActionButton ? null : FloatingActionButton( mini: !keyboardIsVisible, child: Icon( (keyboardIsVisible || _showGestureHelp) ? Icons.expand_more : Icons.expand_less, color: Colors.white, ), backgroundColor: MyTheme.accent, onPressed: () { setState(() { if (keyboardIsVisible) { _showEdit = false; gFFI.invokeMethod("enable_soft_keyboard", false); _mobileFocusNode.unfocus(); _physicalFocusNode.requestFocus(); } else if (_showGestureHelp) { _showGestureHelp = false; } else { _showBar = !_showBar; } }); }), bottomNavigationBar: Obx(() => Stack( alignment: Alignment.bottomCenter, children: [ gFFI.ffiModel.pi.isSet.isTrue && gFFI.ffiModel.waitForFirstImage.isTrue ? emptyOverlay(MyTheme.canvasColor) : () { gFFI.ffiModel.tryShowAndroidActionsOverlay(); return Offstage(); }(), _bottomWidget(), gFFI.ffiModel.pi.isSet.isFalse ? emptyOverlay(MyTheme.canvasColor) : Offstage(), ], )), body: Obx( () => getRawPointerAndKeyBody(Overlay( initialEntries: [ OverlayEntry(builder: (context) { return Container( color: kColorCanvas, child: isWebDesktop ? getBodyForDesktopWithListener() : SafeArea( child: OrientationBuilder(builder: (ctx, orientation) { if (_currentOrientation != orientation) { Timer(const Duration(milliseconds: 200), () { gFFI.dialogManager .resetMobileActionsOverlay(ffi: gFFI); _currentOrientation = orientation; gFFI.canvasModel.updateViewStyle(); }); } return Container( color: MyTheme.canvasColor, child: inputModel.isPhysicalMouse.value ? getBodyForMobile() : RawTouchGestureDetectorRegion( child: getBodyForMobile(), ffi: gFFI, ), ); }), ), ); }) ], )), )), ); } Widget getRawPointerAndKeyBody(Widget child) { final ffiModel = Provider.of(context); return RawPointerMouseRegion( cursor: ffiModel.keyboard ? SystemMouseCursors.none : MouseCursor.defer, inputModel: inputModel, // Disable RawKeyFocusScope before the connecting is established. // The "Delete" key on the soft keyboard may be grabbed when inputting the password dialog. child: gFFI.ffiModel.pi.isSet.isTrue ? RawKeyFocusScope( focusNode: _physicalFocusNode, inputModel: inputModel, child: child) : child, ); } Widget getBottomAppBar() { final ffiModel = Provider.of(context); return BottomAppBar( elevation: 10, color: MyTheme.accent, child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ IconButton( color: Colors.white, icon: Icon(Icons.clear), onPressed: () { clientClose(sessionId, gFFI.dialogManager); }, ), IconButton( color: Colors.white, icon: Icon(Icons.tv), onPressed: () { setState(() => _showEdit = false); showOptions(context, widget.id, gFFI.dialogManager); }, ) ] + (isWebDesktop || ffiModel.viewOnly || !ffiModel.keyboard ? [] : gFFI.ffiModel.isPeerAndroid ? [ IconButton( color: Colors.white, icon: Icon(Icons.keyboard), onPressed: openKeyboard), IconButton( color: Colors.white, icon: const Icon(Icons.build), onPressed: () => gFFI.dialogManager .toggleMobileActionsOverlay(ffi: gFFI), ) ] : [ IconButton( color: Colors.white, icon: Icon(Icons.keyboard), onPressed: openKeyboard), IconButton( color: Colors.white, icon: Icon(gFFI.ffiModel.touchMode ? Icons.touch_app : Icons.mouse), onPressed: () => setState( () => _showGestureHelp = !_showGestureHelp), ), ]) + (isWeb ? [] : [ futureBuilder( future: gFFI.invokeMethod( "get_value", "KEY_IS_SUPPORT_VOICE_CALL"), hasData: (isSupportVoiceCall) => IconButton( color: Colors.white, icon: isAndroid && isSupportVoiceCall ? SvgPicture.asset('assets/chat.svg', colorFilter: ColorFilter.mode( Colors.white, BlendMode.srcIn)) : Icon(Icons.message), onPressed: () => isAndroid && isSupportVoiceCall ? showChatOptions(widget.id) : onPressedTextChat(widget.id), )) ]) + [ IconButton( color: Colors.white, icon: Icon(Icons.more_vert), onPressed: () { setState(() => _showEdit = false); showActions(widget.id); }, ), ]), Obx(() => IconButton( color: Colors.white, icon: Icon(Icons.expand_more), onPressed: gFFI.ffiModel.waitForFirstImage.isTrue ? null : () { setState(() => _showBar = !_showBar); }, )), ], ), ); } bool get showCursorPaint => !gFFI.ffiModel.isPeerAndroid && !gFFI.canvasModel.cursorEmbedded; Widget getBodyForMobile() { final keyboardIsVisible = keyboardVisibilityController.isVisible; return Container( color: MyTheme.canvasColor, child: Stack(children: () { final paints = [ ImagePaint(), Positioned( top: 10, right: 10, child: QualityMonitor(gFFI.qualityMonitorModel), ), KeyHelpTools(requestShow: (keyboardIsVisible || _showGestureHelp)), SizedBox( width: 0, height: 0, child: !_showEdit ? Container() : TextFormField( textInputAction: TextInputAction.newline, autocorrect: false, // Flutter 3.16.9 Android. // `enableSuggestions` causes secure keyboard to be shown. // https://github.com/flutter/flutter/issues/139143 // https://github.com/flutter/flutter/issues/146540 // enableSuggestions: false, autofocus: true, focusNode: _mobileFocusNode, maxLines: null, controller: _textController, // trick way to make backspace work always keyboardType: TextInputType.multiline, onChanged: handleSoftKeyboardInput, ), ), ]; if (showCursorPaint) { paints.add(CursorPaint(widget.id)); } return paints; }())); } Widget getBodyForDesktopWithListener() { final ffiModel = Provider.of(context); var paints = [ImagePaint()]; if (showCursorPaint) { final cursor = bind.sessionGetToggleOptionSync( sessionId: sessionId, arg: 'show-remote-cursor'); if (ffiModel.keyboard || cursor) { paints.add(CursorPaint(widget.id)); } } return Container( color: MyTheme.canvasColor, child: Stack(children: paints)); } List _getMobileActionMenus() { if (gFFI.ffiModel.pi.platform != kPeerPlatformAndroid || !gFFI.ffiModel.keyboard) { return []; } final enabled = versionCmp(gFFI.ffiModel.pi.version, '1.2.7') >= 0; if (!enabled) return []; return [ TTextMenu( child: Text(translate('Back')), onPressed: () => gFFI.inputModel.onMobileBack(), ), TTextMenu( child: Text(translate('Home')), onPressed: () => gFFI.inputModel.onMobileHome(), ), TTextMenu( child: Text(translate('Apps')), onPressed: () => gFFI.inputModel.onMobileApps(), ), TTextMenu( child: Text(translate('Volume up')), onPressed: () => gFFI.inputModel.onMobileVolumeUp(), ), TTextMenu( child: Text(translate('Volume down')), onPressed: () => gFFI.inputModel.onMobileVolumeDown(), ), TTextMenu( child: Text(translate('Power')), onPressed: () => gFFI.inputModel.onMobilePower(), ), ]; } void showActions(String id) async { final size = MediaQuery.of(context).size; final x = 120.0; final y = size.height; final mobileActionMenus = _getMobileActionMenus(); final menus = toolbarControls(context, id, gFFI); final List> more = [ ...mobileActionMenus .asMap() .entries .map((e) => PopupMenuItem(child: e.value.getChild(), value: e.key)) .toList(), if (mobileActionMenus.isNotEmpty) PopupMenuDivider(), ...menus .asMap() .entries .map((e) => PopupMenuItem( child: e.value.getChild(), value: e.key + mobileActionMenus.length)) .toList(), ]; () async { var index = await showMenu( context: context, position: RelativeRect.fromLTRB(x, y, x, y), items: more, elevation: 8, ); if (index != null) { if (index < mobileActionMenus.length) { mobileActionMenus[index].onPressed.call(); } else if (index < mobileActionMenus.length + more.length) { menus[index - mobileActionMenus.length].onPressed.call(); } } }(); } onPressedTextChat(String id) { gFFI.chatModel.changeCurrentKey(MessageKey(id, ChatModel.clientModeID)); gFFI.chatModel.toggleChatOverlay(); } showChatOptions(String id) async { onPressVoiceCall() => bind.sessionRequestVoiceCall(sessionId: sessionId); onPressEndVoiceCall() => bind.sessionCloseVoiceCall(sessionId: sessionId); makeTextMenu(String label, Widget icon, VoidCallback onPressed, {TextStyle? labelStyle}) => TTextMenu( child: Text(translate(label), style: labelStyle), trailingIcon: Transform.scale( scale: (isDesktop || isWebDesktop) ? 0.8 : 1, child: IgnorePointer( child: IconButton( onPressed: null, icon: icon, ), ), ), onPressed: onPressed, ); final isInVoice = [ VoiceCallStatus.waitingForResponse, VoiceCallStatus.connected ].contains(gFFI.chatModel.voiceCallStatus.value); final menus = [ makeTextMenu('Text chat', Icon(Icons.message, color: MyTheme.accent), () => onPressedTextChat(widget.id)), isInVoice ? makeTextMenu( 'End voice call', SvgPicture.asset( 'assets/call_wait.svg', colorFilter: ColorFilter.mode(Colors.redAccent, BlendMode.srcIn), ), onPressEndVoiceCall, labelStyle: TextStyle(color: Colors.redAccent)) : makeTextMenu( 'Voice call', SvgPicture.asset( 'assets/call_wait.svg', colorFilter: ColorFilter.mode(MyTheme.accent, BlendMode.srcIn), ), onPressVoiceCall), ]; final menuItems = menus .asMap() .entries .map((e) => PopupMenuItem(child: e.value.getChild(), value: e.key)) .toList(); Future.delayed(Duration.zero, () async { final size = MediaQuery.of(context).size; final x = 120.0; final y = size.height; var index = await showMenu( context: context, position: RelativeRect.fromLTRB(x, y, x, y), items: menuItems, elevation: 8, ); if (index != null && index < menus.length) { menus[index].onPressed.call(); } }); } /// aka changeTouchMode BottomAppBar getGestureHelp() { return BottomAppBar( child: SingleChildScrollView( controller: ScrollController(), padding: EdgeInsets.symmetric(vertical: 10), child: GestureHelp( touchMode: gFFI.ffiModel.touchMode, onTouchModeChange: (t) { gFFI.ffiModel.toggleTouchMode(); final v = gFFI.ffiModel.touchMode ? 'Y' : ''; bind.sessionPeerOption( sessionId: sessionId, name: kOptionTouchMode, value: v); }))); } // * Currently mobile does not enable map mode // void changePhysicalKeyboardInputMode() async { // var current = await bind.sessionGetKeyboardMode(id: widget.id) ?? "legacy"; // gFFI.dialogManager.show((setState, close) { // void setMode(String? v) async { // await bind.sessionSetKeyboardMode(id: widget.id, value: v ?? ""); // setState(() => current = v ?? ''); // Future.delayed(Duration(milliseconds: 300), close); // } // // return CustomAlertDialog( // title: Text(translate('Physical Keyboard Input Mode')), // content: Column(mainAxisSize: MainAxisSize.min, children: [ // getRadio('Legacy mode', 'legacy', current, setMode), // getRadio('Map mode', 'map', current, setMode), // ])); // }, clickMaskDismiss: true); // } } class KeyHelpTools extends StatefulWidget { /// need to show by external request, etc [keyboardIsVisible] or [changeTouchMode] final bool requestShow; KeyHelpTools({required this.requestShow}); @override State createState() => _KeyHelpToolsState(); } class _KeyHelpToolsState extends State { var _more = true; var _fn = false; var _pin = false; final _keyboardVisibilityController = KeyboardVisibilityController(); final _key = GlobalKey(); InputModel get inputModel => gFFI.inputModel; Widget wrap(String text, void Function() onPressed, {bool? active, IconData? icon}) { return TextButton( style: TextButton.styleFrom( minimumSize: Size(0, 0), padding: EdgeInsets.symmetric(vertical: 10, horizontal: 9.75), //adds padding inside the button tapTargetSize: MaterialTapTargetSize.shrinkWrap, //limits the touch area to the button area shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5.0), ), backgroundColor: active == true ? MyTheme.accent80 : null, ), child: icon != null ? Icon(icon, size: 14, color: Colors.white) : Text(translate(text), style: TextStyle(color: Colors.white, fontSize: 11)), onPressed: onPressed); } @override void initState() { super.initState(); } _updateRect() { RenderObject? renderObject = _key.currentContext?.findRenderObject(); if (renderObject == null) { return; } if (renderObject is RenderBox) { final size = renderObject.size; Offset pos = renderObject.localToGlobal(Offset.zero); gFFI.cursorModel.keyHelpToolsVisibilityChanged( Rect.fromLTWH(pos.dx, pos.dy, size.width, size.height)); } } @override Widget build(BuildContext context) { final hasModifierOn = inputModel.ctrl || inputModel.alt || inputModel.shift || inputModel.command; if (!_pin && !hasModifierOn && !widget.requestShow) { gFFI.cursorModel.keyHelpToolsVisibilityChanged(null); return Offstage(); } final size = MediaQuery.of(context).size; final pi = gFFI.ffiModel.pi; final isMac = pi.platform == kPeerPlatformMacOS; final modifiers = [ wrap('Ctrl ', () { setState(() => inputModel.ctrl = !inputModel.ctrl); }, active: inputModel.ctrl), wrap(' Alt ', () { setState(() => inputModel.alt = !inputModel.alt); }, active: inputModel.alt), wrap('Shift', () { setState(() => inputModel.shift = !inputModel.shift); }, active: inputModel.shift), wrap(isMac ? ' Cmd ' : ' Win ', () { setState(() => inputModel.command = !inputModel.command); }, active: inputModel.command), ]; final keys = [ wrap( ' Fn ', () => setState( () { _fn = !_fn; if (_fn) { _more = false; } }, ), active: _fn), wrap( '', () => setState( () => _pin = !_pin, ), active: _pin, icon: Icons.push_pin), wrap( ' ... ', () => setState( () { _more = !_more; if (_more) { _fn = false; } }, ), active: _more), ]; final fn = [ SizedBox(width: 9999), ]; for (var i = 1; i <= 12; ++i) { final name = 'F$i'; fn.add(wrap(name, () { inputModel.inputKey('VK_$name'); })); } final more = [ SizedBox(width: 9999), wrap('Esc', () { inputModel.inputKey('VK_ESCAPE'); }), wrap('Tab', () { inputModel.inputKey('VK_TAB'); }), wrap('Home', () { inputModel.inputKey('VK_HOME'); }), wrap('End', () { inputModel.inputKey('VK_END'); }), wrap('Ins', () { inputModel.inputKey('VK_INSERT'); }), wrap('Del', () { inputModel.inputKey('VK_DELETE'); }), wrap('PgUp', () { inputModel.inputKey('VK_PRIOR'); }), wrap('PgDn', () { inputModel.inputKey('VK_NEXT'); }), SizedBox(width: 9999), wrap('', () { inputModel.inputKey('VK_LEFT'); }, icon: Icons.keyboard_arrow_left), wrap('', () { inputModel.inputKey('VK_UP'); }, icon: Icons.keyboard_arrow_up), wrap('', () { inputModel.inputKey('VK_DOWN'); }, icon: Icons.keyboard_arrow_down), wrap('', () { inputModel.inputKey('VK_RIGHT'); }, icon: Icons.keyboard_arrow_right), wrap(isMac ? 'Cmd+C' : 'Ctrl+C', () { sendPrompt(isMac, 'VK_C'); }), wrap(isMac ? 'Cmd+V' : 'Ctrl+V', () { sendPrompt(isMac, 'VK_V'); }), wrap(isMac ? 'Cmd+S' : 'Ctrl+S', () { sendPrompt(isMac, 'VK_S'); }), ]; final space = size.width > 320 ? 4.0 : 2.0; // 500 ms is long enough for this widget to be built! Future.delayed(Duration(milliseconds: 500), () { _updateRect(); }); return Container( key: _key, color: Color(0xAA000000), padding: EdgeInsets.only( top: _keyboardVisibilityController.isVisible ? 24 : 4, bottom: 8), child: Wrap( spacing: space, runSpacing: space, children: [SizedBox(width: 9999)] + modifiers + keys + (_fn ? fn : []) + (_more ? more : []), )); } } class ImagePaint extends StatelessWidget { @override Widget build(BuildContext context) { final m = Provider.of(context); final c = Provider.of(context); final adjust = gFFI.cursorModel.adjustForKeyboard(); var s = c.scale; return CustomPaint( painter: ImagePainter( image: m.image, x: c.x / s, y: (c.y - adjust) / s, scale: s), ); } } class CursorPaint extends StatelessWidget { late final String id; CursorPaint(this.id); @override Widget build(BuildContext context) { final m = Provider.of(context); final c = Provider.of(context); final ffiModel = Provider.of(context); final adjust = gFFI.cursorModel.adjustForKeyboard(); final s = c.scale; double hotx = m.hotx; double hoty = m.hoty; var image = m.image; if (image == null) { if (preDefaultCursor.image != null) { image = preDefaultCursor.image; hotx = preDefaultCursor.image!.width / 2; hoty = preDefaultCursor.image!.height / 2; } } if (preForbiddenCursor.image != null && !ffiModel.viewOnly && !ffiModel.keyboard && !ShowRemoteCursorState.find(id).value) { image = preForbiddenCursor.image; hotx = preForbiddenCursor.image!.width / 2; hoty = preForbiddenCursor.image!.height / 2; } if (image == null) { return Offstage(); } final minSize = 12.0; double mins = minSize / (image.width > image.height ? image.width : image.height); double factor = 1.0; if (s < mins) { factor = s / mins; } final s2 = s < mins ? mins : s; return CustomPaint( painter: ImagePainter( image: image, x: (m.x - hotx) * factor + c.x / s2, y: (m.y - hoty) * factor + (c.y - adjust) / s2, scale: s2), ); } } void showOptions( BuildContext context, String id, OverlayDialogManager dialogManager) async { var displays = []; final pi = gFFI.ffiModel.pi; final image = gFFI.ffiModel.getConnectionImage(); if (image != null) { displays.add(Padding(padding: const EdgeInsets.only(top: 8), child: image)); } if (pi.displays.length > 1 && pi.currentDisplay != kAllDisplayValue) { final cur = pi.currentDisplay; final children = []; for (var i = 0; i < pi.displays.length; ++i) { children.add(InkWell( onTap: () { if (i == cur) return; openMonitorInTheSameTab(i, gFFI, pi); gFFI.dialogManager.dismissAll(); }, child: Ink( width: 40, height: 40, decoration: BoxDecoration( border: Border.all(color: Theme.of(context).hintColor), borderRadius: BorderRadius.circular(2), color: i == cur ? Theme.of(context).primaryColor.withOpacity(0.6) : null), child: Center( child: Text((i + 1).toString(), style: TextStyle( color: i == cur ? Colors.white : Colors.black87, fontWeight: FontWeight.bold)))))); } displays.add(Padding( padding: const EdgeInsets.only(top: 8), child: Wrap( alignment: WrapAlignment.center, spacing: 8, children: children, ))); } if (displays.isNotEmpty) { displays.add(const Divider(color: MyTheme.border)); } List> viewStyleRadios = await toolbarViewStyle(context, id, gFFI); List> imageQualityRadios = await toolbarImageQuality(context, id, gFFI); List> codecRadios = await toolbarCodec(context, id, gFFI); List cursorToggles = await toolbarCursor(context, id, gFFI); List displayToggles = await toolbarDisplayToggle(context, id, gFFI); List privacyModeList = []; // privacy mode final privacyModeState = PrivacyModeState.find(id); if (gFFI.ffiModel.keyboard && gFFI.ffiModel.pi.features.privacyMode) { privacyModeList = toolbarPrivacyMode(privacyModeState, context, id, gFFI); if (privacyModeList.length == 1) { displayToggles.add(privacyModeList[0]); } } dialogManager.show((setState, close, context) { var viewStyle = (viewStyleRadios.isNotEmpty ? viewStyleRadios[0].groupValue : '').obs; var imageQuality = (imageQualityRadios.isNotEmpty ? imageQualityRadios[0].groupValue : '') .obs; var codec = (codecRadios.isNotEmpty ? codecRadios[0].groupValue : '').obs; final radios = [ for (var e in viewStyleRadios) Obx(() => getRadio( e.child, e.value, viewStyle.value, e.onChanged != null ? (v) { e.onChanged?.call(v); if (v != null) viewStyle.value = v; } : null)), const Divider(color: MyTheme.border), for (var e in imageQualityRadios) Obx(() => getRadio( e.child, e.value, imageQuality.value, e.onChanged != null ? (v) { e.onChanged?.call(v); if (v != null) imageQuality.value = v; } : null)), const Divider(color: MyTheme.border), for (var e in codecRadios) Obx(() => getRadio( e.child, e.value, codec.value, e.onChanged != null ? (v) { e.onChanged?.call(v); if (v != null) codec.value = v; } : null)), if (codecRadios.isNotEmpty) const Divider(color: MyTheme.border), ]; final rxCursorToggleValues = cursorToggles.map((e) => e.value.obs).toList(); final cursorTogglesList = cursorToggles .asMap() .entries .map((e) => Obx(() => CheckboxListTile( contentPadding: EdgeInsets.zero, visualDensity: VisualDensity.compact, value: rxCursorToggleValues[e.key].value, onChanged: e.value.onChanged != null ? (v) { e.value.onChanged?.call(v); if (v != null) rxCursorToggleValues[e.key].value = v; } : null, title: e.value.child))) .toList(); final rxToggleValues = displayToggles.map((e) => e.value.obs).toList(); final displayTogglesList = displayToggles .asMap() .entries .map((e) => Obx(() => CheckboxListTile( contentPadding: EdgeInsets.zero, visualDensity: VisualDensity.compact, value: rxToggleValues[e.key].value, onChanged: e.value.onChanged != null ? (v) { e.value.onChanged?.call(v); if (v != null) rxToggleValues[e.key].value = v; } : null, title: e.value.child))) .toList(); final toggles = [ ...cursorTogglesList, if (cursorToggles.isNotEmpty) const Divider(color: MyTheme.border), ...displayTogglesList, ]; Widget privacyModeWidget = Offstage(); if (privacyModeList.length > 1) { privacyModeWidget = ListTile( contentPadding: EdgeInsets.zero, visualDensity: VisualDensity.compact, title: Text(translate('Privacy mode')), onTap: () => setPrivacyModeDialog( dialogManager, privacyModeList, privacyModeState), ); } var popupDialogMenus = List.empty(growable: true); final resolution = getResolutionMenu(gFFI, id); if (resolution != null) { popupDialogMenus.add(ListTile( contentPadding: EdgeInsets.zero, visualDensity: VisualDensity.compact, title: resolution.child, onTap: () { close(); resolution.onPressed(); }, )); } final virtualDisplayMenu = getVirtualDisplayMenu(gFFI, id); if (virtualDisplayMenu != null) { popupDialogMenus.add(ListTile( contentPadding: EdgeInsets.zero, visualDensity: VisualDensity.compact, title: virtualDisplayMenu.child, onTap: () { close(); virtualDisplayMenu.onPressed(); }, )); } if (popupDialogMenus.isNotEmpty) { popupDialogMenus.add(const Divider(color: MyTheme.border)); } return CustomAlertDialog( content: Column( mainAxisSize: MainAxisSize.min, children: displays + radios + popupDialogMenus + toggles + [privacyModeWidget]), ); }, clickMaskDismiss: true, backDismiss: true); } TTextMenu? getVirtualDisplayMenu(FFI ffi, String id) { if (!showVirtualDisplayMenu(ffi)) { return null; } return TTextMenu( child: Text(translate("Virtual display")), onPressed: () { ffi.dialogManager.show((setState, close, context) { final children = getVirtualDisplayMenuChildren(ffi, id, close); return CustomAlertDialog( title: Text(translate('Virtual display')), content: Column( mainAxisSize: MainAxisSize.min, children: children, ), ); }, clickMaskDismiss: true, backDismiss: true); }, ); } TTextMenu? getResolutionMenu(FFI ffi, String id) { final ffiModel = ffi.ffiModel; final pi = ffiModel.pi; final resolutions = pi.resolutions; final display = pi.tryGetDisplayIfNotAllDisplay(display: pi.currentDisplay); final visible = ffiModel.keyboard && (resolutions.length > 1) && display != null; if (!visible) return null; return TTextMenu( child: Text(translate("Resolution")), onPressed: () { ffi.dialogManager.show((setState, close, context) { final children = resolutions .map((e) => getRadio( Text('${e.width}x${e.height}'), '${e.width}x${e.height}', '${display.width}x${display.height}', (value) { close(); bind.sessionChangeResolution( sessionId: ffi.sessionId, display: pi.currentDisplay, width: e.width, height: e.height, ); }, )) .toList(); return CustomAlertDialog( title: Text(translate('Resolution')), content: Column( mainAxisSize: MainAxisSize.min, children: children, ), ); }, clickMaskDismiss: true, backDismiss: true); }, ); } void sendPrompt(bool isMac, String key) { final old = isMac ? gFFI.inputModel.command : gFFI.inputModel.ctrl; if (isMac) { gFFI.inputModel.command = true; } else { gFFI.inputModel.ctrl = true; } gFFI.inputModel.inputKey(key); if (isMac) { gFFI.inputModel.command = old; } else { gFFI.inputModel.ctrl = old; } } class FABLocation extends FloatingActionButtonLocation { FloatingActionButtonLocation location; double offsetX; double offsetY; FABLocation(this.location, this.offsetX, this.offsetY); @override Offset getOffset(ScaffoldPrelayoutGeometry scaffoldGeometry) { final offset = location.getOffset(scaffoldGeometry); return Offset(offset.dx + offsetX, offset.dy + offsetY); } }