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'; import 'package:accessible_terminal/ui/views/file_browser_view.dart'; class TerminalView extends ConsumerStatefulWidget { const TerminalView({super.key}); @override ConsumerState createState() => _TerminalViewState(); } class _TerminalViewState extends ConsumerState { 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>(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.sendAnnouncement( View.of(context), 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: 'Open File Browser', button: true, child: IconButton( icon: const Icon(Icons.folder_open), onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (context) => const FileBrowserView()), ); }, ), ), 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'), ), ), ), ); } }