499 lines
17 KiB
Dart
499 lines
17 KiB
Dart
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<ConnectionView> createState() => _ConnectionViewState();
|
|
}
|
|
|
|
class _ConnectionViewState extends ConsumerState<ConnectionView> {
|
|
// Mode: List or Form
|
|
bool _showForm = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Listen for global connection errors (e.g. lost connection)
|
|
ref.listen<String?>(connectionErrorProvider, (previous, next) {
|
|
if (next != null) {
|
|
SemanticsService.sendAnnouncement(
|
|
View.of(context),
|
|
next,
|
|
TextDirection.ltr,
|
|
);
|
|
}
|
|
});
|
|
|
|
// 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<void> _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<NewConnectionForm> createState() => _NewConnectionFormState();
|
|
}
|
|
|
|
class _NewConnectionFormState extends ConsumerState<NewConnectionForm> {
|
|
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<FormState>();
|
|
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<void> _pickKeyFile() async {
|
|
final view = View.of(context);
|
|
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.sendAnnouncement(
|
|
view,
|
|
'Key selected: ${file.name}',
|
|
TextDirection.ltr,
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Error reading key: $e')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _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'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|