[mob] LockExist error fix & log view imporvement (#7141)
## Description ## Tests
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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)}";
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
40
mobile/packages/log_viewer/example_logger_filter.md
Normal file
40
mobile/packages/log_viewer/example_logger_filter.md
Normal 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
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user