import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:gwid/api/api_service.dart'; import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; enum LogType { send, receive, status, pingpong } class LogEntry { final DateTime timestamp; final String message; final int id; final LogType type; LogEntry({ required this.timestamp, required this.message, required this.id, required this.type, }); } class SocketLogScreen extends StatefulWidget { const SocketLogScreen({super.key}); @override State createState() => _SocketLogScreenState(); } class _SocketLogScreenState extends State { final List _allLogEntries = []; List _filteredLogEntries = []; StreamSubscription? _logSubscription; final ScrollController _scrollController = ScrollController(); int _logIdCounter = 0; bool _isAutoScrollEnabled = true; bool _isSearchActive = false; final TextEditingController _searchController = TextEditingController(); String _searchQuery = ''; final Set _activeFilters = { LogType.send, LogType.receive, LogType.status, LogType.pingpong, }; @override void initState() { super.initState(); _searchController.addListener(() { if (_searchQuery != _searchController.text) { setState(() { _searchQuery = _searchController.text; _applyFiltersAndSearch(); }); } }); _loadInitialLogs(); _subscribeToNewLogs(); } LogType _getLogType(String message) { if (message.contains('(ping)') || message.contains('(pong)')) { return LogType.pingpong; } if (message.startsWith('➡️ SEND')) return LogType.send; if (message.startsWith('⬅️ RECV')) return LogType.receive; return LogType.status; } void _addLogEntry(String logMessage, {bool isInitial = false}) { final newEntry = LogEntry( id: _logIdCounter++, timestamp: DateTime.now(), message: logMessage, type: _getLogType(logMessage), ); _allLogEntries.add(newEntry); if (!isInitial) { _applyFiltersAndSearch(); if (_isAutoScrollEnabled) _scrollToBottom(); } } void _loadInitialLogs() { final cachedLogs = ApiService.instance.connectionLogCache; for (var log in cachedLogs) { _addLogEntry(log, isInitial: true); } _applyFiltersAndSearch(); setState( () {}, ); // Однократное обновление UI после загрузки всех кэшированных логов } void _subscribeToNewLogs() { _logSubscription = ApiService.instance.connectionLog.listen((logMessage) { if (mounted) { if (_allLogEntries.isNotEmpty && _allLogEntries.last.message == logMessage) { return; } setState(() => _addLogEntry(logMessage)); } }); } void _scrollToBottom() { WidgetsBinding.instance.addPostFrameCallback((_) { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } }); } void _applyFiltersAndSearch() { List tempFiltered = _allLogEntries.where((entry) { return _activeFilters.contains(entry.type); }).toList(); if (_searchQuery.isNotEmpty) { tempFiltered = tempFiltered.where((entry) { return entry.message.toLowerCase().contains(_searchQuery.toLowerCase()); }).toList(); } setState(() { _filteredLogEntries = tempFiltered; }); } void _copyLogsToClipboard() { final logText = _filteredLogEntries .map( (entry) => "[${DateFormat('HH:mm:ss.SSS').format(entry.timestamp)}] ${entry.message}", ) .join('\n\n'); Clipboard.setData(ClipboardData(text: logText)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Отфильтрованный журнал скопирован')), ); } void _shareLogs() async { final logText = _filteredLogEntries .map( (entry) => "[${DateFormat('HH:mm:ss.SSS').format(entry.timestamp)}] ${entry.message}", ) .join('\n\n'); await Share.share(logText, subject: 'Gwid Connection Log'); } void _clearLogs() { setState(() { _allLogEntries.clear(); _filteredLogEntries.clear(); }); } void _showFilterDialog() { showModalBottomSheet( context: context, builder: (context) { return StatefulBuilder( builder: (context, setSheetState) { return Padding( padding: const EdgeInsets.all(16.0), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text( "Фильтры логов", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), ), const SizedBox(height: 16), SwitchListTile( title: const Text('Исходящие (SEND)'), value: _activeFilters.contains(LogType.send), onChanged: (val) { setSheetState( () => val ? _activeFilters.add(LogType.send) : _activeFilters.remove(LogType.send), ); _applyFiltersAndSearch(); }, ), SwitchListTile( title: const Text('Входящие (RECV)'), value: _activeFilters.contains(LogType.receive), onChanged: (val) { setSheetState( () => val ? _activeFilters.add(LogType.receive) : _activeFilters.remove(LogType.receive), ); _applyFiltersAndSearch(); }, ), SwitchListTile( title: const Text('Статус подключения'), value: _activeFilters.contains(LogType.status), onChanged: (val) { setSheetState( () => val ? _activeFilters.add(LogType.status) : _activeFilters.remove(LogType.status), ); _applyFiltersAndSearch(); }, ), SwitchListTile( title: const Text('Ping/Pong'), value: _activeFilters.contains(LogType.pingpong), onChanged: (val) { setSheetState( () => val ? _activeFilters.add(LogType.pingpong) : _activeFilters.remove(LogType.pingpong), ); _applyFiltersAndSearch(); }, ), ], ), ); }, ); }, ); } @override void dispose() { _logSubscription?.cancel(); _scrollController.dispose(); _searchController.dispose(); super.dispose(); } AppBar _buildDefaultAppBar() { return AppBar( title: const Text("Журнал подключения"), actions: [ IconButton( icon: const Icon(Icons.search), tooltip: "Поиск", onPressed: () => setState(() => _isSearchActive = true), ), IconButton( icon: Icon( _activeFilters.length == 4 ? Icons.filter_list : Icons.filter_list_off, ), tooltip: "Фильтры", onPressed: _showFilterDialog, ), IconButton( icon: const Icon(Icons.delete_sweep), tooltip: "Очистить", onPressed: _allLogEntries.isNotEmpty ? _clearLogs : null, ), ], ); } AppBar _buildSearchAppBar() { return AppBar( leading: IconButton( icon: const Icon(Icons.close), onPressed: () { setState(() { _isSearchActive = false; _searchController.clear(); }); }, ), title: TextField( controller: _searchController, autofocus: true, decoration: const InputDecoration( hintText: 'Поиск по логам...', border: InputBorder.none, ), style: const TextStyle(color: Colors.white), ), ); } @override Widget build(BuildContext context) { return Scaffold( appBar: _isSearchActive ? _buildSearchAppBar() : _buildDefaultAppBar(), body: _filteredLogEntries.isEmpty ? Center( child: Text( _allLogEntries.isEmpty ? "Журнал пуст." : "Записей не найдено.", ), ) : ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB( 8, 8, 8, 80, ), // Оставляем место для FAB itemCount: _filteredLogEntries.length, itemBuilder: (context, index) { return LogEntryCard( key: ValueKey(_filteredLogEntries[index].id), entry: _filteredLogEntries[index], ); }, ), floatingActionButton: Column( mainAxisSize: MainAxisSize.min, children: [ FloatingActionButton( onPressed: () => setState(() => _isAutoScrollEnabled = !_isAutoScrollEnabled), mini: true, tooltip: _isAutoScrollEnabled ? 'Остановить автопрокрутку' : 'Возобновить автопрокрутку', child: Icon( _isAutoScrollEnabled ? Icons.pause : Icons.arrow_downward, ), ), const SizedBox(height: 8), FloatingActionButton.extended( onPressed: _shareLogs, icon: const Icon(Icons.share), label: const Text("Поделиться"), tooltip: "Поделиться отфильтрованными логами", ), ], ), ); } } class LogEntryCard extends StatelessWidget { final LogEntry entry; const LogEntryCard({super.key, required this.entry}); (IconData, Color) _getVisuals( LogType type, String message, BuildContext context, ) { final theme = Theme.of(context); switch (type) { case LogType.send: return (Icons.arrow_upward, theme.colorScheme.primary); case LogType.receive: return (Icons.arrow_downward, Colors.green); case LogType.pingpong: return (Icons.sync_alt, Colors.grey); case LogType.status: if (message.startsWith('✅')) return (Icons.check_circle, Colors.green); if (message.startsWith('❌')) { return (Icons.error, theme.colorScheme.error); } return (Icons.info, Colors.orange.shade600); } } void _showJsonViewer(BuildContext context, String message) { final jsonRegex = RegExp(r'(\{.*\})'); final match = jsonRegex.firstMatch(message); if (match == null) return; try { final jsonPart = match.group(0)!; final decoded = jsonDecode(jsonPart); final prettyJson = const JsonEncoder.withIndent(' ').convert(decoded); showDialog( context: context, builder: (context) => AlertDialog( title: const Text("Содержимое пакета (JSON)"), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( child: SelectableText( prettyJson, style: const TextStyle(fontFamily: 'monospace'), ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text("Закрыть"), ), ], ), ); } catch (_) {} } (String?, String?) _extractInfo(String message) { try { final jsonRegex = RegExp(r'(\{.*\})'); final match = jsonRegex.firstMatch(message); if (match == null) return (null, null); final jsonPart = match.group(0)!; final decoded = jsonDecode(jsonPart) as Map; final opcode = decoded['opcode']?.toString(); final seq = decoded['seq']?.toString(); return (opcode, seq); } catch (e) { return (null, null); } } @override Widget build(BuildContext context) { final (icon, color) = _getVisuals(entry.type, entry.message, context); final (opcode, seq) = _extractInfo(entry.message); final formattedTime = DateFormat('HH:mm:ss.SSS').format(entry.timestamp); final theme = Theme.of(context); return Card( clipBehavior: Clip.antiAlias, margin: const EdgeInsets.symmetric(vertical: 5), child: InkWell( onTap: () => _showJsonViewer(context, entry.message), child: Container( decoration: BoxDecoration( border: Border(left: BorderSide(color: color, width: 4)), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, color: color, size: 20), const SizedBox(width: 8), Text( formattedTime, style: theme.textTheme.labelMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, ), ), const Spacer(), if (opcode != null) Chip( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: EdgeInsets.zero, label: Text( 'OP: $opcode', style: theme.textTheme.labelSmall, ), ), const SizedBox(width: 4), if (seq != null) Chip( materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, padding: EdgeInsets.zero, label: Text( 'SEQ: $seq', style: theme.textTheme.labelSmall, ), ), ], ), const SizedBox(height: 8), SelectableText( entry.message, style: theme.textTheme.bodySmall?.copyWith( fontFamily: 'monospace', ), ), ], ), ), ), ); } }