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 createState() => _SessionScreenState(); } class _SessionScreenState extends State { StreamSubscription? _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 _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.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 _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 _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 _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 _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 _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 _openChat() async { final controller = TextEditingController(); final text = await showDialog( 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 _sheet({ required String title, required List children, }) { return showModalBottomSheet( 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; }