2025-12-22 01:02:44 -06:00
|
|
|
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';
|
2025-12-22 14:15:43 -06:00
|
|
|
import 'package:accessible_terminal/ui/views/file_browser_view.dart';
|
2025-12-22 01:02:44 -06:00
|
|
|
|
|
|
|
|
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) {
|
2025-12-22 14:15:43 -06:00
|
|
|
SemanticsService.sendAnnouncement(
|
|
|
|
|
View.of(context),
|
|
|
|
|
cleanText,
|
|
|
|
|
TextDirection.ltr,
|
|
|
|
|
);
|
2025-12-22 01:02:44 -06:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2025-12-22 14:15:43 -06:00
|
|
|
Semantics(
|
|
|
|
|
label: 'Open File Browser',
|
|
|
|
|
button: true,
|
|
|
|
|
child: IconButton(
|
|
|
|
|
icon: const Icon(Icons.folder_open),
|
|
|
|
|
onPressed: () {
|
|
|
|
|
Navigator.push(
|
|
|
|
|
context,
|
|
|
|
|
MaterialPageRoute(builder: (context) => const FileBrowserView()),
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
),
|
|
|
|
|
),
|
2025-12-22 01:02:44 -06:00
|
|
|
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'),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|