Files
netdesk/native/ui/lib/session_screen.dart

1448 lines
47 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}