1740 lines
55 KiB
Dart
1740 lines
55 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'app_localizations.dart';
|
|
import 'app_preferences.dart';
|
|
import 'control_service.dart';
|
|
import 'desktop_tray.dart';
|
|
|
|
final _desktopTray = DesktopTrayController();
|
|
|
|
Future<void> main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
await _desktopTray.initialize();
|
|
runApp(const RemoteDeskControlApp());
|
|
}
|
|
|
|
class RemoteDeskControlApp extends StatefulWidget {
|
|
const RemoteDeskControlApp({
|
|
super.key,
|
|
this.preferences = const SharedAppPreferences(),
|
|
});
|
|
|
|
final AppPreferences preferences;
|
|
|
|
@override
|
|
State<RemoteDeskControlApp> createState() => _RemoteDeskControlAppState();
|
|
}
|
|
|
|
class _RemoteDeskControlAppState extends State<RemoteDeskControlApp> {
|
|
AppSettings _settings = const AppSettings();
|
|
|
|
Locale? get _locale => switch (_settings.localeCode) {
|
|
'zh' => const Locale('zh', 'Hans'),
|
|
'en' => const Locale('en'),
|
|
_ => null,
|
|
};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
unawaited(_loadSettings());
|
|
}
|
|
|
|
Future<void> _loadSettings() async {
|
|
AppSettings settings;
|
|
try {
|
|
settings = await widget.preferences.load();
|
|
} on Object {
|
|
settings = const AppSettings();
|
|
}
|
|
if (!mounted) return;
|
|
setState(() => _settings = settings);
|
|
_desktopTray
|
|
..updateCloseToTray(settings.closeToTray)
|
|
..updateLocale(_locale);
|
|
}
|
|
|
|
void _updateSettings(AppSettings settings) {
|
|
if (settings == _settings) return;
|
|
setState(() => _settings = settings);
|
|
_desktopTray
|
|
..updateCloseToTray(settings.closeToTray)
|
|
..updateLocale(_locale);
|
|
unawaited(_saveSettings(settings));
|
|
}
|
|
|
|
Future<void> _saveSettings(AppSettings settings) async {
|
|
try {
|
|
await widget.preferences.save(settings);
|
|
} on Object {
|
|
// Keep runtime settings usable if the preference backend is unavailable.
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const colors = ColorScheme.light(
|
|
primary: Color(0xFF006D77),
|
|
onPrimary: Colors.white,
|
|
secondary: Color(0xFFE76F51),
|
|
surface: Color(0xFFF9FBFA),
|
|
onSurface: Color(0xFF172121),
|
|
outline: Color(0xFFB8C5C4),
|
|
error: Color(0xFFB23B28),
|
|
);
|
|
return MaterialApp(
|
|
debugShowCheckedModeBanner: false,
|
|
title: 'RemoteDesk',
|
|
locale: _locale,
|
|
supportedLocales: AppLocalizations.supportedLocales,
|
|
localizationsDelegates: const [
|
|
AppLocalizations.delegate,
|
|
GlobalMaterialLocalizations.delegate,
|
|
GlobalWidgetsLocalizations.delegate,
|
|
GlobalCupertinoLocalizations.delegate,
|
|
],
|
|
theme: ThemeData(
|
|
colorScheme: colors,
|
|
scaffoldBackgroundColor: const Color(0xFFF1F5F4),
|
|
useMaterial3: true,
|
|
visualDensity: VisualDensity.standard,
|
|
inputDecorationTheme: const InputDecorationTheme(
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(6)),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(6)),
|
|
borderSide: BorderSide(color: Color(0xFFC8D3D2)),
|
|
),
|
|
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
|
),
|
|
cardTheme: const CardThemeData(
|
|
elevation: 0,
|
|
margin: EdgeInsets.zero,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(8)),
|
|
side: BorderSide(color: Color(0xFFD5DEDD)),
|
|
),
|
|
),
|
|
),
|
|
home: ControlWorkspace(
|
|
locale: _locale,
|
|
settings: _settings,
|
|
onSettingsChanged: _updateSettings,
|
|
onLocaleChanged: (locale) {
|
|
_updateSettings(_settings.withLocale(locale?.languageCode));
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
enum _WorkspacePage { devices, sessions, settings }
|
|
|
|
class _SessionRecord {
|
|
const _SessionRecord({
|
|
required this.id,
|
|
required this.hostName,
|
|
required this.startedAt,
|
|
this.diagnostics,
|
|
});
|
|
|
|
final String id;
|
|
final String hostName;
|
|
final DateTime startedAt;
|
|
final DesktopSessionDiagnostics? diagnostics;
|
|
|
|
_SessionRecord withDiagnostics(DesktopSessionDiagnostics value) =>
|
|
_SessionRecord(
|
|
id: id,
|
|
hostName: hostName,
|
|
startedAt: startedAt,
|
|
diagnostics: value,
|
|
);
|
|
}
|
|
|
|
class ControlWorkspace extends StatefulWidget {
|
|
const ControlWorkspace({
|
|
super.key,
|
|
this.locale,
|
|
required this.settings,
|
|
required this.onSettingsChanged,
|
|
required this.onLocaleChanged,
|
|
});
|
|
|
|
final Locale? locale;
|
|
final AppSettings settings;
|
|
final ValueChanged<AppSettings> onSettingsChanged;
|
|
final ValueChanged<Locale?> onLocaleChanged;
|
|
|
|
@override
|
|
State<ControlWorkspace> createState() => _ControlWorkspaceState();
|
|
}
|
|
|
|
class _ControlWorkspaceState extends State<ControlWorkspace> {
|
|
final _service = ControlService();
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _name = TextEditingController();
|
|
final _address = TextEditingController();
|
|
final _user = TextEditingController();
|
|
final _fingerprint = TextEditingController();
|
|
final _search = TextEditingController();
|
|
final _searchFocus = FocusNode();
|
|
|
|
Timer? _healthTimer;
|
|
Timer? _sessionTimer;
|
|
ControlServiceStatus _serviceStatus = ControlServiceStatus.checking;
|
|
String _serviceMessage = 'Checking control service';
|
|
_WorkspacePage _page = _WorkspacePage.devices;
|
|
List<SavedHost> _hosts = const [];
|
|
List<_SessionRecord> _sessions = const [];
|
|
String? _selectedHostId;
|
|
AgentProbe? _probe;
|
|
bool _loadingHosts = false;
|
|
bool _savingHost = false;
|
|
bool _probing = false;
|
|
bool _launching = false;
|
|
bool _terminalLaunching = false;
|
|
bool _updatingForm = false;
|
|
bool _formDirty = false;
|
|
|
|
late bool _fullscreen;
|
|
late bool _followWindow;
|
|
late bool _captureInput;
|
|
late bool _clipboardRead;
|
|
late bool _clipboardWrite;
|
|
late int _framesPerSecond;
|
|
late String _resolution;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_applySettings(widget.settings);
|
|
_search.addListener(_refresh);
|
|
for (final controller in [_name, _address, _user, _fingerprint]) {
|
|
controller.addListener(_markFormDirty);
|
|
}
|
|
unawaited(_initialize());
|
|
_healthTimer = Timer.periodic(
|
|
const Duration(seconds: 5),
|
|
(_) => unawaited(_checkService()),
|
|
);
|
|
_sessionTimer = Timer.periodic(
|
|
const Duration(seconds: 2),
|
|
(_) => unawaited(_refreshSessions()),
|
|
);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(ControlWorkspace oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
if (oldWidget.settings != widget.settings) {
|
|
_applySettings(widget.settings);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_healthTimer?.cancel();
|
|
_sessionTimer?.cancel();
|
|
_name.dispose();
|
|
_address.dispose();
|
|
_user.dispose();
|
|
_fingerprint.dispose();
|
|
_search.dispose();
|
|
_searchFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _applySettings(AppSettings settings) {
|
|
_fullscreen = settings.fullscreen;
|
|
_followWindow = settings.followWindow;
|
|
_captureInput = settings.captureInput;
|
|
_clipboardRead = settings.clipboardRead;
|
|
_clipboardWrite = settings.clipboardWrite;
|
|
_framesPerSecond = settings.framesPerSecond;
|
|
_resolution = settings.resolution;
|
|
}
|
|
|
|
void _markFormDirty() {
|
|
if (!_updatingForm && !_formDirty && mounted) {
|
|
setState(() => _formDirty = true);
|
|
}
|
|
}
|
|
|
|
void _refresh() {
|
|
if (mounted) setState(() {});
|
|
}
|
|
|
|
Future<void> _initialize() async {
|
|
final result = await _service.ensureRunning();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_serviceStatus = result.status;
|
|
_serviceMessage = result.message;
|
|
});
|
|
if (result.status == ControlServiceStatus.available) await _loadHosts();
|
|
}
|
|
|
|
Future<void> _checkService() async {
|
|
final result = await _service.health();
|
|
if (!mounted) return;
|
|
final becameAvailable =
|
|
_serviceStatus != ControlServiceStatus.available &&
|
|
result.status == ControlServiceStatus.available;
|
|
setState(() {
|
|
_serviceStatus = result.status;
|
|
_serviceMessage = result.message;
|
|
});
|
|
if (becameAvailable && _hosts.isEmpty) await _loadHosts();
|
|
}
|
|
|
|
Future<void> _loadHosts() async {
|
|
if (_loadingHosts) return;
|
|
setState(() => _loadingHosts = true);
|
|
final result = await _service.loadHosts();
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_loadingHosts = false;
|
|
if (result.ok) _hosts = result.data ?? const [];
|
|
});
|
|
if (!result.ok) _notify(result.message, error: true);
|
|
}
|
|
|
|
Future<void> _selectHost(SavedHost host) async {
|
|
if (host.id == _selectedHostId || !await _confirmDiscardChanges()) return;
|
|
setState(() {
|
|
_updatingForm = true;
|
|
_selectedHostId = host.id;
|
|
_name.text = host.name;
|
|
_address.text = host.address;
|
|
_user.text = host.user;
|
|
_fingerprint.text = host.certificateSha256;
|
|
_probe = null;
|
|
_formDirty = false;
|
|
_updatingForm = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _newHost() async {
|
|
if (!await _confirmDiscardChanges()) return;
|
|
_clearHostEditor();
|
|
}
|
|
|
|
void _clearHostEditor() {
|
|
setState(() {
|
|
_updatingForm = true;
|
|
_selectedHostId = null;
|
|
_name.clear();
|
|
_address.clear();
|
|
_user.clear();
|
|
_fingerprint.clear();
|
|
_probe = null;
|
|
_page = _WorkspacePage.devices;
|
|
_formDirty = false;
|
|
_updatingForm = false;
|
|
});
|
|
}
|
|
|
|
Future<bool> _confirmDiscardChanges() async {
|
|
if (!_formDirty) return true;
|
|
final l10n = AppLocalizations.of(context);
|
|
return await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n.text('Discard unsaved changes?')),
|
|
content: Text(
|
|
l10n.text('The current host changes have not been saved.'),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(l10n.text('Keep editing')),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(l10n.text('Discard')),
|
|
),
|
|
],
|
|
),
|
|
) ??
|
|
false;
|
|
}
|
|
|
|
SavedHost _hostFromForm() => SavedHost(
|
|
id:
|
|
_selectedHostId ??
|
|
'${DateTime.now().microsecondsSinceEpoch}-${_address.text.hashCode.abs()}',
|
|
name: _name.text.trim(),
|
|
address: _address.text.trim(),
|
|
user: _user.text.trim(),
|
|
certificateSha256: _fingerprint.text.trim(),
|
|
);
|
|
|
|
Future<bool> _saveHost() async {
|
|
if (!_formKey.currentState!.validate() || _savingHost) return false;
|
|
setState(() => _savingHost = true);
|
|
final host = _hostFromForm();
|
|
final updated = [..._hosts];
|
|
final index = updated.indexWhere((item) => item.id == host.id);
|
|
if (index == -1) {
|
|
updated.add(host);
|
|
} else {
|
|
updated[index] = host;
|
|
}
|
|
final result = await _service.saveHosts(updated);
|
|
if (!mounted) return false;
|
|
setState(() {
|
|
_savingHost = false;
|
|
if (result.ok) {
|
|
_hosts = updated;
|
|
_selectedHostId = host.id;
|
|
_formDirty = false;
|
|
}
|
|
});
|
|
_notify(result.message, error: !result.ok);
|
|
return result.ok;
|
|
}
|
|
|
|
Future<void> _deleteHost() async {
|
|
final id = _selectedHostId;
|
|
if (id == null) return;
|
|
final l10n = AppLocalizations.of(context);
|
|
final confirmed = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(l10n.text('Delete host?')),
|
|
content: Text(
|
|
AppLocalizations.of(context).format(
|
|
'Remove {name} from this device?',
|
|
{'name': _name.text.trim()},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context, false),
|
|
child: Text(l10n.text('Cancel')),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(context, true),
|
|
child: Text(l10n.text('Delete')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
if (confirmed != true || !mounted) return;
|
|
final updated = _hosts.where((host) => host.id != id).toList();
|
|
final result = await _service.saveHosts(updated);
|
|
if (!mounted) return;
|
|
if (result.ok) {
|
|
setState(() => _hosts = updated);
|
|
_clearHostEditor();
|
|
}
|
|
_notify(result.message, error: !result.ok);
|
|
}
|
|
|
|
Future<void> _probeAgent() async {
|
|
if (!_formKey.currentState!.validate() || _probing) return;
|
|
setState(() {
|
|
_probing = true;
|
|
_probe = null;
|
|
});
|
|
final result = await _service.probeLinuxAgent(
|
|
_address.text.trim(),
|
|
_fingerprint.text.trim(),
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_probing = false;
|
|
_probe = result.data;
|
|
});
|
|
_notify(result.message, error: !result.ok);
|
|
}
|
|
|
|
Future<void> _launchDesktop() async {
|
|
if (!_formKey.currentState!.validate() || _launching) return;
|
|
setState(() => _launching = true);
|
|
final size = _resolution.split('x').map(int.parse).toList();
|
|
final result = await _service.launchLinuxDesktop(
|
|
LinuxDesktopLaunchRequest(
|
|
address: _address.text.trim(),
|
|
user: _user.text.trim(),
|
|
certificateSha256: _fingerprint.text.trim(),
|
|
width: size[0],
|
|
height: size[1],
|
|
framesPerSecond: _framesPerSecond,
|
|
fullscreen: _fullscreen,
|
|
followWindow: _followWindow,
|
|
captureInput: _captureInput,
|
|
clipboardRead: _clipboardRead,
|
|
clipboardWrite: _clipboardWrite,
|
|
),
|
|
);
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_launching = false;
|
|
if (result.ok && result.sessionId != null) {
|
|
_sessions = [
|
|
_SessionRecord(
|
|
id: result.sessionId!,
|
|
hostName: _name.text.trim(),
|
|
startedAt: DateTime.now(),
|
|
),
|
|
..._sessions.where((session) => session.id != result.sessionId),
|
|
];
|
|
}
|
|
});
|
|
_notify(result.message, error: !result.ok);
|
|
}
|
|
|
|
Future<void> _launchTerminal() async {
|
|
if (!_formKey.currentState!.validate() || _terminalLaunching) return;
|
|
setState(() => _terminalLaunching = true);
|
|
final result = await _service.launchLinuxTerminal(
|
|
_address.text.trim(),
|
|
_user.text.trim(),
|
|
_fingerprint.text.trim(),
|
|
);
|
|
if (!mounted) return;
|
|
setState(() => _terminalLaunching = false);
|
|
_notify(result.message, error: !result.ok);
|
|
}
|
|
|
|
Future<void> _refreshSessions() async {
|
|
if (_sessions.isEmpty || _serviceStatus != ControlServiceStatus.available) {
|
|
return;
|
|
}
|
|
final updated = [..._sessions];
|
|
var changed = false;
|
|
for (var index = 0; index < updated.length; index++) {
|
|
final result = await _service.sessionDiagnostics(updated[index].id);
|
|
if (result.ok && result.data != null) {
|
|
updated[index] = updated[index].withDiagnostics(result.data!);
|
|
changed = true;
|
|
}
|
|
}
|
|
if (mounted && changed) setState(() => _sessions = updated);
|
|
}
|
|
|
|
void _notify(String message, {required bool error}) {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context)
|
|
..hideCurrentSnackBar()
|
|
..showSnackBar(
|
|
SnackBar(
|
|
content: Text(AppLocalizations.of(context).text(message)),
|
|
backgroundColor: error ? const Color(0xFF8F3425) : null,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _updateSessionDefaults(VoidCallback update) {
|
|
setState(update);
|
|
widget.onSettingsChanged(
|
|
widget.settings.copyWith(
|
|
fullscreen: _fullscreen,
|
|
followWindow: _followWindow,
|
|
captureInput: _captureInput,
|
|
clipboardRead: _clipboardRead,
|
|
clipboardWrite: _clipboardWrite,
|
|
framesPerSecond: _framesPerSecond,
|
|
resolution: _resolution,
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showPage(_WorkspacePage page) => setState(() => _page = page);
|
|
|
|
void _focusSearch() {
|
|
setState(() => _page = _WorkspacePage.devices);
|
|
_searchFocus.requestFocus();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final compact = MediaQuery.sizeOf(context).width < 760;
|
|
final l10n = AppLocalizations.of(context);
|
|
final content = Column(
|
|
children: [
|
|
_WorkspaceHeader(
|
|
page: _page,
|
|
serviceStatus: _serviceStatus,
|
|
serviceMessage: _serviceMessage,
|
|
onRefresh: _initialize,
|
|
onAdd: _page == _WorkspacePage.devices
|
|
? () => unawaited(_newHost())
|
|
: null,
|
|
),
|
|
const SizedBox(height: 18),
|
|
Expanded(child: _buildPage()),
|
|
],
|
|
);
|
|
return CallbackShortcuts(
|
|
bindings: {
|
|
const SingleActivator(LogicalKeyboardKey.keyN, control: true): () =>
|
|
unawaited(_newHost()),
|
|
const SingleActivator(LogicalKeyboardKey.keyS, control: true): () {
|
|
if (_page == _WorkspacePage.devices) unawaited(_saveHost());
|
|
},
|
|
const SingleActivator(LogicalKeyboardKey.keyF, control: true):
|
|
_focusSearch,
|
|
const SingleActivator(LogicalKeyboardKey.f5): () =>
|
|
unawaited(_initialize()),
|
|
const SingleActivator(LogicalKeyboardKey.digit1, alt: true): () =>
|
|
_showPage(_WorkspacePage.devices),
|
|
const SingleActivator(LogicalKeyboardKey.digit2, alt: true): () =>
|
|
_showPage(_WorkspacePage.sessions),
|
|
const SingleActivator(LogicalKeyboardKey.digit3, alt: true): () =>
|
|
_showPage(_WorkspacePage.settings),
|
|
},
|
|
child: Focus(
|
|
autofocus: true,
|
|
child: Scaffold(
|
|
body: SafeArea(
|
|
child: compact
|
|
? Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
|
|
child: content,
|
|
)
|
|
: Row(
|
|
children: [
|
|
_AppRail(
|
|
page: _page,
|
|
status: _serviceStatus,
|
|
onChanged: _showPage,
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: content,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
bottomNavigationBar: compact
|
|
? NavigationBar(
|
|
selectedIndex: _page.index,
|
|
onDestinationSelected: (index) =>
|
|
_showPage(_WorkspacePage.values[index]),
|
|
destinations: [
|
|
NavigationDestination(
|
|
icon: const Icon(Icons.computer_outlined),
|
|
selectedIcon: const Icon(Icons.computer),
|
|
label: l10n.text('Devices'),
|
|
),
|
|
NavigationDestination(
|
|
icon: const Icon(Icons.monitor_heart_outlined),
|
|
selectedIcon: const Icon(Icons.monitor_heart),
|
|
label: l10n.text('Sessions'),
|
|
),
|
|
NavigationDestination(
|
|
icon: const Icon(Icons.tune_outlined),
|
|
selectedIcon: const Icon(Icons.tune),
|
|
label: l10n.text('Settings'),
|
|
),
|
|
],
|
|
)
|
|
: null,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPage() => switch (_page) {
|
|
_WorkspacePage.devices => _DevicesPage(state: this),
|
|
_WorkspacePage.sessions => _SessionsPage(
|
|
sessions: _sessions,
|
|
onRefresh: _refreshSessions,
|
|
onOpenDevices: () => _showPage(_WorkspacePage.devices),
|
|
),
|
|
_WorkspacePage.settings => _SettingsPage(state: this),
|
|
};
|
|
}
|
|
|
|
class _AppRail extends StatelessWidget {
|
|
const _AppRail({
|
|
required this.page,
|
|
required this.status,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final _WorkspacePage page;
|
|
final ControlServiceStatus status;
|
|
final ValueChanged<_WorkspacePage> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return Container(
|
|
width: 84,
|
|
color: const Color(0xFF172121),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 22),
|
|
const Icon(
|
|
Icons.screen_share_outlined,
|
|
color: Color(0xFFBFE7E2),
|
|
size: 30,
|
|
),
|
|
const SizedBox(height: 26),
|
|
for (final item in _WorkspacePage.values)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
|
child: Tooltip(
|
|
message: l10n.text(_pageLabel(item)),
|
|
child: IconButton(
|
|
onPressed: () => onChanged(item),
|
|
icon: Icon(_pageIcon(item)),
|
|
color: page == item ? Colors.white : const Color(0xFF91A5A3),
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: page == item
|
|
? const Color(0xFF006D77)
|
|
: Colors.transparent,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(6)),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Tooltip(
|
|
message: l10n.text(
|
|
status == ControlServiceStatus.available
|
|
? 'Control service online'
|
|
: 'Control service offline',
|
|
),
|
|
child: Container(
|
|
width: 10,
|
|
height: 10,
|
|
margin: const EdgeInsets.only(bottom: 24),
|
|
decoration: BoxDecoration(
|
|
color: status == ControlServiceStatus.available
|
|
? const Color(0xFF70D6A7)
|
|
: const Color(0xFFE76F51),
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _WorkspaceHeader extends StatelessWidget {
|
|
const _WorkspaceHeader({
|
|
required this.page,
|
|
required this.serviceStatus,
|
|
required this.serviceMessage,
|
|
required this.onRefresh,
|
|
this.onAdd,
|
|
});
|
|
|
|
final _WorkspacePage page;
|
|
final ControlServiceStatus serviceStatus;
|
|
final String serviceMessage;
|
|
final Future<void> Function() onRefresh;
|
|
final VoidCallback? onAdd;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final online = serviceStatus == ControlServiceStatus.available;
|
|
final title = Text(
|
|
l10n.text(_pageLabel(page)),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w700),
|
|
);
|
|
final status = Tooltip(
|
|
message: l10n.text(serviceMessage),
|
|
child: Chip(
|
|
avatar: Icon(
|
|
online ? Icons.check_circle_outline : Icons.cloud_off_outlined,
|
|
size: 17,
|
|
color: online ? const Color(0xFF006D77) : const Color(0xFFB23B28),
|
|
),
|
|
label: Text(l10n.text(online ? 'Service online' : 'Service offline')),
|
|
),
|
|
);
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
if (constraints.maxWidth < 650) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(child: title),
|
|
if (onAdd != null)
|
|
IconButton.filledTonal(
|
|
tooltip: l10n.text('Add host'),
|
|
onPressed: onAdd,
|
|
icon: const Icon(Icons.add),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
status,
|
|
IconButton(
|
|
tooltip: l10n.text('Refresh control service'),
|
|
onPressed: onRefresh,
|
|
icon: const Icon(Icons.refresh),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return Row(
|
|
children: [
|
|
Expanded(child: title),
|
|
if (onAdd != null) ...[
|
|
FilledButton.tonalIcon(
|
|
onPressed: onAdd,
|
|
icon: const Icon(Icons.add),
|
|
label: Text(l10n.text('Add host')),
|
|
),
|
|
const SizedBox(width: 10),
|
|
],
|
|
status,
|
|
IconButton(
|
|
tooltip: l10n.text('Refresh control service'),
|
|
onPressed: onRefresh,
|
|
icon: const Icon(Icons.refresh),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DevicesPage extends StatelessWidget {
|
|
const _DevicesPage({required this.state});
|
|
|
|
final _ControlWorkspaceState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final split = MediaQuery.sizeOf(context).width >= 1050;
|
|
final list = _HostList(state: state);
|
|
final editor = _HostEditor(state: state);
|
|
if (!split) {
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
list,
|
|
const SizedBox(height: 16),
|
|
editor,
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(width: 330, child: list),
|
|
const SizedBox(width: 18),
|
|
Expanded(child: SingleChildScrollView(child: editor)),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HostList extends StatelessWidget {
|
|
const _HostList({required this.state});
|
|
|
|
final _ControlWorkspaceState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final query = state._search.text.trim().toLowerCase();
|
|
final hosts = state._hosts
|
|
.where(
|
|
(host) =>
|
|
query.isEmpty ||
|
|
host.name.toLowerCase().contains(query) ||
|
|
host.address.toLowerCase().contains(query),
|
|
)
|
|
.toList();
|
|
return _Surface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
TextField(
|
|
controller: state._search,
|
|
focusNode: state._searchFocus,
|
|
decoration: InputDecoration(
|
|
hintText: l10n.text('Search hosts'),
|
|
prefixIcon: const Icon(Icons.search),
|
|
suffixIcon: query.isEmpty
|
|
? null
|
|
: IconButton(
|
|
tooltip: l10n.text('Clear search'),
|
|
onPressed: state._search.clear,
|
|
icon: const Icon(Icons.close),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
if (state._loadingHosts)
|
|
const LinearProgressIndicator()
|
|
else if (hosts.isEmpty)
|
|
_EmptyState(
|
|
icon: Icons.dns_outlined,
|
|
title: l10n.text(
|
|
query.isEmpty ? 'No saved hosts' : 'No matching hosts',
|
|
),
|
|
actionLabel: l10n.text(
|
|
query.isEmpty ? 'Add host' : 'Clear search',
|
|
),
|
|
onAction: query.isEmpty
|
|
? () => unawaited(state._newHost())
|
|
: state._search.clear,
|
|
)
|
|
else
|
|
ConstrainedBox(
|
|
constraints: const BoxConstraints(maxHeight: 500),
|
|
child: ListView.separated(
|
|
shrinkWrap: true,
|
|
itemCount: hosts.length,
|
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
itemBuilder: (context, index) {
|
|
final host = hosts[index];
|
|
final selected = state._selectedHostId == host.id;
|
|
return ListTile(
|
|
selected: selected,
|
|
selectedTileColor: const Color(0xFFE4F1EF),
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.all(Radius.circular(6)),
|
|
),
|
|
leading: CircleAvatar(
|
|
backgroundColor: selected
|
|
? const Color(0xFF006D77)
|
|
: const Color(0xFFDDE7E5),
|
|
foregroundColor: selected
|
|
? Colors.white
|
|
: const Color(0xFF365453),
|
|
child: const Icon(Icons.computer, size: 20),
|
|
),
|
|
title: Text(
|
|
host.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
subtitle: Text(
|
|
host.address,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
onTap: () => unawaited(state._selectHost(host)),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HostEditor extends StatelessWidget {
|
|
const _HostEditor({required this.state});
|
|
|
|
final _ControlWorkspaceState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final online = state._serviceStatus == ControlServiceStatus.available;
|
|
return Form(
|
|
key: state._formKey,
|
|
child: Column(
|
|
children: [
|
|
_Surface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
state._selectedHostId == null
|
|
? l10n.text('New Linux host')
|
|
: l10n.text('Linux host'),
|
|
style: Theme.of(context).textTheme.titleMedium
|
|
?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
),
|
|
if (state._formDirty)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Text(
|
|
l10n.text('Unsaved changes'),
|
|
style: Theme.of(context).textTheme.labelMedium
|
|
?.copyWith(
|
|
color: Theme.of(context).colorScheme.error,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.text('Delete host'),
|
|
onPressed: state._selectedHostId == null
|
|
? null
|
|
: state._deleteHost,
|
|
icon: const Icon(Icons.delete_outline),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final twoColumns = constraints.maxWidth >= 650;
|
|
final name = _HostField(
|
|
controller: state._name,
|
|
label: l10n.text('Display name'),
|
|
validator: _required(l10n.text('Enter a display name.')),
|
|
);
|
|
final address = _HostField(
|
|
controller: state._address,
|
|
label: l10n.text('Agent address'),
|
|
hint: 'host.example:39500',
|
|
validator: _required(
|
|
l10n.text('Enter the Agent address.'),
|
|
),
|
|
);
|
|
if (!twoColumns) {
|
|
return Column(
|
|
children: [name, const SizedBox(height: 14), address],
|
|
);
|
|
}
|
|
return Row(
|
|
children: [
|
|
Expanded(child: name),
|
|
const SizedBox(width: 14),
|
|
Expanded(child: address),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 14),
|
|
TextFormField(
|
|
controller: state._user,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.text('Linux user'),
|
|
),
|
|
validator: _required(l10n.text('Enter the Linux user.')),
|
|
),
|
|
const SizedBox(height: 14),
|
|
TextFormField(
|
|
controller: state._fingerprint,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.text('TLS certificate SHA-256'),
|
|
hintText: l10n.text('64 lowercase hexadecimal characters'),
|
|
prefixIcon: const Icon(Icons.fingerprint),
|
|
),
|
|
validator: (value) =>
|
|
RegExp(r'^[0-9a-f]{64}$').hasMatch(value?.trim() ?? '')
|
|
? null
|
|
: l10n.text(
|
|
'Enter a lowercase 64-character SHA-256 fingerprint.',
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Wrap(
|
|
spacing: 10,
|
|
runSpacing: 10,
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: online && !state._launching
|
|
? state._launchDesktop
|
|
: null,
|
|
icon: state._launching
|
|
? const SizedBox.square(
|
|
dimension: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.open_in_full),
|
|
label: Text(l10n.text('Connect desktop')),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: online && !state._terminalLaunching
|
|
? state._launchTerminal
|
|
: null,
|
|
icon: const Icon(Icons.terminal),
|
|
label: Text(l10n.text('Open terminal')),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: online && !state._probing
|
|
? state._probeAgent
|
|
: null,
|
|
icon: state._probing
|
|
? const SizedBox.square(
|
|
dimension: 18,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
)
|
|
: const Icon(Icons.network_ping),
|
|
label: Text(l10n.text('Test connection')),
|
|
),
|
|
TextButton.icon(
|
|
onPressed: online && !state._savingHost
|
|
? state._saveHost
|
|
: null,
|
|
icon: const Icon(Icons.save_outlined),
|
|
label: Text(l10n.text('Save')),
|
|
),
|
|
],
|
|
),
|
|
if (state._probe != null) ...[
|
|
const Divider(height: 30),
|
|
_ProbeSummary(probe: state._probe!),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_RendererControls(state: state, compact: true),
|
|
const SizedBox(height: 20),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProbeSummary extends StatelessWidget {
|
|
const _ProbeSummary({required this.probe});
|
|
|
|
final AgentProbe probe;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return Wrap(
|
|
spacing: 18,
|
|
runSpacing: 8,
|
|
children: [
|
|
_StatusMetric(
|
|
icon: Icons.speed,
|
|
label: l10n.format('Agent responded in {ms} ms', {
|
|
'ms': probe.latencyMs,
|
|
}),
|
|
),
|
|
_StatusMetric(
|
|
icon: Icons.hub_outlined,
|
|
label: l10n.format('Protocol {major}.{minor}', {
|
|
'major': probe.protocolMajor,
|
|
'minor': probe.protocolMinor,
|
|
}),
|
|
),
|
|
_StatusMetric(
|
|
icon: Icons.desktop_windows_outlined,
|
|
label: l10n.text(probe.desktop ? 'Desktop ready' : 'No desktop'),
|
|
),
|
|
_StatusMetric(
|
|
icon: Icons.terminal,
|
|
label: l10n.text(probe.terminal ? 'Terminal ready' : 'No terminal'),
|
|
),
|
|
_StatusMetric(
|
|
icon: Icons.folder_outlined,
|
|
label: l10n.text(probe.files ? 'Files ready' : 'No files'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SessionsPage extends StatelessWidget {
|
|
const _SessionsPage({
|
|
required this.sessions,
|
|
required this.onRefresh,
|
|
required this.onOpenDevices,
|
|
});
|
|
|
|
final List<_SessionRecord> sessions;
|
|
final Future<void> Function() onRefresh;
|
|
final VoidCallback onOpenDevices;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
if (sessions.isEmpty) {
|
|
return _Surface(
|
|
child: _EmptyState(
|
|
icon: Icons.monitor_heart_outlined,
|
|
title: l10n.text('No sessions yet'),
|
|
actionLabel: l10n.text('Open devices'),
|
|
onAction: onOpenDevices,
|
|
),
|
|
);
|
|
}
|
|
return _Surface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
l10n.text('Native sessions'),
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
tooltip: l10n.text('Refresh session diagnostics'),
|
|
onPressed: onRefresh,
|
|
icon: const Icon(Icons.refresh),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Expanded(
|
|
child: ListView.separated(
|
|
itemCount: sessions.length,
|
|
separatorBuilder: (_, _) => const Divider(height: 1),
|
|
itemBuilder: (context, index) =>
|
|
_SessionRow(session: sessions[index]),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SessionRow extends StatelessWidget {
|
|
const _SessionRow({required this.session});
|
|
|
|
final _SessionRecord session;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
final data = session.diagnostics;
|
|
final state = data?.state ?? 'starting';
|
|
final active =
|
|
state == 'connected' ||
|
|
state == 'connecting' ||
|
|
state == 'reconnecting';
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: active ? const Color(0xFFDDF1EA) : const Color(0xFFF1E4E1),
|
|
borderRadius: const BorderRadius.all(Radius.circular(6)),
|
|
),
|
|
child: Icon(
|
|
active
|
|
? Icons.desktop_windows_outlined
|
|
: Icons.desktop_access_disabled_outlined,
|
|
size: 20,
|
|
color: active ? const Color(0xFF006D77) : const Color(0xFF8F3425),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Flexible(
|
|
child: Text(
|
|
session.hostName,
|
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_StateBadge(label: state),
|
|
],
|
|
),
|
|
const SizedBox(height: 5),
|
|
Text(
|
|
data == null
|
|
? l10n.text('Waiting for diagnostics')
|
|
: '${data.width}x${data.height} | ${_metric(data.framesPerSecond, 'FPS')} | ${_metric(data.networkLatencyMs, 'ms RTT')}',
|
|
style: Theme.of(context).textTheme.bodySmall,
|
|
),
|
|
const SizedBox(height: 3),
|
|
Text(
|
|
'${l10n.text('Session')} ${session.id}',
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
|
color: const Color(0xFF687A78),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
if (data != null && MediaQuery.sizeOf(context).width >= 700)
|
|
Wrap(
|
|
spacing: 18,
|
|
children: [
|
|
_CompactMetric(
|
|
label: l10n.text('Decode'),
|
|
value: _metric(data.decodeLatencyMs, 'ms'),
|
|
),
|
|
_CompactMetric(
|
|
label: l10n.text('Present'),
|
|
value: _metric(data.presentationLatencyMs, 'ms'),
|
|
),
|
|
_CompactMetric(
|
|
label: l10n.text('Frames'),
|
|
value: '${data.frameCount}',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SettingsPage extends StatelessWidget {
|
|
const _SettingsPage({required this.state});
|
|
|
|
final _ControlWorkspaceState state;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return SingleChildScrollView(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 820),
|
|
child: Column(
|
|
children: [
|
|
_Surface(
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final languageField = DropdownButtonFormField<String>(
|
|
key: ValueKey(
|
|
state.widget.locale?.languageCode ?? 'system',
|
|
),
|
|
initialValue: state.widget.locale == null
|
|
? 'system'
|
|
: state.widget.locale!.languageCode,
|
|
isExpanded: true,
|
|
decoration: const InputDecoration(isDense: true),
|
|
items: [
|
|
DropdownMenuItem(
|
|
value: 'system',
|
|
child: Text(l10n.text('System default')),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'en',
|
|
child: Text(l10n.text('English')),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'zh',
|
|
child: Text(l10n.text('简体中文')),
|
|
),
|
|
],
|
|
onChanged: (value) {
|
|
state.widget.onLocaleChanged(
|
|
value == 'zh'
|
|
? const Locale('zh', 'Hans')
|
|
: value == 'en'
|
|
? const Locale('en')
|
|
: null,
|
|
);
|
|
},
|
|
);
|
|
final title = Text(
|
|
l10n.text('Language'),
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
);
|
|
if (constraints.maxWidth < 430) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
title,
|
|
const SizedBox(height: 12),
|
|
SizedBox(
|
|
width: constraints.maxWidth,
|
|
child: languageField,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
return Row(
|
|
children: [
|
|
Expanded(child: title),
|
|
SizedBox(width: 210, child: languageField),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_Surface(
|
|
child: SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
secondary: const Icon(Icons.move_to_inbox_outlined),
|
|
title: Text(l10n.text('Close to system tray')),
|
|
subtitle: Text(
|
|
l10n.text(
|
|
'Keep RemoteDesk running when the window is closed.',
|
|
),
|
|
),
|
|
value: state.widget.settings.closeToTray,
|
|
onChanged: (value) => state.widget.onSettingsChanged(
|
|
state.widget.settings.copyWith(closeToTray: value),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_RendererControls(state: state, compact: false),
|
|
const SizedBox(height: 16),
|
|
_Surface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.text('Local service'),
|
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: const Icon(Icons.lan_outlined),
|
|
title: const Text('127.0.0.1:4173'),
|
|
subtitle: Text(l10n.text(state._serviceMessage)),
|
|
trailing: IconButton(
|
|
tooltip: l10n.text('Reconnect service'),
|
|
onPressed: state._initialize,
|
|
icon: const Icon(Icons.refresh),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RendererControls extends StatelessWidget {
|
|
const _RendererControls({required this.state, required this.compact});
|
|
|
|
final _ControlWorkspaceState state;
|
|
final bool compact;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppLocalizations.of(context);
|
|
return _Surface(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.text('Session defaults'),
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
|
),
|
|
const SizedBox(height: 14),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final horizontal = !compact && constraints.maxWidth >= 640;
|
|
final display = Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DropdownButtonFormField<String>(
|
|
key: ValueKey(state._resolution),
|
|
initialValue: state._resolution,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.text('Stream resolution'),
|
|
),
|
|
items: const ['1280x720', '1920x1080', '2560x1440']
|
|
.map(
|
|
(value) => DropdownMenuItem(
|
|
value: value,
|
|
child: Text(value.replaceFirst('x', ' x ')),
|
|
),
|
|
)
|
|
.toList(),
|
|
onChanged: (value) => state._updateSessionDefaults(
|
|
() => state._resolution = value ?? '1920x1080',
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
SegmentedButton<int>(
|
|
segments: const [
|
|
ButtonSegment(value: 15, label: Text('15 FPS')),
|
|
ButtonSegment(value: 30, label: Text('30 FPS')),
|
|
],
|
|
selected: {state._framesPerSecond},
|
|
onSelectionChanged: (value) => state._updateSessionDefaults(
|
|
() => state._framesPerSecond = value.first,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
final toggles = Column(
|
|
children: [
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(l10n.text('Open fullscreen')),
|
|
value: state._fullscreen,
|
|
onChanged: (value) => state._updateSessionDefaults(
|
|
() => state._fullscreen = value,
|
|
),
|
|
),
|
|
SwitchListTile.adaptive(
|
|
contentPadding: EdgeInsets.zero,
|
|
title: Text(l10n.text('Follow window size')),
|
|
value: state._followWindow,
|
|
onChanged: (value) => state._updateSessionDefaults(
|
|
() => state._followWindow = value,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
if (!horizontal) {
|
|
return Column(
|
|
children: [display, const Divider(height: 28), toggles],
|
|
);
|
|
}
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: display),
|
|
const SizedBox(width: 28),
|
|
Expanded(child: toggles),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const Divider(height: 28),
|
|
Wrap(
|
|
spacing: 20,
|
|
runSpacing: 4,
|
|
children: [
|
|
_PermissionToggle(
|
|
label: l10n.text('Remote input'),
|
|
value: state._captureInput,
|
|
onChanged: (value) => state._updateSessionDefaults(
|
|
() => state._captureInput = value,
|
|
),
|
|
),
|
|
_PermissionToggle(
|
|
label: l10n.text('Read clipboard'),
|
|
value: state._clipboardRead,
|
|
onChanged: (value) => state._updateSessionDefaults(
|
|
() => state._clipboardRead = value,
|
|
),
|
|
),
|
|
_PermissionToggle(
|
|
label: l10n.text('Write clipboard'),
|
|
value: state._clipboardWrite,
|
|
onChanged: (value) => state._updateSessionDefaults(
|
|
() => state._clipboardWrite = value,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PermissionToggle extends StatelessWidget {
|
|
const _PermissionToggle({
|
|
required this.label,
|
|
required this.value,
|
|
required this.onChanged,
|
|
});
|
|
|
|
final String label;
|
|
final bool value;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 190,
|
|
child: CheckboxListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
title: Text(label),
|
|
value: value,
|
|
onChanged: (value) => onChanged(value ?? false),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Surface extends StatelessWidget {
|
|
const _Surface({required this.child});
|
|
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
border: Border.all(color: const Color(0xFFD4DEDD)),
|
|
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _HostField extends StatelessWidget {
|
|
const _HostField({
|
|
required this.controller,
|
|
required this.label,
|
|
required this.validator,
|
|
this.hint,
|
|
});
|
|
|
|
final TextEditingController controller;
|
|
final String label;
|
|
final String? hint;
|
|
final FormFieldValidator<String> validator;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => TextFormField(
|
|
controller: controller,
|
|
decoration: InputDecoration(labelText: label, hintText: hint),
|
|
validator: validator,
|
|
);
|
|
}
|
|
|
|
class _StatusMetric extends StatelessWidget {
|
|
const _StatusMetric({required this.icon, required this.label});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 17, color: const Color(0xFF006D77)),
|
|
const SizedBox(width: 6),
|
|
Text(label),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _CompactMetric extends StatelessWidget {
|
|
const _CompactMetric({required this.label, required this.value});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(label, style: Theme.of(context).textTheme.labelSmall),
|
|
Text(value, style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
],
|
|
);
|
|
}
|
|
|
|
class _StateBadge extends StatelessWidget {
|
|
const _StateBadge({required this.label});
|
|
|
|
final String label;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
|
decoration: const BoxDecoration(
|
|
color: Color(0xFFE2EFEC),
|
|
borderRadius: BorderRadius.all(Radius.circular(4)),
|
|
),
|
|
child: Text(
|
|
AppLocalizations.of(context).text(label),
|
|
style: Theme.of(context).textTheme.labelSmall,
|
|
),
|
|
);
|
|
}
|
|
|
|
class _EmptyState extends StatelessWidget {
|
|
const _EmptyState({
|
|
required this.icon,
|
|
required this.title,
|
|
required this.actionLabel,
|
|
this.onAction,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String title;
|
|
final String actionLabel;
|
|
final VoidCallback? onAction;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 34),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 34, color: const Color(0xFF7B908E)),
|
|
const SizedBox(height: 10),
|
|
Text(title, style: const TextStyle(fontWeight: FontWeight.w600)),
|
|
if (onAction != null) ...[
|
|
const SizedBox(height: 10),
|
|
TextButton(onPressed: onAction, child: Text(actionLabel)),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
FormFieldValidator<String> _required(String message) =>
|
|
(value) => value == null || value.trim().isEmpty ? message : null;
|
|
|
|
String _pageLabel(_WorkspacePage page) => switch (page) {
|
|
_WorkspacePage.devices => 'Devices',
|
|
_WorkspacePage.sessions => 'Sessions',
|
|
_WorkspacePage.settings => 'Settings',
|
|
};
|
|
|
|
IconData _pageIcon(_WorkspacePage page) => switch (page) {
|
|
_WorkspacePage.devices => Icons.computer_outlined,
|
|
_WorkspacePage.sessions => Icons.monitor_heart_outlined,
|
|
_WorkspacePage.settings => Icons.tune_outlined,
|
|
};
|
|
|
|
String _metric(double? value, String suffix) =>
|
|
value == null ? '--' : '${value.toStringAsFixed(1)} $suffix';
|