NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 02:02:46 +00:00
commit 40318aafdd
227 files changed
+80798

No files matched your search

+613
View File
@@ -0,0 +1,613 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'host_screen.dart';
import 'rust/api.dart' as nd;
import 'session_screen.dart';
import 'theme.dart';
/// Default NetDesk rendezvous / signaling endpoint (the Frankfurt VPS).
/// A LAN host is reachable by overriding this with `ws://<PC-IP>:8901/ws`
/// from the Advanced panel.
const String kDefaultServer = 'wss://netdesk.space/ws';
/// The landing / connect screen.
///
/// Mirrors the mental model of TeamViewer / RustDesk: the brand mark, a big
/// Partner ID field, a session password, and a primary Connect action. Connect
/// calls into the Rust core via flutter_rust_bridge ([nd.connect]) and hands
/// the returned session handle to the [SessionScreen].
class ConnectScreen extends StatefulWidget {
const ConnectScreen({super.key, this.initError});
/// Non-null when the native engine failed to load (shown as a banner).
final String? initError;
@override
State<ConnectScreen> createState() => _ConnectScreenState();
}
class _ConnectScreenState extends State<ConnectScreen> {
final TextEditingController _idController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _serverController =
TextEditingController(text: kDefaultServer);
bool _obscurePassword = true;
bool _showAdvanced = false;
bool _connecting = false;
@override
void dispose() {
_idController.dispose();
_passwordController.dispose();
_serverController.dispose();
super.dispose();
}
String get _digits => _idController.text.replaceAll(RegExp(r'\s'), '');
bool get _canConnect =>
!_connecting && _digits.length >= 6 && widget.initError == null;
Future<void> _connect() async {
if (!_canConnect) return;
FocusScope.of(context).unfocus();
final hostId = _digits;
final server = _serverController.text.trim().isEmpty
? kDefaultServer
: _serverController.text.trim();
setState(() => _connecting = true);
try {
final handle = await nd.connect(
wsUrl: server,
hostId: hostId,
password: _passwordController.text,
totp: '',
);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => SessionScreen(
partnerId: _idController.text.trim(),
handle: handle,
),
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
backgroundColor: NetDeskColors.surface,
content: Text('Could not start session: $e'),
),
);
}
} finally {
if (mounted) setState(() => _connecting = false);
}
}
/// The form half of the screen: error banner, connect card, device card and
/// the host-mode button. Shared by the narrow and the wide (two-pane) layouts.
List<Widget> _formChildren(BuildContext context) {
return [
if (widget.initError != null) ...[
_EngineErrorBanner(message: widget.initError!),
const SizedBox(height: NetDeskSpacing.md),
],
_ConnectCard(
idController: _idController,
passwordController: _passwordController,
serverController: _serverController,
obscurePassword: _obscurePassword,
canConnect: _canConnect,
connecting: _connecting,
showAdvanced: _showAdvanced,
onToggleObscure: () =>
setState(() => _obscurePassword = !_obscurePassword),
onToggleAdvanced: () => setState(() => _showAdvanced = !_showAdvanced),
onChanged: () => setState(() {}),
onConnect: _connect,
),
const SizedBox(height: NetDeskSpacing.md),
const _ThisDeviceCard(),
const SizedBox(height: NetDeskSpacing.md),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const HostScreen()),
),
icon: const Icon(Icons.phonelink_setup, size: 18),
label: const Text('Allow remote control of this device'),
style: OutlinedButton.styleFrom(
foregroundColor: NetDeskColors.textSecondary,
side: const BorderSide(color: NetDeskColors.border),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NetDeskRadius.md),
),
),
),
];
}
@override
Widget build(BuildContext context) {
// Responsive: the screen must FIT the window on phones, tablets (either
// orientation), split-screen and desktop/DeX windows.
// * wide windows -> brand beside the form (two panes, half the height)
// * short windows -> compact brand + tighter spacing
// * scrolling only remains as a last-resort safety for tiny windows.
return Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (context, box) {
final wide = box.maxWidth >= 720;
final compact = box.maxHeight < 700;
if (wide) {
return Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
_Wordmark(),
SizedBox(height: NetDeskSpacing.lg),
_FooterNote(),
],
),
),
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(NetDeskSpacing.lg),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _formChildren(context),
),
),
),
),
),
],
);
}
return Center(
child: SingleChildScrollView(
padding: EdgeInsets.all(
compact ? NetDeskSpacing.md : NetDeskSpacing.lg),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Wordmark(compact: compact),
SizedBox(
height: compact
? NetDeskSpacing.md
: NetDeskSpacing.xl),
..._formChildren(context),
SizedBox(
height: compact
? NetDeskSpacing.md
: NetDeskSpacing.lg),
const _FooterNote(),
],
),
),
),
);
},
),
),
);
}
}
/// Brand mark: the NetDesk logo, wordmark and the Akronlogic byline.
/// `compact` shrinks it so short windows (landscape phones, small desktop
/// windows) still fit the whole screen without scrolling.
class _Wordmark extends StatelessWidget {
const _Wordmark({this.compact = false});
final bool compact;
@override
Widget build(BuildContext context) {
final logo = compact ? 54.0 : 76.0;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: logo,
height: logo,
padding: EdgeInsets.all(compact ? 7 : 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(NetDeskRadius.lg),
color: NetDeskColors.surface,
border: Border.all(color: NetDeskColors.border),
boxShadow: const [
BoxShadow(
color: NetDeskColors.accentGlow,
blurRadius: 36,
spreadRadius: 1,
),
],
),
child: Image.asset(
'assets/icon.png',
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
),
),
SizedBox(height: compact ? NetDeskSpacing.sm : NetDeskSpacing.md),
Text(
'NetDesk',
style: TextStyle(
fontSize: compact ? 28 : 40,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: NetDeskColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
'by Akronlogic',
style: TextStyle(
fontSize: compact ? 12 : 13,
fontWeight: FontWeight.w600,
letterSpacing: 2.4,
color: NetDeskColors.textSecondary.withValues(alpha: 0.9),
),
),
],
);
}
}
/// The main card: Partner ID + password + Connect (+ optional Advanced server).
class _ConnectCard extends StatelessWidget {
const _ConnectCard({
required this.idController,
required this.passwordController,
required this.serverController,
required this.obscurePassword,
required this.canConnect,
required this.connecting,
required this.showAdvanced,
required this.onToggleObscure,
required this.onToggleAdvanced,
required this.onChanged,
required this.onConnect,
});
final TextEditingController idController;
final TextEditingController passwordController;
final TextEditingController serverController;
final bool obscurePassword;
final bool canConnect;
final bool connecting;
final bool showAdvanced;
final VoidCallback onToggleObscure;
final VoidCallback onToggleAdvanced;
final VoidCallback onChanged;
final VoidCallback onConnect;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(NetDeskSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _FieldLabel('Partner ID'),
const SizedBox(height: NetDeskSpacing.xs),
TextField(
controller: idController,
onChanged: (_) => onChanged(),
enabled: !connecting,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
_PartnerIdFormatter(),
],
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: 3,
color: NetDeskColors.textPrimary,
),
decoration: const InputDecoration(
hintText: '000 000 000',
prefixIcon:
Icon(Icons.badge_outlined, color: NetDeskColors.textMuted),
),
),
const SizedBox(height: NetDeskSpacing.md),
const _FieldLabel('Password'),
const SizedBox(height: NetDeskSpacing.xs),
TextField(
controller: passwordController,
onChanged: (_) => onChanged(),
enabled: !connecting,
obscureText: obscurePassword,
onSubmitted: (_) => canConnect ? onConnect() : null,
decoration: InputDecoration(
hintText: 'Session password',
prefixIcon: const Icon(Icons.lock_outline,
color: NetDeskColors.textMuted),
suffixIcon: IconButton(
tooltip: obscurePassword ? 'Show' : 'Hide',
onPressed: onToggleObscure,
icon: Icon(
obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
),
AnimatedCrossFade(
duration: const Duration(milliseconds: 180),
crossFadeState: showAdvanced
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: const SizedBox(width: double.infinity),
secondChild: Padding(
padding: const EdgeInsets.only(top: NetDeskSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _FieldLabel('Server'),
const SizedBox(height: NetDeskSpacing.xs),
TextField(
controller: serverController,
enabled: !connecting,
keyboardType: TextInputType.url,
autocorrect: false,
style: const TextStyle(
fontSize: 14,
color: NetDeskColors.textPrimary,
),
decoration: const InputDecoration(
hintText: 'wss://netdesk.space/ws',
prefixIcon: Icon(Icons.dns_outlined,
color: NetDeskColors.textMuted),
),
),
],
),
),
),
const SizedBox(height: NetDeskSpacing.lg),
FilledButton.icon(
onPressed: canConnect ? onConnect : null,
icon: connecting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: Color(0xFF04210F),
),
)
: const Icon(Icons.bolt_rounded, size: 20),
label: Text(connecting ? 'Connecting…' : 'Connect'),
),
const SizedBox(height: NetDeskSpacing.xs),
Align(
alignment: Alignment.center,
child: TextButton.icon(
onPressed: onToggleAdvanced,
style: TextButton.styleFrom(
foregroundColor: NetDeskColors.textMuted,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
icon: Icon(
showAdvanced
? Icons.keyboard_arrow_up_rounded
: Icons.tune_rounded,
size: 16,
),
label: Text(showAdvanced ? 'Hide advanced' : 'Advanced'),
),
),
],
),
),
);
}
}
/// Small card standing in for *this* device's own shareable identity.
class _ThisDeviceCard extends StatelessWidget {
const _ThisDeviceCard();
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NetDeskSpacing.lg,
vertical: NetDeskSpacing.md,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: NetDeskColors.accentGlow,
borderRadius: BorderRadius.circular(NetDeskRadius.sm),
),
child: const Icon(Icons.smartphone_rounded,
color: NetDeskColors.accent, size: 20),
),
const SizedBox(width: NetDeskSpacing.md),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'This device',
style: TextStyle(
fontSize: 12,
letterSpacing: 0.4,
color: NetDeskColors.textMuted,
),
),
SizedBox(height: 2),
Text(
'Incoming control — tap “Allow” below',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: NetDeskColors.textSecondary,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: NetDeskColors.surfaceAlt,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: NetDeskColors.border),
),
child: const Text(
'BETA',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: NetDeskColors.textMuted,
),
),
),
],
),
),
);
}
}
class _EngineErrorBanner extends StatelessWidget {
const _EngineErrorBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(NetDeskSpacing.md),
decoration: BoxDecoration(
color: NetDeskColors.danger.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(NetDeskRadius.md),
border: Border.all(color: NetDeskColors.danger.withValues(alpha: 0.5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline_rounded,
color: NetDeskColors.danger, size: 20),
const SizedBox(width: NetDeskSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Engine not loaded',
style: TextStyle(
fontWeight: FontWeight.w700,
color: NetDeskColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
message,
style: const TextStyle(
fontSize: 12,
color: NetDeskColors.textSecondary,
),
),
],
),
),
],
),
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
color: NetDeskColors.textSecondary,
),
);
}
}
class _FooterNote extends StatelessWidget {
const _FooterNote();
@override
Widget build(BuildContext context) {
return const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shield_outlined, size: 14, color: NetDeskColors.textMuted),
SizedBox(width: 6),
Text(
'End-to-end encrypted · v0.1.0',
style: TextStyle(fontSize: 12, color: NetDeskColors.textMuted),
),
],
);
}
}
/// Formats up to 12 digits as "123 456 789" while typing.
class _PartnerIdFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
var digits = newValue.text.replaceAll(RegExp(r'\s'), '');
if (digits.length > 12) digits = digits.substring(0, 12);
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i != 0 && i % 3 == 0) buffer.write(' ');
buffer.write(digits[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}