Files
accessible-ssh/lib/ui/views/connection_view.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'),
),
],
),
),
),
),
),
);
}
}