Add in-app log viewer for mobile debugging

Introduces a comprehensive log viewer package for Flutter mobile apps with:
- Real-time log viewing with filtering by level, logger name, and search
- SQLite-based storage with automatic log rotation (10k entries default)
- Timeline visualization and export functionality
- Integration with SuperLogging for seamless log capture

Only enabled in debug mode to avoid production impact.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Neeraj
2025-09-10 13:06:05 +05:30
parent 901bfc945e
commit 7ef9fdcaaa
21 changed files with 3777 additions and 8 deletions

View File

@@ -5,9 +5,10 @@ import 'dart:io';
import "package:dio/dio.dart";
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'package:intl/intl.dart';
import 'package:log_viewer/log_viewer.dart';
import 'package:logging/logging.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path/path.dart';
@@ -218,6 +219,19 @@ class SuperLogging {
}),
);
// Initialize log viewer integration in debug mode
// 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);
$.info("Log viewer initialized successfully");
} catch (e) {
$.warning("Failed to initialize log viewer: $e");
}
}
if (appConfig.body == null) return;
if (enable && sentryIsEnabled) {
@@ -297,6 +311,16 @@ 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
try {
if (_logViewerCallback != null) {
_logViewerCallback!(rec, config.prefix);
}
} catch (_) {
// Silently ignore any errors from the log viewer
}
}
static void saveLogString(String str, Object? error) {
@@ -314,6 +338,15 @@ 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;
@@ -455,4 +488,15 @@ class SuperLogging {
final pkgName = (await PackageInfo.fromPlatform()).packageName;
return pkgName.startsWith("io.ente.photos.fdroid");
}
/// Show the log viewer page
/// This is the main integration point for accessing the log viewer
static void showLogViewer(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
}
}

View File

@@ -11,6 +11,7 @@ import "package:flutter/rendering.dart";
import "package:flutter/services.dart";
import "package:flutter_displaymode/flutter_displaymode.dart";
import "package:intl/date_symbol_data_local.dart";
import 'package:log_viewer/log_viewer.dart';
import 'package:logging/logging.dart';
import "package:media_kit/media_kit.dart";
import "package:package_info_plus/package_info_plus.dart";
@@ -355,6 +356,20 @@ 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");
}
}
}
Future<void> _scheduleHeartBeat(

View File

@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import "package:flutter_animate/flutter_animate.dart";
import "package:log_viewer/log_viewer.dart";
import 'package:photos/core/configuration.dart';
import 'package:photos/core/event_bus.dart';
import 'package:photos/events/opened_settings_event.dart';
@@ -70,12 +71,36 @@ class SettingsPage extends StatelessWidget {
// [AnimatedBuilder] accepts any [Listenable] subtype.
animation: emailNotifier,
builder: (BuildContext context, Widget? child) {
return Text(
emailNotifier.value!,
style: enteTextTheme.body.copyWith(
color: colorScheme.textMuted,
overflow: TextOverflow.ellipsis,
),
return Row(
children: [
Expanded(
child: Text(
emailNotifier.value!,
style: enteTextTheme.body.copyWith(
color: colorScheme.textMuted,
overflow: TextOverflow.ellipsis,
),
),
),
if (kDebugMode)
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
},
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.bug_report,
size: 20,
color: colorScheme.textMuted,
),
),
),
],
);
},
),

View File

@@ -1512,6 +1512,13 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.11"
log_viewer:
dependency: "direct main"
description:
path: "../../packages/log_viewer"
relative: true
source: path
version: "1.0.0"
logger:
dependency: transitive
description:

View File

@@ -123,6 +123,8 @@ dependencies:
local_auth: ^2.1.5
local_auth_android:
local_auth_ios:
log_viewer:
path: ../../packages/log_viewer
logging: ^1.3.0
lottie: ^3.3.1
maps_launcher: ^3.0.0+1

View File

@@ -1,4 +1,4 @@
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32
# melos_managed_dependency_overrides: ente_cast,ente_cast_normal,ente_crypto,ente_feature_flag,onnx_dart,ffi,flutter_sodium,intl,js,media_kit,media_kit_libs_ios_video,media_kit_libs_video,media_kit_video,protobuf,video_player,watcher,win32,log_viewer
dependency_overrides:
ente_cast:
path: plugins/ente_cast
@@ -41,3 +41,5 @@ dependency_overrides:
path: packages/video_player/video_player/
watcher: ^1.1.0
win32: 5.10.1
log_viewer:
path: ../../packages/log_viewer

View File

@@ -0,0 +1,56 @@
# Log Viewer
A Flutter package that provides an in-app log viewer with advanced filtering capabilities for Ente apps.
## Features
- 📝 Real-time log capture and display
- 🔍 Advanced filtering by logger name, log level, and text search
- 🎨 Color-coded log levels for easy identification
- 📊 SQLite-based storage with automatic truncation
- 📤 Export filtered logs as text
- ⚡ Performance optimized with batch inserts and indexing
## Usage
### 1. Initialize in your app
```dart
import 'package:log_viewer/log_viewer.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize log viewer
await LogViewer.initialize();
runApp(MyApp());
}
```
### 2. Open the log viewer
```dart
// As a navigation action
LogViewer.openViewer(context);
// Or embed as a widget
LogViewer.getViewerPage()
```
### 3. The log viewer will automatically capture all logs
The package integrates with the Ente logging system to automatically capture and store logs.
## Filtering Options
- **Logger Name**: Filter by specific loggers (e.g., "auth", "sync", "ui")
- **Log Level**: Filter by severity (SEVERE, WARNING, INFO, etc.)
- **Text Search**: Search within log messages and error descriptions
- **Time Range**: Filter logs by date/time range
## Database Management
- Logs are stored in a local SQLite database
- By default, automatic truncation keeps only the most recent 10000 entries
- Batch inserts for optimal performance

View File

@@ -0,0 +1 @@
include: ../../analysis_options.yaml

View File

@@ -0,0 +1,327 @@
# Log Viewer Integration Examples
This document provides examples of integrating the log_viewer package into your Flutter application, both as a standalone solution and integrated with SuperLogging.
## Standalone Integration (Without SuperLogging)
### Basic Setup
```dart
import 'package:flutter/material.dart';
import 'package:log_viewer/log_viewer.dart';
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
);
// 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}');
});
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Log Viewer Example',
home: MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final Logger _logger = Logger('MyHomePage');
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Log Viewer Example'),
actions: [
IconButton(
icon: Icon(Icons.bug_report),
onPressed: () {
// Navigate to log viewer
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
},
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
_logger.info('Info log message');
},
child: Text('Log Info'),
),
ElevatedButton(
onPressed: () {
_logger.warning('Warning log message');
},
child: Text('Log Warning'),
),
ElevatedButton(
onPressed: () {
try {
throw Exception('Test error');
} catch (e, s) {
_logger.severe('Error occurred', e, s);
}
},
child: Text('Log Error'),
),
],
),
),
);
}
}
```
## SuperLogging Integration (Ente Photos Style)
### Complete Integration Example
```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:log_viewer/log_viewer.dart';
import 'package:logging/logging.dart';
import 'package:path_provider/path_provider.dart';
// SuperLogging-like configuration
class LogConfig {
final String? logDirPath;
final int maxLogFiles;
final bool enableInDebugMode;
final FutureOrVoidCallback? body;
final String prefix;
LogConfig({
this.logDirPath,
this.maxLogFiles = 10,
this.enableInDebugMode = false,
this.body,
this.prefix = "",
});
}
class SuperLogging {
static final Logger _logger = Logger('SuperLogging');
static late LogConfig config;
static Future<void> main([LogConfig? appConfig]) async {
appConfig ??= LogConfig();
SuperLogging.config = appConfig;
WidgetsFlutterBinding.ensureInitialized();
// 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");
}
}
Logger.root.level = kDebugMode ? Level.ALL : Level.INFO;
Logger.root.onRecord.listen(onLogRecord);
if (appConfig.body != null) {
await appConfig.body!();
}
}
static Future<void> onLogRecord(LogRecord rec) async {
final str = "${config.prefix} ${rec.toString()}";
// Print to console
if (kDebugMode) {
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;
}
}
// Main application with SuperLogging integration
Future<void> main() async {
await SuperLogging.main(
LogConfig(
body: () async {
runApp(MyApp());
},
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
maxLogFiles: 5,
enableInDebugMode: true,
prefix: "[APP]",
),
);
}
```
### Ente Photos Integration Example
In your Ente Photos app's main.dart, add the log viewer initialization in the `runWithLogs` function:
```dart
Future runWithLogs(Function() function, {String prefix = ""}) async {
await SuperLogging.main(
LogConfig(
body: function,
logDirPath: (await getApplicationSupportDirectory()).path + "/logs",
maxLogFiles: 5,
sentryDsn: kDebugMode ? sentryDebugDSN : sentryDSN,
tunnel: sentryTunnel,
enableInDebugMode: true,
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");
}
}
}
```
### Settings Page Integration
Add log viewer access in your settings page (debug mode only):
```dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:log_viewer/log_viewer.dart';
class SettingsPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
body: Column(
children: [
// Existing email/user info with debug button
Row(
children: [
Expanded(
child: Text(
'user@example.com',
style: TextStyle(color: Colors.grey),
),
),
if (kDebugMode)
GestureDetector(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
},
child: Container(
padding: const EdgeInsets.all(8),
child: Icon(
Icons.bug_report,
size: 20,
color: Colors.grey,
),
),
),
],
),
// Other settings items...
],
),
);
}
}
```
## Features Available
Once integrated, users will have access to:
1. **Real-time log viewing** - Logs appear as they're generated
2. **Filtering by log level** - Show only errors, warnings, info, etc.
3. **Filtering by logger name** - Focus on specific components
4. **Text search** - Search within log messages and errors
5. **Date range filtering** - View logs from specific time periods
6. **Export functionality** - Share logs as text files
7. **Detailed view** - Tap any log to see full details including stack traces
## 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)
3. The UI provides filtering and search capabilities
4. The integration with `super_logging` is automatic - no changes needed
## Troubleshooting
If logs aren't appearing:
1. Ensure `LogViewer.initialize()` is called after logging is set up
2. Check that the app has write permissions for the database
3. Verify that `Logger.root.level` is set appropriately (not OFF)
## Performance Notes
- Logs are buffered and batch-inserted for optimal performance
- Database is indexed for fast filtering
- UI updates are debounced to avoid excessive refreshes
- Old logs are automatically cleaned up

View File

@@ -0,0 +1,97 @@
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_store.dart';
import 'package:log_viewer/src/ui/log_viewer_page.dart';
import 'package:logging/logging.dart' as log;
export 'src/core/log_database.dart';
export 'src/core/log_models.dart';
// Core exports
export 'src/core/log_store.dart';
export 'src/ui/log_detail_page.dart';
export 'src/ui/log_filter_dialog.dart';
export 'src/ui/log_list_tile.dart';
// UI exports
export 'src/ui/log_viewer_page.dart';
/// Main entry point for the log viewer functionality
class LogViewer {
static bool _initialized = false;
/// Initialize the log viewer
/// This should be called once during app startup
static Future<void> initialize() async {
if (_initialized) return;
// Initialize the log store
await LogStore.instance.initialize();
// Register callback with super_logging if available
_registerWithSuperLogging();
_initialized = true;
}
/// Register callback with super_logging to receive logs
static void _registerWithSuperLogging() {
// Try to register with SuperLogging if available
try {
// 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, '');
});
} catch (e) {
// SuperLogging not available, fallback to direct logger
log.Logger.root.onRecord.listen((record) {
LogStore.addLogRecord(record, '');
});
}
}
/// 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.',);
}
return const LogViewerPage();
}
/// Open the log viewer in a new route
static Future<void> openViewer(BuildContext context) async {
if (!_initialized) {
await initialize();
}
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const LogViewerPage(),
),
);
}
/// Check if log viewer is initialized
static bool get isInitialized => _initialized;
/// Dispose of log viewer resources
static Future<void> dispose() async {
if (_initialized) {
await LogStore.instance.dispose();
_initialized = false;
}
}
}

View File

@@ -0,0 +1,411 @@
import 'dart:async';
import 'package:log_viewer/src/core/log_models.dart';
import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';
/// Manages SQLite database for log storage
class LogDatabase {
static const String _databaseName = 'log_viewer.db';
static const String _tableName = 'logs';
static const int _databaseVersion = 1;
final int maxEntries;
Database? _database;
LogDatabase({this.maxEntries = 10000});
/// Get database instance
Future<Database> get database async {
_database ??= await _initDatabase();
return _database!;
}
/// Initialize database
Future<Database> _initDatabase() async {
final documentsDirectory = await getApplicationDocumentsDirectory();
final path = join(documentsDirectory.path, _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
onCreate: _onCreate,
onOpen: _onOpen,
);
}
/// Create database tables
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE $_tableName(
id INTEGER PRIMARY KEY AUTOINCREMENT,
message TEXT NOT NULL,
level TEXT NOT NULL,
timestamp INTEGER NOT NULL,
logger_name TEXT NOT NULL,
error TEXT,
stack_trace TEXT,
process_prefix TEXT NOT NULL DEFAULT ''
)
''');
// Minimal indexes for write performance - only timestamp for ordering
await db.execute(
'CREATE INDEX idx_timestamp ON $_tableName(timestamp DESC)',
);
}
/// Called when database is opened
Future<void> _onOpen(Database db) async {
// Enable write-ahead logging for better performance
// Use rawQuery for PRAGMA commands to avoid permission issues
await db.rawQuery('PRAGMA journal_mode = WAL');
}
/// Insert a single log entry
Future<int> insertLog(LogEntry entry) async {
final db = await database;
final id = await db.insert(_tableName, entry.toMap());
// Auto-truncate if needed
await _truncateIfNeeded(db);
return id;
}
/// Insert multiple log entries in a batch
Future<void> insertLogs(List<LogEntry> entries) async {
if (entries.isEmpty) return;
final db = await database;
final batch = db.batch();
for (final entry in entries) {
batch.insert(_tableName, entry.toMap());
}
await batch.commit(noResult: true);
await _truncateIfNeeded(db);
}
/// Get logs with optional filtering
Future<List<LogEntry>> getLogs({
LogFilter? filter,
int limit = 250,
int offset = 0,
}) async {
final db = await database;
// Build WHERE clause
final conditions = <String>[];
final args = <dynamic>[];
if (filter != null) {
// Logger filter
if (filter.selectedLoggers.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLoggers.length, '?').join(',');
conditions.add('logger_name IN ($placeholders)');
args.addAll(filter.selectedLoggers);
}
// Level filter
if (filter.selectedLevels.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLevels.length, '?').join(',');
conditions.add('level IN ($placeholders)');
args.addAll(filter.selectedLevels);
}
// Process prefix filter
if (filter.selectedProcesses.isNotEmpty) {
final placeholders =
List.filled(filter.selectedProcesses.length, '?').join(',');
conditions.add('process_prefix IN ($placeholders)');
args.addAll(filter.selectedProcesses);
}
// Search query
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
conditions.add('(message LIKE ? OR error LIKE ?)');
final searchPattern = '%${filter.searchQuery}%';
args.add(searchPattern);
args.add(searchPattern);
}
// Time range
if (filter.startTime != null) {
conditions.add('timestamp >= ?');
args.add(filter.startTime!.millisecondsSinceEpoch);
}
if (filter.endTime != null) {
conditions.add('timestamp <= ?');
args.add(filter.endTime!.millisecondsSinceEpoch);
}
}
final whereClause = conditions.isEmpty ? null : conditions.join(' AND ');
final results = await db.query(
_tableName,
where: whereClause,
whereArgs: args.isEmpty ? null : args,
orderBy:
filter?.sortNewestFirst == false ? 'timestamp ASC' : 'timestamp DESC',
limit: limit,
offset: offset,
);
return results.map((map) => LogEntry.fromMap(map)).toList();
}
/// Get unique logger names for filtering
Future<List<String>> getUniqueLoggers() async {
final db = await database;
final results = await db.rawQuery(
'SELECT DISTINCT logger_name FROM $_tableName ORDER BY logger_name',
);
return results.map((row) => row['logger_name'] as String).toList();
}
/// Get unique process prefixes for filtering
Future<List<String>> getUniqueProcesses() async {
final db = await database;
final results = await db.rawQuery(
'SELECT DISTINCT process_prefix FROM $_tableName WHERE process_prefix != "" ORDER BY process_prefix',
);
final prefixes =
results.map((row) => row['process_prefix'] as String).toList();
// Always include 'Foreground' as an option for empty prefix
final uniquePrefixes = <String>[''];
uniquePrefixes.addAll(prefixes);
return uniquePrefixes;
}
/// Get count of logs matching filter
Future<int> getLogCount({LogFilter? filter}) async {
final db = await database;
if (filter == null || !filter.hasActiveFilters) {
final result =
await db.rawQuery('SELECT COUNT(*) as count FROM $_tableName');
return result.first['count'] as int;
}
// Build WHERE clause (same as getLogs)
final conditions = <String>[];
final args = <dynamic>[];
if (filter.selectedLoggers.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLoggers.length, '?').join(',');
conditions.add('logger_name IN ($placeholders)');
args.addAll(filter.selectedLoggers);
}
if (filter.selectedLevels.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLevels.length, '?').join(',');
conditions.add('level IN ($placeholders)');
args.addAll(filter.selectedLevels);
}
if (filter.selectedProcesses.isNotEmpty) {
final placeholders =
List.filled(filter.selectedProcesses.length, '?').join(',');
conditions.add('process_prefix IN ($placeholders)');
args.addAll(filter.selectedProcesses);
}
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
conditions.add('(message LIKE ? OR error LIKE ?)');
final searchPattern = '%${filter.searchQuery}%';
args.add(searchPattern);
args.add(searchPattern);
}
if (filter.startTime != null) {
conditions.add('timestamp >= ?');
args.add(filter.startTime!.millisecondsSinceEpoch);
}
if (filter.endTime != null) {
conditions.add('timestamp <= ?');
args.add(filter.endTime!.millisecondsSinceEpoch);
}
final whereClause = conditions.join(' AND ');
final query =
'SELECT COUNT(*) as count FROM $_tableName WHERE $whereClause';
final result = await db.rawQuery(query, args);
return result.first['count'] as int;
}
/// Clear all logs
Future<void> clearLogs() async {
final db = await database;
await db.delete(_tableName);
}
/// Clear logs by logger name
Future<void> clearLogsByLogger(String loggerName) async {
final db = await database;
await db.delete(
_tableName,
where: 'logger_name = ?',
whereArgs: [loggerName],
);
}
/// Truncate old logs if over limit
Future<void> _truncateIfNeeded(Database db) async {
final countResult = await db.rawQuery(
'SELECT COUNT(*) as count FROM $_tableName',
);
final count = countResult.first['count'] as int;
// When we reach 11k+ entries, keep only the last 10k
if (count >= maxEntries + 1000) {
final toDelete = count - maxEntries;
// Delete oldest entries
await db.execute('''
DELETE FROM $_tableName
WHERE id IN (
SELECT id FROM $_tableName
ORDER BY timestamp ASC
LIMIT ?
)
''', [toDelete],);
}
}
/// Get logger statistics with count and percentage
Future<List<LoggerStatistic>> getLoggerStatistics({LogFilter? filter}) async {
final db = await database;
// Build WHERE clause (same as getLogs)
final conditions = <String>[];
final args = <dynamic>[];
if (filter != null) {
if (filter.selectedLoggers.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLoggers.length, '?').join(',');
conditions.add('logger_name IN ($placeholders)');
args.addAll(filter.selectedLoggers);
}
if (filter.selectedLevels.isNotEmpty) {
final placeholders =
List.filled(filter.selectedLevels.length, '?').join(',');
conditions.add('level IN ($placeholders)');
args.addAll(filter.selectedLevels);
}
if (filter.selectedProcesses.isNotEmpty) {
final placeholders =
List.filled(filter.selectedProcesses.length, '?').join(',');
conditions.add('process_prefix IN ($placeholders)');
args.addAll(filter.selectedProcesses);
}
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
conditions.add('(message LIKE ? OR error LIKE ?)');
final searchPattern = '%${filter.searchQuery}%';
args.add(searchPattern);
args.add(searchPattern);
}
if (filter.startTime != null) {
conditions.add('timestamp >= ?');
args.add(filter.startTime!.millisecondsSinceEpoch);
}
if (filter.endTime != null) {
conditions.add('timestamp <= ?');
args.add(filter.endTime!.millisecondsSinceEpoch);
}
}
final whereClause =
conditions.isEmpty ? '' : 'WHERE ${conditions.join(' AND ')}';
// Get total count for percentage calculation
final totalQuery = 'SELECT COUNT(*) as total FROM $_tableName $whereClause';
final totalResult = await db.rawQuery(totalQuery, args);
final totalCount = totalResult.first['total'] as int;
if (totalCount == 0) return [];
// Get logger statistics using single optimized query
final statsQuery = '''
SELECT
logger_name,
COUNT(*) as count,
(COUNT(*) * 100.0 / $totalCount) as percentage
FROM $_tableName
$whereClause
GROUP BY logger_name
ORDER BY count DESC
''';
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,
),)
.toList();
}
/// Get time range of all logs
Future<TimeRange?> getTimeRange() async {
final db = await database;
final result = await db.rawQuery('''
SELECT
MIN(timestamp) as min_timestamp,
MAX(timestamp) as max_timestamp
FROM $_tableName
''');
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),
);
}
return null;
}
/// Get all log timestamps for timeline visualization
Future<List<DateTime>> getLogTimestamps() async {
final db = await database;
final result = await db.rawQuery('''
SELECT timestamp
FROM $_tableName
ORDER BY timestamp ASC
''');
return result
.map((row) => DateTime.fromMillisecondsSinceEpoch(row['timestamp'] as int))
.toList();
}
/// Close database connection
Future<void> close() async {
final db = _database;
if (db != null) {
await db.close();
_database = null;
}
}
}

View File

@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
/// Represents a single log entry
class LogEntry {
final int? id;
final String message;
final String level;
final DateTime timestamp;
final String loggerName;
final String? error;
final String? stackTrace;
final String processPrefix;
LogEntry({
this.id,
required this.message,
required this.level,
required this.timestamp,
required this.loggerName,
this.error,
this.stackTrace,
this.processPrefix = '',
});
/// Create from database map
factory LogEntry.fromMap(Map<String, dynamic> map) {
return LogEntry(
id: map['id'] as int?,
message: map['message'] as String,
level: map['level'] as String,
timestamp: DateTime.fromMillisecondsSinceEpoch(map['timestamp'] as int),
loggerName: map['logger_name'] as String,
error: map['error'] as String?,
stackTrace: map['stack_trace'] as String?,
processPrefix: map['process_prefix'] as String? ?? '',
);
}
/// Convert to database map
Map<String, dynamic> toMap() {
return {
if (id != null) 'id': id,
'message': message,
'level': level,
'timestamp': timestamp.millisecondsSinceEpoch,
'logger_name': loggerName,
'error': error,
'stack_trace': stackTrace,
'process_prefix': processPrefix,
};
}
/// Get color based on log level
Color get levelColor {
switch (level.toUpperCase()) {
case 'SHOUT':
case 'SEVERE':
return Colors.red;
case 'WARNING':
return Colors.orange;
case 'INFO':
return Colors.blue;
case 'CONFIG':
return Colors.green;
case 'FINE':
case 'FINER':
case 'FINEST':
return Colors.grey;
default:
return Colors.black54;
}
}
/// Get background color for list tile
Color? get backgroundColor {
switch (level.toUpperCase()) {
case 'SHOUT':
case 'SEVERE':
return Colors.red.withValues(alpha: 0.1);
case 'WARNING':
return Colors.orange.withValues(alpha: 0.1);
default:
return null;
}
}
/// Truncate message for preview
String get truncatedMessage {
final lines = message.split('\n');
const maxLines = 4;
if (lines.length <= maxLines) {
return message;
}
return '${lines.take(maxLines).join('\n')}...';
}
/// Format timestamp for display
String get formattedTime {
final hour = timestamp.hour.toString().padLeft(2, '0');
final minute = timestamp.minute.toString().padLeft(2, '0');
final second = timestamp.second.toString().padLeft(2, '0');
final millis = timestamp.millisecond.toString().padLeft(3, '0');
return '$hour:$minute:$second.$millis';
}
/// Get display name for process prefix
String get processDisplayName {
if (processPrefix.isEmpty) {
return 'Foreground';
}
// Remove square brackets if present (e.g., "[fbg]" -> "fbg")
final cleanPrefix = processPrefix.replaceAll(RegExp(r'[\[\]]'), '');
switch (cleanPrefix) {
case 'fbg':
return 'Firebase Background';
default:
return cleanPrefix.isEmpty ? 'Foreground' : cleanPrefix;
}
}
@override
String toString() {
final buffer = StringBuffer();
buffer.writeln('[$formattedTime] [$loggerName] [$level]');
buffer.writeln(message);
if (error != null) {
buffer.writeln('Error: $error');
}
if (stackTrace != null) {
buffer.writeln('Stack trace:\n$stackTrace');
}
return buffer.toString();
}
}
/// Filter configuration for log queries
class LogFilter {
final Set<String> selectedLoggers;
final Set<String> selectedLevels;
final Set<String> selectedProcesses;
final String? searchQuery;
final DateTime? startTime;
final DateTime? endTime;
final bool sortNewestFirst;
const LogFilter({
this.selectedLoggers = const {},
this.selectedLevels = const {},
this.selectedProcesses = const {},
this.searchQuery,
this.startTime,
this.endTime,
this.sortNewestFirst = true,
});
/// Create a copy with modifications
LogFilter copyWith({
Set<String>? selectedLoggers,
Set<String>? selectedLevels,
Set<String>? selectedProcesses,
String? searchQuery,
DateTime? startTime,
DateTime? endTime,
bool? sortNewestFirst,
bool clearSearchQuery = false,
bool clearTimeFilter = false,
}) {
return LogFilter(
selectedLoggers: selectedLoggers ?? this.selectedLoggers,
selectedLevels: selectedLevels ?? this.selectedLevels,
selectedProcesses: selectedProcesses ?? this.selectedProcesses,
searchQuery: clearSearchQuery ? null : (searchQuery ?? this.searchQuery),
startTime: clearTimeFilter ? null : (startTime ?? this.startTime),
endTime: clearTimeFilter ? null : (endTime ?? this.endTime),
sortNewestFirst: sortNewestFirst ?? this.sortNewestFirst,
);
}
/// Check if any filters are active
bool get hasActiveFilters {
return selectedLoggers.isNotEmpty ||
selectedLevels.isNotEmpty ||
selectedProcesses.isNotEmpty ||
(searchQuery != null && searchQuery!.isNotEmpty) ||
startTime != null ||
endTime != null;
}
/// Clear all filters
static const LogFilter empty = LogFilter();
}
/// Logger statistics data
class LoggerStatistic {
final String loggerName;
final int logCount;
final double percentage;
const LoggerStatistic({
required this.loggerName,
required this.logCount,
required this.percentage,
});
/// Alias for logCount for compatibility
int get count => logCount;
/// Format percentage for display
String get formattedPercentage {
if (percentage >= 10) {
return '${percentage.toStringAsFixed(1)}%';
} else if (percentage >= 1) {
return '${percentage.toStringAsFixed(1)}%';
} else {
return '${percentage.toStringAsFixed(2)}%';
}
}
}
/// Available log levels
class LogLevels {
static const List<String> all = [
'ALL',
'FINEST',
'FINER',
'FINE',
'CONFIG',
'INFO',
'WARNING',
'SEVERE',
'SHOUT',
'OFF',
];
/// Get levels typically shown by default
static const List<String> defaultVisible = [
'INFO',
'WARNING',
'SEVERE',
'SHOUT',
];
}
/// Represents a time range for logs
class TimeRange {
final DateTime start;
final DateTime end;
const TimeRange({
required this.start,
required this.end,
});
}

View File

@@ -0,0 +1,223 @@
import 'dart:async';
import 'package:log_viewer/src/core/log_database.dart';
import 'package:log_viewer/src/core/log_models.dart';
import 'package:logging/logging.dart' as log;
/// Singleton store that receives and manages logs
class LogStore {
static final LogStore _instance = LogStore._internal();
static LogStore get instance => _instance;
LogStore._internal();
final LogDatabase _database = LogDatabase();
final _logStreamController = StreamController<LogEntry>.broadcast();
// Buffer for batch inserts - optimized for small entries
final List<LogEntry> _buffer = [];
Timer? _flushTimer;
static const int _bufferSize = 50; // Increased from 10 for better batching
static const int _maxBufferSize = 200; // Safety limit
bool _initialized = false;
bool get initialized => _initialized;
/// Stream of new log entries
Stream<LogEntry> get logStream => _logStreamController.stream;
/// Initialize the log store
Future<void> initialize() async {
if (_initialized) return;
await _database.database; // Initialize database
// Start periodic flush timer - less frequent for better batching
_flushTimer = Timer.periodic(
const Duration(seconds: 15),
(_) => _flush(),
);
_initialized = true;
}
/// Static method that super_logging.dart will call
static void addLogRecord(log.LogRecord record, [String? processPrefix]) {
if (_instance._initialized) {
_instance._addLog(record, processPrefix ?? '');
}
}
/// Add a log from a LogRecord
void _addLog(log.LogRecord record, String processPrefix) {
final entry = LogEntry(
message: record.message,
level: record.level.name,
timestamp: record.time,
loggerName: record.loggerName,
error: record.error?.toString(),
stackTrace: record.stackTrace?.toString(),
processPrefix: processPrefix,
);
// Add to buffer for batch insert
_buffer.add(entry);
// Emit to stream for real-time updates
_logStreamController.add(entry);
// Flush when buffer reaches optimal size or safety limit
if (_buffer.length >= _bufferSize) {
_flush();
} else if (_buffer.length >= _maxBufferSize) {
// Emergency flush if buffer grows too large
_flush();
}
}
/// Flush buffered logs to database
Future<void> _flush() async {
if (_buffer.isEmpty) return;
final toInsert = List<LogEntry>.from(_buffer);
_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');
}));
}
/// Get logs with filtering
Future<List<LogEntry>> getLogs({
LogFilter? filter,
int limit = 250,
int offset = 0,
}) async {
// Flush any pending logs first
await _flush();
return _database.getLogs(
filter: filter,
limit: limit,
offset: offset,
);
}
/// Get unique logger names
Future<List<String>> getLoggerNames() async {
return _database.getUniqueLoggers();
}
/// Get unique process prefixes
Future<List<String>> getProcessNames() async {
return _database.getUniqueProcesses();
}
/// Get logger statistics with count and percentage
Future<List<LoggerStatistic>> getLoggerStatistics({LogFilter? filter}) async {
await _flush();
return _database.getLoggerStatistics(filter: filter);
}
/// Get count of logs matching filter
Future<int> getLogCount({LogFilter? filter}) async {
await _flush();
return _database.getLogCount(filter: filter);
}
/// Clear all logs
Future<void> clearLogs() async {
_buffer.clear();
await _database.clearLogs();
}
/// Clear logs by logger
Future<void> clearLogsByLogger(String loggerName) async {
_buffer.removeWhere((log) => log.loggerName == loggerName);
await _database.clearLogsByLogger(loggerName);
}
/// Export logs as text
Future<String> exportLogs({LogFilter? filter}) async {
final logs = await getLogs(filter: filter, limit: 10000);
final buffer = StringBuffer();
buffer.writeln('=== Ente App Logs ===');
buffer.writeln('Exported at: ${DateTime.now()}');
if (filter != null && filter.hasActiveFilters) {
buffer.writeln('Filters applied:');
if (filter.selectedLoggers.isNotEmpty) {
buffer.writeln(' Loggers: ${filter.selectedLoggers.join(', ')}');
}
if (filter.selectedLevels.isNotEmpty) {
buffer.writeln(' Levels: ${filter.selectedLevels.join(', ')}');
}
if (filter.searchQuery != null && filter.searchQuery!.isNotEmpty) {
buffer.writeln(' Search: ${filter.searchQuery}');
}
}
buffer.writeln('Total logs: ${logs.length}');
buffer.writeln('=' * 40);
buffer.writeln();
for (final log in logs) {
buffer.writeln(log.toString());
buffer.writeln('-' * 40);
}
return buffer.toString();
}
/// Export logs as JSON
Future<String> exportLogsAsJson({LogFilter? filter}) async {
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,
},)
.toList();
// Manual JSON formatting for readability
final buffer = StringBuffer();
buffer.writeln('[');
for (int i = 0; i < jsonLogs.length; i++) {
buffer.write(' ');
buffer.write(jsonLogs[i].toString());
if (i < jsonLogs.length - 1) {
buffer.writeln(',');
} else {
buffer.writeln();
}
}
buffer.writeln(']');
return buffer.toString();
}
/// Get time range of all logs
Future<TimeRange?> getTimeRange() async {
return _database.getTimeRange();
}
/// Get all log timestamps for timeline visualization
Future<List<DateTime>> getLogTimestamps() async {
return _database.getLogTimestamps();
}
/// Dispose resources
Future<void> dispose() async {
_flushTimer?.cancel();
await _flush();
await _database.close();
await _logStreamController.close();
_initialized = false;
}
}

View File

@@ -0,0 +1,197 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:log_viewer/src/core/log_models.dart';
/// Detailed view of a single log entry
class LogDetailPage extends StatelessWidget {
final LogEntry log;
const LogDetailPage({
super.key,
required this.log,
});
void _copyToClipboard(BuildContext context, String text, String label) {
Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label copied to clipboard'),
duration: const Duration(seconds: 2),
),
);
}
Widget _buildSection({
required BuildContext context,
required String title,
required String content,
bool isMonospace = true,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: theme.primaryColor,
),
),
IconButton(
icon: const Icon(Icons.copy, size: 18),
onPressed: () => _copyToClipboard(context, content, title),
tooltip: 'Copy $title',
),
],
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: theme.dividerColor,
width: 1,
),
),
child: SelectableText(
content,
style: TextStyle(
fontFamily: isMonospace ? 'monospace' : null,
fontSize: 13,
),
),
),
],
),
);
}
Widget _buildInfoRow({
required BuildContext context,
required IconData icon,
required String label,
required String value,
Color? valueColor,
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(icon, size: 20, color: theme.disabledColor),
const SizedBox(width: 12),
Text(
'$label: ',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
Expanded(
child: Text(
value,
style: theme.textTheme.bodyMedium?.copyWith(
color: valueColor,
fontWeight: valueColor != null ? FontWeight.bold : null,
),
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Log Details'),
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(
context,
log.toString(),
'Complete log',
),
tooltip: 'Copy all',
),
],
),
body: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Log metadata
Container(
width: double.infinity,
color: theme.appBarTheme.backgroundColor,
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
_buildInfoRow(
context: context,
icon: Icons.access_time,
label: 'Time',
value: '${log.timestamp.toLocal()}',
),
_buildInfoRow(
context: context,
icon: Icons.flag,
label: 'Level',
value: log.level,
valueColor: log.levelColor,
),
_buildInfoRow(
context: context,
icon: Icons.source,
label: 'Logger',
value: log.loggerName,
),
],
),
),
// Message section
_buildSection(
context: context,
title: 'MESSAGE',
content: log.message,
),
// Error section (if present)
if (log.error != null)
_buildSection(
context: context,
title: 'ERROR',
content: log.error!,
),
// Stack trace section (if present)
if (log.stackTrace != null)
_buildSection(
context: context,
title: 'STACK TRACE',
content: log.stackTrace!,
),
const SizedBox(height: 16),
],
),
),
);
}
}

View File

@@ -0,0 +1,294 @@
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_models.dart';
/// Dialog for configuring log filters
class LogFilterDialog extends StatefulWidget {
final List<String> availableLoggers;
final List<String> availableProcesses;
final LogFilter currentFilter;
const LogFilterDialog({
super.key,
required this.availableLoggers,
required this.availableProcesses,
required this.currentFilter,
});
@override
State<LogFilterDialog> createState() => _LogFilterDialogState();
}
class _LogFilterDialogState extends State<LogFilterDialog> {
late Set<String> _selectedLoggers;
late Set<String> _selectedLevels;
late Set<String> _selectedProcesses;
DateTime? _startTime;
DateTime? _endTime;
@override
void initState() {
super.initState();
_selectedLoggers = Set.from(widget.currentFilter.selectedLoggers);
_selectedLevels = Set.from(widget.currentFilter.selectedLevels);
_selectedProcesses = Set.from(widget.currentFilter.selectedProcesses);
_startTime = widget.currentFilter.startTime;
_endTime = widget.currentFilter.endTime;
}
void _applyFilters() {
final newFilter = LogFilter(
selectedLoggers: _selectedLoggers,
selectedLevels: _selectedLevels,
selectedProcesses: _selectedProcesses,
searchQuery: widget.currentFilter.searchQuery,
startTime: _startTime,
endTime: _endTime,
);
Navigator.pop(context, newFilter);
}
void _clearFilters() {
setState(() {
_selectedLoggers.clear();
_selectedLevels.clear();
_selectedProcesses.clear();
});
}
Widget _buildLevelChip(String level) {
final isSelected = _selectedLevels.contains(level);
final color = LogEntry(
message: '',
level: level,
timestamp: DateTime.now(),
loggerName: '',
).levelColor;
return FilterChip(
label: Text(
level,
style: TextStyle(
color: isSelected ? Colors.white : null,
fontSize: 12,
),
),
selected: isSelected,
selectedColor: color,
checkmarkColor: Colors.white,
onSelected: (selected) {
setState(() {
if (selected) {
_selectedLevels.add(level);
} else {
_selectedLevels.remove(level);
}
});
},
);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Dialog(
child: Container(
constraints: const BoxConstraints(maxWidth: 400, maxHeight: 600),
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)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Filter Logs',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
),
// Content
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Log Levels
Text(
'Log Levels',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: LogLevels.all
.where((level) => level != 'ALL' && level != 'OFF')
.map(_buildLevelChip)
.toList(),
),
const SizedBox(height: 24),
// Loggers
if (widget.availableLoggers.isNotEmpty) ...[
Text(
'Loggers',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Container(
constraints: const BoxConstraints(maxHeight: 150),
decoration: BoxDecoration(
border: Border.all(color: theme.dividerColor),
borderRadius: BorderRadius.circular(4),
),
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,
),
)
: null,
value: _selectedProcesses.contains(process),
dense: true,
onChanged: (selected) {
setState(() {
if (selected == true) {
_selectedProcesses.add(process);
} else {
_selectedProcesses.remove(process);
}
});
},
);
},
),
),
const SizedBox(height: 24),
],
],
),
),
),
// Actions
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.cardColor,
borderRadius:
const BorderRadius.vertical(bottom: Radius.circular(4)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
TextButton(
onPressed: _clearFilters,
child: const Text('Clear All'),
),
Row(
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: _applyFilters,
child: const Text('Apply'),
),
],
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_models.dart';
/// Individual log item widget
class LogListTile extends StatelessWidget {
final LogEntry log;
final VoidCallback onTap;
const LogListTile({
super.key,
required this.log,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
onTap: onTap,
tileColor: log.backgroundColor,
leading: Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: log.levelColor,
shape: BoxShape.circle,
),
),
title: Text(
log.truncatedMessage,
style: TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: theme.textTheme.bodyLarge?.color,
),
maxLines: 4,
overflow: TextOverflow.ellipsis,
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Row(
children: [
Icon(
Icons.access_time,
size: 12,
color: theme.textTheme.bodySmall?.color,
),
const SizedBox(width: 4),
Text(
log.formattedTime,
style: TextStyle(
fontSize: 11,
color: theme.textTheme.bodySmall?.color,
),
),
const SizedBox(width: 12),
Icon(
Icons.source,
size: 12,
color: theme.textTheme.bodySmall?.color,
),
const SizedBox(width: 4),
Flexible(
child: Text(
log.loggerName,
style: TextStyle(
fontSize: 11,
color: theme.textTheme.bodySmall?.color,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
),
if (log.error != null) ...[
const SizedBox(width: 8),
Icon(
Icons.error_outline,
size: 14,
color: Colors.red[400],
),
],
],
),
),
trailing: Icon(
Icons.chevron_right,
size: 20,
color: theme.disabledColor,
),
visualDensity: VisualDensity.compact,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
);
}
}

View File

@@ -0,0 +1,639 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_models.dart';
import 'package:log_viewer/src/core/log_store.dart';
import 'package:log_viewer/src/ui/log_detail_page.dart';
import 'package:log_viewer/src/ui/log_filter_dialog.dart';
import 'package:log_viewer/src/ui/log_list_tile.dart';
import 'package:log_viewer/src/ui/logger_statistics_page.dart';
import 'package:log_viewer/src/ui/timeline_widget.dart';
import 'package:share_plus/share_plus.dart';
/// Main log viewer page
class LogViewerPage extends StatefulWidget {
const LogViewerPage({super.key});
@override
State<LogViewerPage> createState() => _LogViewerPageState();
}
class _LogViewerPageState extends State<LogViewerPage> {
final LogStore _logStore = LogStore.instance;
final TextEditingController _searchController = TextEditingController();
List<LogEntry> _logs = [];
List<String> _availableLoggers = [];
List<String> _availableProcesses = [];
LogFilter _filter = const LogFilter();
bool _isLoading = true;
bool _isLoadingMore = false;
bool _hasMoreLogs = true;
int _currentOffset = 0;
static const int _pageSize = 100; // Load 100 logs at a time
StreamSubscription<LogEntry>? _logStreamSubscription;
// Time filtering state
bool _timeFilterEnabled = false;
// Timeline state
DateTime? _overallStartTime;
DateTime? _overallEndTime;
DateTime? _timelineStartTime;
DateTime? _timelineEndTime;
List<DateTime> _logTimestamps = [];
@override
void initState() {
super.initState();
_initialize();
}
Future<void> _initialize() async {
await _loadLoggers();
await _loadProcesses();
await _initializeTimeline();
await _loadLogs();
// Listen for new logs
_logStreamSubscription = _logStore.logStream.listen((_) {
// Debounce updates to avoid too frequent refreshes
_scheduleRefresh();
});
}
Future<void> _initializeTimeline() async {
final timeRange = await _logStore.getTimeRange();
if (timeRange != null) {
setState(() {
_overallStartTime = timeRange.start;
_overallEndTime = timeRange.end;
_timelineStartTime = timeRange.start;
_timelineEndTime = timeRange.end;
});
}
await _loadLogTimestamps();
}
Future<void> _loadLogTimestamps() async {
final timestamps = await _logStore.getLogTimestamps();
setState(() {
_logTimestamps = timestamps;
});
}
void _onTimelineRangeChanged(DateTime start, DateTime end) {
setState(() {
_timelineStartTime = start;
_timelineEndTime = end;
_filter = _filter.copyWith(
startTime: start,
endTime: end,
);
});
_loadLogs();
}
Timer? _refreshTimer;
void _scheduleRefresh() {
_refreshTimer?.cancel();
_refreshTimer = Timer(const Duration(seconds: 1), () {
if (mounted) {
_loadLogs();
}
});
}
Future<void> _loadLogs({bool reset = true}) async {
if (reset) {
setState(() {
_isLoading = true;
_currentOffset = 0;
_hasMoreLogs = true;
_logs.clear();
});
} else {
setState(() => _isLoadingMore = true);
}
try {
final logs = await _logStore.getLogs(
filter: _filter,
limit: _pageSize,
offset: _currentOffset,
);
if (mounted) {
setState(() {
if (reset) {
_logs = logs;
_isLoading = false;
} else {
_logs.addAll(logs);
_isLoadingMore = false;
}
_currentOffset += logs.length;
_hasMoreLogs = logs.length == _pageSize;
});
}
} catch (e) {
if (mounted) {
setState(() {
_isLoading = false;
_isLoadingMore = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to load logs: $e')),
);
}
}
}
Future<void> _loadMoreLogs() async {
if (!_hasMoreLogs || _isLoadingMore) return;
await _loadLogs(reset: false);
}
Future<void> _loadLoggers() async {
try {
final loggers = await _logStore.getLoggerNames();
if (mounted) {
setState(() => _availableLoggers = loggers);
}
} catch (e) {
debugPrint('Failed to load logger names: $e');
}
}
Future<void> _loadProcesses() async {
try {
final processes = await _logStore.getProcessNames();
if (mounted) {
setState(() => _availableProcesses = processes);
}
} catch (e) {
debugPrint('Failed to load process names: $e');
}
}
void _onSearchChanged(String query) {
setState(() {
_filter = _filter.copyWith(
searchQuery: query.isEmpty ? null : query,
clearSearchQuery: query.isEmpty,
);
});
_loadLogs();
}
void _updateTimeFilter() {
setState(() {
if (_timeFilterEnabled && _timelineStartTime != null && _timelineEndTime != null) {
_filter = _filter.copyWith(
startTime: _timelineStartTime,
endTime: _timelineEndTime,
);
} else {
_filter = _filter.copyWith(
clearTimeFilter: true,
);
}
});
_loadLogs();
}
String _formatTimeRange(double hours) {
if (hours < 1) {
final minutes = (hours * 60).round();
return '${minutes}m';
} else if (hours < 24) {
return '${hours.round()}h';
} else {
final days = (hours / 24).round();
return '${days}d';
}
}
Future<void> _showFilterDialog() async {
final newFilter = await showDialog<LogFilter>(
context: context,
builder: (context) => LogFilterDialog(
availableLoggers: _availableLoggers,
availableProcesses: _availableProcesses,
currentFilter: _filter,
),
);
if (newFilter != null && mounted) {
setState(() => _filter = newFilter);
await _loadLogs();
}
}
Future<void> _clearLogs() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Logs'),
content: const Text('Are you sure you want to clear all logs?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Clear', style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirmed == true) {
await _logStore.clearLogs();
await _loadLogs();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Logs cleared')),
);
}
}
}
Future<void> _exportLogs() async {
try {
final logText = await _logStore.exportLogs(filter: _filter);
await Share.share(logText);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to export logs: $e')),
);
}
}
}
void _toggleSort() {
setState(() {
_filter = _filter.copyWith(
sortNewestFirst: !_filter.sortNewestFirst,
);
});
_loadLogs();
}
void _showAnalytics() {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LoggerStatisticsPage(filter: _filter),
),
);
}
void _showLogDetail(LogEntry log) {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => LogDetailPage(log: log),
),
);
}
@override
void dispose() {
_searchController.dispose();
_logStreamSubscription?.cancel();
_refreshTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Logs'),
elevation: 0,
actions: [
if (_filter.hasActiveFilters)
IconButton(
icon: Stack(
children: [
const Icon(Icons.filter_list),
Positioned(
right: 0,
top: 0,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
],
),
onPressed: _showFilterDialog,
tooltip: 'Filters',
)
else
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: _showFilterDialog,
tooltip: 'Filters',
),
IconButton(
icon: Icon(_filter.sortNewestFirst
? Icons.arrow_downward
: Icons.arrow_upward,),
onPressed: _toggleSort,
tooltip: _filter.sortNewestFirst
? 'Sort oldest first'
: 'Sort newest first',
),
PopupMenuButton<String>(
onSelected: (value) {
switch (value) {
case 'analytics':
_showAnalytics();
break;
case 'clear':
_clearLogs();
break;
case 'export':
_exportLogs();
break;
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'analytics',
child: ListTile(
leading: Icon(Icons.analytics),
title: Text('View Analytics'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'export',
child: ListTile(
leading: Icon(Icons.share),
title: Text('Export Logs'),
contentPadding: EdgeInsets.zero,
),
),
const PopupMenuItem(
value: 'clear',
child: ListTile(
leading: Icon(Icons.clear_all, color: Colors.red),
title:
Text('Clear Logs', style: TextStyle(color: Colors.red)),
contentPadding: EdgeInsets.zero,
),
),
],
),
],
),
body: Column(
children: [
// Search bar
Container(
color: theme.appBarTheme.backgroundColor,
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search logs...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)
: null,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
onChanged: _onSearchChanged,
),
),
// Timeline filter
if (_overallStartTime != null && _overallEndTime != null) ...[
Container(
color: theme.appBarTheme.backgroundColor,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
Icon(
Icons.timeline,
size: 18,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
Text(
'Timeline Filter',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w500,
),
),
const Spacer(),
IconButton(
icon: Icon(
_timeFilterEnabled
? Icons.timeline
: Icons.timeline_outlined,
color: _timeFilterEnabled
? theme.colorScheme.primary
: theme.colorScheme.onSurface,
),
onPressed: () {
setState(() {
_timeFilterEnabled = !_timeFilterEnabled;
if (_timeFilterEnabled) {
// Reset timeline to full range when enabled
_timelineStartTime = _overallStartTime;
_timelineEndTime = _overallEndTime;
}
});
_updateTimeFilter();
},
tooltip: _timeFilterEnabled ? 'Disable Timeline Filter' : 'Enable Timeline Filter',
),
],
),
),
if (_timeFilterEnabled) ...[
TimelineWidget(
startTime: _overallStartTime!,
endTime: _overallEndTime!,
currentStart: _timelineStartTime ?? _overallStartTime!,
currentEnd: _timelineEndTime ?? _overallEndTime!,
onTimeRangeChanged: _onTimelineRangeChanged,
logTimestamps: _logTimestamps,
),
],
],
// Active filters display
if (_filter.hasActiveFilters)
Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: ListView(
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();
},
),
),),
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(
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: '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();
},
),
),),
],
),
),
// Log list
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _logs.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox,
size: 64,
color: theme.disabledColor,
),
const SizedBox(height: 16),
Text(
_filter.hasActiveFilters
? 'No logs match the current filters'
: 'No logs available',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.disabledColor,
),
),
],
),
)
: RefreshIndicator(
onRefresh: _loadLogs,
child: ListView.separated(
itemCount: _logs.length + (_hasMoreLogs ? 1 : 0),
separatorBuilder: (context, index) =>
index >= _logs.length
? const SizedBox.shrink()
: const Divider(height: 1),
itemBuilder: (context, index) {
// Show loading indicator at the bottom
if (index >= _logs.length) {
if (_isLoadingMore) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(),),
);
} else {
// Trigger loading more when reaching the end
WidgetsBinding.instance
.addPostFrameCallback((_) {
_loadMoreLogs();
});
return const SizedBox.shrink();
}
}
final log = _logs[index];
return LogListTile(
log: log,
onTap: () => _showLogDetail(log),
);
},
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,300 @@
import 'package:flutter/material.dart';
import 'package:log_viewer/src/core/log_models.dart';
import 'package:log_viewer/src/core/log_store.dart';
/// Page showing logger statistics with percentage breakdown
class LoggerStatisticsPage extends StatefulWidget {
final LogFilter filter;
const LoggerStatisticsPage({
super.key,
required this.filter,
});
@override
State<LoggerStatisticsPage> createState() => _LoggerStatisticsPageState();
}
class _LoggerStatisticsPageState extends State<LoggerStatisticsPage> {
final LogStore _logStore = LogStore.instance;
List<LoggerStatistic> _statistics = [];
bool _isLoading = true;
String? _error;
@override
void initState() {
super.initState();
_loadStatistics();
}
Future<void> _loadStatistics() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final stats = await _logStore.getLoggerStatistics(filter: widget.filter);
if (mounted) {
setState(() {
_statistics = stats;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
Color _getLoggerColor(int index, double percentage) {
// Color coding based on percentage
if (percentage > 50) return Colors.red.shade400;
if (percentage > 20) return Colors.orange.shade400;
if (percentage > 10) return Colors.blue.shade400;
return Colors.green.shade400;
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Scaffold(
appBar: AppBar(
title: const Text('Logger Analytics'),
elevation: 0,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 48,
color: theme.colorScheme.error,
),
const SizedBox(height: 16),
Text(
'Failed to load statistics',
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
_error!,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.error,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _loadStatistics,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
),
)
: _statistics.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.analytics_outlined,
size: 64,
color: theme.disabledColor,
),
const SizedBox(height: 16),
Text(
'No log data available',
style: theme.textTheme.headlineSmall?.copyWith(
color: theme.disabledColor,
),
),
],
),
)
: Column(
children: [
// Summary cards
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: _SummaryCard(
title: 'Total Logs',
value: _statistics
.fold(0, (sum, stat) => sum + stat.count)
.toString(),
icon: Icons.notes,
color: Colors.blue,
),
),
const SizedBox(width: 16),
Expanded(
child: _SummaryCard(
title: 'Loggers',
value: _statistics.length.toString(),
icon: Icons.category,
color: Colors.green,
),
),
],
),
),
// Statistics list
Expanded(
child: RefreshIndicator(
onRefresh: _loadStatistics,
child: ListView.builder(
padding:
const EdgeInsets.symmetric(horizontal: 16),
itemCount: _statistics.length,
itemBuilder: (context, index) {
final stat = _statistics[index];
final color =
_getLoggerColor(index, stat.percentage);
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,
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,
),
),
],
),
],
),
),
);
},
),
),
),
],
),
);
}
}
class _SummaryCard extends StatelessWidget {
final String title;
final String value;
final IconData icon;
final Color color;
const _SummaryCard({
required this.title,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: color.withValues(alpha: 0.3),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
icon,
color: color,
size: 20,
),
const SizedBox(width: 8),
Text(
title,
style: theme.textTheme.bodyMedium?.copyWith(
color: color,
fontWeight: FontWeight.w500,
),
),
],
),
const SizedBox(height: 8),
Text(
value,
style: theme.textTheme.headlineMedium?.copyWith(
color: color,
fontWeight: FontWeight.bold,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
class TimelineWidget extends StatefulWidget {
final DateTime startTime;
final DateTime endTime;
final DateTime currentStart;
final DateTime currentEnd;
final Function(DateTime start, DateTime end) onTimeRangeChanged;
final List<DateTime> logTimestamps;
const TimelineWidget({
Key? key,
required this.startTime,
required this.endTime,
required this.currentStart,
required this.currentEnd,
required this.onTimeRangeChanged,
this.logTimestamps = const [],
}) : super(key: key);
@override
State<TimelineWidget> createState() => _TimelineWidgetState();
}
class _TimelineWidgetState extends State<TimelineWidget> {
late double _leftPosition;
late double _rightPosition;
bool _isDraggingLeft = false;
bool _isDraggingRight = false;
@override
void initState() {
super.initState();
_updatePositions();
}
@override
void didUpdateWidget(TimelineWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.currentStart != widget.currentStart ||
oldWidget.currentEnd != widget.currentEnd ||
oldWidget.startTime != widget.startTime ||
oldWidget.endTime != widget.endTime) {
_updatePositions();
}
}
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;
_leftPosition = startOffset / totalDuration;
_rightPosition = endOffset / totalDuration;
}
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 position = (localX / width).clamp(0.0, 1.0);
setState(() {
if (isLeft) {
_leftPosition = position.clamp(0.0, _rightPosition - 0.01);
} else {
_rightPosition = position.clamp(_leftPosition + 0.01, 1.0);
}
});
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),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Timeline Filter',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 8),
Expanded(
child: LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
// Timeline track
Positioned(
left: 20,
right: 20,
top: 20,
bottom: 20,
child: Container(
decoration: BoxDecoration(
color: theme.colorScheme.surfaceVariant,
borderRadius: BorderRadius.circular(4),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
width: 1,
),
),
child: _buildLogDensityIndicator(constraints.maxWidth - 40),
),
),
// Selected range
Positioned(
left: 20 + (_leftPosition * (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),
borderRadius: BorderRadius.circular(4),
border: Border.all(
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),
child: Container(
width: 20,
height: 32,
decoration: BoxDecoration(
color: _isDraggingLeft
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Icon(
Icons.drag_indicator,
size: 12,
color: theme.colorScheme.onPrimary,
),
),
),
),
// 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),
child: Container(
width: 20,
height: 32,
decoration: BoxDecoration(
color: _isDraggingRight
? theme.colorScheme.primary
: theme.colorScheme.primary.withValues(alpha: 0.8),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Icon(
Icons.drag_indicator,
size: 12,
color: theme.colorScheme.onPrimary,
),
),
),
),
],
);
},
),
),
// Time labels
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatTime(widget.currentStart),
style: theme.textTheme.bodySmall,
),
Text(
_formatTime(widget.currentEnd),
style: theme.textTheme.bodySmall,
),
],
),
],
),
);
}
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;
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);
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;
return Expanded(
child: Container(
height: double.infinity,
margin: const EdgeInsets.symmetric(horizontal: 0.5),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: intensity * 0.6),
borderRadius: BorderRadius.circular(1),
),
),
);
}).toList(),
);
}
String _formatTime(DateTime time) {
return '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}:${time.second.toString().padLeft(2, '0')}';
}
}

View File

@@ -0,0 +1,482 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
source: hosted
version: "2.13.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
characters:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev"
source: hosted
version: "1.4.0"
clock:
dependency: transitive
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.2"
collection:
dependency: "direct main"
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "7caf6a750a0c04effbb52a676dce9a4a592e10ad35c34d6d2d0e4811160d5670"
url: "https://pub.dev"
source: hosted
version: "0.3.4+2"
crypto:
dependency: transitive
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
url: "https://pub.dev"
source: hosted
version: "3.0.6"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
intl:
dependency: "direct main"
description:
name: intl
sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5"
url: "https://pub.dev"
source: hosted
version: "0.20.2"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
url: "https://pub.dev"
source: hosted
version: "10.0.9"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
lints:
dependency: transitive
description:
name: lints
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "5.1.1"
logging:
dependency: "direct main"
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev"
source: hosted
version: "0.11.1"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
url: "https://pub.dev"
source: hosted
version: "1.16.0"
mime:
dependency: transitive
description:
name: mime
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
path:
dependency: "direct main"
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
url: "https://pub.dev"
source: hosted
version: "2.2.18"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
share_plus:
dependency: "direct main"
description:
name: share_plus
sha256: d7dc0630a923883c6328ca31b89aa682bacbf2f8304162d29f7c6aaff03a27a1
url: "https://pub.dev"
source: hosted
version: "11.1.0"
share_plus_platform_interface:
dependency: transitive
description:
name: share_plus_platform_interface
sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a"
url: "https://pub.dev"
source: hosted
version: "6.1.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
sprintf:
dependency: transitive
description:
name: sprintf
sha256: "1fc9ffe69d4df602376b52949af107d8f5703b77cda567c4d7d86a0693120f23"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
sqflite:
dependency: "direct main"
description:
name: sqflite
sha256: e2297b1da52f127bc7a3da11439985d9b536f75070f3325e62ada69a5c585d03
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_android:
dependency: transitive
description:
name: sqflite_android
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
sqflite_darwin:
dependency: transitive
description:
name: sqflite_darwin
sha256: "279832e5cde3fe99e8571879498c9211f3ca6391b0d818df4e17d9fff5c6ccb3"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
sqflite_platform_interface:
dependency: transitive
description:
name: sqflite_platform_interface
sha256: "8dd4515c7bdcae0a785b0062859336de775e8c65db81ae33dd5445f35be61920"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
url: "https://pub.dev"
source: hosted
version: "3.4.0"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
url: "https://pub.dev"
source: hosted
version: "0.7.4"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: "4e9ba368772369e3e08f231d2301b4ef72b9ff87c31192ef471b380ef29a4935"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "3284b6d2ac454cf34f114e1d3319866fdd1e19cdc329999057e44ffe936cfa77"
url: "https://pub.dev"
source: hosted
version: "3.1.4"
uuid:
dependency: transitive
description:
name: uuid
sha256: a5be9ef6618a7ac1e964353ef476418026db906c4facdedaa299b7a2e71690ff
url: "https://pub.dev"
source: hosted
version: "4.5.1"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
url: "https://pub.dev"
source: hosted
version: "15.0.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
url: "https://pub.dev"
source: hosted
version: "5.14.0"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
sdks:
dart: ">=3.8.0 <4.0.0"
flutter: ">=3.29.0"

View File

@@ -0,0 +1,25 @@
name: log_viewer
description: In-app log viewer with filtering capabilities for Ente apps
version: 1.0.0
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.0.0"
dependencies:
collection: ^1.18.0
flutter:
sdk: flutter
intl: ^0.20.2
logging: ^1.3.0 # For LogRecord type compatibility
path: ^1.9.0
path_provider: ^2.1.5
share_plus: ^11.0.0 # For log export functionality
sqflite: ^2.4.2
dev_dependencies:
flutter_lints: ^5.0.0
flutter_test:
sdk: flutter
flutter: