12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340 |
- import 'dart:async';
- import 'dart:ui' as ui;
- 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<RemotePage> createState() => _RemotePageState(id);
- }
- class _RemotePageState extends State<RemotePage> with WidgetsBindingObserver {
- Timer? _timer;
- bool _showBar = !isWebDesktop;
- bool _showGestureHelp = false;
- String _value = '';
- Orientation? _currentOrientation;
- double _viewInsetsBottom = 0;
- Timer? _timerDidChangeMetrics;
- final _blockableOverlayState = BlockableOverlayState();
- final keyboardVisibilityController = KeyboardVisibilityController();
- late final StreamSubscription<bool> 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);
- _RemotePageState(String id) {
- initSharedStates(id);
- gFFI.chatModel.voiceCallStatus.value = VoiceCallStatus.notStarted;
- gFFI.dialogManager.loadMobileActionsOverlayVisible();
- }
- @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);
- gFFI.chatModel
- .changeCurrentKey(MessageKey(widget.id, ChatModel.clientModeID));
- _blockableOverlayState.applyFfi(gFFI);
- gFFI.imageModel.addCallbackOnFirstImage((String peerId) {
- gFFI.recordingModel
- .updateStatus(bind.sessionGetIsRecording(sessionId: gFFI.sessionId));
- if (gFFI.recordingModel.start) {
- showToast(translate('Automatically record outgoing sessions'));
- }
- });
- WidgetsBinding.instance.addObserver(this);
- }
- @override
- Future<void> dispose() async {
- WidgetsBinding.instance.removeObserver(this);
- // 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();
- _timerDidChangeMetrics?.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");
- }
- @override
- void didChangeMetrics() {
- // If the soft keyboard is visible and the canvas has been changed(panned or scaled)
- // Don't try reset the view style and focus the cursor.
- if (gFFI.cursorModel.lastKeyboardIsVisible &&
- gFFI.canvasModel.isMobileCanvasChanged) {
- return;
- }
- final newBottom = MediaQueryData.fromView(ui.window).viewInsets.bottom;
- _timerDidChangeMetrics?.cancel();
- _timerDidChangeMetrics = Timer(Duration(milliseconds: 100), () async {
- // We need this comparation because poping up the floating action will also trigger `didChangeMetrics()`.
- if (newBottom != _viewInsetsBottom) {
- gFFI.canvasModel.mobileFocusCanvasCursor();
- _viewInsetsBottom = newBottom;
- }
- });
- }
- // 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<FfiModel>(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<FfiModel>(context);
- return BottomAppBar(
- elevation: 10,
- color: MyTheme.accent,
- child: Row(
- mainAxisSize: MainAxisSize.max,
- mainAxisAlignment: MainAxisAlignment.spaceBetween,
- children: <Widget>[
- Row(
- children: <Widget>[
- 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
- ? []
- : <Widget>[
- 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(
- keyboardIsVisible: keyboardIsVisible,
- showGestureHelp: _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` may be called depending on the input method if this widget is wrapped in
- // `Focus(onKeyEvent: ..., child: ...)`
- // For `Backspace` button in the soft keyboard:
- // en/fr input method:
- // 1. The button will not trigger `onKeyEvent` if the text field is not empty.
- // 2. The button will trigger `onKeyEvent` if the text field is empty.
- // ko/zh/ja input method: the button will trigger `onKeyEvent`
- // and the event will not popup if `KeyEventResult.handled` is returned.
- onChanged: handleSoftKeyboardInput,
- ),
- ),
- ];
- if (showCursorPaint) {
- paints.add(CursorPaint(widget.id));
- }
- return paints;
- }()));
- }
- Widget getBodyForDesktopWithListener() {
- final ffiModel = Provider.of<FfiModel>(context);
- var paints = <Widget>[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<TTextMenu> _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<PopupMenuEntry<int>> more = [
- ...mobileActionMenus
- .asMap()
- .entries
- .map((e) =>
- PopupMenuItem<int>(child: e.value.getChild(), value: e.key))
- .toList(),
- if (mobileActionMenus.isNotEmpty) PopupMenuDivider(),
- ...menus
- .asMap()
- .entries
- .map((e) => PopupMenuItem<int>(
- 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<int>(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 {
- final bool keyboardIsVisible;
- final bool showGestureHelp;
- /// need to show by external request, etc [keyboardIsVisible] or [changeTouchMode]
- bool get requestShow => keyboardIsVisible || showGestureHelp;
- KeyHelpTools(
- {required this.keyboardIsVisible, required this.showGestureHelp});
- @override
- State<KeyHelpTools> createState() => _KeyHelpToolsState();
- }
- class _KeyHelpToolsState extends State<KeyHelpTools> {
- 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);
- }
- _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),
- widget.keyboardIsVisible);
- }
- }
- @override
- Widget build(BuildContext context) {
- final hasModifierOn = inputModel.ctrl ||
- inputModel.alt ||
- inputModel.shift ||
- inputModel.command;
- if (!_pin && !hasModifierOn && !widget.requestShow) {
- gFFI.cursorModel
- .keyHelpToolsVisibilityChanged(null, widget.keyboardIsVisible);
- return Offstage();
- }
- final size = MediaQuery.of(context).size;
- final pi = gFFI.ffiModel.pi;
- final isMac = pi.platform == kPeerPlatformMacOS;
- final modifiers = <Widget>[
- 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 = <Widget>[
- 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 = <Widget>[
- SizedBox(width: 9999),
- ];
- for (var i = 1; i <= 12; ++i) {
- final name = 'F$i';
- fn.add(wrap(name, () {
- inputModel.inputKey('VK_$name');
- }));
- }
- final more = <Widget>[
- 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: <Widget>[SizedBox(width: 9999)] +
- modifiers +
- keys +
- (_fn ? fn : []) +
- (_more ? more : []),
- ));
- }
- }
- class ImagePaint extends StatelessWidget {
- @override
- Widget build(BuildContext context) {
- final m = Provider.of<ImageModel>(context);
- final c = Provider.of<CanvasModel>(context);
- var s = c.scale;
- final adjust = c.getAdjustY();
- 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<CursorModel>(context);
- final c = Provider.of<CanvasModel>(context);
- final ffiModel = Provider.of<FfiModel>(context);
- 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;
- final adjust = c.getAdjustY();
- 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 = <Widget>[];
- 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 = <Widget>[];
- 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<TRadioMenu<String>> viewStyleRadios =
- await toolbarViewStyle(context, id, gFFI);
- List<TRadioMenu<String>> imageQualityRadios =
- await toolbarImageQuality(context, id, gFFI);
- List<TRadioMenu<String>> codecRadios = await toolbarCodec(context, id, gFFI);
- List<TToggleMenu> cursorToggles = await toolbarCursor(context, id, gFFI);
- List<TToggleMenu> displayToggles =
- await toolbarDisplayToggle(context, id, gFFI);
- List<TToggleMenu> 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<String>(
- 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<String>(
- 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<String>(
- 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<Widget>.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<String>(
- 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);
- }
- }
|