614 lines
21 KiB
Dart
614 lines
21 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|