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:
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
56
mobile/packages/log_viewer/README.md
Normal file
56
mobile/packages/log_viewer/README.md
Normal 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
|
||||
1
mobile/packages/log_viewer/analysis_options.yaml
Normal file
1
mobile/packages/log_viewer/analysis_options.yaml
Normal file
@@ -0,0 +1 @@
|
||||
include: ../../analysis_options.yaml
|
||||
327
mobile/packages/log_viewer/example_integration.md
Normal file
327
mobile/packages/log_viewer/example_integration.md
Normal 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
|
||||
97
mobile/packages/log_viewer/lib/log_viewer.dart
Normal file
97
mobile/packages/log_viewer/lib/log_viewer.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
411
mobile/packages/log_viewer/lib/src/core/log_database.dart
Normal file
411
mobile/packages/log_viewer/lib/src/core/log_database.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
255
mobile/packages/log_viewer/lib/src/core/log_models.dart
Normal file
255
mobile/packages/log_viewer/lib/src/core/log_models.dart
Normal 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,
|
||||
});
|
||||
}
|
||||
223
mobile/packages/log_viewer/lib/src/core/log_store.dart
Normal file
223
mobile/packages/log_viewer/lib/src/core/log_store.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
197
mobile/packages/log_viewer/lib/src/ui/log_detail_page.dart
Normal file
197
mobile/packages/log_viewer/lib/src/ui/log_detail_page.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
294
mobile/packages/log_viewer/lib/src/ui/log_filter_dialog.dart
Normal file
294
mobile/packages/log_viewer/lib/src/ui/log_filter_dialog.dart
Normal 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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
95
mobile/packages/log_viewer/lib/src/ui/log_list_tile.dart
Normal file
95
mobile/packages/log_viewer/lib/src/ui/log_list_tile.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
639
mobile/packages/log_viewer/lib/src/ui/log_viewer_page.dart
Normal file
639
mobile/packages/log_viewer/lib/src/ui/log_viewer_page.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
272
mobile/packages/log_viewer/lib/src/ui/timeline_widget.dart
Normal file
272
mobile/packages/log_viewer/lib/src/ui/timeline_widget.dart
Normal 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')}';
|
||||
}
|
||||
}
|
||||
482
mobile/packages/log_viewer/pubspec.lock
Normal file
482
mobile/packages/log_viewer/pubspec.lock
Normal 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"
|
||||
25
mobile/packages/log_viewer/pubspec.yaml
Normal file
25
mobile/packages/log_viewer/pubspec.yaml
Normal 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:
|
||||
Reference in New Issue
Block a user