import 'package:flutter/material.dart'; import 'src/rtty_mobile_client.dart'; import 'src/theme.dart'; void main() { runApp(const RttyMobileApp()); } class RttyMobileApp extends StatelessWidget { const RttyMobileApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Rtty Mobile', debugShowCheckedModeBanner: false, theme: RttyMobileTheme.app(), home: const TerminalPage(), ); } } /// 最小验证页:连接 Rtty 服务端,消费语义化快照实时显示终端内容, /// 并支持输入命令 + 常用按键(ESC/TAB/Ctrl/方向键)做端到端同步验证。 class TerminalPage extends StatefulWidget { const TerminalPage({super.key}); @override State createState() => _TerminalPageState(); } class _TerminalPageState extends State { final RttyMobileClient _client = RttyMobileClient(); final TextEditingController _serverCtrl = TextEditingController(); final TextEditingController _tokenCtrl = TextEditingController(); final TextEditingController _sessionCtrl = TextEditingController(); final TextEditingController _inputCtrl = TextEditingController(); final ScrollController _scrollCtrl = ScrollController(); ConnState _state = ConnState.disconnected; String? _sessionId; bool _resumed = false; List _lines = const []; int _cursorX = 0; int _cursorY = 0; /// 终端行高(逻辑像素),与 [_buildLine] 的字号/行高保持一致, /// 用于“让光标行保持可见”的滚动计算。 static const double _lineHeight = 17.0; /// 常用按键:标签 -> 发送到 PTY 的字节序列。 static const List<(String, String)> _quickKeys = [ ('ESC', '\x1b'), ('TAB', '\t'), ('CTRL+C', '\x03'), ('CTRL+L', '\x0c'), ('↑', '\x1b[A'), ('↓', '\x1b[B'), ('←', '\x1b[D'), ('→', '\x1b[C'), ]; @override void initState() { super.initState(); _client.onStateChanged = _onStateChanged; _client.onReady = (id, cols, rows, resumed) { if (!mounted) return; setState(() { _sessionId = id; _resumed = resumed; _state = ConnState.connected; }); }; _client.onSnapshot = _onSnapshot; _client.onSessionClosed = () { if (!mounted) return; setState(() => _state = ConnState.disconnected); }; } @override void dispose() { _client.disconnect(); _serverCtrl.dispose(); _tokenCtrl.dispose(); _sessionCtrl.dispose(); _inputCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } void _onStateChanged(ConnState s) { if (!mounted) return; setState(() { _state = s; if (s == ConnState.disconnected || s == ConnState.error) { _sessionId = null; _resumed = false; } }); } void _onSnapshot(Snapshot snap) { if (!mounted) return; setState(() { _lines = snap.lines; _cursorX = snap.cursorX; _cursorY = snap.cursorY; }); // 终端语义:让光标行始终可见(尽量靠近视口底部),而不是盲目滚到 // 列表末尾——网格下半部分通常是空行,滚到底会把内容顶出屏幕。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollCtrl.hasClients) return; final viewport = _scrollCtrl.position.viewportDimension; final max = _scrollCtrl.position.maxScrollExtent; final target = ((_cursorY + 1) * _lineHeight) - viewport; _scrollCtrl.jumpTo(target.clamp(0.0, max)); }); } (String, int) _parseServer() { final raw = _serverCtrl.text.trim(); if (raw.isEmpty) return ('', 8080); final idx = raw.lastIndexOf(':'); if (idx <= 0) return (raw, 8080); final port = int.tryParse(raw.substring(idx + 1)) ?? 8080; return (raw.substring(0, idx), port); } Future _connect() async { final (host, port) = _parseServer(); if (host.isEmpty) { _showSnack('请输入服务端地址,如 192.168.1.100:8080'); return; } setState(() => _state = ConnState.connecting); await _client.connect( host: host, port: port, session: _sessionCtrl.text.trim().isEmpty ? null : _sessionCtrl.text.trim(), token: _tokenCtrl.text.trim().isEmpty ? null : _tokenCtrl.text.trim(), ); } Future _disconnect() async { await _client.disconnect(); } void _send(String data) { if (!_client.isConnected) return; _client.sendInput(data); } void _sendLine() { if (_inputCtrl.text.isEmpty) return; _send('${_inputCtrl.text}\r'); _inputCtrl.clear(); } void _showSnack(String msg) { if (!mounted) return; ScaffoldMessenger.of(context) .showSnackBar(SnackBar(content: Text(msg), duration: const Duration(seconds: 2))); } @override Widget build(BuildContext context) { final connected = _client.isConnected; return Scaffold( body: SafeArea( child: Column( children: [ _buildConnectionPanel(connected), const Divider(height: 1, color: RttyMobileTheme.border), Expanded(child: _buildTerminal()), _buildKeybar(connected), _buildInputRow(connected), ], ), ), ); } Widget _buildConnectionPanel(bool connected) { return Padding( padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), child: Column( children: [ Row( children: [ Expanded( child: TextField( controller: _serverCtrl, enabled: !connected, style: const TextStyle(fontFamily: 'monospace', fontSize: 14), decoration: const InputDecoration( labelText: 'SERVER', hintText: '192.168.1.100:8080', isDense: true, ), ), ), const SizedBox(width: 8), SizedBox( width: 110, child: TextField( controller: _tokenCtrl, enabled: !connected, obscureText: true, style: const TextStyle(fontFamily: 'monospace', fontSize: 14), decoration: const InputDecoration( labelText: 'TOKEN', hintText: '可选', isDense: true, ), ), ), const SizedBox(width: 8), FilledButton( onPressed: connected ? _disconnect : _connect, style: FilledButton.styleFrom( backgroundColor: connected ? RttyMobileTheme.danger : RttyMobileTheme.primary, foregroundColor: connected ? Colors.white : RttyMobileTheme.background, ), child: Text(connected ? '断开' : '连接'), ), ], ), const SizedBox(height: 8), Row( children: [ Expanded( child: TextField( controller: _sessionCtrl, enabled: !connected, style: const TextStyle(fontFamily: 'monospace', fontSize: 14), decoration: const InputDecoration( labelText: 'SESSION', hintText: '留空自动新建', isDense: true, ), ), ), const SizedBox(width: 8), Expanded(child: _buildStatus()), ], ), ], ), ); } Widget _buildStatus() { final stateText = switch (_state) { ConnState.disconnected => '未连接', ConnState.connecting => '连接中…', ConnState.connected => '已连接', ConnState.error => '连接失败', }; final color = switch (_state) { ConnState.connected => RttyMobileTheme.primary, ConnState.connecting => RttyMobileTheme.accent, ConnState.error => RttyMobileTheme.danger, ConnState.disconnected => RttyMobileTheme.textFaint, }; final session = (_sessionId == null || _sessionId!.isEmpty) ? '' : ' · ${_resumed ? '恢复' : '新建'}:$_sessionId'; final err = (_state == ConnState.error && _client.error != null) ? '\n${_client.error}' : ''; return Text( '$stateText$session$err', maxLines: 2, overflow: TextOverflow.ellipsis, style: TextStyle( color: color, fontSize: 12, fontFamily: 'monospace', ), ); } Widget _buildTerminal() { if (!_client.isConnected) { final (title, sub) = switch (_state) { ConnState.error => ( '连接失败', _client.error ?? '请检查地址、令牌与服务端是否启动', ), ConnState.connecting => ('连接中', '正在协商 WebSocket 会话…'), _ => ('未连接', '输入服务端地址后点击「连接」'), }; return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.terminal, size: 48, color: _state == ConnState.error ? RttyMobileTheme.danger : RttyMobileTheme.textFaint, ), const SizedBox(height: 12), Text(title, style: TextStyle( color: RttyMobileTheme.textDim, fontSize: 16, fontWeight: FontWeight.w600)), const SizedBox(height: 6), Text(sub, textAlign: TextAlign.center, style: const TextStyle( color: RttyMobileTheme.textFaint, fontSize: 12)), ], ), ); } return Container( color: RttyMobileTheme.background, padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: ListView.builder( controller: _scrollCtrl, itemCount: _lines.length, itemBuilder: (context, i) => _buildLine(i), ), ); } Widget _buildLine(int index) { final line = index < _lines.length ? _lines[index] : ''; final mono = const TextStyle( fontFamily: 'monospace', fontSize: 13, height: 1.25, color: RttyMobileTheme.text, ); if (index != _cursorY) { return SizedBox(height: _lineHeight, child: Text(line, style: mono)); } // 光标行:用高亮色块标出光标位置。 final x = _cursorX.clamp(0, line.length); return SizedBox( height: _lineHeight, child: Text.rich( TextSpan( style: mono, children: [ TextSpan(text: line.substring(0, x)), if (x < line.length) TextSpan( text: line[x], style: const TextStyle( color: RttyMobileTheme.background, backgroundColor: RttyMobileTheme.primary, ), ) else const TextSpan( text: ' ', style: TextStyle(backgroundColor: RttyMobileTheme.primary), ), if (x + 1 < line.length) TextSpan(text: line.substring(x + 1)), ], ), ), ); } Widget _buildKeybar(bool connected) { return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: Wrap( spacing: 6, runSpacing: 6, children: [ for (final (label, seq) in _quickKeys) _KeyButton( label: label, onTap: connected ? () => _send(seq) : null, ), ], ), ); } Widget _buildInputRow(bool connected) { return Padding( padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), child: Row( children: [ Expanded( child: TextField( controller: _inputCtrl, enabled: connected, style: const TextStyle(fontFamily: 'monospace', fontSize: 14), textInputAction: TextInputAction.send, onSubmitted: (_) => _sendLine(), decoration: const InputDecoration( hintText: '输入命令,回车发送', isDense: true, ), ), ), const SizedBox(width: 8), FilledButton( onPressed: connected ? _sendLine : null, child: const Text('发送'), ), ], ), ); } } class _KeyButton extends StatelessWidget { const _KeyButton({required this.label, this.onTap}); final String label; final VoidCallback? onTap; @override Widget build(BuildContext context) { return SizedBox( height: 30, child: OutlinedButton( onPressed: onTap, style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(horizontal: 10), side: BorderSide( color: onTap == null ? RttyMobileTheme.border : RttyMobileTheme.primaryDim, ), foregroundColor: onTap == null ? RttyMobileTheme.textFaint : RttyMobileTheme.primary, textStyle: const TextStyle(fontSize: 11, fontFamily: 'monospace'), ), child: Text(label), ), ); } }