[mob] LockExist error fix & log view imporvement (#7141)

## Description

## Tests
This commit is contained in:
Neeraj
2025-09-11 09:29:35 +05:30
committed by GitHub
13 changed files with 591 additions and 421 deletions

View File

@@ -189,6 +189,15 @@ class SuperLogging {
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.onRecord.listen(onLogRecord);
if (_preferences.getBool("enable_db_logging") ?? kDebugMode) {
try {
await LogViewer.initialize(prefix: appConfig.prefix);
$.info("Log viewer initialized successfully");
} catch (e) {
$.warning("Failed to initialize log viewer: $e");
}
}
if (isFDroidClient) {
assert(
sentryIsEnabled == false,
@@ -219,19 +228,6 @@ class SuperLogging {
}),
);
// Initialize log viewer integration in debug mode
// Initialize log viewer in debug mode only
if (_preferences.getBool("enable_db_logging") ?? kDebugMode) {
try {
await LogViewer.initialize();
// Register LogViewer with SuperLogging to receive logs with process prefix
LogViewer.registerWithSuperLogging(SuperLogging.registerLogCallback);
$.info("Log viewer initialized successfully");
} catch (e) {
$.warning("Failed to initialize log viewer: $e");
}
}
if (appConfig.body == null) return;
if (enable && sentryIsEnabled) {
@@ -311,17 +307,6 @@ class SuperLogging {
printLog(str);
saveLogString(str, rec.error);
// Hook for external log viewer (if available)
// This allows the log_viewer package to capture logs without creating a dependency
if(_logViewerCallback != null) {
try {
if (_logViewerCallback != null) {
_logViewerCallback!(rec, config.prefix);
}
} catch (_) {
// Silently ignore any errors from the log viewer
}
}
}
static void saveLogString(String str, Object? error) {
@@ -339,15 +324,6 @@ class SuperLogging {
}
}
// Callback that can be set by external packages (like log_viewer)
static void Function(LogRecord, String)? _logViewerCallback;
/// Register a callback to receive log records
/// This is used by the log_viewer package to capture logs
static void registerLogCallback(void Function(LogRecord, String) callback) {
_logViewerCallback = callback;
}
static final Queue<String> fileQueueEntries = Queue();
static bool isFlushing = false;

View File

@@ -183,7 +183,7 @@ class UploadLocksDB {
return "No lock found for $id";
}
final row = rows.first;
final time = row[_uploadLocksTable.columnTime] as int;
final time = int.tryParse(row[_uploadLocksTable.columnTime].toString()) ?? 0 ;
final owner = row[_uploadLocksTable.columnOwner] as String;
final duration = DateTime.now().millisecondsSinceEpoch - time;
return "Lock for $id acquired by $owner since ${Duration(milliseconds: duration)}";

View File

@@ -2147,6 +2147,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
qr:
dependency: transitive
description:
name: qr
sha256: "5a1d2586170e172b8a8c8470bbbffd5eb0cd38a66c0d77155ea138d3af3a4445"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
qr_flutter:
dependency: "direct main"
description:
name: qr_flutter
sha256: "5095f0fc6e3f71d08adef8feccc8cea4f12eec18a2e31c2e8d82cb6019f4b097"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
quiver:
dependency: transitive
description:

View File

@@ -10,6 +10,7 @@ A Flutter package that provides an in-app log viewer with advanced filtering cap
- 📊 SQLite-based storage with automatic truncation
- 📤 Export filtered logs as text
- ⚡ Performance optimized with batch inserts and indexing
- 🏷️ Optional prefix support for multi-process logging
## Usage
@@ -21,9 +22,12 @@ import 'package:log_viewer/log_viewer.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize log viewer
// Initialize log viewer (basic)
await LogViewer.initialize();
// Or with a prefix for multi-process apps
await LogViewer.initialize(prefix: '[MAIN]');
runApp(MyApp());
}
```
@@ -38,9 +42,9 @@ LogViewer.openViewer(context);
LogViewer.getViewerPage()
```
### 3. The log viewer will automatically capture all logs
### 3. Automatic log capture
The package integrates with the Ente logging system to automatically capture and store logs.
The log viewer automatically registers with `Logger.root.onRecord` to capture all logs from the logging package. No additional setup is required.
## Filtering Options

View File

@@ -14,20 +14,13 @@ import 'package:logging/logging.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize the log viewer with custom configuration
await LogViewer.initialize(
maxEntries: 5000, // Optional: default is 10000
);
// Initialize the log viewer
await LogViewer.initialize();
// Set up logging
Logger.root.level = Level.ALL;
Logger.root.onRecord.listen((record) {
// Send logs to log viewer
LogViewer.addLog(record);
// Also print to console
print('${record.level.name}: ${record.time}: ${record.message}');
});
// Log viewer automatically captures all logs - no manual setup needed!
runApp(MyApp());
}
@@ -60,11 +53,7 @@ class _MyHomePageState extends State<MyHomePage> {
icon: Icon(Icons.bug_report),
onPressed: () {
// Navigate to log viewer
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
LogViewer.openViewer(context);
},
),
],
@@ -141,14 +130,10 @@ class SuperLogging {
WidgetsFlutterBinding.ensureInitialized();
// Initialize log viewer in debug mode only
// Initialize log viewer in debug mode with prefix
if (kDebugMode) {
try {
await LogViewer.initialize();
// Register LogViewer with SuperLogging to receive logs with process prefix
LogViewer.registerWithSuperLogging(SuperLogging.registerLogCallback);
await LogViewer.initialize(prefix: appConfig.prefix);
_logger.info("Log viewer initialized successfully");
} catch (e) {
_logger.warning("Failed to initialize log viewer: $e");
@@ -171,23 +156,7 @@ class SuperLogging {
print(str);
}
// Send to log viewer callback if registered
try {
if (_logViewerCallback != null) {
_logViewerCallback!(rec, config.prefix);
}
} catch (_) {
// Silently ignore any errors from the log viewer
}
}
// Callback that can be set by external packages (like log_viewer)
static void Function(LogRecord, String)? _logViewerCallback;
/// Register a callback to receive log records
/// This is used by the log_viewer package to capture logs
static void registerLogCallback(void Function(LogRecord, String) callback) {
_logViewerCallback = callback;
// Log viewer automatically captures all logs - no manual integration needed!
}
}
@@ -209,7 +178,7 @@ Future<void> main() async {
### Ente Photos Integration Example
In your Ente Photos app's main.dart, add the log viewer initialization in the `runWithLogs` function:
In your Ente Photos app's main.dart or SuperLogging class, add the log viewer initialization:
```dart
Future runWithLogs(Function() function, {String prefix = ""}) async {
@@ -224,19 +193,16 @@ Future runWithLogs(Function() function, {String prefix = ""}) async {
prefix: prefix,
),
);
// Initialize log viewer in debug mode only
if (kDebugMode) {
try {
await LogViewer.initialize();
// Register LogViewer with SuperLogging to receive logs with process prefix
LogViewer.registerWithSuperLogging(SuperLogging.registerLogCallback);
_logger.info("Log viewer initialized successfully");
} catch (e) {
_logger.warning("Failed to initialize log viewer: $e");
}
}
// In SuperLogging.main():
if (kDebugMode) {
try {
// Simply initialize with prefix - no callbacks needed!
await LogViewer.initialize(prefix: appConfig.prefix);
_logger.info("Log viewer initialized successfully");
} catch (e) {
_logger.warning("Failed to initialize log viewer: $e");
}
}
```
@@ -268,11 +234,7 @@ class SettingsPage extends StatelessWidget {
if (kDebugMode)
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
LogViewer.openViewer(context);
},
child: Container(
padding: const EdgeInsets.all(8),
@@ -307,17 +269,19 @@ Once integrated, users will have access to:
## How It Works
1. The `log_viewer` package listens to all logs via `Logger.root.onRecord`
2. Logs are stored in a local SQLite database (auto-truncated to 2000 entries)
1. The `log_viewer` package automatically registers with `Logger.root.onRecord` on initialization
2. Logs are stored in a local SQLite database (auto-truncated to 10000 entries by default)
3. The UI provides filtering and search capabilities
4. The integration with `super_logging` is automatic - no changes needed
4. When a prefix is provided, it's automatically prepended to all log messages
5. No manual callback registration or integration needed - just initialize and go!
## Troubleshooting
If logs aren't appearing:
1. Ensure `LogViewer.initialize()` is called after logging is set up
1. Ensure `LogViewer.initialize()` is called early in app initialization
2. Check that the app has write permissions for the database
3. Verify that `Logger.root.level` is set appropriately (not OFF)
4. If using a prefix, verify it's being passed correctly to `LogViewer.initialize(prefix: yourPrefix)`
## Performance Notes

View File

@@ -0,0 +1,40 @@
# Logger Filter Feature Usage
The log viewer now supports filtering logs by logger names directly through the search box, without any UI changes.
## Search Syntax
### Basic Logger Filtering
- `logger:AuthService` - Shows only logs from the AuthService logger
- `logger:UserService` - Shows only logs from the UserService logger
### Wildcard Support
- `logger:Auth*` - Shows logs from all loggers starting with "Auth" (e.g., AuthService, Authentication, AuthManager)
- `logger:*Service` - Not supported yet (only prefix wildcards are supported)
### Combined Search
- `logger:AuthService error` - Shows logs from AuthService that contain "error" in the message
- `login logger:UserService` - Shows logs from UserService that contain "login"
- `logger:Auth* failed` - Shows logs from loggers starting with "Auth" that contain "failed"
## Quick Access from Analytics
1. Navigate to Logger Analytics (via the menu in the log viewer)
2. Tap on any logger name card
3. The log viewer will automatically populate the search box with `logger:LoggerName` and filter the logs
## Implementation Details
- The search box hint text now shows "Search logs or use logger:name..."
- When logger: syntax is detected, it's parsed and converted to logger filters
- The remaining text (after removing logger: patterns) is used for message search
- Multiple logger patterns can be used: `logger:Auth* logger:User*`
- Clearing the search box removes all filters
## Benefits
1. **No UI Changes**: The existing search box is enhanced with new functionality
2. **Intuitive Syntax**: Similar to GitHub and Google search operators
3. **Quick Navigation**: Tap logger names in analytics to instantly filter
4. **Powerful Combinations**: Mix logger filters with text search
5. **Wildcard Support**: Filter multiple related loggers with prefix patterns

View File

@@ -16,12 +16,15 @@ export 'src/ui/log_viewer_page.dart';
/// Main entry point for the log viewer functionality
class LogViewer {
static bool _initialized = false;
static String _prefix = '';
/// Initialize the log viewer
/// This should be called once during app startup
static Future<void> initialize() async {
static Future<void> initialize({String prefix = ''}) async {
if (_initialized) return;
_prefix = prefix;
// Initialize the log store
await LogStore.instance.initialize();
@@ -38,7 +41,7 @@ class LogViewer {
// This will be called dynamically by the main app if SuperLogging is available
// For now, fallback to direct logger listening without prefix
log.Logger.root.onRecord.listen((record) {
LogStore.addLogRecord(record, '');
LogStore.addLogRecord(record, _prefix);
});
} catch (e) {
// SuperLogging not available, fallback to direct logger
@@ -48,24 +51,12 @@ class LogViewer {
}
}
/// Register with SuperLogging callback system (called by main app)
static void registerWithSuperLogging(
void Function(void Function(log.LogRecord, String)) registerCallback,) {
try {
registerCallback((record, processPrefix) {
LogStore.addLogRecord(record, processPrefix);
});
} catch (e) {
// Fallback if registration fails
_registerWithSuperLogging();
}
}
/// Get the log viewer page widget
static Widget getViewerPage() {
if (!_initialized) {
throw StateError(
'LogViewer not initialized. Call LogViewer.initialize() first.',);
'LogViewer not initialized. Call LogViewer.initialize() first.',
);
}
return const LogViewerPage();
}

View File

@@ -10,7 +10,7 @@ class LogDatabase {
static const String _databaseName = 'log_viewer.db';
static const String _tableName = 'logs';
static const int _databaseVersion = 1;
final int maxEntries;
Database? _database;
@@ -56,7 +56,6 @@ class LogDatabase {
);
}
/// Called when database is opened
Future<void> _onOpen(Database db) async {
// Enable write-ahead logging for better performance
@@ -275,14 +274,17 @@ class LogDatabase {
final toDelete = count - maxEntries;
// Delete oldest entries
await db.execute('''
await db.execute(
'''
DELETE FROM $_tableName
WHERE id IN (
SELECT id FROM $_tableName
ORDER BY timestamp ASC
LIMIT ?
)
''', [toDelete],);
''',
[toDelete],
);
}
}
@@ -358,11 +360,13 @@ class LogDatabase {
final results = await db.rawQuery(statsQuery, args);
return results
.map((row) => LoggerStatistic(
loggerName: row['logger_name'] as String,
logCount: row['count'] as int,
percentage: row['percentage'] as double,
),)
.map(
(row) => LoggerStatistic(
loggerName: row['logger_name'] as String,
logCount: row['count'] as int,
percentage: row['percentage'] as double,
),
)
.toList();
}
@@ -378,8 +382,12 @@ class LogDatabase {
if (result.isNotEmpty && result.first['min_timestamp'] != null) {
return TimeRange(
start: DateTime.fromMillisecondsSinceEpoch(result.first['min_timestamp'] as int),
end: DateTime.fromMillisecondsSinceEpoch(result.first['max_timestamp'] as int),
start: DateTime.fromMillisecondsSinceEpoch(
result.first['min_timestamp'] as int,
),
end: DateTime.fromMillisecondsSinceEpoch(
result.first['max_timestamp'] as int,
),
);
}
@@ -396,7 +404,11 @@ class LogDatabase {
''');
return result
.map((row) => DateTime.fromMillisecondsSinceEpoch(row['timestamp'] as int))
.map(
(row) => DateTime.fromMillisecondsSinceEpoch(
row['timestamp'] as int,
),
)
.toList();
}

View File

@@ -17,7 +17,7 @@ class LogStore {
// Buffer for batch inserts - optimized for small entries
final List<LogEntry> _buffer = [];
Timer? _flushTimer;
static const int _bufferSize = 10;
static const int _bufferSize = 10;
static const int _maxBufferSize = 200; // Safety limit
bool _initialized = false;
@@ -83,10 +83,12 @@ class LogStore {
_buffer.clear();
// Use non-blocking database insert for better write performance
unawaited(_database.insertLogs(toInsert).catchError((e) {
// ignore: avoid_print
print('Failed to insert logs to database: $e');
}),);
unawaited(
_database.insertLogs(toInsert).catchError((e) {
// ignore: avoid_print
print('Failed to insert logs to database: $e');
}),
);
}
/// Get logs with filtering
@@ -187,14 +189,16 @@ class LogStore {
final logs = await getLogs(filter: filter, limit: 10000);
final jsonLogs = logs
.map((log) => {
'timestamp': log.timestamp.toIso8601String(),
'level': log.level,
'logger': log.loggerName,
'message': log.message,
if (log.error != null) 'error': log.error,
if (log.stackTrace != null) 'stackTrace': log.stackTrace,
},)
.map(
(log) => {
'timestamp': log.timestamp.toIso8601String(),
'level': log.level,
'logger': log.loggerName,
'message': log.message,
if (log.error != null) 'error': log.error,
if (log.stackTrace != null) 'stackTrace': log.stackTrace,
},
)
.toList();
// Manual JSON formatting for readability

View File

@@ -55,7 +55,6 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
});
}
Widget _buildLevelChip(String level) {
final isSelected = _selectedLevels.contains(level);
final color = LogEntry(
@@ -69,13 +68,20 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
label: Text(
level,
style: TextStyle(
color: isSelected ? Colors.white : null,
fontSize: 12,
color: isSelected ? Colors.white : color,
fontSize: 13,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
),
),
selected: isSelected,
selectedColor: color,
backgroundColor: color.withValues(alpha: 0.15),
checkmarkColor: Colors.white,
side: BorderSide(
color: isSelected ? color : color.withValues(alpha: 0.3),
width: isSelected ? 2 : 1,
),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
onSelected: (selected) {
setState(() {
if (selected) {
@@ -93,18 +99,22 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
final theme = Theme.of(context);
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Container(
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.primaryColor.withValues(alpha: 0.1),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(4)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
decoration: const BoxDecoration(
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -112,12 +122,15 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
Text(
'Filter Logs',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
fontSize: 20,
),
),
IconButton(
icon: const Icon(Icons.close),
icon: const Icon(Icons.close, size: 22),
onPressed: () => Navigator.pop(context),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
@@ -126,7 +139,8 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
// Content
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -134,10 +148,11 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
Text(
'Log Levels',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
const SizedBox(height: 8),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
@@ -146,110 +161,103 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
.map(_buildLevelChip)
.toList(),
),
const SizedBox(height: 24),
const SizedBox(height: 20),
// Loggers
if (widget.availableLoggers.isNotEmpty) ...[
Text(
'Loggers',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
const SizedBox(height: 8),
const SizedBox(height: 12),
Container(
constraints: const BoxConstraints(maxHeight: 150),
constraints: const BoxConstraints(maxHeight: 180),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.dividerColor.withValues(alpha: 0.5),
width: 1,
),
borderRadius: BorderRadius.circular(8),
color: theme.cardColor,
),
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.availableLoggers.length,
itemBuilder: (context, index) {
final logger = widget.availableLoggers[index];
return CheckboxListTile(
title: Text(
logger,
style: const TextStyle(fontSize: 14),
),
value: _selectedLoggers.contains(logger),
dense: true,
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedLoggers.add(logger);
} else {
_selectedLoggers.remove(logger);
}
});
},
);
},
),
),
const SizedBox(height: 24),
],
// Processes
if (widget.availableProcesses.isNotEmpty) ...[
Text(
'Processes',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(4),
),
child: ListView.builder(
shrinkWrap: true,
itemCount: widget.availableProcesses.length,
itemBuilder: (context, index) {
final process = widget.availableProcesses[index];
final displayName = LogEntry(
message: '',
level: 'INFO',
timestamp: DateTime.now(),
loggerName: '',
processPrefix: process,
).processDisplayName;
return CheckboxListTile(
title: Text(
displayName,
style: const TextStyle(fontSize: 14),
),
subtitle: process.isNotEmpty
? Text(
'Prefix: $process',
style: TextStyle(
fontSize: 12,
color: theme.textTheme.bodySmall?.color,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: ListView.separated(
shrinkWrap: true,
itemCount: widget.availableLoggers.length,
separatorBuilder: (context, index) => Divider(
height: 1,
thickness: 0.5,
color: theme.dividerColor.withValues(alpha: 0.3),
),
itemBuilder: (context, index) {
final logger = widget.availableLoggers[index];
final isSelected =
_selectedLoggers.contains(logger);
return InkWell(
onTap: () {
setState(() {
if (isSelected) {
_selectedLoggers.remove(logger);
} else {
_selectedLoggers.add(logger);
}
});
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Row(
children: [
Expanded(
child: Text(
logger,
style: TextStyle(
fontSize: 14,
color: isSelected
? theme.primaryColor
: theme
.textTheme.bodyLarge?.color,
fontWeight: isSelected
? FontWeight.w500
: FontWeight.normal,
),
overflow: TextOverflow.ellipsis,
),
),
)
: null,
value: _selectedProcesses.contains(process),
dense: true,
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedProcesses.add(process);
} else {
_selectedProcesses.remove(process);
}
});
},
);
},
SizedBox(
width: 24,
height: 24,
child: Checkbox(
value: isSelected,
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedLoggers.add(logger);
} else {
_selectedLoggers.remove(logger);
}
});
},
materialTapTargetSize:
MaterialTapTargetSize.shrinkWrap,
),
),
],
),
),
);
},
),
),
),
const SizedBox(height: 24),
const SizedBox(height: 20),
],
],
),
),
@@ -259,27 +267,69 @@ class _LogFilterDialogState extends State<LogFilterDialog> {
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.cardColor,
border: Border(
top: BorderSide(
color: theme.dividerColor.withValues(alpha: 0.2),
width: 1,
),
),
borderRadius:
const BorderRadius.vertical(bottom: Radius.circular(4)),
const BorderRadius.vertical(bottom: Radius.circular(16)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _clearFilters,
child: const Text('Clear All'),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.error,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 10,
),
),
child: const Text(
'Clear All',
style: TextStyle(fontSize: 14),
),
),
Row(
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
),
child: Text(
'Cancel',
style: TextStyle(
fontSize: 14,
color: theme.textTheme.bodyLarge?.color,
),
),
),
const SizedBox(width: 8),
ElevatedButton(
const SizedBox(width: 12),
FilledButton(
onPressed: _applyFilters,
child: const Text('Apply'),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 10,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: const Text(
'Apply',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
],
),

View File

@@ -25,7 +25,9 @@ class _LogViewerPageState extends State<LogViewerPage> {
List<LogEntry> _logs = [];
List<String> _availableLoggers = [];
List<String> _availableProcesses = [];
LogFilter _filter = const LogFilter();
LogFilter _filter = const LogFilter(
selectedLevels: {'WARNING', 'SEVERE', 'SHOUT'},
);
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMoreLogs = true;
@@ -35,7 +37,7 @@ class _LogViewerPageState extends State<LogViewerPage> {
// Time filtering state
bool _timeFilterEnabled = false;
// Timeline state
DateTime? _overallStartTime;
DateTime? _overallEndTime;
@@ -178,10 +180,55 @@ class _LogViewerPageState extends State<LogViewerPage> {
}
void _onSearchChanged(String query) {
// Parse query for special syntax like "logger:SomeName"
String? searchText = query;
Set<String>? loggerFilters;
if (query.isNotEmpty) {
// Regular expression to match logger:name patterns
final loggerPattern = RegExp(r'logger:(\S+)');
final matches = loggerPattern.allMatches(query);
if (matches.isNotEmpty) {
loggerFilters = {};
for (final match in matches) {
final loggerName = match.group(1);
if (loggerName != null) {
// Support wildcards (e.g., Auth* matches AuthService, Authentication, etc.)
if (loggerName.endsWith('*')) {
final prefix = loggerName.substring(0, loggerName.length - 1);
// Find all loggers that start with this prefix
for (final logger in _availableLoggers) {
if (logger.startsWith(prefix)) {
loggerFilters.add(logger);
}
}
} else {
loggerFilters.add(loggerName);
}
}
}
// Remove logger:name patterns from search text
searchText = query.replaceAll(loggerPattern, '').trim();
if (searchText.isEmpty) {
searchText = null;
}
}
} else {
// Clear logger filters when search is empty
loggerFilters = {};
}
setState(() {
// Only update logger filters if logger: syntax was found or query is empty
final newLoggerFilters = loggerFilters ??
(query.isEmpty ? <String>{} : _filter.selectedLoggers);
_filter = _filter.copyWith(
searchQuery: query.isEmpty ? null : query,
searchQuery: searchText,
clearSearchQuery: query.isEmpty,
selectedLoggers: newLoggerFilters,
);
});
_loadLogs();
@@ -189,7 +236,9 @@ class _LogViewerPageState extends State<LogViewerPage> {
void _updateTimeFilter() {
setState(() {
if (_timeFilterEnabled && _timelineStartTime != null && _timelineEndTime != null) {
if (_timeFilterEnabled &&
_timelineStartTime != null &&
_timelineEndTime != null) {
_filter = _filter.copyWith(
startTime: _timelineStartTime,
endTime: _timelineEndTime,
@@ -264,7 +313,7 @@ class _LogViewerPageState extends State<LogViewerPage> {
Future<void> _exportLogs() async {
try {
final logText = await _logStore.exportLogs(filter: _filter);
await Share.share(logText, subject: 'App Logs');
} catch (e) {
if (mounted) {
@@ -284,13 +333,19 @@ class _LogViewerPageState extends State<LogViewerPage> {
_loadLogs();
}
void _showAnalytics() {
Navigator.push(
void _showAnalytics() async {
final result = await Navigator.push<String>(
context,
MaterialPageRoute(
builder: (context) => LoggerStatisticsPage(filter: _filter),
),
);
// If a logger filter was returned, apply it to the search box
if (result != null && mounted) {
_searchController.text = result;
_onSearchChanged(result);
}
}
void _showLogDetail(LogEntry log) {
@@ -302,7 +357,6 @@ class _LogViewerPageState extends State<LogViewerPage> {
);
}
@override
void dispose() {
_searchController.dispose();
@@ -349,9 +403,11 @@ class _LogViewerPageState extends State<LogViewerPage> {
tooltip: 'Filters',
),
IconButton(
icon: Icon(_filter.sortNewestFirst
? Icons.arrow_downward
: Icons.arrow_upward,),
icon: Icon(
_filter.sortNewestFirst
? Icons.arrow_downward
: Icons.arrow_upward,
),
onPressed: _toggleSort,
tooltip: _filter.sortNewestFirst
? 'Sort oldest first'
@@ -406,15 +462,17 @@ class _LogViewerPageState extends State<LogViewerPage> {
// Search bar
Container(
color: theme.appBarTheme.backgroundColor,
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 6.0),
child: TextField(
controller: _searchController,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: 'Search logs...',
prefixIcon: const Icon(Icons.search),
hintStyle: const TextStyle(fontSize: 14),
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
icon: const Icon(Icons.clear, size: 20),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
@@ -424,7 +482,9 @@ class _LogViewerPageState extends State<LogViewerPage> {
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
contentPadding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
),
onChanged: _onSearchChanged,
),
@@ -470,7 +530,9 @@ class _LogViewerPageState extends State<LogViewerPage> {
});
_updateTimeFilter();
},
tooltip: _timeFilterEnabled ? 'Disable Timeline Filter' : 'Enable Timeline Filter',
tooltip: _timeFilterEnabled
? 'Disable Timeline Filter'
: 'Enable Timeline Filter',
),
],
),
@@ -496,76 +558,89 @@ class _LogViewerPageState extends State<LogViewerPage> {
scrollDirection: Axis.horizontal,
children: [
if (_filter.selectedLoggers.isNotEmpty)
..._filter.selectedLoggers.map((logger) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(logger,
style: const TextStyle(fontSize: 12),),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLoggers =
Set<String>.from(_filter.selectedLoggers);
newLoggers.remove(logger);
_filter = _filter.copyWith(
selectedLoggers: newLoggers,);
});
_loadLogs();
},
..._filter.selectedLoggers.map(
(logger) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
logger,
style: const TextStyle(fontSize: 12),
),
),),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLoggers =
Set<String>.from(_filter.selectedLoggers);
newLoggers.remove(logger);
_filter = _filter.copyWith(
selectedLoggers: newLoggers,
);
});
_loadLogs();
},
),
),
),
if (_filter.selectedLevels.isNotEmpty)
..._filter.selectedLevels.map((level) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(level,
style: const TextStyle(fontSize: 12),),
backgroundColor: LogEntry(
..._filter.selectedLevels.map(
(level) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
level,
style: const TextStyle(fontSize: 12),
),
backgroundColor: LogEntry(
message: '',
level: level,
timestamp: DateTime.now(),
loggerName: '',
).levelColor.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLevels =
Set<String>.from(_filter.selectedLevels);
newLevels.remove(level);
_filter =
_filter.copyWith(selectedLevels: newLevels);
});
_loadLogs();
},
),
),
),
if (_filter.selectedProcesses.isNotEmpty)
..._filter.selectedProcesses.map(
(process) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
LogEntry(
message: '',
level: level,
level: 'INFO',
timestamp: DateTime.now(),
loggerName: '',
).levelColor.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newLevels =
Set<String>.from(_filter.selectedLevels);
newLevels.remove(level);
_filter =
_filter.copyWith(selectedLevels: newLevels);
});
_loadLogs();
},
processPrefix: process,
).processDisplayName,
style: const TextStyle(fontSize: 12),
),
),),
if (_filter.selectedProcesses.isNotEmpty)
..._filter.selectedProcesses.map((process) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Chip(
label: Text(
LogEntry(
message: '',
level: 'INFO',
timestamp: DateTime.now(),
loggerName: '',
processPrefix: process,
).processDisplayName,
style: const TextStyle(fontSize: 12),),
backgroundColor: Colors.purple.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newProcesses =
Set<String>.from(_filter.selectedProcesses);
newProcesses.remove(process);
_filter =
_filter.copyWith(selectedProcesses: newProcesses);
});
_loadLogs();
},
),
),),
backgroundColor: Colors.purple.withValues(alpha: 0.2),
deleteIcon: const Icon(Icons.close, size: 16),
onDeleted: () {
setState(() {
final newProcesses =
Set<String>.from(_filter.selectedProcesses);
newProcesses.remove(process);
_filter = _filter.copyWith(
selectedProcesses: newProcesses,
);
});
_loadLogs();
},
),
),
),
],
),
),
@@ -611,7 +686,8 @@ class _LogViewerPageState extends State<LogViewerPage> {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(),),
child: CircularProgressIndicator(),
),
);
} else {
// Trigger loading more when reaching the end

View File

@@ -167,64 +167,78 @@ class _LoggerStatisticsPageState extends State<LoggerStatisticsPage> {
return Card(
margin: const EdgeInsets.only(bottom: 8),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
stat.loggerName,
child: InkWell(
onTap: () {
// Navigate back to log viewer with logger filter in search
Navigator.pop(
context,
'logger:${stat.loggerName}',
);
},
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
stat.loggerName,
style: theme
.textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w500,
),
overflow:
TextOverflow.ellipsis,
),
),
Text(
stat.formattedPercentage,
style: theme
.textTheme.titleMedium
?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: stat.percentage / 100,
backgroundColor: color
.withValues(alpha: 0.2),
valueColor:
AlwaysStoppedAnimation(
color,
),
minHeight: 6,
),
),
const SizedBox(width: 12),
Text(
'${stat.count} logs',
style: theme
.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme
.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
Text(
stat.formattedPercentage,
style: theme.textTheme.titleMedium
?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: LinearProgressIndicator(
value: stat.percentage / 100,
backgroundColor:
color.withValues(alpha: 0.2),
valueColor:
AlwaysStoppedAnimation(
color,),
minHeight: 6,
),
),
const SizedBox(width: 12),
Text(
'${stat.count} logs',
style: theme.textTheme.bodyMedium
?.copyWith(
color: theme.colorScheme
.onSurfaceVariant,
fontWeight: FontWeight.w500,
),
),
],
),
],
],
),
],
),
),
),
);

View File

@@ -27,7 +27,7 @@ class _TimelineWidgetState extends State<TimelineWidget> {
late double _rightPosition;
bool _isDraggingLeft = false;
bool _isDraggingRight = false;
@override
void initState() {
super.initState();
@@ -46,10 +46,13 @@ class _TimelineWidgetState extends State<TimelineWidget> {
}
void _updatePositions() {
final totalDuration = widget.endTime.difference(widget.startTime).inMilliseconds;
final startOffset = widget.currentStart.difference(widget.startTime).inMilliseconds;
final endOffset = widget.currentEnd.difference(widget.startTime).inMilliseconds;
final totalDuration =
widget.endTime.difference(widget.startTime).inMilliseconds;
final startOffset =
widget.currentStart.difference(widget.startTime).inMilliseconds;
final endOffset =
widget.currentEnd.difference(widget.startTime).inMilliseconds;
_leftPosition = startOffset / totalDuration;
_rightPosition = endOffset / totalDuration;
}
@@ -57,11 +60,12 @@ class _TimelineWidgetState extends State<TimelineWidget> {
void _onPanUpdate(DragUpdateDetails details, bool isLeft) {
final RenderBox renderBox = context.findRenderObject() as RenderBox;
final double width = renderBox.size.width - 40; // Account for handle width
// Convert global position to local position within the timeline track
final Offset globalPosition = details.globalPosition;
final Offset localPosition = renderBox.globalToLocal(globalPosition);
final double localX = localPosition.dx - 20; // Account for left handle width
final double localX =
localPosition.dx - 20; // Account for left handle width
final double position = (localX / width).clamp(0.0, 1.0);
setState(() {
@@ -72,17 +76,20 @@ class _TimelineWidgetState extends State<TimelineWidget> {
}
});
final totalDuration = widget.endTime.difference(widget.startTime).inMilliseconds;
final newStart = widget.startTime.add(Duration(milliseconds: (_leftPosition * totalDuration).round()));
final newEnd = widget.startTime.add(Duration(milliseconds: (_rightPosition * totalDuration).round()));
final totalDuration =
widget.endTime.difference(widget.startTime).inMilliseconds;
final newStart = widget.startTime
.add(Duration(milliseconds: (_leftPosition * totalDuration).round()));
final newEnd = widget.startTime
.add(Duration(milliseconds: (_rightPosition * totalDuration).round()));
widget.onTimeRangeChanged(newStart, newEnd);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
height: 120,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -112,47 +119,57 @@ class _TimelineWidgetState extends State<TimelineWidget> {
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
color: theme.colorScheme.outline
.withValues(alpha: 0.3),
width: 1,
),
),
child: _buildLogDensityIndicator(constraints.maxWidth - 40),
child: _buildLogDensityIndicator(
constraints.maxWidth - 40,
),
),
),
// Selected range
Positioned(
left: 20 + (_leftPosition * (constraints.maxWidth - 40)),
right: constraints.maxWidth - 20 - (_rightPosition * (constraints.maxWidth - 40)),
right: constraints.maxWidth -
20 -
(_rightPosition * (constraints.maxWidth - 40)),
top: 20,
bottom: 20,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.4),
color:
theme.colorScheme.primary.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.7),
color: theme.colorScheme.primary
.withValues(alpha: 0.7),
width: 1,
),
),
),
),
// Left handle
Positioned(
left: (_leftPosition * (constraints.maxWidth - 40)),
top: 12,
child: GestureDetector(
onPanUpdate: (details) => _onPanUpdate(details, true),
onPanStart: (_) => setState(() => _isDraggingLeft = true),
onPanEnd: (_) => setState(() => _isDraggingLeft = false),
onPanStart: (_) =>
setState(() => _isDraggingLeft = true),
onPanEnd: (_) =>
setState(() => _isDraggingLeft = false),
child: Container(
width: 20,
height: 32,
decoration: BoxDecoration(
color: _isDraggingLeft
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.8),
color: _isDraggingLeft
? theme.colorScheme.primary
: theme.colorScheme.primary
.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
@@ -170,22 +187,25 @@ class _TimelineWidgetState extends State<TimelineWidget> {
),
),
),
// Right handle
Positioned(
left: (_rightPosition * (constraints.maxWidth - 40)),
top: 12,
child: GestureDetector(
onPanUpdate: (details) => _onPanUpdate(details, false),
onPanStart: (_) => setState(() => _isDraggingRight = true),
onPanEnd: (_) => setState(() => _isDraggingRight = false),
onPanStart: (_) =>
setState(() => _isDraggingRight = true),
onPanEnd: (_) =>
setState(() => _isDraggingRight = false),
child: Container(
width: 20,
height: 32,
decoration: BoxDecoration(
color: _isDraggingRight
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.8),
color: _isDraggingRight
? theme.colorScheme.primary
: theme.colorScheme.primary
.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
@@ -208,7 +228,7 @@ class _TimelineWidgetState extends State<TimelineWidget> {
},
),
),
// Time labels
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
@@ -230,25 +250,27 @@ class _TimelineWidgetState extends State<TimelineWidget> {
Widget _buildLogDensityIndicator(double width) {
if (widget.logTimestamps.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
final totalDuration = widget.endTime.difference(widget.startTime).inMilliseconds;
final totalDuration =
widget.endTime.difference(widget.startTime).inMilliseconds;
const bucketCount = 50;
final bucketDuration = totalDuration / bucketCount;
final buckets = List<int>.filled(bucketCount, 0);
// Count logs in each bucket
for (final timestamp in widget.logTimestamps) {
final offset = timestamp.difference(widget.startTime).inMilliseconds;
if (offset >= 0 && offset <= totalDuration) {
final bucketIndex = (offset / bucketDuration).floor().clamp(0, bucketCount - 1);
final bucketIndex =
(offset / bucketDuration).floor().clamp(0, bucketCount - 1);
buckets[bucketIndex]++;
}
}
final maxCount = buckets.reduce((a, b) => a > b ? a : b);
if (maxCount == 0) return const SizedBox.shrink();
return Row(
children: buckets.map((count) {
final intensity = count / maxCount;
@@ -257,7 +279,8 @@ class _TimelineWidgetState extends State<TimelineWidget> {
height: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 0.5),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: intensity * 0.6),
color:
theme.colorScheme.primary.withValues(alpha: intensity * 0.6),
borderRadius: BorderRadius.circular(1),
),
),
@@ -269,4 +292,4 @@ class _TimelineWidgetState extends State<TimelineWidget> {
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
}
}
}