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://: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 createState() => _ConnectScreenState(); } class _ConnectScreenState extends State { 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 _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( 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 _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), ); } }