From ad0ef841ccc1779e386765896018dc6a609a023e Mon Sep 17 00:00:00 2001 From: otsmr Date: Tue, 30 Dec 2025 11:59:03 +0100 Subject: [PATCH] improved log view --- CHANGELOG.md | 5 + lib/src/utils/log.dart | 1 + lib/src/views/components/loader.dart | 7 +- .../views/settings/help/diagnostics.view.dart | 384 +++++++++++++++--- 4 files changed, 332 insertions(+), 65 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7810d0..811b901 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/lib/src/utils/log.dart b/lib/src/utils/log.dart index 05fe62f..a46a3bf 100644 --- a/lib/src/utils/log.dart +++ b/lib/src/utils/log.dart @@ -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'; } diff --git a/lib/src/views/components/loader.dart b/lib/src/views/components/loader.dart index 9fe6de9..6d82ca8 100644 --- a/lib/src/views/components/loader.dart +++ b/lib/src/views/components/loader.dart @@ -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(Tween 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 createState() => _ThreeRotatingDotsState(); @@ -52,7 +53,7 @@ class _ThreeRotatingDotsState extends State @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; diff --git a/lib/src/views/settings/help/diagnostics.view.dart b/lib/src/views/settings/help/diagnostics.view.dart index a25685a..8b05bcf 100644 --- a/lib/src/views/settings/help/diagnostics.view.dart +++ b/lib/src/views/settings/help/diagnostics.view.dart @@ -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 { final ScrollController _scrollController = ScrollController(); + String? _debugLogText; + + @override + void initState() { + super.initState(); + initAsync(); + } + + Future initAsync() async { + _debugLogText = await readLast1000Lines(); + setState(() {}); + } + Future _scrollToBottom() async { // Assuming the button is at the bottom of the scroll view await _scrollController.animateTo( @@ -23,60 +37,70 @@ class _DiagnosticsViewState extends State { ); } + Future _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 _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( - 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 { 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 createState() => _LogViewerWidgetState(); +} + +class _LogViewerWidgetState extends State { + 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; +}