import 'dart:io'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/semantics.dart'; // Ensure SemanticsService is available import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:accessible_terminal/state/terminal_provider.dart'; import 'package:accessible_terminal/state/saved_connections_provider.dart'; import 'package:accessible_terminal/models/saved_connection.dart'; class ConnectionView extends ConsumerStatefulWidget { const ConnectionView({super.key}); @override ConsumerState createState() => _ConnectionViewState(); } class _ConnectionViewState extends ConsumerState { // Mode: List or Form bool _showForm = false; @override Widget build(BuildContext context) { // Listen for global connection errors (e.g. lost connection) ref.listen(connectionErrorProvider, (previous, next) { if (next != null) { _showErrorDialog(next); ref.read(connectionErrorProvider.notifier).state = null; } }); // Check for existing errors on build (e.g. after navigating back) WidgetsBinding.instance.addPostFrameCallback((_) { final error = ref.read(connectionErrorProvider); if (error != null) { _showErrorDialog(error); ref.read(connectionErrorProvider.notifier).state = null; } }); if (_showForm) { return NewConnectionForm( onCancel: () => setState(() => _showForm = false), ); } return SavedConnectionsList( onCreateNew: () => setState(() => _showForm = true), ); } void _showErrorDialog(String message) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Connection Error'), content: Text(message), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('OK'), ), ], ), ); } } class SavedConnectionsList extends ConsumerWidget { final VoidCallback onCreateNew; const SavedConnectionsList({super.key, required this.onCreateNew}); void _showPasswordDialog(BuildContext context, WidgetRef ref, SavedConnection connection) { final passController = TextEditingController(); final hasKey = connection.privateKey != null; showDialog( context: context, builder: (context) => AlertDialog( title: Text(hasKey ? 'Unlock Key for ${connection.username}@${connection.host}' : 'Connect to ${connection.username}@${connection.host}'), content: Column( mainAxisSize: MainAxisSize.min, children: [ if (hasKey) const Text('Using saved private key.', style: TextStyle(fontStyle: FontStyle.italic)), const SizedBox(height: 8), TextField( controller: passController, autofocus: true, obscureText: true, decoration: InputDecoration( labelText: hasKey ? 'Passphrase (leave empty if none)' : 'Password', border: const OutlineInputBorder(), ), onSubmitted: (_) { Navigator.pop(context); // Close dialog _connect(context, ref, connection, passController.text); }, ), ], ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('Cancel'), ), ElevatedButton( onPressed: () { Navigator.pop(context); _connect(context, ref, connection, passController.text); }, child: const Text('Connect'), ), ], ), ); } Future _connect(BuildContext context, WidgetRef ref, SavedConnection connection, String password) async { try { await ref.read(terminalOutputProvider.notifier).connect( connection.host, connection.port, connection.username, password: password.isNotEmpty ? password : null, privateKey: connection.privateKey, ); } catch (e) { if (context.mounted) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Connection Failed'), content: Text(e.toString()), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('OK'), ), ], ), ); } } } @override Widget build(BuildContext context, WidgetRef ref) { final savedConnections = ref.watch(savedConnectionsProvider); return Scaffold( appBar: AppBar(title: const Text('Connections')), floatingActionButton: FloatingActionButton( onPressed: onCreateNew, tooltip: 'New Connection', child: const Icon(Icons.add), ), body: savedConnections.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ const Text('No saved connections.'), const SizedBox(height: 16), ElevatedButton( onPressed: onCreateNew, child: const Text('Create New Connection'), ), ], ), ) : ListView.builder( itemCount: savedConnections.length, itemBuilder: (context, index) { final connection = savedConnections[index]; return Card( margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: ListTile( title: Text('${connection.username}@${connection.host}'), subtitle: Text('Port: ${connection.port}'), onTap: () => _showPasswordDialog(context, ref, connection), trailing: IconButton( icon: const Icon(Icons.delete), tooltip: 'Delete connection', onPressed: () { ref.read(savedConnectionsProvider.notifier).removeConnection(connection.id); }, ), ), ); }, ), ); } } class NewConnectionForm extends ConsumerStatefulWidget { final VoidCallback onCancel; const NewConnectionForm({super.key, required this.onCancel}); @override ConsumerState createState() => _NewConnectionFormState(); } class _NewConnectionFormState extends ConsumerState { final _hostController = TextEditingController(text: ''); final _portController = TextEditingController(text: '22'); final _userController = TextEditingController(); final _passController = TextEditingController(); String? _privateKeyContent; String? _privateKeyName; late FocusNode _hostFocus; late FocusNode _portFocus; late FocusNode _userFocus; late FocusNode _passFocus; late FocusNode _keyFocus; late FocusNode _connectFocus; final _formKey = GlobalKey(); bool _isLoading = false; bool _saveConnection = true; // Default to save @override void initState() { super.initState(); _hostFocus = FocusNode(); _portFocus = FocusNode(); _userFocus = FocusNode(); _passFocus = FocusNode(); _keyFocus = FocusNode(); _connectFocus = FocusNode(); } @override void dispose() { _hostController.dispose(); _portController.dispose(); _userController.dispose(); _passController.dispose(); _hostFocus.dispose(); _portFocus.dispose(); _userFocus.dispose(); _passFocus.dispose(); _keyFocus.dispose(); _connectFocus.dispose(); super.dispose(); } Future _pickKeyFile() async { try { FilePickerResult? result = await FilePicker.platform.pickFiles( type: FileType.any, // SSH keys often have no extension or .pem/.key ); if (result != null) { final file = result.files.single; if (file.path != null) { final content = await File(file.path!).readAsString(); setState(() { _privateKeyContent = content; _privateKeyName = file.name; }); SemanticsService.announce('Key selected: ${file.name}', TextDirection.ltr); } } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error reading key: $e')), ); } } } Future _connect() async { if (!_formKey.currentState!.validate()) return; setState(() => _isLoading = true); final port = int.tryParse(_portController.text) ?? 22; try { // 1. Save if requested if (_saveConnection) { await ref.read(savedConnectionsProvider.notifier).addConnection( host: _hostController.text, port: port, username: _userController.text, privateKey: _privateKeyContent, ); } // 2. Connect await ref.read(terminalOutputProvider.notifier).connect( _hostController.text, port, _userController.text, password: _passController.text.isNotEmpty ? _passController.text : null, privateKey: _privateKeyContent, ); } catch (e) { if (mounted) { showDialog( context: context, builder: (context) => AlertDialog( title: const Text('Connection Failed'), content: Text(e.toString()), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: const Text('OK'), ), ], ), ); _hostFocus.requestFocus(); } } finally { if (mounted) { setState(() => _isLoading = false); } } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('New Connection'), leading: IconButton( icon: const Icon(Icons.arrow_back), onPressed: widget.onCancel, tooltip: 'Back to list', ), ), body: Center( child: Container( constraints: const BoxConstraints(maxWidth: 400), padding: const EdgeInsets.all(16.0), child: Form( key: _formKey, child: SingleChildScrollView( child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ MergeSemantics( child: TextFormField( controller: _hostController, focusNode: _hostFocus, autofocus: true, decoration: const InputDecoration( labelText: 'Host', hintText: 'Enter hostname or IP', border: OutlineInputBorder(), prefixIcon: Icon(Icons.computer), ), validator: (value) => value?.isEmpty ?? true ? 'Required' : null, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => _portFocus.requestFocus(), ), ), const SizedBox(height: 16), MergeSemantics( child: TextFormField( controller: _portController, focusNode: _portFocus, decoration: const InputDecoration( labelText: 'Port', hintText: '22', border: OutlineInputBorder(), prefixIcon: Icon(Icons.settings_ethernet), ), keyboardType: TextInputType.number, validator: (value) => value?.isEmpty ?? true ? 'Required' : null, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => _userFocus.requestFocus(), ), ), const SizedBox(height: 16), MergeSemantics( child: TextFormField( controller: _userController, focusNode: _userFocus, decoration: const InputDecoration( labelText: 'Username', hintText: 'Enter username', border: OutlineInputBorder(), prefixIcon: Icon(Icons.person), ), validator: (value) => value?.isEmpty ?? true ? 'Required' : null, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => _passFocus.requestFocus(), ), ), const SizedBox(height: 16), MergeSemantics( child: TextFormField( controller: _passController, focusNode: _passFocus, decoration: const InputDecoration( labelText: 'Password / Passphrase', hintText: 'Enter password or key passphrase', border: OutlineInputBorder(), prefixIcon: Icon(Icons.key), ), obscureText: true, textInputAction: TextInputAction.next, onFieldSubmitted: (_) => _keyFocus.requestFocus(), ), ), const SizedBox(height: 16), // Key Picker MergeSemantics( child: Row( children: [ ElevatedButton.icon( focusNode: _keyFocus, onPressed: _pickKeyFile, icon: const Icon(Icons.file_open), label: const Text('Select Private Key'), ), const SizedBox(width: 8), Expanded( child: Text( _privateKeyName ?? 'No key selected', maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: _privateKeyName != null ? Colors.green : Colors.grey, ), ), ), if (_privateKeyName != null) IconButton( icon: const Icon(Icons.clear), tooltip: 'Clear key', onPressed: () { setState(() { _privateKeyContent = null; _privateKeyName = null; }); }, ), ], ), ), const SizedBox(height: 16), // Save Toggle MergeSemantics( child: SwitchListTile( title: const Text('Save Connection Details'), subtitle: const Text('Key content will be saved locally'), value: _saveConnection, onChanged: (val) => setState(() => _saveConnection = val), ), ), const SizedBox(height: 24), ElevatedButton( focusNode: _connectFocus, onPressed: _isLoading ? null : _connect, style: ElevatedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), ), child: _isLoading ? const SizedBox( height: 20, width: 20, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('Connect'), ), ], ), ), ), ), ), ); } }