mirror of
https://github.com/rustdesk/rustdesk.git
synced 2025-11-28 16:50:24 +08:00
* feat: mobile, virtual mouse Signed-off-by: fufesou <linlong1266@gmail.com> * feat: mobile, virtual mouse, mouse mode Signed-off-by: fufesou <linlong1266@gmail.com> * refact: mobile, virtual mouse, mouse mode Signed-off-by: fufesou <linlong1266@gmail.com> * feat: mobile, virtual mouse mode Signed-off-by: fufesou <linlong1266@gmail.com> * feat: mobile virtual mouse, options Signed-off-by: fufesou <linlong1266@gmail.com> --------- Signed-off-by: fufesou <linlong1266@gmail.com> Co-authored-by: RustDesk <71636191+rustdesk@users.noreply.github.com>
880 lines
26 KiB
Dart
880 lines
26 KiB
Dart
// 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),
|
|
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 sends the absolute movement for now.
|
|
// Maybe we need to change it to relative movement in the future.
|
|
class VirtualJoystick extends StatefulWidget {
|
|
final CursorModel cursorModel;
|
|
|
|
const VirtualJoystick({super.key, required this.cursorModel});
|
|
|
|
@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;
|
|
|
|
// One-shot timer to detect a drag gesture
|
|
Timer? _dragStartTimer;
|
|
// Periodic timer for continuous movement
|
|
Timer? _continuousMoveTimer;
|
|
Size? _lastScreenSize;
|
|
bool _isPressed = false;
|
|
|
|
@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,
|
|
);
|
|
}
|
|
|
|
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) {
|
|
widget.cursorModel.updatePan(initialDelta, Offset.zero, false);
|
|
}
|
|
|
|
// 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) {
|
|
widget.cursorModel.updatePan(
|
|
_offsetToPanDelta(_offset) * _moveStep * _speed,
|
|
Offset.zero,
|
|
false);
|
|
}
|
|
});
|
|
});
|
|
},
|
|
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;
|
|
}
|
|
}
|