improved log view
Some checks are pending
Flutter analyze & test / flutter_analyze_and_test (push) Waiting to run

This commit is contained in:
otsmr 2025-12-30 11:59:03 +01:00
parent e97f0a910f
commit ad0ef841cc
4 changed files with 332 additions and 65 deletions

View file

@ -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

View file

@ -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';
}

View file

@ -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;

View file

@ -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,38 +37,9 @@ class _DiagnosticsViewState extends State<DiagnosticsView> {
);
}
@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(
children: [
Expanded(
child: SingleChildScrollView(
controller: _scrollController,
padding: const EdgeInsets.all(16),
child: Text(logText),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: () async {
final directory =
await getApplicationSupportDirectory();
Future<void> _shareDebugLog() async {
if (_debugLogText == null) return;
final directory = await getApplicationSupportDirectory();
final logFile = XFile('${directory.path}/app.log');
final params = ShareParams(
@ -66,9 +51,9 @@ class _DiagnosticsViewState extends State<DiagnosticsView> {
if (result.status != ShareResultStatus.success) {
await Clipboard.setData(
ClipboardData(text: logText),
ClipboardData(text: _debugLogText!),
);
if (context.mounted) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Log copied to clipboard!'),
@ -76,7 +61,46 @@ class _DiagnosticsViewState extends State<DiagnosticsView> {
);
}
}
},
}
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: (_debugLogText == null)
? const Center(child: ThreeRotatingDots(size: 40))
: Column(
children: [
Expanded(
child: LogViewerWidget(
logLines: _debugLogText!,
),
),
Padding(
padding: const EdgeInsets.all(8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
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;
}