diff --git a/mobile/apps/photos/lib/core/error-reporting/super_logging.dart b/mobile/apps/photos/lib/core/error-reporting/super_logging.dart index 1df930696f..533574eff1 100644 --- a/mobile/apps/photos/lib/core/error-reporting/super_logging.dart +++ b/mobile/apps/photos/lib/core/error-reporting/super_logging.dart @@ -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 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(), + ), + ); + } } diff --git a/mobile/apps/photos/lib/main.dart b/mobile/apps/photos/lib/main.dart index a575787c5e..2956da176f 100644 --- a/mobile/apps/photos/lib/main.dart +++ b/mobile/apps/photos/lib/main.dart @@ -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 _scheduleHeartBeat( diff --git a/mobile/apps/photos/lib/ui/settings_page.dart b/mobile/apps/photos/lib/ui/settings_page.dart index 1574ef4c34..04061007ea 100644 --- a/mobile/apps/photos/lib/ui/settings_page.dart +++ b/mobile/apps/photos/lib/ui/settings_page.dart @@ -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, + ), + ), + ), + ], ); }, ), diff --git a/mobile/apps/photos/pubspec.lock b/mobile/apps/photos/pubspec.lock index e4fe9fa913..bc4d4cf1d1 100644 --- a/mobile/apps/photos/pubspec.lock +++ b/mobile/apps/photos/pubspec.lock @@ -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: diff --git a/mobile/apps/photos/pubspec.yaml b/mobile/apps/photos/pubspec.yaml index 30ff4fd563..a544e603c2 100644 --- a/mobile/apps/photos/pubspec.yaml +++ b/mobile/apps/photos/pubspec.yaml @@ -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 diff --git a/mobile/apps/photos/pubspec_overrides.yaml b/mobile/apps/photos/pubspec_overrides.yaml index 8891502fc5..b5a1bd35f2 100644 --- a/mobile/apps/photos/pubspec_overrides.yaml +++ b/mobile/apps/photos/pubspec_overrides.yaml @@ -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 diff --git a/mobile/packages/log_viewer/README.md b/mobile/packages/log_viewer/README.md new file mode 100644 index 0000000000..677531c5cf --- /dev/null +++ b/mobile/packages/log_viewer/README.md @@ -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 diff --git a/mobile/packages/log_viewer/analysis_options.yaml b/mobile/packages/log_viewer/analysis_options.yaml new file mode 100644 index 0000000000..fac60e247c --- /dev/null +++ b/mobile/packages/log_viewer/analysis_options.yaml @@ -0,0 +1 @@ +include: ../../analysis_options.yaml \ No newline at end of file diff --git a/mobile/packages/log_viewer/example_integration.md b/mobile/packages/log_viewer/example_integration.md new file mode 100644 index 0000000000..8418413651 --- /dev/null +++ b/mobile/packages/log_viewer/example_integration.md @@ -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 { + 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 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 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 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 \ No newline at end of file diff --git a/mobile/packages/log_viewer/lib/log_viewer.dart b/mobile/packages/log_viewer/lib/log_viewer.dart new file mode 100644 index 0000000000..9a2c3d30d4 --- /dev/null +++ b/mobile/packages/log_viewer/lib/log_viewer.dart @@ -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 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 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 dispose() async { + if (_initialized) { + await LogStore.instance.dispose(); + _initialized = false; + } + } +} diff --git a/mobile/packages/log_viewer/lib/src/core/log_database.dart b/mobile/packages/log_viewer/lib/src/core/log_database.dart new file mode 100644 index 0000000000..29824dd607 --- /dev/null +++ b/mobile/packages/log_viewer/lib/src/core/log_database.dart @@ -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 get database async { + _database ??= await _initDatabase(); + return _database!; + } + + /// Initialize database + Future _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 _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 _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 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 insertLogs(List 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> getLogs({ + LogFilter? filter, + int limit = 250, + int offset = 0, + }) async { + final db = await database; + + // Build WHERE clause + final conditions = []; + final args = []; + + 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> 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> 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 = ['']; + uniquePrefixes.addAll(prefixes); + + return uniquePrefixes; + } + + /// Get count of logs matching filter + Future 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 = []; + final args = []; + + 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 clearLogs() async { + final db = await database; + await db.delete(_tableName); + } + + /// Clear logs by logger name + Future clearLogsByLogger(String loggerName) async { + final db = await database; + await db.delete( + _tableName, + where: 'logger_name = ?', + whereArgs: [loggerName], + ); + } + + /// Truncate old logs if over limit + Future _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> getLoggerStatistics({LogFilter? filter}) async { + final db = await database; + + // Build WHERE clause (same as getLogs) + final conditions = []; + final args = []; + + 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 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> 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 close() async { + final db = _database; + if (db != null) { + await db.close(); + _database = null; + } + } +} diff --git a/mobile/packages/log_viewer/lib/src/core/log_models.dart b/mobile/packages/log_viewer/lib/src/core/log_models.dart new file mode 100644 index 0000000000..33630a3d80 --- /dev/null +++ b/mobile/packages/log_viewer/lib/src/core/log_models.dart @@ -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 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 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 selectedLoggers; + final Set selectedLevels; + final Set 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? selectedLoggers, + Set? selectedLevels, + Set? 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 all = [ + 'ALL', + 'FINEST', + 'FINER', + 'FINE', + 'CONFIG', + 'INFO', + 'WARNING', + 'SEVERE', + 'SHOUT', + 'OFF', + ]; + + /// Get levels typically shown by default + static const List 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, + }); +} diff --git a/mobile/packages/log_viewer/lib/src/core/log_store.dart b/mobile/packages/log_viewer/lib/src/core/log_store.dart new file mode 100644 index 0000000000..66c9cb2062 --- /dev/null +++ b/mobile/packages/log_viewer/lib/src/core/log_store.dart @@ -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.broadcast(); + + // Buffer for batch inserts - optimized for small entries + final List _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 get logStream => _logStreamController.stream; + + /// Initialize the log store + Future 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 _flush() async { + if (_buffer.isEmpty) return; + + final toInsert = List.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> 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> getLoggerNames() async { + return _database.getUniqueLoggers(); + } + + /// Get unique process prefixes + Future> getProcessNames() async { + return _database.getUniqueProcesses(); + } + + /// Get logger statistics with count and percentage + Future> getLoggerStatistics({LogFilter? filter}) async { + await _flush(); + return _database.getLoggerStatistics(filter: filter); + } + + /// Get count of logs matching filter + Future getLogCount({LogFilter? filter}) async { + await _flush(); + return _database.getLogCount(filter: filter); + } + + /// Clear all logs + Future clearLogs() async { + _buffer.clear(); + await _database.clearLogs(); + } + + /// Clear logs by logger + Future clearLogsByLogger(String loggerName) async { + _buffer.removeWhere((log) => log.loggerName == loggerName); + await _database.clearLogsByLogger(loggerName); + } + + /// Export logs as text + Future 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 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 getTimeRange() async { + return _database.getTimeRange(); + } + + /// Get all log timestamps for timeline visualization + Future> getLogTimestamps() async { + return _database.getLogTimestamps(); + } + + /// Dispose resources + Future dispose() async { + _flushTimer?.cancel(); + await _flush(); + await _database.close(); + await _logStreamController.close(); + _initialized = false; + } +} diff --git a/mobile/packages/log_viewer/lib/src/ui/log_detail_page.dart b/mobile/packages/log_viewer/lib/src/ui/log_detail_page.dart new file mode 100644 index 0000000000..f2bef51e54 --- /dev/null +++ b/mobile/packages/log_viewer/lib/src/ui/log_detail_page.dart @@ -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), + ], + ), + ), + ); + } +} diff --git a/mobile/packages/log_viewer/lib/src/ui/log_filter_dialog.dart b/mobile/packages/log_viewer/lib/src/ui/log_filter_dialog.dart new file mode 100644 index 0000000000..9907552f11 --- /dev/null +++ b/mobile/packages/log_viewer/lib/src/ui/log_filter_dialog.dart @@ -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 availableLoggers; + final List availableProcesses; + final LogFilter currentFilter; + + const LogFilterDialog({ + super.key, + required this.availableLoggers, + required this.availableProcesses, + required this.currentFilter, + }); + + @override + State createState() => _LogFilterDialogState(); +} + +class _LogFilterDialogState extends State { + late Set _selectedLoggers; + late Set _selectedLevels; + late Set _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'), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/packages/log_viewer/lib/src/ui/log_list_tile.dart b/mobile/packages/log_viewer/lib/src/ui/log_list_tile.dart new file mode 100644 index 0000000000..3dda7edf64 --- /dev/null +++ b/mobile/packages/log_viewer/lib/src/ui/log_list_tile.dart @@ -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), + ); + } +} diff --git a/mobile/packages/log_viewer/lib/src/ui/log_viewer_page.dart b/mobile/packages/log_viewer/lib/src/ui/log_viewer_page.dart new file mode 100644 index 0000000000..06ef48eb71 --- /dev/null +++ b/mobile/packages/log_viewer/lib/src/ui/log_viewer_page.dart @@ -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 createState() => _LogViewerPageState(); +} + +class _LogViewerPageState extends State { + final LogStore _logStore = LogStore.instance; + final TextEditingController _searchController = TextEditingController(); + + List _logs = []; + List _availableLoggers = []; + List _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? _logStreamSubscription; + + // Time filtering state + bool _timeFilterEnabled = false; + + // Timeline state + DateTime? _overallStartTime; + DateTime? _overallEndTime; + DateTime? _timelineStartTime; + DateTime? _timelineEndTime; + List _logTimestamps = []; + + @override + void initState() { + super.initState(); + _initialize(); + } + + Future _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 _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 _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 _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 _loadMoreLogs() async { + if (!_hasMoreLogs || _isLoadingMore) return; + await _loadLogs(reset: false); + } + + Future _loadLoggers() async { + try { + final loggers = await _logStore.getLoggerNames(); + if (mounted) { + setState(() => _availableLoggers = loggers); + } + } catch (e) { + debugPrint('Failed to load logger names: $e'); + } + } + + Future _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 _showFilterDialog() async { + final newFilter = await showDialog( + context: context, + builder: (context) => LogFilterDialog( + availableLoggers: _availableLoggers, + availableProcesses: _availableProcesses, + currentFilter: _filter, + ), + ); + + if (newFilter != null && mounted) { + setState(() => _filter = newFilter); + await _loadLogs(); + } + } + + Future _clearLogs() async { + final confirmed = await showDialog( + 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 _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( + 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.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.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.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), + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/log_viewer/lib/src/ui/logger_statistics_page.dart b/mobile/packages/log_viewer/lib/src/ui/logger_statistics_page.dart new file mode 100644 index 0000000000..383177513e --- /dev/null +++ b/mobile/packages/log_viewer/lib/src/ui/logger_statistics_page.dart @@ -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 createState() => _LoggerStatisticsPageState(); +} + +class _LoggerStatisticsPageState extends State { + final LogStore _logStore = LogStore.instance; + List _statistics = []; + bool _isLoading = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadStatistics(); + } + + Future _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, + ), + ), + ], + ), + ); + } +} diff --git a/mobile/packages/log_viewer/lib/src/ui/timeline_widget.dart b/mobile/packages/log_viewer/lib/src/ui/timeline_widget.dart new file mode 100644 index 0000000000..7f5255afed --- /dev/null +++ b/mobile/packages/log_viewer/lib/src/ui/timeline_widget.dart @@ -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 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 createState() => _TimelineWidgetState(); +} + +class _TimelineWidgetState extends State { + 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.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')}'; + } +} \ No newline at end of file diff --git a/mobile/packages/log_viewer/pubspec.lock b/mobile/packages/log_viewer/pubspec.lock new file mode 100644 index 0000000000..fd2441b027 --- /dev/null +++ b/mobile/packages/log_viewer/pubspec.lock @@ -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" diff --git a/mobile/packages/log_viewer/pubspec.yaml b/mobile/packages/log_viewer/pubspec.yaml new file mode 100644 index 0000000000..beb94535f3 --- /dev/null +++ b/mobile/packages/log_viewer/pubspec.yaml @@ -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: \ No newline at end of file