Initial commit: Accessible SSH Terminal

This commit is contained in:
2025-12-22 01:02:44 -06:00
commit 7a967ef759
133 changed files with 6629 additions and 0 deletions

30
lib/main.dart Normal file
View File

@@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:accessible_terminal/ui/terminal_screen.dart';
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Accessible Terminal',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
fontFamily: GoogleFonts.jetBrainsMono().fontFamily,
),
home: const TerminalScreen(),
);
}
}

View File

@@ -0,0 +1,37 @@
class SavedConnection {
final String id;
final String host;
final int port;
final String username;
final String? privateKey; // Stores the content of the private key file
SavedConnection({
required this.id,
required this.host,
required this.port,
required this.username,
this.privateKey,
});
String get label => '$username@$host:$port';
Map<String, dynamic> toJson() {
return {
'id': id,
'host': host,
'port': port,
'username': username,
'privateKey': privateKey,
};
}
factory SavedConnection.fromJson(Map<String, dynamic> json) {
return SavedConnection(
id: json['id'] as String,
host: json['host'] as String,
port: json['port'] as int,
username: json['username'] as String,
privateKey: json['privateKey'] as String?,
);
}
}

View File

@@ -0,0 +1,8 @@
// lib/models/terminal_line.dart
// This file will define the data model for a single line of terminal output.
class TerminalLine {
final String text;
// TODO: Add properties for ANSI styling, e.g., color, bold, etc.
TerminalLine({required this.text});
}

View File

@@ -0,0 +1,136 @@
import 'dart:async';
import 'dart:convert';
import 'package:dartssh2/dartssh2.dart';
class SshService {
SSHClient? _client;
SSHSession? _session;
Timer? _keepAliveTimer;
// Broadcast controller so multiple listeners (UI, loggers) can subscribe
final StreamController<String> _outputController = StreamController<String>.broadcast();
// Controller to notify about disconnection (true = clean exit/0, false = error/drop)
final StreamController<bool> _disconnectController = StreamController.broadcast();
Stream<String> get outputStream => _outputController.stream;
Stream<bool> get disconnectStream => _disconnectController.stream;
bool get isConnected => _client != null && !(_client?.isClosed ?? true);
Future<void> connect({
required String host,
required int port,
required String username,
String? password,
String? privateKey,
}) async {
try {
_outputController.add('Connecting to $host:$port as $username...\n');
final socket = await SSHSocket.connect(host, port);
_client = SSHClient(
socket,
username: username,
onPasswordRequest: password != null ? () => password : null,
identities: privateKey != null ? SSHKeyPair.fromPem(privateKey) : [],
);
_outputController.add('Authenticating...\n');
await _client!.authenticated;
_outputController.add('Authenticated. Starting shell...\n');
_startKeepAlive();
await _startShell();
} catch (e) {
_outputController.add('Connection failed: $e\n');
await disconnect();
rethrow;
}
}
void _startKeepAlive() {
_keepAliveTimer?.cancel();
_keepAliveTimer = Timer.periodic(const Duration(seconds: 30), (timer) async {
if (!isConnected) {
timer.cancel();
return;
}
try {
// Send a window change request with the same size.
// This generates valid SSH traffic to keep the connection alive.
_session?.resizeTerminal(80, 24);
} catch (_) {
// Fails silently; the main connection handlers will deal with actual drops.
}
});
}
Future<void> _startShell() async {
if (_client == null) return;
try {
_session = await _client!.shell(
pty: const SSHPtyConfig(
width: 80,
height: 24,
),
);
// Listen for session completion (e.g. exit command)
_session!.done.then((_) {
final code = _session!.exitCode;
disconnect(isClean: code == 0);
});
// Use transform(utf8.decoder) to correctly handle multi-byte characters split across chunks
_session!.stdout.cast<List<int>>().transform(const Utf8Decoder()).listen(
(data) => _outputController.add(data),
onError: (e) => _outputController.add('Error reading stdout: $e\n'),
onDone: () {
if (isConnected) _outputController.add('Session stdout closed.\n');
},
);
_session!.stderr.cast<List<int>>().transform(const Utf8Decoder()).listen(
(data) => _outputController.add(data),
onError: (e) => _outputController.add('Error reading stderr: $e\n'),
);
// Wait for the connection to complete (unexpected drop or client close)
_client!.done.then((_) {
_outputController.add('Connection closed.\n');
disconnect(isClean: false);
});
} catch (e) {
_outputController.add('Failed to start shell: $e\n');
await disconnect();
}
}
void write(String data) {
if (_session != null) {
_session!.write(utf8.encode(data));
}
}
Future<void> disconnect({bool isClean = false}) async {
_keepAliveTimer?.cancel();
if (_client == null && _session == null) return;
_session?.close();
_client?.close();
_client = null;
_session = null;
_disconnectController.add(isClean);
}
void dispose() {
_keepAliveTimer?.cancel();
_outputController.close();
_disconnectController.close();
}
}

View File

@@ -0,0 +1,69 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:accessible_terminal/models/saved_connection.dart';
// Key for SharedPreferences
const String _kSavedConnectionsKey = 'saved_connections';
class SavedConnectionsNotifier extends StateNotifier<List<SavedConnection>> {
SavedConnectionsNotifier() : super([]) {
_loadConnections();
}
Future<void> _loadConnections() async {
final prefs = await SharedPreferences.getInstance();
final String? jsonString = prefs.getString(_kSavedConnectionsKey);
if (jsonString != null) {
try {
final List<dynamic> jsonList = jsonDecode(jsonString);
state = jsonList.map((e) => SavedConnection.fromJson(e)).toList();
} catch (e) {
// Handle corruption or format change gracefully
state = [];
}
}
}
Future<void> addConnection({
required String host,
required int port,
required String username,
String? privateKey,
}) async {
// Check for duplicates (simple check based on host/user/port)
final exists = state.any((c) =>
c.host == host && c.port == port && c.username == username
);
if (exists) return; // Don't save if already exists
// Use timestamp for simple ID generation
final simpleId = DateTime.now().millisecondsSinceEpoch.toString();
final connection = SavedConnection(
id: simpleId,
host: host,
port: port,
username: username,
privateKey: privateKey,
);
state = [...state, connection];
await _saveToDisk();
}
Future<void> removeConnection(String id) async {
state = state.where((c) => c.id != id).toList();
await _saveToDisk();
}
Future<void> _saveToDisk() async {
final prefs = await SharedPreferences.getInstance();
final jsonList = state.map((c) => c.toJson()).toList();
await prefs.setString(_kSavedConnectionsKey, jsonEncode(jsonList));
}
}
final savedConnectionsProvider = StateNotifierProvider<SavedConnectionsNotifier, List<SavedConnection>>((ref) {
return SavedConnectionsNotifier();
});

View File

@@ -0,0 +1,141 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:accessible_terminal/models/terminal_line.dart';
import 'package:accessible_terminal/services/ssh_service.dart';
// Provider for the SSH Service instance
final sshServiceProvider = Provider<SshService>((ref) {
final service = SshService();
ref.onDispose(() => service.dispose());
return service;
});
// State for connection status
final connectionStateProvider = StateProvider<bool>((ref) => false);
// State for connection errors
final connectionErrorProvider = StateProvider<String?>((ref) => null);
class TerminalNotifier extends StateNotifier<List<TerminalLine>> {
final SshService _sshService;
final StateController<bool> _connectionStateController;
final StateController<String?> _errorController;
String _currentLineBuffer = '';
bool _isUserInitiatedDisconnect = false;
TerminalNotifier(this._sshService, this._connectionStateController, this._errorController) : super([]) {
_sshService.outputStream.listen((data) {
_processOutput(data);
});
_sshService.disconnectStream.listen((isClean) {
if (!_isUserInitiatedDisconnect && !isClean && _connectionStateController.state) {
_errorController.state = 'Connection lost unexpectedly (Timeout or Server closed).';
}
_connectionStateController.state = false;
_processOutput('\n[Connection Closed]\n');
});
}
Future<void> connect(String host, int port, String username, {String? password, String? privateKey}) async {
_isUserInitiatedDisconnect = false;
try {
state = [TerminalLine(text: 'Connecting to $host...'), ...state];
await _sshService.connect(
host: host,
port: port,
username: username,
password: password,
privateKey: privateKey,
);
_connectionStateController.state = true;
} catch (e) {
state = [TerminalLine(text: 'Error: $e'), ...state];
_connectionStateController.state = false;
rethrow;
}
}
Future<void> disconnect() async {
_isUserInitiatedDisconnect = true;
await _sshService.disconnect();
_connectionStateController.state = false;
}
void sendInput(String input) {
if (_connectionStateController.state) {
_sshService.write(input);
}
}
void _processOutput(String data) {
// Normalize line endings to \n
final normalizedData = data.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
final parts = normalizedData.split('\n');
for (int i = 0; i < parts.length; i++) {
final part = parts[i];
if (i == 0) {
// Append to the current pending line
_currentLineBuffer += part;
} else {
// The previous part ended with a newline, so push the buffer and start new
if (_currentLineBuffer.isNotEmpty) {
state = [TerminalLine(text: _currentLineBuffer), ...state];
} else if (state.isNotEmpty) {
// Handle empty lines if needed, or just spacers
state = [TerminalLine(text: ''), ...state];
}
_currentLineBuffer = part;
}
}
// Note: We don't push the last chunk immediately if it doesn't end with \n
// It remains in _currentLineBuffer for the next packet.
// However, to see typing in real-time, we might want to update the latest line on screen?
// For a log-based view (reverse list), the "active" line is usually at index 0.
// If we have a buffer, we might want to temporarily show it.
// For simplicity in this step, we only commit lines when a newline occurs,
// OR we can perform a more complex "update top line" logic.
// Let's stick to: if we have a buffer, it's NOT in the list yet.
// BUT this makes the prompt invisible until you hit enter.
// FIX: Always expose the buffer as the first element if it exists?
// Better approach for "live" feel in a chat/log interface:
// If we have content in _currentLineBuffer, we should probably show it.
// Since we can't easily "edit" the immutable list state efficiently without copying,
// we might just accept that partial lines aren't committed or we commit every chunk.
// Let's commit every chunk for now to ensure feedback, even if it splits weirdly?
// No, that breaks the "line" concept.
// Compromise: We won't show the partial line in the *history* list,
// but the UI could consume `_currentLineBuffer` if we exposed it.
// OR, we just push it to state and replace the first element if it was partial?
// Too complex for now. Let's just push lines as they complete.
// Actually, for SSH, echoing characters back is handled by the remote.
// If I type 'a', remote sends 'a'. I want to see 'a'.
// If I don't flush _currentLineBuffer, I won't see it.
// So I MUST flush _currentLineBuffer to the state if it's not empty,
// effectively "updating" the top line.
if (_currentLineBuffer.isNotEmpty) {
// Filter out purely empty or whitespace-only lines to reduce accessibility noise
if (_currentLineBuffer.trim().isNotEmpty) {
state = [TerminalLine(text: _currentLineBuffer), ...state];
}
_currentLineBuffer = '';
}
}
void clear() {
state = [];
_currentLineBuffer = '';
}
}
final terminalOutputProvider = StateNotifierProvider<TerminalNotifier, List<TerminalLine>>((ref) {
final sshService = ref.watch(sshServiceProvider);
final connectionState = ref.watch(connectionStateProvider.notifier);
final errorController = ref.watch(connectionErrorProvider.notifier);
return TerminalNotifier(sshService, connectionState, errorController);
});

View File

@@ -0,0 +1,30 @@
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';
import 'package:accessible_terminal/ui/views/tablet_screen.dart';
class TerminalScreen extends ConsumerWidget {
const TerminalScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isConnected = ref.watch(connectionStateProvider);
return LayoutBuilder(
builder: (context, constraints) {
if (constraints.maxWidth >= 600) {
// Tablet Layout
return const TabletScreen();
}
// Mobile Layout
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: isConnected ? const TerminalView() : const ConnectionView(),
);
},
);
}
}

View 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'),
),
],
),
),
),
),
),
);
}
}

View 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),
),
],
),
);
}
},
),
),
],
),
);
}
}

View 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'),
),
),
),
);
}
}

View File

@@ -0,0 +1,113 @@
import 'package:flutter/material.dart';
class AnsiTextParser {
// Matches CSI (Control Sequence Introducer) sequences: ESC [ ... FinalByte
// And OSC (Operating System Command) sequences: ESC ] ... BEL(\x07) or ST(\x1B\)
static final RegExp _ansiRegex = RegExp(r'\x1B\[[0-9;?]*[ -/]*[@-~]|\x1B\].*?(\x07|\x1B\\)');
static String strip(String text) {
// Remove ANSI codes
var clean = text.replaceAll(_ansiRegex, '');
// Remove control chars (same regex as in parse)
clean = clean.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), '');
return clean;
}
TextSpan parse(String text) {
final List<TextSpan> spans = [];
final matches = _ansiRegex.allMatches(text);
int currentIndex = 0;
TextStyle currentStyle = const TextStyle(color: Colors.white, fontFamily: 'monospace');
for (final match in matches) {
if (match.start > currentIndex) {
final plainText = text.substring(currentIndex, match.start);
// Clean up common non-printable control chars from the plain text segment
// \x07 (Bell), \x08 (Backspace - simple strip, handling logic is complex for stream)
final cleanText = plainText.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), '');
if (cleanText.isNotEmpty) {
spans.add(TextSpan(
text: cleanText,
style: currentStyle,
));
}
}
final String sequence = match.group(0)!;
// We only process SGR (Select Graphic Rendition) which ends in 'm'
// All other sequences (cursor movement, clear screen, etc.) are stripped.
if (sequence.endsWith('m')) {
currentStyle = _parseSgrSequence(sequence, currentStyle);
}
currentIndex = match.end;
}
if (currentIndex < text.length) {
final plainText = text.substring(currentIndex);
final cleanText = plainText.replaceAll(RegExp(r'[\x00-\x08\x0B\x0C\x0E-\x1F]'), '');
if (cleanText.isNotEmpty) {
spans.add(TextSpan(
text: cleanText,
style: currentStyle,
));
}
}
return TextSpan(children: spans);
}
TextStyle _parseSgrSequence(String sequence, TextStyle currentStyle) {
// Remove \x1B[ and m
final content = sequence.substring(2, sequence.length - 1);
final parts = content.split(';');
TextStyle newStyle = currentStyle;
if (parts.isEmpty || (parts.length == 1 && parts.first.isEmpty)) {
// Empty usually means reset 0
return const TextStyle(color: Colors.white, fontFamily: 'monospace');
}
for (final part in parts) {
final int? value = int.tryParse(part);
if (value == null) continue;
if (value == 0) {
newStyle = const TextStyle(color: Colors.white, fontFamily: 'monospace');
} else if (value == 1) {
newStyle = newStyle.copyWith(fontWeight: FontWeight.bold);
} else if (value >= 30 && value <= 37) {
newStyle = newStyle.copyWith(color: _getAnsiColor(value));
} else if (value >= 90 && value <= 97) {
newStyle = newStyle.copyWith(color: _getAnsiColor(value));
}
// Add background colors or other styles if needed
}
return newStyle;
}
Color _getAnsiColor(int code) {
switch (code) {
case 30: return Colors.black;
case 31: return Colors.red;
case 32: return Colors.green;
case 33: return Colors.yellow;
case 34: return Colors.blue;
case 35: return Colors.purple;
case 36: return Colors.cyan;
case 37: return Colors.white;
case 90: return Colors.grey;
case 91: return Colors.redAccent;
case 92: return Colors.greenAccent;
case 93: return Colors.yellowAccent;
case 94: return Colors.blueAccent;
case 95: return Colors.purpleAccent;
case 96: return Colors.cyanAccent;
case 97: return Colors.white;
default: return Colors.white;
}
}
}