Initial commit: Accessible SSH Terminal
This commit is contained in:
30
lib/main.dart
Normal file
30
lib/main.dart
Normal 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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
lib/models/saved_connection.dart
Normal file
37
lib/models/saved_connection.dart
Normal 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?,
|
||||
);
|
||||
}
|
||||
}
|
||||
8
lib/models/terminal_line.dart
Normal file
8
lib/models/terminal_line.dart
Normal 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});
|
||||
}
|
||||
136
lib/services/ssh_service.dart
Normal file
136
lib/services/ssh_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
69
lib/state/saved_connections_provider.dart
Normal file
69
lib/state/saved_connections_provider.dart
Normal 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();
|
||||
});
|
||||
141
lib/state/terminal_provider.dart
Normal file
141
lib/state/terminal_provider.dart
Normal 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);
|
||||
});
|
||||
30
lib/ui/terminal_screen.dart
Normal file
30
lib/ui/terminal_screen.dart
Normal 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(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
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'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
113
lib/ui/widgets/ansi_text_parser.dart
Normal file
113
lib/ui/widgets/ansi_text_parser.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user