1448 lines
47 KiB
Dart
1448 lines
47 KiB
Dart
import 'dart:async';
|
||
import 'dart:ui' as ui;
|
||
|
||
import 'package:flutter/gestures.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
|
||
import 'rust/api.dart' as nd;
|
||
import 'theme.dart';
|
||
|
||
/// Mouse-button bitmask values used by the Rust input layer.
|
||
const int _btnLeft = 1;
|
||
const int _btnRight = 2;
|
||
|
||
enum _Phase { connecting, live, ended, denied }
|
||
|
||
/// How finger touch maps to the remote cursor.
|
||
/// * [trackpad] — the screen is a relative trackpad: drag to nudge the cursor,
|
||
/// tap to click (precise, works at any zoom, like a laptop pad).
|
||
/// * [direct] — the screen is the remote surface: the cursor jumps to wherever
|
||
/// you touch, tap-to-click there (fast, like touching the PC).
|
||
/// A stylus/pen is ALWAYS treated as a precise direct pointer (with hover),
|
||
/// regardless of this mode.
|
||
enum _InputMode { trackpad, direct }
|
||
|
||
/// The active remote-session screen.
|
||
///
|
||
/// It subscribes to [nd.sessionEvents] for the session [handle] opened by the
|
||
/// connect screen, paints each decoded RGBA [nd.NdEvent_Frame] to fill the
|
||
/// display (fit + pinch-zoom + pan), and forwards touch and keyboard input back
|
||
/// through the FRB methods:
|
||
///
|
||
/// * one-finger drag → move cursor ([nd.sendMouse])
|
||
/// * tap → left click ([nd.sendMouse])
|
||
/// * long-press → right click ([nd.sendMouse])
|
||
/// * two-finger drag → scroll wheel ([nd.sendMouse])
|
||
/// * pinch → zoom the view (local)
|
||
/// * hidden TextField → keystrokes ([nd.sendKey])
|
||
///
|
||
/// A floating toolbar exposes disconnect, keyboard, monitors, quality and
|
||
/// Ctrl+Alt+Del; an overflow sheet adds chat, clipboard push and reset-zoom.
|
||
class SessionScreen extends StatefulWidget {
|
||
const SessionScreen({
|
||
super.key,
|
||
required this.partnerId,
|
||
required this.handle,
|
||
});
|
||
|
||
/// The Partner ID being controlled (display only).
|
||
final String partnerId;
|
||
|
||
/// The session handle returned by [nd.connect].
|
||
final BigInt handle;
|
||
|
||
@override
|
||
State<SessionScreen> createState() => _SessionScreenState();
|
||
}
|
||
|
||
class _SessionScreenState extends State<SessionScreen> {
|
||
StreamSubscription<nd.NdEvent>? _sub;
|
||
|
||
// ---- session state -------------------------------------------------------
|
||
_Phase _phase = _Phase.connecting;
|
||
String? _error;
|
||
String _hostName = '';
|
||
bool _e2e = false;
|
||
ui.Image? _image;
|
||
int _remoteW = 0;
|
||
int _remoteH = 0;
|
||
int _currentMon = 0;
|
||
bool _closed = false;
|
||
bool _showHint = false;
|
||
/// When false, the top bar + toolbar are hidden for a pure full-screen view.
|
||
bool _controlsVisible = true;
|
||
|
||
/// Virtual cursor position, in remote pixel space (touchpad model).
|
||
Offset _cursor = Offset.zero;
|
||
|
||
/// Touch input mode (trackpad vs direct). Persisted for the session.
|
||
_InputMode _mode = _InputMode.trackpad;
|
||
|
||
/// Mouse buttons currently held (so pen/touch drags keep the button down
|
||
/// across move events). Bitmask of [_btnLeft]/[_btnRight].
|
||
int _heldButtons = 0;
|
||
|
||
/// Kind of the most recent pointer down — lets the touch gestures ignore a
|
||
/// stylus (which the Listener handles as a precise absolute pointer instead).
|
||
PointerDeviceKind _activeKind = PointerDeviceKind.touch;
|
||
bool get _penActive =>
|
||
_activeKind == PointerDeviceKind.stylus ||
|
||
_activeKind == PointerDeviceKind.invertedStylus;
|
||
|
||
// ---- view transform (pinch-zoom + pan) -----------------------------------
|
||
double _viewScale = 1.0;
|
||
Offset _viewOffset = Offset.zero;
|
||
double _gestureStartScale = 1.0;
|
||
Offset _gestureStartOffset = Offset.zero;
|
||
Offset _gestureStartFocal = Offset.zero;
|
||
Size _viewport = Size.zero;
|
||
static const double _minScale = 1.0;
|
||
static const double _maxScale = 6.0;
|
||
static const double _touchSensitivity = 2.2;
|
||
|
||
// ---- keyboard ------------------------------------------------------------
|
||
final FocusNode _bodyFocus = FocusNode(debugLabel: 'netdesk-body');
|
||
final FocusNode _kbFocus = FocusNode(debugLabel: 'netdesk-keyboard');
|
||
final TextEditingController _kbController =
|
||
TextEditingController(text: _kbBase);
|
||
static const String _kbBase = ''; // zero-width placeholder
|
||
bool _keyboardVisible = false;
|
||
|
||
static final Map<LogicalKeyboardKey, String> _logicalMap = {
|
||
LogicalKeyboardKey.enter: 'Enter',
|
||
LogicalKeyboardKey.numpadEnter: 'Enter',
|
||
LogicalKeyboardKey.backspace: 'Backspace',
|
||
LogicalKeyboardKey.tab: 'Tab',
|
||
LogicalKeyboardKey.escape: 'Escape',
|
||
LogicalKeyboardKey.space: 'Space',
|
||
LogicalKeyboardKey.delete: 'Delete',
|
||
LogicalKeyboardKey.insert: 'Insert',
|
||
LogicalKeyboardKey.arrowLeft: 'ArrowLeft',
|
||
LogicalKeyboardKey.arrowRight: 'ArrowRight',
|
||
LogicalKeyboardKey.arrowUp: 'ArrowUp',
|
||
LogicalKeyboardKey.arrowDown: 'ArrowDown',
|
||
LogicalKeyboardKey.home: 'Home',
|
||
LogicalKeyboardKey.end: 'End',
|
||
LogicalKeyboardKey.pageUp: 'PageUp',
|
||
LogicalKeyboardKey.pageDown: 'PageDown',
|
||
LogicalKeyboardKey.capsLock: 'CapsLock',
|
||
LogicalKeyboardKey.controlLeft: 'ControlLeft',
|
||
LogicalKeyboardKey.controlRight: 'ControlRight',
|
||
LogicalKeyboardKey.shiftLeft: 'ShiftLeft',
|
||
LogicalKeyboardKey.shiftRight: 'ShiftRight',
|
||
LogicalKeyboardKey.altLeft: 'AltLeft',
|
||
LogicalKeyboardKey.altRight: 'AltRight',
|
||
LogicalKeyboardKey.metaLeft: 'MetaLeft',
|
||
LogicalKeyboardKey.metaRight: 'MetaRight',
|
||
LogicalKeyboardKey.f1: 'F1',
|
||
LogicalKeyboardKey.f2: 'F2',
|
||
LogicalKeyboardKey.f3: 'F3',
|
||
LogicalKeyboardKey.f4: 'F4',
|
||
LogicalKeyboardKey.f5: 'F5',
|
||
LogicalKeyboardKey.f6: 'F6',
|
||
LogicalKeyboardKey.f7: 'F7',
|
||
LogicalKeyboardKey.f8: 'F8',
|
||
LogicalKeyboardKey.f9: 'F9',
|
||
LogicalKeyboardKey.f10: 'F10',
|
||
LogicalKeyboardKey.f11: 'F11',
|
||
LogicalKeyboardKey.f12: 'F12',
|
||
};
|
||
|
||
// ==========================================================================
|
||
// lifecycle
|
||
// ==========================================================================
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
// Full-screen immersive session: hide the Android status + nav bars so the
|
||
// remote screen uses the whole tablet. They slide back in on an edge swipe.
|
||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||
_sub = nd.sessionEvents(handle: widget.handle).listen(
|
||
_onEvent,
|
||
onError: (Object e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_phase = _Phase.ended;
|
||
_error = '$e';
|
||
});
|
||
},
|
||
);
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
// Restore the normal (edge-to-edge) system bars for the rest of the app.
|
||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||
_sub?.cancel();
|
||
if (!_closed) {
|
||
_closed = true;
|
||
nd.disconnect(handle: widget.handle);
|
||
}
|
||
_image?.dispose();
|
||
_kbController.dispose();
|
||
_kbFocus.dispose();
|
||
_bodyFocus.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
// ==========================================================================
|
||
// events
|
||
// ==========================================================================
|
||
void _onEvent(nd.NdEvent event) {
|
||
if (!mounted) return;
|
||
switch (event) {
|
||
case nd.NdEvent_Connecting():
|
||
setState(() => _phase = _Phase.connecting);
|
||
case nd.NdEvent_Connected(
|
||
:final w,
|
||
:final h,
|
||
:final e2E,
|
||
:final hostName
|
||
):
|
||
setState(() {
|
||
_remoteW = w;
|
||
_remoteH = h;
|
||
_e2e = e2E;
|
||
_hostName = hostName;
|
||
_cursor = Offset(w / 2, h / 2);
|
||
_phase = _Phase.live;
|
||
_showHint = true;
|
||
_currentMon = nd.sessionCurrentMonitor(handle: widget.handle);
|
||
});
|
||
_scheduleHintHide();
|
||
case nd.NdEvent_Frame(:final width, :final height, :final rgba):
|
||
_ingestFrame(width, height, rgba);
|
||
case nd.NdEvent_Ended(:final reason):
|
||
_closed = true;
|
||
setState(() {
|
||
_phase = _Phase.ended;
|
||
_error = reason;
|
||
});
|
||
case nd.NdEvent_Denied(:final reason):
|
||
_closed = true;
|
||
setState(() {
|
||
_phase = _Phase.denied;
|
||
_error = reason;
|
||
});
|
||
case nd.NdEvent_ChatMessage(:final from, :final text):
|
||
_toast('$from: $text', icon: Icons.chat_bubble_outline_rounded);
|
||
case nd.NdEvent_Clipboard(:final text):
|
||
Clipboard.setData(ClipboardData(text: text));
|
||
_toast('Clipboard received from host',
|
||
icon: Icons.content_paste_rounded);
|
||
}
|
||
}
|
||
|
||
void _ingestFrame(int w, int h, Uint8List rgba) {
|
||
ui.decodeImageFromPixels(rgba, w, h, ui.PixelFormat.rgba8888, (ui.Image img) {
|
||
if (!mounted) {
|
||
img.dispose();
|
||
return;
|
||
}
|
||
final old = _image;
|
||
setState(() {
|
||
_image = img;
|
||
if (_remoteW == 0 || _remoteH == 0) {
|
||
_remoteW = w;
|
||
_remoteH = h;
|
||
_cursor = Offset(w / 2, h / 2);
|
||
}
|
||
if (_phase == _Phase.connecting) {
|
||
_phase = _Phase.live;
|
||
_showHint = true;
|
||
_scheduleHintHide();
|
||
}
|
||
});
|
||
old?.dispose();
|
||
});
|
||
}
|
||
|
||
void _scheduleHintHide() {
|
||
Future<void>.delayed(const Duration(seconds: 4), () {
|
||
if (mounted && _showHint) setState(() => _showHint = false);
|
||
});
|
||
}
|
||
|
||
// ==========================================================================
|
||
// input → FRB
|
||
// ==========================================================================
|
||
bool get _inputReady => _phase == _Phase.live && _remoteW > 0 && _remoteH > 0;
|
||
|
||
void _moveCursorBy(Offset delta) {
|
||
if (!_inputReady) return;
|
||
final speed = _touchSensitivity / _viewScale;
|
||
final nx = (_cursor.dx + delta.dx * speed).clamp(0.0, _remoteW.toDouble());
|
||
final ny = (_cursor.dy + delta.dy * speed).clamp(0.0, _remoteH.toDouble());
|
||
setState(() => _cursor = Offset(nx, ny));
|
||
nd.sendMouse(
|
||
handle: widget.handle,
|
||
x: nx.round(),
|
||
y: ny.round(),
|
||
buttons: 0,
|
||
wheel: 0,
|
||
hwheel: 0,
|
||
);
|
||
}
|
||
|
||
void _click(int button) {
|
||
if (!_inputReady) return;
|
||
final x = _cursor.dx.round();
|
||
final y = _cursor.dy.round();
|
||
nd.sendMouse(
|
||
handle: widget.handle,
|
||
x: x,
|
||
y: y,
|
||
buttons: button,
|
||
wheel: 0,
|
||
hwheel: 0);
|
||
nd.sendMouse(
|
||
handle: widget.handle, x: x, y: y, buttons: 0, wheel: 0, hwheel: 0);
|
||
HapticFeedback.selectionClick();
|
||
}
|
||
|
||
void _scroll(double dy) {
|
||
if (!_inputReady) return;
|
||
nd.sendMouse(
|
||
handle: widget.handle,
|
||
x: _cursor.dx.round(),
|
||
y: _cursor.dy.round(),
|
||
buttons: 0,
|
||
wheel: dy / 6.0,
|
||
hwheel: 0,
|
||
);
|
||
}
|
||
|
||
/// Map a local canvas position (viewport space) to remote pixel coords,
|
||
/// undoing the pinch-zoom/pan transform AND the BoxFit.contain letterbox.
|
||
Offset? _toRemote(Offset local) {
|
||
if (_viewport == Size.zero || _remoteW <= 0 || _remoteH <= 0) return null;
|
||
final child = (local - _viewOffset) / _viewScale;
|
||
final imgSize = Size(_remoteW.toDouble(), _remoteH.toDouble());
|
||
final fitted = applyBoxFit(BoxFit.contain, imgSize, _viewport);
|
||
final dst =
|
||
Alignment.center.inscribe(fitted.destination, Offset.zero & _viewport);
|
||
if (dst.width <= 0 || dst.height <= 0) return null;
|
||
final rx = ((child.dx - dst.left) / dst.width * _remoteW)
|
||
.clamp(0.0, _remoteW.toDouble());
|
||
final ry = ((child.dy - dst.top) / dst.height * _remoteH)
|
||
.clamp(0.0, _remoteH.toDouble());
|
||
return Offset(rx, ry);
|
||
}
|
||
|
||
/// Send an absolute cursor position (remote space) with the given buttons held.
|
||
void _sendAbsolute(Offset remote, int buttons) {
|
||
setState(() => _cursor = remote);
|
||
nd.sendMouse(
|
||
handle: widget.handle,
|
||
x: remote.dx.round(),
|
||
y: remote.dy.round(),
|
||
buttons: buttons,
|
||
wheel: 0,
|
||
hwheel: 0,
|
||
);
|
||
}
|
||
|
||
/// Direct-touch / stylus: move the cursor to where the finger/pen points.
|
||
void _moveTo(Offset local) {
|
||
if (!_inputReady) return;
|
||
final r = _toRemote(local);
|
||
if (r != null) _sendAbsolute(r, _heldButtons);
|
||
}
|
||
|
||
// ---- stylus / pen (precise, absolute, hover-aware) -----------------------
|
||
// Touch and mouse fall through to the GestureDetector (trackpad/direct); only
|
||
// a stylus is handled here, so the two paths never fight over one pointer.
|
||
bool _isPen(PointerDeviceKind k) =>
|
||
k == PointerDeviceKind.stylus || k == PointerDeviceKind.invertedStylus;
|
||
|
||
void _onPointerDown(PointerDownEvent e) {
|
||
_activeKind = e.kind; // record for the gesture handlers' pen guard
|
||
if (!_inputReady || !_isPen(e.kind)) return;
|
||
final r = _toRemote(e.localPosition);
|
||
if (r == null) return;
|
||
_heldButtons = _btnLeft; // pen touching = left button down
|
||
_sendAbsolute(r, _btnLeft);
|
||
}
|
||
|
||
void _onPointerMove(PointerMoveEvent e) {
|
||
if (_isPen(e.kind)) _moveTo(e.localPosition);
|
||
}
|
||
|
||
void _onPointerHover(PointerHoverEvent e) {
|
||
if (_isPen(e.kind)) {
|
||
_heldButtons = 0;
|
||
_moveTo(e.localPosition);
|
||
}
|
||
}
|
||
|
||
void _onPointerUp(PointerUpEvent e) {
|
||
if (!_isPen(e.kind)) return;
|
||
_heldButtons = 0;
|
||
final r = _toRemote(e.localPosition);
|
||
if (r != null) _sendAbsolute(r, 0);
|
||
}
|
||
|
||
void _onPointerSignal(PointerSignalEvent e) {
|
||
if (e is PointerScrollEvent) _scroll(-e.scrollDelta.dy);
|
||
}
|
||
|
||
void _onScaleStart(ScaleStartDetails d) {
|
||
_gestureStartScale = _viewScale;
|
||
_gestureStartOffset = _viewOffset;
|
||
_gestureStartFocal = d.localFocalPoint;
|
||
}
|
||
|
||
void _onScaleUpdate(ScaleUpdateDetails d) {
|
||
if (_penActive) return; // pen drags are handled by the Listener
|
||
if (d.pointerCount >= 2) {
|
||
// Pinch → zoom about the focal point; two-finger translation → pan.
|
||
final desired =
|
||
(_gestureStartScale * d.scale).clamp(_minScale, _maxScale);
|
||
final childPoint =
|
||
(_gestureStartFocal - _gestureStartOffset) / _gestureStartScale;
|
||
var newOffset = d.localFocalPoint - childPoint * desired;
|
||
setState(() {
|
||
_viewScale = desired;
|
||
_viewOffset = newOffset;
|
||
_clampOffset();
|
||
});
|
||
// At base scale there is nothing to pan, so a two-finger drag scrolls.
|
||
if (_viewScale <= 1.001 && (d.scale - 1).abs() < 0.02) {
|
||
final dy = d.focalPointDelta.dy;
|
||
if (dy.abs() > 0.3) _scroll(-dy);
|
||
}
|
||
} else {
|
||
// Single finger: trackpad = relative nudge; direct = jump to the finger.
|
||
if (_mode == _InputMode.direct) {
|
||
_moveTo(d.localFocalPoint);
|
||
} else {
|
||
_moveCursorBy(d.focalPointDelta);
|
||
}
|
||
}
|
||
}
|
||
|
||
void _clampOffset() {
|
||
if (_viewport == Size.zero) return;
|
||
final minX = _viewport.width * (1 - _viewScale);
|
||
final minY = _viewport.height * (1 - _viewScale);
|
||
_viewOffset = Offset(
|
||
_viewOffset.dx.clamp(minX, 0.0),
|
||
_viewOffset.dy.clamp(minY, 0.0),
|
||
);
|
||
}
|
||
|
||
void _resetZoom() {
|
||
setState(() {
|
||
_viewScale = 1.0;
|
||
_viewOffset = Offset.zero;
|
||
});
|
||
}
|
||
|
||
// ==========================================================================
|
||
// keyboard
|
||
// ==========================================================================
|
||
void _sendCode(String code, {bool shift = false}) {
|
||
final h = widget.handle;
|
||
if (shift) nd.sendKey(handle: h, code: 'ShiftLeft', down: true);
|
||
nd.sendKey(handle: h, code: code, down: true);
|
||
nd.sendKey(handle: h, code: code, down: false);
|
||
if (shift) nd.sendKey(handle: h, code: 'ShiftLeft', down: false);
|
||
}
|
||
|
||
void _typeChar(String ch) {
|
||
final spec = _codeForChar(ch);
|
||
if (spec == null) return;
|
||
_sendCode(spec.$1, shift: spec.$2);
|
||
}
|
||
|
||
void _onKbChanged(String value) {
|
||
if (value == _kbBase) return;
|
||
if (!value.startsWith(_kbBase) || value.length < _kbBase.length) {
|
||
// The placeholder was eaten → a backspace.
|
||
_sendCode('Backspace');
|
||
} else {
|
||
final inserted = value.substring(_kbBase.length);
|
||
for (final ch in inserted.characters) {
|
||
if (ch == '\n') {
|
||
_sendCode('Enter');
|
||
} else {
|
||
_typeChar(ch);
|
||
}
|
||
}
|
||
}
|
||
_resetKb();
|
||
}
|
||
|
||
void _resetKb() {
|
||
_kbController.value = const TextEditingValue(
|
||
text: _kbBase,
|
||
selection: TextSelection.collapsed(offset: _kbBase.length),
|
||
);
|
||
}
|
||
|
||
/// Hardware / IME key events for non-character keys (arrows, F-keys, …).
|
||
KeyEventResult _onKey(FocusNode node, KeyEvent event) {
|
||
final code = _logicalMap[event.logicalKey];
|
||
if (code == null) return KeyEventResult.ignored;
|
||
if (event is KeyDownEvent || event is KeyRepeatEvent) {
|
||
nd.sendKey(handle: widget.handle, code: code, down: true);
|
||
return KeyEventResult.handled;
|
||
}
|
||
if (event is KeyUpEvent) {
|
||
nd.sendKey(handle: widget.handle, code: code, down: false);
|
||
return KeyEventResult.handled;
|
||
}
|
||
return KeyEventResult.ignored;
|
||
}
|
||
|
||
void _toggleKeyboard() {
|
||
setState(() => _keyboardVisible = !_keyboardVisible);
|
||
if (_keyboardVisible) {
|
||
_resetKb();
|
||
_kbFocus.requestFocus();
|
||
} else {
|
||
_kbFocus.unfocus();
|
||
_bodyFocus.requestFocus();
|
||
}
|
||
}
|
||
|
||
void _toggleMode() {
|
||
setState(() {
|
||
_mode =
|
||
_mode == _InputMode.trackpad ? _InputMode.direct : _InputMode.trackpad;
|
||
_showHint = true;
|
||
});
|
||
_toast(
|
||
_mode == _InputMode.trackpad
|
||
? 'Trackpad — drag to move, tap to click'
|
||
: 'Direct touch — tap where you want to click',
|
||
icon: _mode == _InputMode.trackpad
|
||
? Icons.touch_app_rounded
|
||
: Icons.ads_click_rounded,
|
||
);
|
||
_scheduleHintHide();
|
||
}
|
||
|
||
/// Maps one character to its DOM `code` + whether Shift is required.
|
||
(String, bool)? _codeForChar(String ch) {
|
||
if (ch.length != 1) return null;
|
||
final c = ch.codeUnitAt(0);
|
||
if (c >= 0x61 && c <= 0x7A) return ('Key${ch.toUpperCase()}', false); // a-z
|
||
if (c >= 0x41 && c <= 0x5A) return ('Key$ch', true); // A-Z
|
||
if (c >= 0x30 && c <= 0x39) return ('Digit$ch', false); // 0-9
|
||
switch (ch) {
|
||
case ' ':
|
||
return ('Space', false);
|
||
case '!':
|
||
return ('Digit1', true);
|
||
case '@':
|
||
return ('Digit2', true);
|
||
case '#':
|
||
return ('Digit3', true);
|
||
case r'$':
|
||
return ('Digit4', true);
|
||
case '%':
|
||
return ('Digit5', true);
|
||
case '^':
|
||
return ('Digit6', true);
|
||
case '&':
|
||
return ('Digit7', true);
|
||
case '*':
|
||
return ('Digit8', true);
|
||
case '(':
|
||
return ('Digit9', true);
|
||
case ')':
|
||
return ('Digit0', true);
|
||
case '-':
|
||
return ('Minus', false);
|
||
case '_':
|
||
return ('Minus', true);
|
||
case '=':
|
||
return ('Equal', false);
|
||
case '+':
|
||
return ('Equal', true);
|
||
case '[':
|
||
return ('BracketLeft', false);
|
||
case '{':
|
||
return ('BracketLeft', true);
|
||
case ']':
|
||
return ('BracketRight', false);
|
||
case '}':
|
||
return ('BracketRight', true);
|
||
case '\\':
|
||
return ('Backslash', false);
|
||
case '|':
|
||
return ('Backslash', true);
|
||
case ';':
|
||
return ('Semicolon', false);
|
||
case ':':
|
||
return ('Semicolon', true);
|
||
case "'":
|
||
return ('Quote', false);
|
||
case '"':
|
||
return ('Quote', true);
|
||
case '`':
|
||
return ('Backquote', false);
|
||
case '~':
|
||
return ('Backquote', true);
|
||
case ',':
|
||
return ('Comma', false);
|
||
case '<':
|
||
return ('Comma', true);
|
||
case '.':
|
||
return ('Period', false);
|
||
case '>':
|
||
return ('Period', true);
|
||
case '/':
|
||
return ('Slash', false);
|
||
case '?':
|
||
return ('Slash', true);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ==========================================================================
|
||
// toolbar actions → FRB
|
||
// ==========================================================================
|
||
Future<void> _disconnectAndPop() async {
|
||
if (!_closed) {
|
||
_closed = true;
|
||
nd.disconnect(handle: widget.handle);
|
||
}
|
||
if (mounted) Navigator.of(context).maybePop();
|
||
}
|
||
|
||
void _ctrlAltDel() {
|
||
nd.ctrlAltDel(handle: widget.handle);
|
||
_toast('Sent Ctrl + Alt + Del', icon: Icons.keyboard_command_key_rounded);
|
||
}
|
||
|
||
Future<void> _sendClipboard() async {
|
||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||
final text = data?.text ?? '';
|
||
if (text.isEmpty) {
|
||
_toast('Local clipboard is empty');
|
||
return;
|
||
}
|
||
nd.setClipboard(handle: widget.handle, text: text);
|
||
_toast('Clipboard sent to host', icon: Icons.content_paste_go_rounded);
|
||
}
|
||
|
||
Future<void> _showQuality() async {
|
||
const presets = <(String, String, IconData, int, int, double)>[
|
||
('Auto', 'Balanced 30 fps · 5 Mbps', Icons.auto_awesome_rounded, 30, 5000,
|
||
1.0),
|
||
('Smooth', 'High frame rate · 60 fps', Icons.speed_rounded, 60, 3500,
|
||
0.8),
|
||
('Sharp', 'High detail · 12 Mbps', Icons.hd_rounded, 24, 12000, 1.0),
|
||
('Data saver', 'Low bandwidth · 720p', Icons.data_saver_on_rounded, 20,
|
||
1500, 0.6),
|
||
];
|
||
await _sheet(
|
||
title: 'Quality',
|
||
children: [
|
||
for (final p in presets)
|
||
_SheetTile(
|
||
icon: p.$3,
|
||
title: p.$1,
|
||
subtitle: p.$2,
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
nd.setQuality(
|
||
handle: widget.handle,
|
||
fps: p.$4,
|
||
bitrate: p.$5,
|
||
scale: p.$6);
|
||
_toast('Quality: ${p.$1}');
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _showMonitors() async {
|
||
// Ask the core for the displays the host actually reported — never a
|
||
// hardcoded count. The switch index is each monitor's own `i`.
|
||
final mons = nd.sessionMonitors(handle: widget.handle);
|
||
if (mons.length <= 1) {
|
||
_toast(
|
||
mons.isEmpty
|
||
? 'The host reported no displays'
|
||
: 'The host has a single display',
|
||
icon: Icons.monitor_rounded,
|
||
);
|
||
return;
|
||
}
|
||
await _sheet(
|
||
title: 'Displays',
|
||
children: [
|
||
for (var idx = 0; idx < mons.length; idx++)
|
||
_SheetTile(
|
||
icon: Icons.monitor_rounded,
|
||
title: 'Monitor ${idx + 1}',
|
||
subtitle: _currentMon == mons[idx].i
|
||
? '${mons[idx].w} × ${mons[idx].h} · Now viewing'
|
||
: '${mons[idx].w} × ${mons[idx].h}',
|
||
selected: _currentMon == mons[idx].i,
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
if (_currentMon == mons[idx].i) return; // already on this one
|
||
nd.monitorSwitch(handle: widget.handle, i: mons[idx].i);
|
||
setState(() => _currentMon = mons[idx].i);
|
||
_toast('Switched to Monitor ${idx + 1}',
|
||
icon: Icons.monitor_rounded);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _showMore() async {
|
||
await _sheet(
|
||
title: 'More',
|
||
children: [
|
||
_SheetTile(
|
||
icon: Icons.chat_bubble_outline_rounded,
|
||
title: 'Chat',
|
||
subtitle: 'Send a message to the host',
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_openChat();
|
||
},
|
||
),
|
||
_SheetTile(
|
||
icon: Icons.content_paste_go_rounded,
|
||
title: 'Send clipboard',
|
||
subtitle: 'Push this device\'s clipboard to the host',
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_sendClipboard();
|
||
},
|
||
),
|
||
_SheetTile(
|
||
icon: Icons.center_focus_strong_rounded,
|
||
title: 'Reset zoom',
|
||
subtitle: 'Fit the remote screen again',
|
||
onTap: () {
|
||
Navigator.of(context).pop();
|
||
_resetZoom();
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _openChat() async {
|
||
final controller = TextEditingController();
|
||
final text = await showDialog<String>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
backgroundColor: NetDeskColors.surface,
|
||
title: const Text('Send a message'),
|
||
content: TextField(
|
||
controller: controller,
|
||
autofocus: true,
|
||
minLines: 1,
|
||
maxLines: 4,
|
||
decoration: const InputDecoration(hintText: 'Type a message…'),
|
||
onSubmitted: (v) => Navigator.of(ctx).pop(v),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.of(ctx).pop(),
|
||
child: const Text('Cancel'),
|
||
),
|
||
FilledButton(
|
||
onPressed: () => Navigator.of(ctx).pop(controller.text),
|
||
child: const Text('Send'),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
controller.dispose();
|
||
final msg = text?.trim() ?? '';
|
||
if (msg.isNotEmpty) {
|
||
nd.sendChat(handle: widget.handle, text: msg);
|
||
_toast('Message sent', icon: Icons.send_rounded);
|
||
}
|
||
}
|
||
|
||
// ==========================================================================
|
||
// helpers
|
||
// ==========================================================================
|
||
void _toast(String message, {IconData? icon}) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
behavior: SnackBarBehavior.floating,
|
||
backgroundColor: NetDeskColors.surface,
|
||
content: Row(
|
||
children: [
|
||
if (icon != null) ...[
|
||
Icon(icon, size: 18, color: NetDeskColors.accent),
|
||
const SizedBox(width: NetDeskSpacing.sm),
|
||
],
|
||
Expanded(child: Text(message)),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Future<void> _sheet({
|
||
required String title,
|
||
required List<Widget> children,
|
||
}) {
|
||
return showModalBottomSheet<void>(
|
||
context: context,
|
||
backgroundColor: NetDeskColors.surface,
|
||
showDragHandle: true,
|
||
shape: const RoundedRectangleBorder(
|
||
borderRadius:
|
||
BorderRadius.vertical(top: Radius.circular(NetDeskRadius.xl)),
|
||
),
|
||
builder: (ctx) => SafeArea(
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(
|
||
NetDeskSpacing.md,
|
||
0,
|
||
NetDeskSpacing.md,
|
||
NetDeskSpacing.md,
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: NetDeskSpacing.sm, vertical: NetDeskSpacing.xs),
|
||
child: Text(
|
||
title.toUpperCase(),
|
||
style: const TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: 1.4,
|
||
color: NetDeskColors.textMuted,
|
||
),
|
||
),
|
||
),
|
||
...children,
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ==========================================================================
|
||
// build
|
||
// ==========================================================================
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return PopScope(
|
||
canPop: false,
|
||
onPopInvokedWithResult: (didPop, result) {
|
||
if (didPop) return;
|
||
_disconnectAndPop();
|
||
},
|
||
child: Scaffold(
|
||
backgroundColor: Colors.black,
|
||
resizeToAvoidBottomInset: false,
|
||
body: Focus(
|
||
focusNode: _bodyFocus,
|
||
autofocus: true,
|
||
onKeyEvent: _onKey,
|
||
child: Stack(
|
||
children: [
|
||
Positioned.fill(child: _buildCanvas()),
|
||
_buildHiddenKeyboard(),
|
||
if (_phase == _Phase.live) ...[
|
||
if (_controlsVisible) _buildTopBar(),
|
||
if (_showHint) _buildHint(),
|
||
if (_controlsVisible) _buildToolbar(),
|
||
if (!_controlsVisible) _buildShowControlsButton(),
|
||
],
|
||
if (_phase != _Phase.live) _buildStatusOverlay(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildCanvas() {
|
||
return LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
_viewport = constraints.biggest;
|
||
return Listener(
|
||
onPointerDown: _onPointerDown,
|
||
onPointerMove: _onPointerMove,
|
||
onPointerUp: _onPointerUp,
|
||
onPointerHover: _onPointerHover,
|
||
onPointerSignal: _onPointerSignal,
|
||
child: GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTapUp: (d) {
|
||
if (_penActive) return; // the pen's tap is a Listener click
|
||
if (_mode == _InputMode.direct) _moveTo(d.localPosition);
|
||
_click(_btnLeft);
|
||
},
|
||
onLongPressStart: (d) {
|
||
if (_penActive) return;
|
||
if (_mode == _InputMode.direct) _moveTo(d.localPosition);
|
||
_click(_btnRight);
|
||
},
|
||
onScaleStart: _onScaleStart,
|
||
onScaleUpdate: _onScaleUpdate,
|
||
child: ClipRect(
|
||
child: Transform.translate(
|
||
offset: _viewOffset,
|
||
child: Transform.scale(
|
||
scale: _viewScale,
|
||
alignment: Alignment.topLeft,
|
||
child: _image == null
|
||
? const SizedBox.expand()
|
||
: CustomPaint(
|
||
size: _viewport,
|
||
painter: _RemotePainter(
|
||
image: _image!,
|
||
cursor: _cursor,
|
||
remoteW: _remoteW,
|
||
remoteH: _remoteH,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
Widget _buildTopBar() {
|
||
return Positioned(
|
||
top: 0,
|
||
left: 0,
|
||
right: 0,
|
||
child: IgnorePointer(
|
||
child: Container(
|
||
padding: EdgeInsets.fromLTRB(
|
||
NetDeskSpacing.md,
|
||
MediaQuery.of(context).padding.top + NetDeskSpacing.sm,
|
||
NetDeskSpacing.md,
|
||
NetDeskSpacing.md,
|
||
),
|
||
decoration: const BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.topCenter,
|
||
end: Alignment.bottomCenter,
|
||
colors: [Color(0xCC000000), Color(0x00000000)],
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const _ConnectedDot(),
|
||
const SizedBox(width: NetDeskSpacing.sm),
|
||
Expanded(
|
||
child: Text(
|
||
_hostName.isEmpty ? widget.partnerId : _hostName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: NetDeskColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
if (_e2e)
|
||
Row(
|
||
children: [
|
||
Icon(Icons.lock_rounded,
|
||
size: 13,
|
||
color: NetDeskColors.accent.withValues(alpha: 0.9)),
|
||
const SizedBox(width: 4),
|
||
const Text(
|
||
'E2E',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w700,
|
||
letterSpacing: 0.5,
|
||
color: NetDeskColors.accent,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildHint() {
|
||
return Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
top: MediaQuery.of(context).padding.top + 48,
|
||
child: IgnorePointer(
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: NetDeskSpacing.md, vertical: NetDeskSpacing.sm),
|
||
decoration: BoxDecoration(
|
||
color: NetDeskColors.surface.withValues(alpha: 0.92),
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.all(color: NetDeskColors.border),
|
||
),
|
||
child: Text(
|
||
_mode == _InputMode.trackpad
|
||
? 'Trackpad · drag to move · tap to click · hold = right-click'
|
||
: 'Direct touch · tap the screen where you want to click',
|
||
style: const TextStyle(
|
||
fontSize: 12, color: NetDeskColors.textSecondary),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildToolbar() {
|
||
return Positioned(
|
||
left: 0,
|
||
right: 0,
|
||
bottom: MediaQuery.of(context).padding.bottom + NetDeskSpacing.md,
|
||
child: Center(
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: NetDeskColors.surface.withValues(alpha: 0.96),
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.all(color: NetDeskColors.border),
|
||
boxShadow: const [
|
||
BoxShadow(color: Color(0x66000000), blurRadius: 24, spreadRadius: 2),
|
||
],
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
_ToolButton(
|
||
icon: Icons.call_end_rounded,
|
||
tooltip: 'Disconnect',
|
||
color: NetDeskColors.danger,
|
||
onPressed: _disconnectAndPop,
|
||
),
|
||
_ToolButton(
|
||
icon: _keyboardVisible
|
||
? Icons.keyboard_hide_rounded
|
||
: Icons.keyboard_rounded,
|
||
tooltip: 'Keyboard',
|
||
active: _keyboardVisible,
|
||
onPressed: _toggleKeyboard,
|
||
),
|
||
_ToolButton(
|
||
icon: _mode == _InputMode.trackpad
|
||
? Icons.touch_app_rounded
|
||
: Icons.ads_click_rounded,
|
||
tooltip: _mode == _InputMode.trackpad
|
||
? 'Trackpad mode'
|
||
: 'Direct touch',
|
||
active: _mode == _InputMode.direct,
|
||
onPressed: _toggleMode,
|
||
),
|
||
_ToolButton(
|
||
icon: Icons.monitor_rounded,
|
||
tooltip: 'Monitors',
|
||
onPressed: _showMonitors,
|
||
),
|
||
_ToolButton(
|
||
icon: Icons.tune_rounded,
|
||
tooltip: 'Quality',
|
||
onPressed: _showQuality,
|
||
),
|
||
_ToolButton(
|
||
icon: Icons.keyboard_command_key_rounded,
|
||
tooltip: 'Ctrl + Alt + Del',
|
||
onPressed: _ctrlAltDel,
|
||
),
|
||
_ToolButton(
|
||
icon: Icons.more_horiz_rounded,
|
||
tooltip: 'More',
|
||
onPressed: _showMore,
|
||
),
|
||
_ToolButton(
|
||
icon: Icons.fullscreen_rounded,
|
||
tooltip: 'Full screen (hide controls)',
|
||
onPressed: _toggleControls,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Hide/show the top bar + toolbar for a pure full-screen remote view.
|
||
void _toggleControls() {
|
||
setState(() => _controlsVisible = !_controlsVisible);
|
||
}
|
||
|
||
/// A small, unobtrusive handle shown while controls are hidden, to bring the
|
||
/// toolbar back without interfering with the trackpad/touch area.
|
||
Widget _buildShowControlsButton() {
|
||
return Positioned(
|
||
top: MediaQuery.of(context).padding.top + 6,
|
||
right: 8,
|
||
child: SafeArea(
|
||
child: Material(
|
||
color: Colors.black.withValues(alpha: 0.35),
|
||
shape: const CircleBorder(),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: IconButton(
|
||
iconSize: 20,
|
||
visualDensity: VisualDensity.compact,
|
||
icon: const Icon(Icons.fullscreen_exit_rounded, color: Colors.white70),
|
||
tooltip: 'Show controls',
|
||
onPressed: _toggleControls,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildHiddenKeyboard() {
|
||
return Positioned(
|
||
left: 0,
|
||
bottom: 0,
|
||
width: 1,
|
||
height: 1,
|
||
child: Opacity(
|
||
opacity: 0,
|
||
child: TextField(
|
||
focusNode: _kbFocus,
|
||
controller: _kbController,
|
||
autocorrect: false,
|
||
enableSuggestions: false,
|
||
keyboardType: TextInputType.text,
|
||
textCapitalization: TextCapitalization.none,
|
||
textInputAction: TextInputAction.done,
|
||
cursorColor: Colors.transparent,
|
||
style: const TextStyle(color: Colors.transparent, fontSize: 1),
|
||
decoration: const InputDecoration(
|
||
border: InputBorder.none,
|
||
isDense: true,
|
||
contentPadding: EdgeInsets.zero,
|
||
),
|
||
onChanged: _onKbChanged,
|
||
onSubmitted: (_) {
|
||
_sendCode('Enter');
|
||
_resetKb();
|
||
_kbFocus.requestFocus();
|
||
},
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildStatusOverlay() {
|
||
final connecting = _phase == _Phase.connecting;
|
||
final denied = _phase == _Phase.denied;
|
||
return Positioned.fill(
|
||
child: Container(
|
||
color: NetDeskColors.background.withValues(alpha: 0.96),
|
||
padding: const EdgeInsets.all(NetDeskSpacing.xl),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (connecting) ...[
|
||
const SizedBox(
|
||
width: 44,
|
||
height: 44,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 3, color: NetDeskColors.accent),
|
||
),
|
||
const SizedBox(height: NetDeskSpacing.lg),
|
||
Text(
|
||
'Connecting to ${widget.partnerId}',
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: NetDeskColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: NetDeskSpacing.xs),
|
||
const Text(
|
||
'Negotiating a secure session…',
|
||
style:
|
||
TextStyle(fontSize: 13, color: NetDeskColors.textMuted),
|
||
),
|
||
] else ...[
|
||
Container(
|
||
width: 72,
|
||
height: 72,
|
||
decoration: BoxDecoration(
|
||
color: (denied ? NetDeskColors.danger : NetDeskColors.accent)
|
||
.withValues(alpha: 0.12),
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
denied
|
||
? Icons.gpp_bad_rounded
|
||
: Icons.link_off_rounded,
|
||
size: 34,
|
||
color:
|
||
denied ? NetDeskColors.danger : NetDeskColors.textSecondary,
|
||
),
|
||
),
|
||
const SizedBox(height: NetDeskSpacing.lg),
|
||
Text(
|
||
denied ? 'Access denied' : 'Session ended',
|
||
style: const TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: NetDeskColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: NetDeskSpacing.xs),
|
||
Text(
|
||
_error ?? 'The connection was closed.',
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(
|
||
fontSize: 13, color: NetDeskColors.textSecondary),
|
||
),
|
||
const SizedBox(height: NetDeskSpacing.xl),
|
||
SizedBox(
|
||
width: 220,
|
||
child: FilledButton.icon(
|
||
onPressed: () => Navigator.of(context).maybePop(),
|
||
icon: const Icon(Icons.arrow_back_rounded, size: 18),
|
||
label: const Text('Back'),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A small pulsing green dot indicating an active session.
|
||
class _ConnectedDot extends StatelessWidget {
|
||
const _ConnectedDot();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: 9,
|
||
height: 9,
|
||
decoration: const BoxDecoration(
|
||
color: NetDeskColors.accent,
|
||
shape: BoxShape.circle,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: NetDeskColors.accentGlow, blurRadius: 8, spreadRadius: 2),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A round toolbar button.
|
||
class _ToolButton extends StatelessWidget {
|
||
const _ToolButton({
|
||
required this.icon,
|
||
required this.tooltip,
|
||
required this.onPressed,
|
||
this.color,
|
||
this.active = false,
|
||
});
|
||
|
||
final IconData icon;
|
||
final String tooltip;
|
||
final VoidCallback onPressed;
|
||
final Color? color;
|
||
final bool active;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final fg = color ??
|
||
(active ? NetDeskColors.accent : NetDeskColors.textSecondary);
|
||
return Tooltip(
|
||
message: tooltip,
|
||
child: Material(
|
||
color: active
|
||
? NetDeskColors.accentGlow
|
||
: Colors.transparent,
|
||
shape: const CircleBorder(),
|
||
child: InkWell(
|
||
onTap: onPressed,
|
||
customBorder: const CircleBorder(),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(11),
|
||
child: Icon(icon, size: 22, color: fg),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// A row in a bottom-sheet menu.
|
||
class _SheetTile extends StatelessWidget {
|
||
const _SheetTile({
|
||
required this.icon,
|
||
required this.title,
|
||
required this.subtitle,
|
||
required this.onTap,
|
||
this.selected = false,
|
||
this.trailing,
|
||
});
|
||
|
||
final IconData icon;
|
||
final String title;
|
||
final String subtitle;
|
||
final VoidCallback onTap;
|
||
|
||
/// Highlight this row as the current choice (e.g. the active monitor).
|
||
final bool selected;
|
||
|
||
/// Override the trailing glyph; defaults to a chevron, or a check when
|
||
/// [selected]. Pass [Icons.check_circle_rounded] etc. explicitly if needed.
|
||
final IconData? trailing;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final accent = NetDeskColors.accent;
|
||
final trailingIcon = trailing ??
|
||
(selected ? Icons.check_circle_rounded : Icons.chevron_right_rounded);
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||
child: Material(
|
||
color: selected
|
||
? Color.alphaBlend(accent.withValues(alpha: 0.14),
|
||
NetDeskColors.surfaceAlt)
|
||
: NetDeskColors.surfaceAlt,
|
||
borderRadius: BorderRadius.circular(NetDeskRadius.md),
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(NetDeskRadius.md),
|
||
child: Container(
|
||
decoration: selected
|
||
? BoxDecoration(
|
||
borderRadius: BorderRadius.circular(NetDeskRadius.md),
|
||
border: Border.all(color: accent.withValues(alpha: 0.55)),
|
||
)
|
||
: null,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: NetDeskSpacing.md, vertical: 14),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, color: accent, size: 22),
|
||
const SizedBox(width: NetDeskSpacing.md),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: NetDeskColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 1),
|
||
Text(
|
||
subtitle,
|
||
style: const TextStyle(
|
||
fontSize: 12, color: NetDeskColors.textMuted),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Icon(trailingIcon,
|
||
color: selected ? accent : NetDeskColors.textMuted,
|
||
size: 20),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Paints the latest remote frame fit-to-box plus the virtual touchpad cursor.
|
||
class _RemotePainter extends CustomPainter {
|
||
_RemotePainter({
|
||
required this.image,
|
||
required this.cursor,
|
||
required this.remoteW,
|
||
required this.remoteH,
|
||
});
|
||
|
||
final ui.Image image;
|
||
final Offset cursor;
|
||
final int remoteW;
|
||
final int remoteH;
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final imgSize = Size(image.width.toDouble(), image.height.toDouble());
|
||
final fitted = applyBoxFit(BoxFit.contain, imgSize, size);
|
||
final dst = Alignment.center.inscribe(fitted.destination, Offset.zero & size);
|
||
final src = Alignment.center.inscribe(fitted.source, Offset.zero & imgSize);
|
||
|
||
canvas.drawImageRect(
|
||
image,
|
||
src,
|
||
dst,
|
||
Paint()
|
||
..filterQuality = FilterQuality.medium
|
||
..isAntiAlias = true,
|
||
);
|
||
|
||
if (remoteW > 0 && remoteH > 0) {
|
||
final cx = dst.left + (cursor.dx / remoteW) * dst.width;
|
||
final cy = dst.top + (cursor.dy / remoteH) * dst.height;
|
||
_drawCursor(canvas, Offset(cx, cy));
|
||
}
|
||
}
|
||
|
||
void _drawCursor(Canvas canvas, Offset p) {
|
||
// A classic arrow pointer with the tip exactly at p — white fill with a dark
|
||
// outline so it stays visible on any background (not a target/crosshair).
|
||
final path = Path()
|
||
..moveTo(p.dx, p.dy)
|
||
..lineTo(p.dx, p.dy + 17.5)
|
||
..lineTo(p.dx + 4.4, p.dy + 13.4)
|
||
..lineTo(p.dx + 7.4, p.dy + 20.0)
|
||
..lineTo(p.dx + 10.2, p.dy + 18.7)
|
||
..lineTo(p.dx + 7.3, p.dy + 12.2)
|
||
..lineTo(p.dx + 12.8, p.dy + 12.2)
|
||
..close();
|
||
canvas.drawPath(
|
||
path,
|
||
Paint()
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 2.4
|
||
..strokeJoin = StrokeJoin.round
|
||
..color = const Color(0xD0000000),
|
||
);
|
||
canvas.drawPath(path, Paint()..color = Colors.white);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_RemotePainter old) =>
|
||
old.image != image ||
|
||
old.cursor != cursor ||
|
||
old.remoteW != remoteW ||
|
||
old.remoteH != remoteH;
|
||
}
|