Files
fuckKomet/lib/screens/settings/socket_log_screen.dart
2025-11-19 18:45:44 +03:00

514 lines
15 KiB
Dart

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<SocketLogScreen> createState() => _SocketLogScreenState();
}
class _SocketLogScreenState extends State<SocketLogScreen> {
final List<LogEntry> _allLogEntries = [];
List<LogEntry> _filteredLogEntries = [];
StreamSubscription? _logSubscription;
final ScrollController _scrollController = ScrollController();
int _logIdCounter = 0;
bool _isAutoScrollEnabled = true;
bool _isSearchActive = false;
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';
final Set<LogType> _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<LogEntry> 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<String, dynamic>;
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',
),
),
],
),
),
),
);
}
}