import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:desktop_multi_window/desktop_multi_window.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:proxypin/l10n/app_localizations.dart'; import 'package:file_picker/file_picker.dart'; import '../../utils/platform.dart'; import '../component/app_dialog.dart'; import '../component/json/json_text.dart'; import '../component/json/json_viewer.dart'; import '../component/json/theme.dart'; /// Simple WebSocket request page: connect to ws/wss URL, send text, view messages class WebSocketRequestPage extends StatefulWidget { final int? windowId; // optional for desktop multi-window const WebSocketRequestPage({super.key, this.windowId}); @override State createState() => _WebSocketRequestPageState(); } class _WebSocketRequestPageState extends State { final ScrollController _scrollController = ScrollController(); bool _scrollScheduled = false; // whether the view is considered near the bottom. If false, auto-scroll is disabled bool _isNearBottom = true; static const double _autoScrollThreshold = 150.0; // key for the currently last message widget so we can ensureVisible it final GlobalKey _lastMessageKey = GlobalKey(); // key for the input bar so we can position the jump button just above it final GlobalKey _inputBarKey = GlobalKey(); final TextEditingController _urlController = TextEditingController(text: 'ws://'); final TextEditingController _sendController = TextEditingController(); final List<_WsMessage> _messages = []; WebSocket? _socket; StreamSubscription? _sub; bool _connecting = false; bool _connected = false; AppLocalizations get localizations => AppLocalizations.of(context)!; @override void initState() { super.initState(); // listen to scroll changes to determine whether we should auto-scroll _scrollController.addListener(() { if (!_scrollController.hasClients) return; final max = _scrollController.position.maxScrollExtent; final offset = _scrollController.offset; final near = (max - offset) <= _autoScrollThreshold; if (near != _isNearBottom) { setState(() { _isNearBottom = near; }); } }); } @override void dispose() { _sub?.cancel(); _socket?.close(); _scrollController.dispose(); _urlController.dispose(); _sendController.dispose(); super.dispose(); } Future _connect() async { final url = _urlController.text.trim(); if (url.isEmpty || !(url.startsWith('ws://') || url.startsWith('wss://'))) { CustomToast.error('Invalid URL').show(context); return; } setState(() { _connecting = true; }); try { final socket = await WebSocket.connect(url); _socket = socket; _connected = true; _connecting = false; _listen(); setState(() {}); _addSys('Connected'); } catch (e) { _connecting = false; _connected = false; setState(() {}); _addSys('Connect failed: $e'); } } void _listen() { _sub?.cancel(); _sub = _socket?.listen((data) { // data can be String or List if (data is String) { _messages.add(_WsMessage(false, utf8.encode(data), false, time: DateTime.now())); } else if (data is List) { _messages.add(_WsMessage(false, List.from(data), true, time: DateTime.now())); } else { _messages.add(_WsMessage(false, utf8.encode('$data'), false, time: DateTime.now())); } setState(() {}); _scheduleScroll(); }, onError: (error) { _addSys('Error: $error'); }, onDone: () { _connected = false; setState(() {}); _addSys('Closed'); }); } Future _disconnect() async { try { await _socket?.close(); } catch (_) {} _connected = false; setState(() {}); _addSys('Disconnected'); } void _sendText() { final text = _sendController.text.trim(); if (!_connected || text.isEmpty) return; _socket?.add(text); _messages.add(_WsMessage(true, utf8.encode(text), false, time: DateTime.now())); _sendController.clear(); setState(() {}); _scheduleScroll(); } Future _sendFile() async { if (!_connected) return; try { String? path; if (Platforms.isMobile()) { final result = await FilePicker.platform.pickFiles(allowMultiple: false); if (result == null || result.files.isEmpty) return; path = result.files.single.path; } else { path = path = await DesktopMultiWindow.invokeMethod(0, "pickFiles"); if (widget.windowId != null) WindowController.fromWindowId(widget.windowId!).show(); } if (path == null) return; final file = File(path); final bytes = await file.readAsBytes(); if (bytes.isEmpty) return; _socket?.add(bytes); _messages.add(_WsMessage(true, bytes.toList(), true, time: DateTime.now())); setState(() {}); _scheduleScroll(); if (mounted) { CustomToast.success(AppLocalizations.of(context)!.send).show(context); } } catch (e) { if (mounted) { CustomToast.error('Send file failed: $e').show(context); } } } void _addSys(String msg) { _messages.add(_WsMessage.system(msg)); setState(() {}); _scheduleScroll(); } void _clearMessages() { if (_messages.isEmpty) return; setState(() { _messages.clear(); }); } String _formatTime(DateTime dt) { final d = dt.toLocal(); String two(int n) => n.toString().padLeft(2, '0'); return '${two(d.hour)}:${two(d.minute)}:${two(d.second)}'; } String _formatSize(int bytes) { const units = ['B', 'KB', 'MB', 'GB', 'TB']; double size = bytes.toDouble(); int unitIndex = 0; while (size >= 1024 && unitIndex < units.length - 1) { size /= 1024; unitIndex++; } return '${size.toStringAsFixed(size < 10 ? 2 : 1)} ${units[unitIndex]}'; } void _scheduleScroll() { // only auto-scroll when the user is already near the bottom if (!_isNearBottom) return; if (_scrollScheduled) return; _scrollScheduled = true; WidgetsBinding.instance.addPostFrameCallback((_) async { _scrollScheduled = false; // give layout a bit more time to settle (helps when many messages are added quickly) await Future.delayed(const Duration(milliseconds: 120)); // prefer Scrollable.ensureVisible on the last message for more natural behavior try { final ctx = _lastMessageKey.currentContext; if (ctx != null) { // use alignment slightly above bottom to avoid being hidden by input controls await Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 350), curve: Curves.easeInOut, alignment: 0.9); return; } } catch (_) {} await _animateToBottom(); }); } Future _animateToBottom() async { if (!_scrollController.hasClients) return; final max = _scrollController.position.maxScrollExtent; try { await _scrollController.animateTo(max - 10, duration: Duration(milliseconds: 350), curve: Curves.easeInOut); } catch (_) { try { _scrollController.jumpTo(_scrollController.position.maxScrollExtent); } catch (_) {} } } @override Widget build(BuildContext context) { final theme = Theme.of(context); // Use a Stack so we can place a custom-styled "jump to latest" button return Scaffold( appBar: AppBar( title: Text('WebSocket', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), centerTitle: true, actions: [ IconButton( tooltip: 'Clear messages', icon: const Icon(Icons.delete), onPressed: () => _clearMessages(), ), SizedBox(width: 8), ]), body: Stack(children: [ // main content Column(children: [ Padding( padding: const EdgeInsets.all(10), child: Row(children: [ Expanded( child: TextField( controller: _urlController, decoration: InputDecoration(labelText: 'ws(s)://', border: const OutlineInputBorder(), isDense: true), ), ), const SizedBox(width: 8), ElevatedButton( onPressed: _connecting ? null : (_connected ? _disconnect : _connect), child: Text(_connected ? localizations.disconnect : localizations.connect)), ]), ), const Divider(height: 0), Expanded(child: _messageList(theme)), const Divider(height: 0, thickness: 0.2), Padding( key: _inputBarKey, padding: const EdgeInsets.all(10), child: Row(children: [ IconButton( icon: Icon(Icons.attach_file, color: theme.colorScheme.primary), onPressed: _connected ? _sendFile : null, ), const SizedBox(width: 4), Expanded( child: Shortcuts( shortcuts: { // Enter sends SingleActivator(LogicalKeyboardKey.enter): const _SendIntent(), // Ctrl+Enter inserts newline (also meta/cmd on macOS) SingleActivator(LogicalKeyboardKey.enter, control: true): const _InsertNewlineIntent(), SingleActivator(LogicalKeyboardKey.enter, meta: true): const _InsertNewlineIntent(), }, child: Actions( actions: { _SendIntent: CallbackAction<_SendIntent>(onInvoke: (intent) { if (_connected) _sendText(); return null; }), _InsertNewlineIntent: CallbackAction<_InsertNewlineIntent>(onInvoke: (intent) { // Insert a newline at the current cursor position final controller = _sendController; final text = controller.text; final sel = controller.selection; final start = sel.start >= 0 ? sel.start : text.length; final end = sel.end >= 0 ? sel.end : text.length; final newText = text.replaceRange(start, end, '\n'); controller.value = TextEditingValue( text: newText, selection: TextSelection.collapsed(offset: start + 1), ); return null; }), }, child: TextField( controller: _sendController, minLines: 1, maxLines: 4, keyboardType: TextInputType.multiline, textInputAction: TextInputAction.newline, decoration: InputDecoration( labelText: localizations.requestBody, border: const OutlineInputBorder(), isDense: true, ), ), ), ), ), const SizedBox(width: 8), // Telegram-style circular send button Tooltip( message: localizations.send, child: Opacity( opacity: _connected ? 1.0 : 0.5, child: InkWell( onTap: _connected ? _sendText : null, borderRadius: BorderRadius.circular(22), child: Container( width: 34, height: 34, decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.2), blurRadius: 6, offset: const Offset(0, 3)) ], ), child: const Icon(Icons.send, color: Colors.white, size: 20), ), ), ), ), ]), ) ]), // positioned jump-to-latest button (custom style). It is placed above the input area and above // the keyboard by using MediaQuery.viewInsets.bottom as extra offset. if (!_isNearBottom) // pill-shaped jump button placed just above the input bar, aligned to the right Positioned( right: 16, bottom: () { final inputContext = _inputBarKey.currentContext; final viewInsets = MediaQuery.of(context).viewInsets.bottom; if (inputContext != null) { final renderBox = inputContext.findRenderObject() as RenderBox?; if (renderBox != null) { final h = renderBox.size.height; return (h + 12.0 + viewInsets); } } return 80.0 + viewInsets; }(), child: AnimatedOpacity( duration: const Duration(milliseconds: 220), opacity: !_isNearBottom ? 1.0 : 0.0, child: Semantics( label: 'Jump to latest messages', button: true, child: Material( elevation: 10, color: Colors.transparent, child: InkWell( onTap: () async { await _animateToBottom(); if (!_isNearBottom) { setState(() { _isNearBottom = true; }); } }, borderRadius: BorderRadius.circular(20.0), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: Theme.of(context).colorScheme.primary, borderRadius: BorderRadius.circular(20.0), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.24), blurRadius: 8, offset: const Offset(0, 4)) ], ), child: const Icon(Icons.arrow_downward, color: Colors.white, size: 18), ), ), ), ), ), ), ]), ); } Widget _messageList(ThemeData theme) { // Add extra bottom padding when the jump button is visible to avoid covering content double baseBottom = 10; double extraBottom = 0; if (!_isNearBottom) { final inputContext = _inputBarKey.currentContext; if (inputContext != null) { final renderBox = inputContext.findRenderObject() as RenderBox?; if (renderBox != null) { // button height ~ 32 (pill) + margin 16; ensure some extra spacing for safe area extraBottom = 48; // conservative spacing } } else { extraBottom = 48; } } return ListView.separated( controller: _scrollController, padding: EdgeInsets.fromLTRB(10, 10, 10, baseBottom + extraBottom), itemBuilder: (context, index) { final m = _messages[index]; if (m.isSystem) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ SelectionContainer.disabled( child: Text(_formatTime(m.time), style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey))), const SizedBox(height: 4), Text(m.textPreview(), style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)), ], )); } final displayOnLeft = !m.isClient; final avatar = CircleAvatar( backgroundColor: m.isClient ? Colors.green : Colors.blue, child: Text(m.isClient ? 'C' : 'S', style: const TextStyle(color: Colors.white)), ); final bubbleText = m.isBinary ? '[binary ${_formatSize(m.bytes.length)}]' : m.textPreview(); final bubble = Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: displayOnLeft ? Colors.green.withValues(alpha: 0.2) : Colors.blue.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(8), ), child: SelectableText(bubbleText), ); final previewButton = IconButton( onPressed: () { showDialog(context: context, builder: (context) => _PreviewDialog(bytes: m.bytes)); }, icon: Icon(Icons.expand_more, color: ColorScheme.of(context).primary), ); // attach key to the last message so we can ensureVisible it final widgetKey = index == _messages.length - 1 ? _lastMessageKey : null; return Padding( padding: const EdgeInsets.only(bottom: 8), key: widgetKey, child: Row( mainAxisAlignment: displayOnLeft ? MainAxisAlignment.start : MainAxisAlignment.end, children: [ if (displayOnLeft) avatar, const SizedBox(width: 8), Flexible( child: Column( crossAxisAlignment: displayOnLeft ? CrossAxisAlignment.start : CrossAxisAlignment.end, children: [ SelectionContainer.disabled( child: Text(_formatTime(m.time), style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey))), const SizedBox(height: 4), Row(mainAxisSize: MainAxisSize.min, children: [ if (!displayOnLeft) previewButton, Flexible(child: bubble), if (displayOnLeft) previewButton, ]), ], ), ), if (!displayOnLeft) const SizedBox(width: 8), if (!displayOnLeft) avatar, ], ), ); }, separatorBuilder: (context, index) => const SizedBox(height: 8), itemCount: _messages.length, ); } } class _WsMessage { final bool isClient; final List bytes; final bool isBinary; final DateTime time; _WsMessage(this.isClient, this.bytes, this.isBinary, {DateTime? time}) : time = time ?? DateTime.now(); bool get isSystem => bytes.isEmpty; String textPreview() { if (isSystem) return utf8.decode(bytes); return utf8.decode(bytes); } @override String toString() { return 'Message(isClient: $isClient, bytes: $bytes, isBinary: $isBinary, time: $time)'; } factory _WsMessage.system(String text) { return _WsMessage(false, utf8.encode(text), false); } } class _PreviewDialog extends StatefulWidget { final List bytes; const _PreviewDialog({required this.bytes}); @override State<_PreviewDialog> createState() => _PreviewDialogState(); } class _PreviewDialogState extends State<_PreviewDialog> { int tabIndex = 0; // 0: HEX, 1: TEXT @override Widget build(BuildContext context) { var tabs = [ if (isJsonText(widget.bytes)) const Tab(text: "JSON Text"), if (isJsonText(widget.bytes)) const Tab(text: "JSON"), const Tab(text: "TEXT"), const Tab(text: "HEX"), ]; return AlertDialog( content: SizedBox( width: MediaQuery.of(context).size.width * 0.8, height: MediaQuery.of(context).size.height * 0.6, child: DefaultTabController( length: tabs.length, initialIndex: tabIndex, child: Column(mainAxisSize: MainAxisSize.min, children: [ TabBar( tabs: tabs, onTap: (index) { setState(() { tabIndex = index; }); }, ), Expanded( child: TabBarView(children: [ if (isJsonText(widget.bytes)) SingleChildScrollView(padding: const EdgeInsets.all(8.0), child: jsonText()), if (isJsonText(widget.bytes)) SingleChildScrollView(padding: const EdgeInsets.all(8.0), child: jsonView()), // TEXT SingleChildScrollView( padding: const EdgeInsets.all(8), child: SelectableText(safeTextPreview(widget.bytes)), ), // HEX SingleChildScrollView( padding: const EdgeInsets.all(8), child: SelectableText(widget.bytes.map(intToHex).join(" ")), ), ]), ), ]), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text(MaterialLocalizations.of(context).closeButtonLabel)) ], ); } Widget jsonText() { String body = utf8.decode(widget.bytes, allowMalformed: true); dynamic jsonData; try { jsonData = json.decode(body); } catch (e) { jsonData = null; } if (jsonData == null) { return SelectableText(safeTextPreview(widget.bytes)); } return JsonText(json: jsonData, indent: ' ', colorTheme: ColorTheme.of(context)); } Widget jsonView() { String body = utf8.decode(widget.bytes, allowMalformed: true); dynamic jsonData; try { jsonData = json.decode(body); } catch (e) { jsonData = null; } if (jsonData == null) { return SelectableText(safeTextPreview(widget.bytes)); } return JsonViewer(json.decode(body), colorTheme: ColorTheme.of(context)); } //判断是否是json格式 bool isJsonText(List bytes) { return bytes.isNotEmpty && (bytes[0] == 0x7B || bytes[0] == 0x5B); } String intToHex(int b) => b.toRadixString(16).padLeft(2, '0'); /// Decode bytes to string, non-printable as '.' String safeTextPreview(List bytes) { try { return utf8.decode(bytes); } catch (_) { return bytes.map((b) => b >= 32 && b <= 126 ? String.fromCharCode(b) : '.').join(); } } } class _SendIntent extends Intent { const _SendIntent(); } class _InsertNewlineIntent extends Intent { const _InsertNewlineIntent(); }