Initial commit: Accessible SSH Terminal
This commit is contained in:
490
lib/ui/views/connection_view.dart
Normal file
490
lib/ui/views/connection_view.dart
Normal file
@@ -0,0 +1,490 @@
|
||||
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) {
|
||||
_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<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 {
|
||||
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<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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
71
lib/ui/views/tablet_screen.dart
Normal file
71
lib/ui/views/tablet_screen.dart
Normal file
@@ -0,0 +1,71 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:accessible_terminal/state/terminal_provider.dart';
|
||||
import 'package:accessible_terminal/ui/views/connection_view.dart';
|
||||
import 'package:accessible_terminal/ui/views/terminal_view.dart';
|
||||
|
||||
class TabletScreen extends ConsumerStatefulWidget {
|
||||
const TabletScreen({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TabletScreen> createState() => _TabletScreenState();
|
||||
}
|
||||
|
||||
class _TabletScreenState extends ConsumerState<TabletScreen> {
|
||||
bool _isCreatingNew = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isConnected = ref.watch(connectionStateProvider);
|
||||
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
// Left Panel: Saved Connections List
|
||||
SizedBox(
|
||||
width: 320,
|
||||
child: SavedConnectionsList(
|
||||
onCreateNew: () {
|
||||
setState(() => _isCreatingNew = true);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
const VerticalDivider(width: 1),
|
||||
|
||||
// Right Panel: Dynamic Content
|
||||
Expanded(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (isConnected) {
|
||||
return const TerminalView();
|
||||
} else if (_isCreatingNew) {
|
||||
return NewConnectionForm(
|
||||
onCancel: () {
|
||||
setState(() => _isCreatingNew = false);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.terminal, size: 64, color: Colors.grey),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'Select a connection from the list\nor create a new one.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 18, color: Colors.grey),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
301
lib/ui/views/terminal_view.dart
Normal file
301
lib/ui/views/terminal_view.dart
Normal file
@@ -0,0 +1,301 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart'; // For SemanticsService
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
import 'package:accessible_terminal/state/terminal_provider.dart';
|
||||
import 'package:accessible_terminal/models/terminal_line.dart';
|
||||
import 'package:accessible_terminal/ui/widgets/ansi_text_parser.dart';
|
||||
|
||||
class TerminalView extends ConsumerStatefulWidget {
|
||||
const TerminalView({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<TerminalView> createState() => _TerminalViewState();
|
||||
}
|
||||
|
||||
class _TerminalViewState extends ConsumerState<TerminalView> {
|
||||
final FocusNode _inputFocusNode = FocusNode();
|
||||
final TextEditingController _inputController = TextEditingController();
|
||||
final AnsiTextParser _ansiParser = AnsiTextParser();
|
||||
bool _isChatMode = true; // Default to Chat Mode for better a11y
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WakelockPlus.enable();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_inputFocusNode.requestFocus();
|
||||
});
|
||||
// We start in Chat Mode, so no HardwareKeyboard handler initially needed
|
||||
// (unless we want to support it there too, but Chat Mode uses the TextField).
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WakelockPlus.disable();
|
||||
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||
_inputFocusNode.dispose();
|
||||
_inputController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleMode(bool value) {
|
||||
setState(() => _isChatMode = value);
|
||||
if (_isChatMode) {
|
||||
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||
// Clear any partial input from interactive mode if needed, or just focus the chat field
|
||||
} else {
|
||||
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
||||
}
|
||||
// Refocus to ensure input works immediately after switch
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _inputFocusNode.requestFocus());
|
||||
}
|
||||
|
||||
bool _handleKeyEvent(KeyEvent event) {
|
||||
if (_isChatMode) return false; // Should not happen if handler removed, but safe check
|
||||
if (!_inputFocusNode.hasFocus) return false;
|
||||
|
||||
if (event is KeyDownEvent) {
|
||||
final String? character = event.character;
|
||||
if (character != null) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput(character);
|
||||
return true;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.enter) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput('\r');
|
||||
return true;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.backspace) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput('\x08');
|
||||
return true;
|
||||
}
|
||||
// Simple Arrow key mapping for Interactive Mode
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput('\x1B[A');
|
||||
return true;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput('\x1B[B');
|
||||
return true;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowRight) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput('\x1B[C');
|
||||
return true;
|
||||
}
|
||||
if (event.logicalKey == LogicalKeyboardKey.arrowLeft) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput('\x1B[D');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _inputController.text;
|
||||
if (text.isNotEmpty) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput('$text\r');
|
||||
_inputController.clear();
|
||||
_inputFocusNode.requestFocus();
|
||||
} else {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput('\r');
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final terminalOutput = ref.watch(terminalOutputProvider);
|
||||
|
||||
// Auto-announce new lines for accessibility
|
||||
ref.listen<List<TerminalLine>>(terminalOutputProvider, (previous, next) {
|
||||
if (previous != null && next.length > previous.length) {
|
||||
// A new line was added at the beginning (index 0)
|
||||
final newLine = next.first;
|
||||
final cleanText = AnsiTextParser.strip(newLine.text);
|
||||
if (cleanText.trim().isNotEmpty) {
|
||||
SemanticsService.announce(cleanText, TextDirection.ltr);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Terminal'),
|
||||
actions: [
|
||||
Row(
|
||||
children: [
|
||||
const Text('Chat Mode', style: TextStyle(fontSize: 12)),
|
||||
Semantics(
|
||||
label: 'Toggle Chat Mode',
|
||||
child: Switch(
|
||||
value: _isChatMode,
|
||||
onChanged: _toggleMode,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Semantics(
|
||||
label: 'Disconnect',
|
||||
button: true,
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.link_off),
|
||||
onPressed: () {
|
||||
ref.read(terminalOutputProvider.notifier).disconnect();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
reverse: true,
|
||||
itemCount: terminalOutput.length,
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
itemBuilder: (context, index) {
|
||||
final line = terminalOutput[index];
|
||||
return _TerminalLineItem(
|
||||
textSpan: _ansiParser.parse(line.text),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
if (_isChatMode)
|
||||
// Chat Mode Input
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).scaffoldBackgroundColor,
|
||||
border: Border(top: BorderSide(color: Theme.of(context).dividerColor)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Semantics(
|
||||
label: 'Command Input',
|
||||
textField: true,
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
focusNode: _inputFocusNode,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.send,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Type command...',
|
||||
border: OutlineInputBorder(),
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
tooltip: 'Send Command',
|
||||
onPressed: _sendMessage,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
// Interactive Mode Input Trap (Invisible)
|
||||
Semantics(
|
||||
label: 'Interactive Input (Type directly)',
|
||||
textField: true,
|
||||
child: Opacity(
|
||||
opacity: 0,
|
||||
child: SizedBox(
|
||||
height: 1,
|
||||
width: 1,
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
focusNode: _inputFocusNode,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
// In interactive mode + soft keyboard, we send chars immediately
|
||||
onChanged: (value) {
|
||||
if (value.isNotEmpty) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput(value);
|
||||
_inputController.clear();
|
||||
}
|
||||
},
|
||||
onSubmitted: (value) {
|
||||
ref.read(terminalOutputProvider.notifier).sendInput('\r');
|
||||
_inputController.clear();
|
||||
_inputFocusNode.requestFocus();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!_isChatMode)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(4),
|
||||
color: Colors.amber.withValues(alpha: 0.2),
|
||||
child: const Text(
|
||||
'Interactive Mode: Character-by-character input active.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TerminalLineItem extends StatefulWidget {
|
||||
final TextSpan textSpan;
|
||||
|
||||
const _TerminalLineItem({required this.textSpan});
|
||||
|
||||
@override
|
||||
State<_TerminalLineItem> createState() => _TerminalLineItemState();
|
||||
}
|
||||
|
||||
class _TerminalLineItemState extends State<_TerminalLineItem> {
|
||||
bool _isFocused = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final plainText = widget.textSpan.toPlainText();
|
||||
final isEmpty = plainText.trim().isEmpty;
|
||||
|
||||
if (isEmpty) {
|
||||
// Return a visual spacer only.
|
||||
// No Focus widget = Not navigable by keyboard.
|
||||
// No Semantics = Ignored by screen reader.
|
||||
return const SizedBox(height: 12);
|
||||
}
|
||||
|
||||
return Focus(
|
||||
onFocusChange: (focused) {
|
||||
setState(() => _isFocused = focused);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: _isFocused ? Theme.of(context).colorScheme.primaryContainer : null,
|
||||
border: _isFocused
|
||||
? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
|
||||
: Border.all(color: Colors.transparent, width: 2),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
||||
child: MergeSemantics(
|
||||
child: SelectableText.rich(
|
||||
widget.textSpan,
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user