Initial commit: RustDesk 1.4.7 macOS desktop port
- Filled empty libs/hbb_common/ submodule (cloned from rustdesk/hbb_common) - Patched Flutter 3.44 / Dart 3.12 compatibility: * flutter/lib/generated_bridge.dart: asTypedList with cast<>, DartPort=Int64 * flutter/lib/common.dart: DialogTheme->DialogThemeData, TabBarTheme->TabBarThemeData * flutter/pubspec.yaml: extended_text 14.0.0->15.0.2, google_fonts override 5.0.0 * flutter/macos/Runner/Configs/Release.xcconfig: EXCLUDED_ARCHS=x86_64 - Build verified: cargo check + cargo build --features flutter + cargo build --release --features flutter - Verified flutter build macos --debug and --release both produce working .app - Verified .dmg installer (27MB arm64) created via hdiutil - Build deps: Xcode 26.5, CocoaPods 1.16.2, Flutter 3.44, VCPKG arm64-osx
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/custom_scale_base.dart';
|
||||
|
||||
class MobileCustomScaleControls extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
final ValueChanged<int>? onChanged;
|
||||
const MobileCustomScaleControls({super.key, required this.ffi, this.onChanged});
|
||||
|
||||
@override
|
||||
State<MobileCustomScaleControls> createState() => _MobileCustomScaleControlsState();
|
||||
}
|
||||
|
||||
class _MobileCustomScaleControlsState extends CustomScaleControls<MobileCustomScaleControls> {
|
||||
@override
|
||||
FFI get ffi => widget.ffi;
|
||||
|
||||
@override
|
||||
ValueChanged<int>? get onScaleChanged => widget.onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Smaller button size for mobile
|
||||
const smallBtnConstraints = BoxConstraints(minWidth: 32, minHeight: 32);
|
||||
|
||||
final sliderControl = Slider(
|
||||
value: scalePos,
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
divisions: (CustomScaleControls.maxPercent - CustomScaleControls.minPercent).round(),
|
||||
label: '$scaleValue%',
|
||||
onChanged: onSliderChanged,
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${translate("Scale custom")}: $scaleValue%',
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
IconButton(
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: smallBtnConstraints,
|
||||
icon: const Icon(Icons.remove),
|
||||
tooltip: translate('Decrease'),
|
||||
onPressed: () => nudgeScale(-1),
|
||||
),
|
||||
Expanded(child: sliderControl),
|
||||
IconButton(
|
||||
iconSize: 20,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: smallBtnConstraints,
|
||||
icon: const Icon(Icons.add),
|
||||
tooltip: translate('Increase'),
|
||||
onPressed: () => nudgeScale(1),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
|
||||
const _deployDialogTag = 'android-deploy-device';
|
||||
|
||||
void showDeployPromptDialog() {
|
||||
gFFI.dialogManager.dismissByTag(_deployDialogTag);
|
||||
gFFI.dialogManager.show<bool>((setState, close, context) {
|
||||
submit() => close(true);
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Deploy")),
|
||||
content: Text(translate("server_requires_deployment_tip")),
|
||||
actions: [
|
||||
dialogButton("Cancel", onPressed: close, isOutline: true),
|
||||
dialogButton("OK", onPressed: submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: close,
|
||||
);
|
||||
}, tag: _deployDialogTag).then((deploy) {
|
||||
if (deploy == true) {
|
||||
showDeployDialog();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void showDeployDialog() {
|
||||
gFFI.dialogManager.dismissByTag(_deployDialogTag);
|
||||
final tokenController = TextEditingController();
|
||||
final idController = TextEditingController();
|
||||
var errorText = "";
|
||||
var isInProgress = false;
|
||||
gFFI.dialogManager.show((setState, close, context) {
|
||||
submit() async {
|
||||
if (isInProgress) return;
|
||||
final token = tokenController.text.trim();
|
||||
if (token.isEmpty) {
|
||||
setState(() {
|
||||
errorText = translate("token is required!");
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
errorText = "";
|
||||
isInProgress = true;
|
||||
});
|
||||
String res;
|
||||
try {
|
||||
res = await bind.mainDeployDevice(
|
||||
token: token, id: idController.text.trim());
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
errorText = translate(e.toString());
|
||||
isInProgress = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (res.isEmpty) {
|
||||
close();
|
||||
await gFFI.serverModel.fetchID();
|
||||
showToast(translate("Successful"));
|
||||
} else {
|
||||
setState(() {
|
||||
errorText = translate(res.toString());
|
||||
isInProgress = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Deploy")),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: tokenController,
|
||||
decoration: InputDecoration(labelText: translate("API Token")),
|
||||
obscureText: true,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
autofocus: true,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
TextField(
|
||||
controller: idController,
|
||||
decoration:
|
||||
InputDecoration(labelText: translate("Custom ID (optional)")),
|
||||
).workaroundFreezeLinuxMint(),
|
||||
if (errorText.isNotEmpty)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SelectableText(
|
||||
errorText,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
).paddingOnly(top: 8),
|
||||
),
|
||||
if (isInProgress) const LinearProgressIndicator().paddingOnly(top: 8),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
dialogButton("Cancel",
|
||||
onPressed: isInProgress ? null : close, isOutline: true),
|
||||
dialogButton("OK", onPressed: isInProgress ? null : submit),
|
||||
],
|
||||
onSubmit: submit,
|
||||
onCancel: isInProgress ? null : close,
|
||||
);
|
||||
}, tag: _deployDialogTag);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common/widgets/setting_widgets.dart';
|
||||
import 'package:flutter_hbb/common/widgets/toolbar.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../common.dart';
|
||||
import '../../models/platform_model.dart';
|
||||
|
||||
void _showSuccess() {
|
||||
showToast(translate("Successful"));
|
||||
}
|
||||
|
||||
void setTemporaryPasswordLengthDialog(
|
||||
OverlayDialogManager dialogManager) async {
|
||||
List<String> lengths = ['6', '8', '10'];
|
||||
String length = await bind.mainGetOption(key: "temporary-password-length");
|
||||
var index = lengths.indexOf(length);
|
||||
if (index < 0) index = 0;
|
||||
length = lengths[index];
|
||||
dialogManager.show((setState, close, context) {
|
||||
setLength(newValue) {
|
||||
final oldValue = length;
|
||||
if (oldValue == newValue) return;
|
||||
setState(() {
|
||||
length = newValue;
|
||||
});
|
||||
bind.mainSetOption(key: "temporary-password-length", value: newValue);
|
||||
bind.mainUpdateTemporaryPassword();
|
||||
Future.delayed(Duration(milliseconds: 200), () {
|
||||
close();
|
||||
_showSuccess();
|
||||
});
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate("Set one-time password length")),
|
||||
content: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: lengths
|
||||
.map(
|
||||
(value) => Row(
|
||||
children: [
|
||||
Text(value),
|
||||
Radio(
|
||||
value: value, groupValue: length, onChanged: setLength),
|
||||
],
|
||||
),
|
||||
)
|
||||
.toList()),
|
||||
);
|
||||
}, backDismiss: true, clickMaskDismiss: true);
|
||||
}
|
||||
|
||||
void showServerSettings(OverlayDialogManager dialogManager,
|
||||
void Function(VoidCallback) setState) async {
|
||||
Map<String, dynamic> options = {};
|
||||
try {
|
||||
options = jsonDecode(await bind.mainGetOptions());
|
||||
} catch (e) {
|
||||
print("Invalid server config: $e");
|
||||
}
|
||||
showServerSettingsWithValue(
|
||||
ServerConfig.fromOptions(options), dialogManager, setState);
|
||||
}
|
||||
|
||||
void showServerSettingsWithValue(
|
||||
ServerConfig serverConfig,
|
||||
OverlayDialogManager dialogManager,
|
||||
void Function(VoidCallback)? upSetState) async {
|
||||
var isInProgress = false;
|
||||
final idCtrl = TextEditingController(text: serverConfig.idServer);
|
||||
final relayCtrl = TextEditingController(text: serverConfig.relayServer);
|
||||
final apiCtrl = TextEditingController(text: serverConfig.apiServer);
|
||||
final keyCtrl = TextEditingController(text: serverConfig.key);
|
||||
|
||||
RxString idServerMsg = ''.obs;
|
||||
RxString relayServerMsg = ''.obs;
|
||||
RxString apiServerMsg = ''.obs;
|
||||
|
||||
final controllers = [idCtrl, relayCtrl, apiCtrl, keyCtrl];
|
||||
final errMsgs = [
|
||||
idServerMsg,
|
||||
relayServerMsg,
|
||||
apiServerMsg,
|
||||
];
|
||||
|
||||
dialogManager.show((setState, close, context) {
|
||||
Future<bool> submit() async {
|
||||
setState(() {
|
||||
isInProgress = true;
|
||||
});
|
||||
bool ret = await setServerConfig(
|
||||
null,
|
||||
errMsgs,
|
||||
ServerConfig(
|
||||
idServer: idCtrl.text.trim(),
|
||||
relayServer: relayCtrl.text.trim(),
|
||||
apiServer: apiCtrl.text.trim(),
|
||||
key: keyCtrl.text.trim()));
|
||||
setState(() {
|
||||
isInProgress = false;
|
||||
});
|
||||
return ret;
|
||||
}
|
||||
|
||||
Widget buildField(
|
||||
String label, TextEditingController controller, String errorMsg,
|
||||
{String? Function(String?)? validator, bool autofocus = false}) {
|
||||
if (isDesktop || isWeb) {
|
||||
return Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
child: Text(label),
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
contentPadding:
|
||||
EdgeInsets.symmetric(horizontal: 8, vertical: 12),
|
||||
),
|
||||
validator: validator,
|
||||
autofocus: autofocus,
|
||||
).workaroundFreezeLinuxMint(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
errorText: errorMsg.isEmpty ? null : errorMsg,
|
||||
),
|
||||
validator: validator,
|
||||
).workaroundFreezeLinuxMint();
|
||||
}
|
||||
|
||||
return CustomAlertDialog(
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(translate('ID/Relay Server'))),
|
||||
...ServerConfigImportExportWidgets(controllers, errMsgs),
|
||||
],
|
||||
),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(minWidth: 500),
|
||||
child: Form(
|
||||
child: Obx(() => Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
buildField(translate('ID Server'), idCtrl, idServerMsg.value,
|
||||
autofocus: true),
|
||||
SizedBox(height: 8),
|
||||
if (!isIOS && !isWeb) ...[
|
||||
buildField(translate('Relay Server'), relayCtrl,
|
||||
relayServerMsg.value),
|
||||
SizedBox(height: 8),
|
||||
],
|
||||
buildField(
|
||||
translate('API Server'),
|
||||
apiCtrl,
|
||||
apiServerMsg.value,
|
||||
validator: (v) {
|
||||
if (v != null && v.isNotEmpty) {
|
||||
if (!(v.startsWith('http://') ||
|
||||
v.startsWith("https://"))) {
|
||||
return translate("invalid_http");
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
buildField('Key', keyCtrl, ''),
|
||||
if (isInProgress)
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: LinearProgressIndicator(),
|
||||
),
|
||||
],
|
||||
)),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
dialogButton('Cancel', onPressed: () {
|
||||
close();
|
||||
}, isOutline: true),
|
||||
dialogButton(
|
||||
'OK',
|
||||
onPressed: () async {
|
||||
if (await submit()) {
|
||||
close();
|
||||
showToast(translate('Successful'));
|
||||
upSetState?.call(() {});
|
||||
} else {
|
||||
showToast(translate('Failed'));
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void setPrivacyModeDialog(
|
||||
OverlayDialogManager dialogManager,
|
||||
List<TToggleMenu> privacyModeList,
|
||||
RxString privacyModeState,
|
||||
) async {
|
||||
dialogManager.dismissAll();
|
||||
dialogManager.show((setState, close, context) {
|
||||
return CustomAlertDialog(
|
||||
title: Text(translate('Privacy mode')),
|
||||
content: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: privacyModeList
|
||||
.map((value) => CheckboxListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
visualDensity: VisualDensity.compact,
|
||||
title: value.child,
|
||||
value: value.value,
|
||||
onChanged: value.onChanged,
|
||||
))
|
||||
.toList()),
|
||||
);
|
||||
}, backDismiss: true, clickMaskDismiss: true);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,905 @@
|
||||
// These floating mouse widgets are used to simulate a physical mouse
|
||||
// when "mobile" -> "desktop" in mouse mode.
|
||||
// This file does not contain whole mouse widgets, it only contains
|
||||
// parts that help to control, such as wheel scroll and wheel button.
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/common/widgets/remote_input.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:flutter_hbb/models/platform_model.dart';
|
||||
|
||||
// Used for the wheel button and wheel scroll widgets
|
||||
const double _kSpaceToHorizontalEdge = 25;
|
||||
const double _wheelWidth = 50;
|
||||
const double _wheelHeight = 162;
|
||||
// Used for the left/right button widgets
|
||||
const double _kSpaceToVerticalEdge = 15;
|
||||
const double _kSpaceBetweenLeftRightButtons = 40;
|
||||
const double _kLeftRightButtonWidth = 55;
|
||||
const double _kLeftRightButtonHeight = 40;
|
||||
const double _kBorderWidth = 1;
|
||||
final Color _kDefaultBorderColor = Colors.white.withOpacity(0.7);
|
||||
final Color _kDefaultColor = Colors.black.withOpacity(0.4);
|
||||
final Color _kTapDownColor = Colors.blue.withOpacity(0.7);
|
||||
final Color _kWidgetHighlightColor = Colors.white.withOpacity(0.9);
|
||||
const int _kInputTimerIntervalMillis = 100;
|
||||
|
||||
class FloatingMouseWidgets extends StatefulWidget {
|
||||
final FFI ffi;
|
||||
const FloatingMouseWidgets({
|
||||
super.key,
|
||||
required this.ffi,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FloatingMouseWidgets> createState() => _FloatingMouseWidgetsState();
|
||||
}
|
||||
|
||||
class _FloatingMouseWidgetsState extends State<FloatingMouseWidgets> {
|
||||
InputModel get _inputModel => widget.ffi.inputModel;
|
||||
CursorModel get _cursorModel => widget.ffi.cursorModel;
|
||||
late final VirtualMouseMode _virtualMouseMode;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_virtualMouseMode = widget.ffi.ffiModel.virtualMouseMode;
|
||||
_virtualMouseMode.addListener(_onVirtualMouseModeChanged);
|
||||
_cursorModel.blockEvents = false;
|
||||
isSpecialHoldDragActive = false;
|
||||
}
|
||||
|
||||
void _onVirtualMouseModeChanged() {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_virtualMouseMode.removeListener(_onVirtualMouseModeChanged);
|
||||
super.dispose();
|
||||
_cursorModel.blockEvents = false;
|
||||
isSpecialHoldDragActive = false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final virtualMouseMode = _virtualMouseMode;
|
||||
if (!virtualMouseMode.showVirtualMouse) {
|
||||
return const Offstage();
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
FloatingWheel(
|
||||
inputModel: _inputModel,
|
||||
cursorModel: _cursorModel,
|
||||
),
|
||||
if (virtualMouseMode.showVirtualJoystick)
|
||||
VirtualJoystick(
|
||||
cursorModel: _cursorModel,
|
||||
inputModel: _inputModel,
|
||||
),
|
||||
FloatingLeftRightButton(
|
||||
isLeft: true,
|
||||
inputModel: _inputModel,
|
||||
cursorModel: _cursorModel,
|
||||
),
|
||||
FloatingLeftRightButton(
|
||||
isLeft: false,
|
||||
inputModel: _inputModel,
|
||||
cursorModel: _cursorModel,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FloatingWheel extends StatefulWidget {
|
||||
final InputModel inputModel;
|
||||
final CursorModel cursorModel;
|
||||
const FloatingWheel(
|
||||
{super.key, required this.inputModel, required this.cursorModel});
|
||||
|
||||
@override
|
||||
State<FloatingWheel> createState() => _FloatingWheelState();
|
||||
}
|
||||
|
||||
class _FloatingWheelState extends State<FloatingWheel> {
|
||||
Offset _position = Offset.zero;
|
||||
bool _isInitialized = false;
|
||||
Rect? _lastBlockedRect;
|
||||
|
||||
bool _isUpDown = false;
|
||||
bool _isMidDown = false;
|
||||
bool _isDownDown = false;
|
||||
|
||||
Orientation? _previousOrientation;
|
||||
|
||||
Timer? _scrollTimer;
|
||||
|
||||
InputModel get _inputModel => widget.inputModel;
|
||||
CursorModel get _cursorModel => widget.cursorModel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_resetPosition();
|
||||
});
|
||||
}
|
||||
|
||||
void _resetPosition() {
|
||||
final size = MediaQuery.of(context).size;
|
||||
setState(() {
|
||||
_position = Offset(
|
||||
size.width - _wheelWidth - _kSpaceToHorizontalEdge,
|
||||
(size.height - _wheelHeight) / 2,
|
||||
);
|
||||
_isInitialized = true;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _updateBlockedRect();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateBlockedRect() {
|
||||
if (_lastBlockedRect != null) {
|
||||
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||
}
|
||||
final newRect =
|
||||
Rect.fromLTWH(_position.dx, _position.dy, _wheelWidth, _wheelHeight);
|
||||
_cursorModel.addBlockedRect(newRect);
|
||||
_lastBlockedRect = newRect;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollTimer?.cancel();
|
||||
if (_lastBlockedRect != null) {
|
||||
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final currentOrientation = MediaQuery.of(context).orientation;
|
||||
if (_previousOrientation != null &&
|
||||
_previousOrientation != currentOrientation) {
|
||||
_resetPosition();
|
||||
}
|
||||
_previousOrientation = currentOrientation;
|
||||
}
|
||||
|
||||
Widget _buildUpDownButton(
|
||||
void Function(PointerDownEvent) onPointerDown,
|
||||
void Function(PointerUpEvent) onPointerUp,
|
||||
void Function(PointerCancelEvent) onPointerCancel,
|
||||
bool Function() flagGetter,
|
||||
BorderRadiusGeometry borderRadius,
|
||||
IconData iconData) {
|
||||
return Listener(
|
||||
onPointerDown: onPointerDown,
|
||||
onPointerUp: onPointerUp,
|
||||
onPointerCancel: onPointerCancel,
|
||||
child: Container(
|
||||
width: _wheelWidth,
|
||||
height: 55,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _kDefaultColor,
|
||||
border: Border.all(
|
||||
color: flagGetter() ? _kTapDownColor : _kDefaultBorderColor,
|
||||
width: 1),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: Icon(iconData, color: _kDefaultBorderColor, size: 32),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized) {
|
||||
return Positioned(child: Offstage());
|
||||
}
|
||||
return Positioned(
|
||||
left: _position.dx,
|
||||
top: _position.dy,
|
||||
child: _buildWidget(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWidget(BuildContext context) {
|
||||
return Container(
|
||||
width: _wheelWidth,
|
||||
height: _wheelHeight,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildUpDownButton(
|
||||
(event) {
|
||||
setState(() {
|
||||
_isUpDown = true;
|
||||
});
|
||||
_startScrollTimer(1);
|
||||
},
|
||||
(event) {
|
||||
setState(() {
|
||||
_isUpDown = false;
|
||||
});
|
||||
_stopScrollTimer();
|
||||
},
|
||||
(event) {
|
||||
setState(() {
|
||||
_isUpDown = false;
|
||||
});
|
||||
_stopScrollTimer();
|
||||
},
|
||||
() => _isUpDown,
|
||||
BorderRadius.vertical(top: Radius.circular(_wheelWidth * 0.5)),
|
||||
Icons.keyboard_arrow_up,
|
||||
),
|
||||
Listener(
|
||||
onPointerDown: (event) {
|
||||
setState(() {
|
||||
_isMidDown = true;
|
||||
});
|
||||
_inputModel.tapDown(MouseButtons.wheel);
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
setState(() {
|
||||
_isMidDown = false;
|
||||
});
|
||||
_inputModel.tapUp(MouseButtons.wheel);
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
setState(() {
|
||||
_isMidDown = false;
|
||||
});
|
||||
_inputModel.tapUp(MouseButtons.wheel);
|
||||
},
|
||||
child: Container(
|
||||
width: _wheelWidth,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: _kDefaultColor,
|
||||
border: Border.symmetric(
|
||||
vertical: BorderSide(
|
||||
color:
|
||||
_isMidDown ? _kTapDownColor : _kDefaultBorderColor,
|
||||
width: _kBorderWidth)),
|
||||
),
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: _wheelWidth - 10,
|
||||
height: _wheelWidth - 10,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 2,
|
||||
color: _kDefaultBorderColor,
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Container(
|
||||
width: 24,
|
||||
height: 2,
|
||||
color: _kDefaultBorderColor,
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Container(
|
||||
width: 18,
|
||||
height: 2,
|
||||
color: _kDefaultBorderColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildUpDownButton(
|
||||
(event) {
|
||||
setState(() {
|
||||
_isDownDown = true;
|
||||
});
|
||||
_startScrollTimer(-1);
|
||||
},
|
||||
(event) {
|
||||
setState(() {
|
||||
_isDownDown = false;
|
||||
});
|
||||
_stopScrollTimer();
|
||||
},
|
||||
(event) {
|
||||
setState(() {
|
||||
_isDownDown = false;
|
||||
});
|
||||
_stopScrollTimer();
|
||||
},
|
||||
() => _isDownDown,
|
||||
BorderRadius.vertical(bottom: Radius.circular(_wheelWidth * 0.5)),
|
||||
Icons.keyboard_arrow_down,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _startScrollTimer(int direction) {
|
||||
_scrollTimer?.cancel();
|
||||
_inputModel.scroll(direction);
|
||||
_scrollTimer = Timer.periodic(
|
||||
Duration(milliseconds: _kInputTimerIntervalMillis), (timer) {
|
||||
_inputModel.scroll(direction);
|
||||
});
|
||||
}
|
||||
|
||||
void _stopScrollTimer() {
|
||||
_scrollTimer?.cancel();
|
||||
_scrollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
class FloatingLeftRightButton extends StatefulWidget {
|
||||
final bool isLeft;
|
||||
final InputModel inputModel;
|
||||
final CursorModel cursorModel;
|
||||
const FloatingLeftRightButton(
|
||||
{super.key,
|
||||
required this.isLeft,
|
||||
required this.inputModel,
|
||||
required this.cursorModel});
|
||||
|
||||
@override
|
||||
State<FloatingLeftRightButton> createState() =>
|
||||
_FloatingLeftRightButtonState();
|
||||
}
|
||||
|
||||
class _FloatingLeftRightButtonState extends State<FloatingLeftRightButton> {
|
||||
Offset _position = Offset.zero;
|
||||
bool _isInitialized = false;
|
||||
bool _isDown = false;
|
||||
Rect? _lastBlockedRect;
|
||||
|
||||
Orientation? _previousOrientation;
|
||||
Offset _preSavedPos = Offset.zero;
|
||||
|
||||
// Gesture ambiguity resolution
|
||||
Timer? _tapDownTimer;
|
||||
final Duration _pressTimeout = const Duration(milliseconds: 200);
|
||||
bool _isDragging = false;
|
||||
|
||||
bool get _isLeft => widget.isLeft;
|
||||
InputModel get _inputModel => widget.inputModel;
|
||||
CursorModel get _cursorModel => widget.cursorModel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final currentOrientation = MediaQuery.of(context).orientation;
|
||||
_previousOrientation = currentOrientation;
|
||||
_resetPosition(currentOrientation);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_lastBlockedRect != null) {
|
||||
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||
}
|
||||
_tapDownTimer?.cancel();
|
||||
_trySavePosition();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final currentOrientation = MediaQuery.of(context).orientation;
|
||||
if (_previousOrientation == null ||
|
||||
_previousOrientation != currentOrientation) {
|
||||
_resetPosition(currentOrientation);
|
||||
}
|
||||
_previousOrientation = currentOrientation;
|
||||
}
|
||||
|
||||
double _getOffsetX(double w) {
|
||||
if (_isLeft) {
|
||||
return (w - _kLeftRightButtonWidth * 2 - _kSpaceBetweenLeftRightButtons) *
|
||||
0.5;
|
||||
} else {
|
||||
return (w + _kSpaceBetweenLeftRightButtons) * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
String _getPositionKey(Orientation ori) {
|
||||
final strLeftRight = _isLeft ? 'l' : 'r';
|
||||
final strOri = ori == Orientation.landscape ? 'l' : 'p';
|
||||
return '$strLeftRight$strOri-mouse-btn-pos';
|
||||
}
|
||||
|
||||
static Offset? _loadPositionFromString(String s) {
|
||||
if (s.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final m = jsonDecode(s);
|
||||
return Offset(m['x'], m['y']);
|
||||
} catch (e) {
|
||||
debugPrintStack(label: 'Failed to load position "$s" $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
void _trySavePosition() {
|
||||
if (_previousOrientation == null) return;
|
||||
if (((_position - _preSavedPos)).distanceSquared < 0.1) return;
|
||||
final pos = jsonEncode({
|
||||
'x': _position.dx,
|
||||
'y': _position.dy,
|
||||
});
|
||||
bind.setLocalFlutterOption(
|
||||
k: _getPositionKey(_previousOrientation!), v: pos);
|
||||
_preSavedPos = _position;
|
||||
}
|
||||
|
||||
void _restorePosition(Orientation ori) {
|
||||
final ps = bind.getLocalFlutterOption(k: _getPositionKey(ori));
|
||||
final pos = _loadPositionFromString(ps);
|
||||
if (pos == null) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
_position = Offset(_getOffsetX(size.width),
|
||||
size.height - _kSpaceToVerticalEdge - _kLeftRightButtonHeight);
|
||||
} else {
|
||||
_position = pos;
|
||||
_preSavedPos = pos;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetPosition(Orientation ori) {
|
||||
setState(() {
|
||||
_restorePosition(ori);
|
||||
_isInitialized = true;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _updateBlockedRect();
|
||||
});
|
||||
}
|
||||
|
||||
void _updateBlockedRect() {
|
||||
if (_lastBlockedRect != null) {
|
||||
_cursorModel.removeBlockedRect(_lastBlockedRect!);
|
||||
}
|
||||
final newRect = Rect.fromLTWH(_position.dx, _position.dy,
|
||||
_kLeftRightButtonWidth, _kLeftRightButtonHeight);
|
||||
_cursorModel.addBlockedRect(newRect);
|
||||
_lastBlockedRect = newRect;
|
||||
}
|
||||
|
||||
void _onMoveUpdateDelta(Offset delta) {
|
||||
final context = this.context;
|
||||
final size = MediaQuery.of(context).size;
|
||||
Offset newPosition = _position + delta;
|
||||
double minX = _kSpaceToHorizontalEdge;
|
||||
double minY = _kSpaceToVerticalEdge;
|
||||
double maxX = size.width - _kLeftRightButtonWidth - _kSpaceToHorizontalEdge;
|
||||
double maxY = size.height - _kLeftRightButtonHeight - _kSpaceToVerticalEdge;
|
||||
newPosition = Offset(
|
||||
newPosition.dx.clamp(minX, maxX),
|
||||
newPosition.dy.clamp(minY, maxY),
|
||||
);
|
||||
final isPositionChanged = !(isDoubleEqual(newPosition.dx, _position.dx) &&
|
||||
isDoubleEqual(newPosition.dy, _position.dy));
|
||||
setState(() {
|
||||
_position = newPosition;
|
||||
});
|
||||
if (isPositionChanged) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _updateBlockedRect();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _onBodyPointerMoveUpdate(PointerMoveEvent event) {
|
||||
_cursorModel.blockEvents = true;
|
||||
// If move, it's a drag, not a tap.
|
||||
_isDragging = true;
|
||||
// Cancel the timer to prevent it from being recognized as a tap/hold.
|
||||
_tapDownTimer?.cancel();
|
||||
_tapDownTimer = null;
|
||||
_onMoveUpdateDelta(event.delta);
|
||||
}
|
||||
|
||||
Widget _buildButtonIcon() {
|
||||
final double w = _kLeftRightButtonWidth * 0.45;
|
||||
final double h = _kLeftRightButtonHeight * 0.75;
|
||||
final double borderRadius = w * 0.5;
|
||||
final double quarterCircleRadius = borderRadius * 0.9;
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
width: w,
|
||||
height: h,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(_kLeftRightButtonWidth * 0.225),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: _isLeft ? quarterCircleRadius * 0.25 : null,
|
||||
right: _isLeft ? null : quarterCircleRadius * 0.25,
|
||||
top: quarterCircleRadius * 0.25,
|
||||
child: CustomPaint(
|
||||
size: Size(quarterCircleRadius * 2, quarterCircleRadius * 2),
|
||||
painter: _QuarterCirclePainter(
|
||||
color: _kDefaultColor,
|
||||
isLeft: _isLeft,
|
||||
radius: quarterCircleRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized) {
|
||||
return Positioned(child: Offstage());
|
||||
}
|
||||
return Positioned(
|
||||
left: _position.dx,
|
||||
top: _position.dy,
|
||||
// We can't use the GestureDetector here, because `onTapDown` may be
|
||||
// triggered sometimes when dragging.
|
||||
child: Listener(
|
||||
onPointerMove: _onBodyPointerMoveUpdate,
|
||||
onPointerDown: (event) async {
|
||||
_isDragging = false;
|
||||
setState(() {
|
||||
_isDown = true;
|
||||
});
|
||||
// Start a timer. If it fires, it's a hold.
|
||||
_tapDownTimer?.cancel();
|
||||
_tapDownTimer = Timer(_pressTimeout, () {
|
||||
isSpecialHoldDragActive = true;
|
||||
() async {
|
||||
await _cursorModel.syncCursorPosition();
|
||||
await _inputModel
|
||||
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||
}();
|
||||
_tapDownTimer = null;
|
||||
});
|
||||
},
|
||||
onPointerUp: (event) {
|
||||
_cursorModel.blockEvents = false;
|
||||
setState(() {
|
||||
_isDown = false;
|
||||
});
|
||||
// If timer is active, it's a quick tap.
|
||||
if (_tapDownTimer != null) {
|
||||
_tapDownTimer!.cancel();
|
||||
_tapDownTimer = null;
|
||||
// Fire tap down and up quickly.
|
||||
_inputModel
|
||||
.tapDown(_isLeft ? MouseButtons.left : MouseButtons.right)
|
||||
.then(
|
||||
(_) => Future.delayed(const Duration(milliseconds: 50), () {
|
||||
_inputModel.tapUp(
|
||||
_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||
}));
|
||||
} else {
|
||||
// If it's not a quick tap, it could be a hold or drag.
|
||||
// If it was a hold, isSpecialHoldDragActive is true.
|
||||
if (isSpecialHoldDragActive) {
|
||||
_inputModel
|
||||
.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||
}
|
||||
}
|
||||
|
||||
if (_isDragging) {
|
||||
_trySavePosition();
|
||||
}
|
||||
isSpecialHoldDragActive = false;
|
||||
},
|
||||
onPointerCancel: (event) {
|
||||
_cursorModel.blockEvents = false;
|
||||
setState(() {
|
||||
_isDown = false;
|
||||
});
|
||||
_tapDownTimer?.cancel();
|
||||
_tapDownTimer = null;
|
||||
if (isSpecialHoldDragActive) {
|
||||
_inputModel.tapUp(_isLeft ? MouseButtons.left : MouseButtons.right);
|
||||
}
|
||||
isSpecialHoldDragActive = false;
|
||||
if (_isDragging) {
|
||||
_trySavePosition();
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
width: _kLeftRightButtonWidth,
|
||||
height: _kLeftRightButtonHeight,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _kDefaultColor,
|
||||
border: Border.all(
|
||||
color: _isDown ? _kTapDownColor : _kDefaultBorderColor,
|
||||
width: _kBorderWidth),
|
||||
borderRadius: _isLeft
|
||||
? BorderRadius.horizontal(
|
||||
left: Radius.circular(_kLeftRightButtonHeight * 0.5))
|
||||
: BorderRadius.horizontal(
|
||||
right: Radius.circular(_kLeftRightButtonHeight * 0.5)),
|
||||
),
|
||||
child: _buildButtonIcon(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuarterCirclePainter extends CustomPainter {
|
||||
final Color color;
|
||||
final bool isLeft;
|
||||
final double radius;
|
||||
_QuarterCirclePainter(
|
||||
{required this.color, required this.isLeft, required this.radius});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill;
|
||||
final rect = Rect.fromLTWH(0, 0, radius * 2, radius * 2);
|
||||
if (isLeft) {
|
||||
canvas.drawArc(rect, -pi, pi / 2, true, paint);
|
||||
} else {
|
||||
canvas.drawArc(rect, -pi / 2, pi / 2, true, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
// Virtual joystick can send either absolute movement (via updatePan)
|
||||
// or relative movement (via sendMobileRelativeMouseMove) depending on the
|
||||
// InputModel.relativeMouseMode setting.
|
||||
class VirtualJoystick extends StatefulWidget {
|
||||
final CursorModel cursorModel;
|
||||
final InputModel inputModel;
|
||||
|
||||
const VirtualJoystick({
|
||||
super.key,
|
||||
required this.cursorModel,
|
||||
required this.inputModel,
|
||||
});
|
||||
|
||||
@override
|
||||
State<VirtualJoystick> createState() => _VirtualJoystickState();
|
||||
}
|
||||
|
||||
class _VirtualJoystickState extends State<VirtualJoystick> {
|
||||
Offset _position = Offset.zero;
|
||||
bool _isInitialized = false;
|
||||
Offset _offset = Offset.zero;
|
||||
final double _joystickRadius = 50.0;
|
||||
final double _thumbRadius = 20.0;
|
||||
final double _moveStep = 3.0;
|
||||
final double _speed = 1.0;
|
||||
|
||||
/// Scale factor for relative mouse movement sensitivity.
|
||||
/// Higher values result in faster cursor movement on the remote machine.
|
||||
static const double _kRelativeMouseScale = 3.0;
|
||||
|
||||
// One-shot timer to detect a drag gesture
|
||||
Timer? _dragStartTimer;
|
||||
// Periodic timer for continuous movement
|
||||
Timer? _continuousMoveTimer;
|
||||
Size? _lastScreenSize;
|
||||
bool _isPressed = false;
|
||||
|
||||
/// Check if relative mouse mode is enabled.
|
||||
bool get _useRelativeMouse => widget.inputModel.relativeMouseMode.value;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.cursorModel.blockEvents = false;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_lastScreenSize = MediaQuery.of(context).size;
|
||||
_resetPosition();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopSendEventTimer();
|
||||
widget.cursorModel.blockEvents = false;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final currentScreenSize = MediaQuery.of(context).size;
|
||||
if (_lastScreenSize != null && _lastScreenSize != currentScreenSize) {
|
||||
_resetPosition();
|
||||
}
|
||||
_lastScreenSize = currentScreenSize;
|
||||
}
|
||||
|
||||
void _resetPosition() {
|
||||
final size = MediaQuery.of(context).size;
|
||||
setState(() {
|
||||
_position = Offset(
|
||||
_kSpaceToHorizontalEdge + _joystickRadius,
|
||||
size.height * 0.5 + _joystickRadius * 1.5,
|
||||
);
|
||||
_isInitialized = true;
|
||||
});
|
||||
}
|
||||
|
||||
Offset _offsetToPanDelta(Offset offset) {
|
||||
return Offset(
|
||||
offset.dx / _joystickRadius,
|
||||
offset.dy / _joystickRadius,
|
||||
);
|
||||
}
|
||||
|
||||
/// Send movement delta to remote machine.
|
||||
/// Uses relative mouse mode if enabled, otherwise uses absolute updatePan.
|
||||
void _sendMovement(Offset delta) {
|
||||
if (_useRelativeMouse) {
|
||||
widget.inputModel.sendMobileRelativeMouseMove(
|
||||
delta.dx * _kRelativeMouseScale, delta.dy * _kRelativeMouseScale);
|
||||
} else {
|
||||
// In absolute mode, use cursorModel.updatePan which tracks position.
|
||||
widget.cursorModel.updatePan(delta, Offset.zero, false);
|
||||
}
|
||||
}
|
||||
|
||||
void _stopSendEventTimer() {
|
||||
_dragStartTimer?.cancel();
|
||||
_continuousMoveTimer?.cancel();
|
||||
_dragStartTimer = null;
|
||||
_continuousMoveTimer = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isInitialized) {
|
||||
return Positioned(child: Offstage());
|
||||
}
|
||||
return Positioned(
|
||||
left: _position.dx - _joystickRadius,
|
||||
top: _position.dy - _joystickRadius,
|
||||
child: GestureDetector(
|
||||
onPanStart: (details) {
|
||||
setState(() {
|
||||
_isPressed = true;
|
||||
});
|
||||
widget.cursorModel.blockEvents = true;
|
||||
_updateOffset(details.localPosition);
|
||||
|
||||
// 1. Send a single, small pan event immediately for responsiveness.
|
||||
// The movement is small for a gentle start.
|
||||
final initialDelta = _offsetToPanDelta(_offset);
|
||||
if (initialDelta.distance > 0) {
|
||||
_sendMovement(initialDelta);
|
||||
}
|
||||
|
||||
// 2. Start a one-shot timer to check if the user is holding for a drag.
|
||||
_dragStartTimer?.cancel();
|
||||
_dragStartTimer = Timer(const Duration(milliseconds: 120), () {
|
||||
// 3. If the timer fires, it's a drag. Start the continuous movement timer.
|
||||
_continuousMoveTimer?.cancel();
|
||||
_continuousMoveTimer =
|
||||
periodic_immediate(const Duration(milliseconds: 20), () async {
|
||||
if (_offset != Offset.zero) {
|
||||
_sendMovement(_offsetToPanDelta(_offset) * _moveStep * _speed);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
onPanUpdate: (details) {
|
||||
_updateOffset(details.localPosition);
|
||||
},
|
||||
onPanEnd: (details) {
|
||||
setState(() {
|
||||
_offset = Offset.zero;
|
||||
_isPressed = false;
|
||||
});
|
||||
widget.cursorModel.blockEvents = false;
|
||||
|
||||
// 4. Critical step: On pan end, cancel all timers.
|
||||
// If it was a flick, this cancels the drag detection before it fires.
|
||||
// If it was a drag, this stops the continuous movement.
|
||||
_stopSendEventTimer();
|
||||
},
|
||||
child: CustomPaint(
|
||||
size: Size(_joystickRadius * 2, _joystickRadius * 2),
|
||||
painter: _JoystickPainter(
|
||||
_offset, _joystickRadius, _thumbRadius, _isPressed),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _updateOffset(Offset localPosition) {
|
||||
final center = Offset(_joystickRadius, _joystickRadius);
|
||||
final offset = localPosition - center;
|
||||
final distance = offset.distance;
|
||||
|
||||
if (distance <= _joystickRadius) {
|
||||
setState(() {
|
||||
_offset = offset;
|
||||
});
|
||||
} else {
|
||||
final clampedOffset = offset / distance * _joystickRadius;
|
||||
setState(() {
|
||||
_offset = clampedOffset;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _JoystickPainter extends CustomPainter {
|
||||
final Offset _offset;
|
||||
final double _joystickRadius;
|
||||
final double _thumbRadius;
|
||||
final bool _isPressed;
|
||||
|
||||
_JoystickPainter(
|
||||
this._offset, this._joystickRadius, this._thumbRadius, this._isPressed);
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final joystickColor = _kDefaultColor;
|
||||
final borderColor = _isPressed ? _kTapDownColor : _kDefaultBorderColor;
|
||||
final thumbColor = _kWidgetHighlightColor;
|
||||
|
||||
final joystickPaint = Paint()
|
||||
..color = joystickColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
final borderPaint = Paint()
|
||||
..color = borderColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.5;
|
||||
|
||||
final thumbPaint = Paint()
|
||||
..color = thumbColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
// Draw joystick base and border
|
||||
canvas.drawCircle(center, _joystickRadius, joystickPaint);
|
||||
canvas.drawCircle(center, _joystickRadius, borderPaint);
|
||||
|
||||
// Draw thumb
|
||||
final thumbCenter = center + _offset;
|
||||
canvas.drawCircle(thumbCenter, _thumbRadius, thumbPaint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _JoystickPainter oldDelegate) {
|
||||
return oldDelegate._offset != _offset ||
|
||||
oldDelegate._isPressed != _isPressed;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hbb/common.dart';
|
||||
import 'package:flutter_hbb/models/input_model.dart';
|
||||
import 'package:flutter_hbb/models/model.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:toggle_switch/toggle_switch.dart';
|
||||
|
||||
class GestureIcons {
|
||||
static const String _family = 'gestureicons';
|
||||
|
||||
GestureIcons._();
|
||||
|
||||
static const IconData iconMouse = IconData(0xe65c, fontFamily: _family);
|
||||
static const IconData iconTabletTouch = IconData(0xe9ce, fontFamily: _family);
|
||||
static const IconData iconGestureFDrag =
|
||||
IconData(0xe686, fontFamily: _family);
|
||||
static const IconData iconMobileTouch = IconData(0xe9cd, fontFamily: _family);
|
||||
static const IconData iconGesturePress =
|
||||
IconData(0xe66c, fontFamily: _family);
|
||||
static const IconData iconGestureTap = IconData(0xe66f, fontFamily: _family);
|
||||
static const IconData iconGesturePinch =
|
||||
IconData(0xe66a, fontFamily: _family);
|
||||
static const IconData iconGesturePressHold =
|
||||
IconData(0xe66b, fontFamily: _family);
|
||||
static const IconData iconGestureFDragUpDown_ =
|
||||
IconData(0xe685, fontFamily: _family);
|
||||
static const IconData iconGestureFTap_ =
|
||||
IconData(0xe68e, fontFamily: _family);
|
||||
static const IconData iconGestureFSwipeRight =
|
||||
IconData(0xe68f, fontFamily: _family);
|
||||
static const IconData iconGestureFdoubleTap =
|
||||
IconData(0xe691, fontFamily: _family);
|
||||
static const IconData iconGestureFThreeFingers =
|
||||
IconData(0xe687, fontFamily: _family);
|
||||
}
|
||||
|
||||
typedef OnTouchModeChange = void Function(bool);
|
||||
|
||||
class GestureHelp extends StatefulWidget {
|
||||
GestureHelp(
|
||||
{Key? key,
|
||||
required this.touchMode,
|
||||
required this.onTouchModeChange,
|
||||
required this.virtualMouseMode,
|
||||
this.inputModel})
|
||||
: super(key: key);
|
||||
final bool touchMode;
|
||||
final OnTouchModeChange onTouchModeChange;
|
||||
final VirtualMouseMode virtualMouseMode;
|
||||
final InputModel? inputModel;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() =>
|
||||
_GestureHelpState(touchMode, virtualMouseMode);
|
||||
}
|
||||
|
||||
class _GestureHelpState extends State<GestureHelp> {
|
||||
late int _selectedIndex;
|
||||
late bool _touchMode;
|
||||
final VirtualMouseMode _virtualMouseMode;
|
||||
|
||||
_GestureHelpState(bool touchMode, VirtualMouseMode virtualMouseMode)
|
||||
: _virtualMouseMode = virtualMouseMode {
|
||||
_touchMode = touchMode;
|
||||
_selectedIndex = _touchMode ? 1 : 0;
|
||||
}
|
||||
|
||||
/// Helper to exit relative mouse mode when certain conditions are met.
|
||||
/// This reduces code duplication across multiple UI callbacks.
|
||||
void _exitRelativeMouseModeIf(bool condition) {
|
||||
if (condition) {
|
||||
widget.inputModel?.setRelativeMouseMode(false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
final space = 12.0;
|
||||
var width = size.width - 2 * space;
|
||||
final minWidth = 90;
|
||||
if (size.width > minWidth + 2 * space) {
|
||||
final n = (size.width / (minWidth + 2 * space)).floor();
|
||||
width = size.width / n - 2 * space;
|
||||
}
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12.0),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
ToggleSwitch(
|
||||
initialLabelIndex: _selectedIndex,
|
||||
activeFgColor: Colors.white,
|
||||
inactiveFgColor: Colors.white60,
|
||||
activeBgColor: [MyTheme.accent],
|
||||
inactiveBgColor: Theme.of(context).hintColor,
|
||||
totalSwitches: 2,
|
||||
minWidth: 150,
|
||||
fontSize: 15,
|
||||
iconSize: 18,
|
||||
labels: [
|
||||
translate("Mouse mode"),
|
||||
translate("Touch mode")
|
||||
],
|
||||
icons: [Icons.mouse, Icons.touch_app],
|
||||
onToggle: (index) {
|
||||
setState(() {
|
||||
if (_selectedIndex != index) {
|
||||
_selectedIndex = index ?? 0;
|
||||
_touchMode = index == 0 ? false : true;
|
||||
widget.onTouchModeChange(_touchMode);
|
||||
// Exit relative mouse mode when switching to touch mode
|
||||
_exitRelativeMouseModeIf(_touchMode);
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
Transform.translate(
|
||||
offset: const Offset(-10.0, 0.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: _virtualMouseMode.showVirtualMouse,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await _virtualMouseMode.toggleVirtualMouse();
|
||||
// Exit relative mouse mode when virtual mouse is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode.showVirtualMouse);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await _virtualMouseMode.toggleVirtualMouse();
|
||||
// Exit relative mouse mode when virtual mouse is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode.showVirtualMouse);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(translate('Show virtual mouse')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_touchMode && _virtualMouseMode.showVirtualMouse)
|
||||
Padding(
|
||||
// Indent "Virtual mouse size"
|
||||
padding: const EdgeInsets.only(left: 24.0),
|
||||
child: SizedBox(
|
||||
width: 260,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 0.0, bottom: 0),
|
||||
child: Text(translate('Virtual mouse size')),
|
||||
),
|
||||
Transform.translate(
|
||||
offset: Offset(-0.0, -6.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(left: 0.0),
|
||||
child: Text(translate('Small')),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: _virtualMouseMode
|
||||
.virtualMouseScale,
|
||||
min: 0.8,
|
||||
max: 1.8,
|
||||
divisions: 10,
|
||||
onChanged: (value) {
|
||||
_virtualMouseMode
|
||||
.setVirtualMouseScale(value);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(right: 16.0),
|
||||
child: Text(translate('Large')),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_touchMode && _virtualMouseMode.showVirtualMouse)
|
||||
Transform.translate(
|
||||
offset: const Offset(-10.0, -12.0),
|
||||
child: Padding(
|
||||
// Indent "Show virtual joystick"
|
||||
padding: const EdgeInsets.only(left: 24.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value:
|
||||
_virtualMouseMode.showVirtualJoystick,
|
||||
onChanged: (value) async {
|
||||
if (value == null) return;
|
||||
await _virtualMouseMode
|
||||
.toggleVirtualJoystick();
|
||||
// Exit relative mouse mode when joystick is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode
|
||||
.showVirtualJoystick);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
await _virtualMouseMode
|
||||
.toggleVirtualJoystick();
|
||||
// Exit relative mouse mode when joystick is hidden
|
||||
_exitRelativeMouseModeIf(
|
||||
!_virtualMouseMode
|
||||
.showVirtualJoystick);
|
||||
setState(() {});
|
||||
},
|
||||
child: Text(
|
||||
translate("Show virtual joystick")),
|
||||
),
|
||||
],
|
||||
)),
|
||||
),
|
||||
// Relative mouse mode option - only visible when joystick is shown
|
||||
if (!_touchMode &&
|
||||
_virtualMouseMode.showVirtualMouse &&
|
||||
_virtualMouseMode.showVirtualJoystick &&
|
||||
widget.inputModel != null)
|
||||
Obx(() => Transform.translate(
|
||||
offset: const Offset(-10.0, -24.0),
|
||||
child: Padding(
|
||||
// Indent further for 'Relative mouse mode'
|
||||
padding: const EdgeInsets.only(left: 48.0),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Checkbox(
|
||||
value: widget.inputModel!
|
||||
.relativeMouseMode.value,
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
widget.inputModel!
|
||||
.setRelativeMouseMode(value);
|
||||
},
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
widget.inputModel!
|
||||
.toggleRelativeMouseMode();
|
||||
},
|
||||
child: Text(
|
||||
translate('Relative mouse mode')),
|
||||
),
|
||||
],
|
||||
)),
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
child: Wrap(
|
||||
spacing: space,
|
||||
runSpacing: 2 * space,
|
||||
children: _touchMode
|
||||
? [
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconMobileTouch,
|
||||
translate("One-Finger Tap"),
|
||||
translate("Left Mouse")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGesturePressHold,
|
||||
translate("One-Long Tap"),
|
||||
translate("Right Mouse")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGestureFSwipeRight,
|
||||
translate("One-Finger Move"),
|
||||
translate("Mouse Drag")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGestureFThreeFingers,
|
||||
translate("Three-Finger vertically"),
|
||||
translate("Mouse Wheel")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGestureFDrag,
|
||||
translate("Two-Finger Move"),
|
||||
translate("Canvas Move")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGesturePinch,
|
||||
translate("Pinch to Zoom"),
|
||||
translate("Canvas Zoom")),
|
||||
]
|
||||
: [
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconMobileTouch,
|
||||
translate("One-Finger Tap"),
|
||||
translate("Left Mouse")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGesturePressHold,
|
||||
translate("One-Long Tap"),
|
||||
translate("Right Mouse")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGestureFSwipeRight,
|
||||
translate("Double Tap & Move"),
|
||||
translate("Mouse Drag")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGestureFThreeFingers,
|
||||
translate("Three-Finger vertically"),
|
||||
translate("Mouse Wheel")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGestureFDrag,
|
||||
translate("Two-Finger Move"),
|
||||
translate("Canvas Move")),
|
||||
GestureInfo(
|
||||
width,
|
||||
GestureIcons.iconGesturePinch,
|
||||
translate("Pinch to Zoom"),
|
||||
translate("Canvas Zoom")),
|
||||
],
|
||||
)),
|
||||
],
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
class GestureInfo extends StatelessWidget {
|
||||
const GestureInfo(this.width, this.icon, this.fromText, this.toText,
|
||||
{Key? key})
|
||||
: super(key: key);
|
||||
|
||||
final String fromText;
|
||||
final String toText;
|
||||
final IconData icon;
|
||||
final double width;
|
||||
|
||||
final iconSize = 35.0;
|
||||
final iconColor = MyTheme.accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: iconSize,
|
||||
color: iconColor,
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(fromText,
|
||||
textAlign: TextAlign.center,
|
||||
style:
|
||||
TextStyle(fontSize: 9, color: Theme.of(context).hintColor)),
|
||||
SizedBox(height: 3),
|
||||
Text(toText,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).textTheme.bodySmall?.color))
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user