Initial Commit
This commit is contained in:
513
lib/screens/settings/socket_log_screen.dart
Normal file
513
lib/screens/settings/socket_log_screen.dart
Normal file
@@ -0,0 +1,513 @@
|
||||
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:gwid/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',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user