mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 06:28:41 +00:00
improved log view
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run
This commit is contained in:
parent
e97f0a910f
commit
ad0ef841cc
4 changed files with 332 additions and 65 deletions
|
|
@ -1,5 +1,10 @@
|
|||
# Changelog
|
||||
|
||||
## 0.0.83
|
||||
|
||||
- Improved view of the diagnostic log
|
||||
- Several bug fixes
|
||||
|
||||
## 0.0.82
|
||||
|
||||
- Added an option in the settings to automatically save all sent images
|
||||
|
|
|
|||
|
|
@ -149,5 +149,6 @@ String _getCallerSourceCodeFilename() {
|
|||
firstLine.split('/').last.split(':').first; // Extract the file name
|
||||
lineNumber = firstLine.split(':')[1]; // Extract the line number
|
||||
}
|
||||
lineNumber = lineNumber.replaceAll(')', '');
|
||||
return '$fileName:$lineNumber';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:math' as math;
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
extension LoadingAnimationControllerX on AnimationController {
|
||||
T eval<T>(Tween<T> tween, {Curve curve = Curves.linear}) =>
|
||||
|
|
@ -25,12 +26,12 @@ extension LoadingAnimationControllerX on AnimationController {
|
|||
|
||||
class ThreeRotatingDots extends StatefulWidget {
|
||||
const ThreeRotatingDots({
|
||||
required this.color,
|
||||
required this.size,
|
||||
this.color,
|
||||
super.key,
|
||||
});
|
||||
final double size;
|
||||
final Color color;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
State<ThreeRotatingDots> createState() => _ThreeRotatingDotsState();
|
||||
|
|
@ -52,7 +53,7 @@ class _ThreeRotatingDotsState extends State<ThreeRotatingDots>
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = widget.color;
|
||||
final color = widget.color ?? context.color.primary;
|
||||
final size = widget.size;
|
||||
final dotSize = size / 3;
|
||||
final edgeOffset = (size - dotSize) / 2;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/views/components/loader.dart';
|
||||
|
||||
class DiagnosticsView extends StatefulWidget {
|
||||
const DiagnosticsView({super.key});
|
||||
|
|
@ -14,6 +15,19 @@ class DiagnosticsView extends StatefulWidget {
|
|||
class _DiagnosticsViewState extends State<DiagnosticsView> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
String? _debugLogText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initAsync();
|
||||
}
|
||||
|
||||
Future<void> initAsync() async {
|
||||
_debugLogText = await readLast1000Lines();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _scrollToBottom() async {
|
||||
// Assuming the button is at the bottom of the scroll view
|
||||
await _scrollController.animateTo(
|
||||
|
|
@ -23,60 +37,70 @@ class _DiagnosticsViewState extends State<DiagnosticsView> {
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> _shareDebugLog() async {
|
||||
if (_debugLogText == null) return;
|
||||
final directory = await getApplicationSupportDirectory();
|
||||
final logFile = XFile('${directory.path}/app.log');
|
||||
|
||||
final params = ShareParams(
|
||||
text: 'Debug log',
|
||||
files: [logFile],
|
||||
);
|
||||
|
||||
final result = await SharePlus.instance.share(params);
|
||||
|
||||
if (result.status != ShareResultStatus.success) {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: _debugLogText!),
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Log copied to clipboard!'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteDebugLog() async {
|
||||
if (await deleteLogFile()) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Log file deleted!'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Log file does not exist.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Diagnostics')),
|
||||
body: FutureBuilder<String>(
|
||||
future: readLast1000Lines(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
} else if (snapshot.hasError) {
|
||||
return Center(child: Text('Error: ${snapshot.error}'));
|
||||
} else {
|
||||
final logText = snapshot.data ?? '';
|
||||
|
||||
return Column(
|
||||
body: (_debugLogText == null)
|
||||
? const Center(child: ThreeRotatingDots(size: 40))
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(logText),
|
||||
child: LogViewerWidget(
|
||||
logLines: _debugLogText!,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
final directory =
|
||||
await getApplicationSupportDirectory();
|
||||
final logFile = XFile('${directory.path}/app.log');
|
||||
|
||||
final params = ShareParams(
|
||||
text: 'Debug log',
|
||||
files: [logFile],
|
||||
);
|
||||
|
||||
final result = await SharePlus.instance.share(params);
|
||||
|
||||
if (result.status != ShareResultStatus.success) {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: logText),
|
||||
);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Log copied to clipboard!'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
onPressed: _shareDebugLog,
|
||||
child: const Text('Share debug log'),
|
||||
),
|
||||
TextButton(
|
||||
|
|
@ -84,33 +108,269 @@ class _DiagnosticsViewState extends State<DiagnosticsView> {
|
|||
child: const Text('Scroll to Bottom'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
if (await deleteLogFile()) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Log file deleted!'),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Log file does not exist.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onPressed: _deleteDebugLog,
|
||||
child: const Text('Delete Log File'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LogViewerWidget extends StatefulWidget {
|
||||
const LogViewerWidget({required this.logLines, super.key});
|
||||
final String logLines;
|
||||
|
||||
@override
|
||||
State<LogViewerWidget> createState() => _LogViewerWidgetState();
|
||||
}
|
||||
|
||||
class _LogViewerWidgetState extends State<LogViewerWidget> {
|
||||
String _filterLevel = 'ALL'; // ALL, FINE, WARNING, SHOUT
|
||||
bool _showTimestamps = true;
|
||||
late List<_LogEntry> _entries;
|
||||
final ScrollController _controller = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_entries =
|
||||
widget.logLines.split('\n').reversed.map(_LogEntry.parse).toList();
|
||||
}
|
||||
|
||||
void _setFilter(String level) => setState(() => _filterLevel = level);
|
||||
void _toggleTimestamps() =>
|
||||
setState(() => _showTimestamps = !_showTimestamps);
|
||||
|
||||
List<_LogEntry> get _filtered {
|
||||
return _entries.where((e) {
|
||||
if (_filterLevel != 'ALL' && e.level != _filterLevel) return false;
|
||||
return true;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Color _colorForLevel(String? level) {
|
||||
switch (level) {
|
||||
case 'WARNING':
|
||||
return Colors.orange.shade700;
|
||||
case 'SHOUT':
|
||||
return Colors.red.shade600;
|
||||
case 'FINE':
|
||||
return Colors.blueGrey.shade600;
|
||||
default:
|
||||
return Colors.grey.shade700;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _iconForLevel(String? level) {
|
||||
switch (level) {
|
||||
case 'WARNING':
|
||||
return Icons.warning_amber_rounded;
|
||||
case 'SHOUT':
|
||||
return Icons.error_outline;
|
||||
case 'FINE':
|
||||
return Icons.info_outline;
|
||||
default:
|
||||
return Icons.notes;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLevelChip(String label) {
|
||||
final selected = _filterLevel == label;
|
||||
return ChoiceChip(
|
||||
padding: EdgeInsets.zero,
|
||||
label: Text(label),
|
||||
selected: selected,
|
||||
onSelected: (_) => _setFilter(label),
|
||||
selectedColor: _colorForLevel(label).withAlpha(120),
|
||||
backgroundColor: Colors.grey.shade200,
|
||||
);
|
||||
}
|
||||
|
||||
TextSpan _formatLineSpan(_LogEntry e) {
|
||||
final tsStyle =
|
||||
TextStyle(color: Colors.grey.shade500, fontFamily: 'monospace');
|
||||
final levelStyle = TextStyle(
|
||||
color: Colors.blueGrey.shade600,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontFamily: 'monospace',
|
||||
);
|
||||
const msgStyle = TextStyle(color: Colors.black87, fontFamily: 'monospace');
|
||||
|
||||
return TextSpan(
|
||||
children: [
|
||||
if (_showTimestamps && e.timestamp != null)
|
||||
TextSpan(
|
||||
text: '${e.timestamp} '.replaceAll('.000', ''),
|
||||
style: tsStyle,
|
||||
),
|
||||
TextSpan(text: '${e.fileName}\n', style: levelStyle),
|
||||
TextSpan(text: e.message, style: msgStyle),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip:
|
||||
_showTimestamps ? 'Hide timestamps' : 'Show timestamps',
|
||||
onPressed: _toggleTimestamps,
|
||||
icon: Icon(
|
||||
_showTimestamps
|
||||
? Icons.access_time
|
||||
: Icons.access_time_filled_outlined,
|
||||
),
|
||||
),
|
||||
_buildLevelChip('ALL'),
|
||||
const SizedBox(width: 3),
|
||||
_buildLevelChip('FINE'),
|
||||
const SizedBox(width: 3),
|
||||
_buildLevelChip('WARNING'),
|
||||
const SizedBox(width: 3),
|
||||
_buildLevelChip('SHOUT'),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${_filtered.length} lines',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
),
|
||||
child: Scrollbar(
|
||||
controller: _controller,
|
||||
child: ListView.builder(
|
||||
controller: _controller,
|
||||
itemCount: _filtered.length,
|
||||
itemBuilder: (context, i) {
|
||||
final e = _filtered[i];
|
||||
return InkWell(
|
||||
onLongPress: () {
|
||||
Clipboard.setData(ClipboardData(text: e.line));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Copied line')),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: _colorForLevel(e.level).withAlpha(40),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(
|
||||
_iconForLevel(e.level),
|
||||
color: _colorForLevel(e.level),
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: _formatLineSpan(e),
|
||||
overflow: TextOverflow.visible,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogEntry {
|
||||
_LogEntry({
|
||||
required this.message,
|
||||
required this.line,
|
||||
required this.fileName,
|
||||
this.timestamp,
|
||||
this.level,
|
||||
});
|
||||
|
||||
// Minimal parser based on the sample log format
|
||||
factory _LogEntry.parse(String raw) {
|
||||
// Example line:
|
||||
// 2025-12-25 23:36:52 WARNING [twonly] api.service.dart:189) > websocket error: ...
|
||||
final trimmed = raw.trim();
|
||||
DateTime? ts;
|
||||
String? level;
|
||||
var msg = trimmed;
|
||||
|
||||
// Try to parse leading timestamp (YYYY-MM-DD HH:MM:SS)
|
||||
final tsRegex = RegExp(r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\s+(.*)$');
|
||||
final mTs = tsRegex.firstMatch(trimmed);
|
||||
if (mTs != null) {
|
||||
try {
|
||||
ts = DateTime.parse(mTs.group(1)!.replaceFirst(' ', 'T'));
|
||||
} catch (_) {
|
||||
ts = null;
|
||||
}
|
||||
msg = mTs.group(2)!;
|
||||
}
|
||||
|
||||
// Try to extract level token (FINE|WARNING|SHOUT)
|
||||
final lvlRegex = RegExp(r'^(FINE|WARNING|SHOUT)\b\s*(.*)$');
|
||||
final mLvl = lvlRegex.firstMatch(msg);
|
||||
if (mLvl != null) {
|
||||
level = mLvl.group(1);
|
||||
msg = mLvl.group(2)!;
|
||||
} else {
|
||||
// sometimes level appears after timestamp then tag like: "FINE [twonly] ..."
|
||||
final lvl2 = RegExp(r'^(?:\[[^\]]+\]\s*)?(FINE|WARNING|SHOUT)\b\s*(.*)$');
|
||||
final m2 = lvl2.firstMatch(msg);
|
||||
if (m2 != null) {
|
||||
level = m2.group(1);
|
||||
msg = m2.group(2)!;
|
||||
}
|
||||
}
|
||||
|
||||
msg = msg.trim().replaceAll('[twonly] ', '');
|
||||
final fileNameS = msg.split(' > ');
|
||||
final fileName = fileNameS[0];
|
||||
|
||||
msg = fileNameS.sublist(1).join();
|
||||
|
||||
return _LogEntry(
|
||||
timestamp: ts,
|
||||
level: level,
|
||||
message: msg,
|
||||
line: raw,
|
||||
fileName: fileName,
|
||||
);
|
||||
}
|
||||
final DateTime? timestamp;
|
||||
final String? level;
|
||||
final String message;
|
||||
final String line;
|
||||
final String fileName;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue