mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-01-15 12:48: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
|
# Changelog
|
||||||
|
|
||||||
|
## 0.0.83
|
||||||
|
|
||||||
|
- Improved view of the diagnostic log
|
||||||
|
- Several bug fixes
|
||||||
|
|
||||||
## 0.0.82
|
## 0.0.82
|
||||||
|
|
||||||
- Added an option in the settings to automatically save all sent images
|
- 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
|
firstLine.split('/').last.split(':').first; // Extract the file name
|
||||||
lineNumber = firstLine.split(':')[1]; // Extract the line number
|
lineNumber = firstLine.split(':')[1]; // Extract the line number
|
||||||
}
|
}
|
||||||
|
lineNumber = lineNumber.replaceAll(')', '');
|
||||||
return '$fileName:$lineNumber';
|
return '$fileName:$lineNumber';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import 'dart:math' as math;
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:twonly/src/utils/misc.dart';
|
||||||
|
|
||||||
extension LoadingAnimationControllerX on AnimationController {
|
extension LoadingAnimationControllerX on AnimationController {
|
||||||
T eval<T>(Tween<T> tween, {Curve curve = Curves.linear}) =>
|
T eval<T>(Tween<T> tween, {Curve curve = Curves.linear}) =>
|
||||||
|
|
@ -25,12 +26,12 @@ extension LoadingAnimationControllerX on AnimationController {
|
||||||
|
|
||||||
class ThreeRotatingDots extends StatefulWidget {
|
class ThreeRotatingDots extends StatefulWidget {
|
||||||
const ThreeRotatingDots({
|
const ThreeRotatingDots({
|
||||||
required this.color,
|
|
||||||
required this.size,
|
required this.size,
|
||||||
|
this.color,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
final double size;
|
final double size;
|
||||||
final Color color;
|
final Color? color;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ThreeRotatingDots> createState() => _ThreeRotatingDotsState();
|
State<ThreeRotatingDots> createState() => _ThreeRotatingDotsState();
|
||||||
|
|
@ -52,7 +53,7 @@ class _ThreeRotatingDotsState extends State<ThreeRotatingDots>
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final color = widget.color;
|
final color = widget.color ?? context.color.primary;
|
||||||
final size = widget.size;
|
final size = widget.size;
|
||||||
final dotSize = size / 3;
|
final dotSize = size / 3;
|
||||||
final edgeOffset = (size - dotSize) / 2;
|
final edgeOffset = (size - dotSize) / 2;
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:twonly/src/utils/log.dart';
|
import 'package:twonly/src/utils/log.dart';
|
||||||
|
import 'package:twonly/src/views/components/loader.dart';
|
||||||
|
|
||||||
class DiagnosticsView extends StatefulWidget {
|
class DiagnosticsView extends StatefulWidget {
|
||||||
const DiagnosticsView({super.key});
|
const DiagnosticsView({super.key});
|
||||||
|
|
@ -14,6 +15,19 @@ class DiagnosticsView extends StatefulWidget {
|
||||||
class _DiagnosticsViewState extends State<DiagnosticsView> {
|
class _DiagnosticsViewState extends State<DiagnosticsView> {
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
String? _debugLogText;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
initAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> initAsync() async {
|
||||||
|
_debugLogText = await readLast1000Lines();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _scrollToBottom() async {
|
Future<void> _scrollToBottom() async {
|
||||||
// Assuming the button is at the bottom of the scroll view
|
// Assuming the button is at the bottom of the scroll view
|
||||||
await _scrollController.animateTo(
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Diagnostics')),
|
appBar: AppBar(title: const Text('Diagnostics')),
|
||||||
body: FutureBuilder<String>(
|
body: (_debugLogText == null)
|
||||||
future: readLast1000Lines(),
|
? const Center(child: ThreeRotatingDots(size: 40))
|
||||||
builder: (context, snapshot) {
|
: Column(
|
||||||
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: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SingleChildScrollView(
|
child: LogViewerWidget(
|
||||||
controller: _scrollController,
|
logLines: _debugLogText!,
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Text(logText),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(8),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: _shareDebugLog,
|
||||||
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!'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Share debug log'),
|
child: const Text('Share debug log'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
|
|
@ -84,33 +108,269 @@ class _DiagnosticsViewState extends State<DiagnosticsView> {
|
||||||
child: const Text('Scroll to Bottom'),
|
child: const Text('Scroll to Bottom'),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () async {
|
onPressed: _deleteDebugLog,
|
||||||
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.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Text('Delete Log File'),
|
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