mobile bluesky app made with flutter lazurite.stormlightlabs.org/
mobile bluesky flutter
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: app logging and viewer screen

+1249 -63
-16
docs/specs/phase-1.md
··· 328 328 | iris | `#907aa9` | Purple accent | 329 329 330 330 Map to `RosePineTheme.dark()` / `RosePineTheme.light()`. 331 - 332 - ## Development 333 - 334 - `/scripts` directory with Python utilities for inspecting AT Protocol / BlueSky 335 - API data. 336 - 337 - Scripts to include: 338 - 339 - | Script | Purpose | 340 - | ------------------- | ---------------------------------------------------- | 341 - | `fetch_profile.py` | Fetch and pretty-print a profile via `getProfile` | 342 - | `fetch_feed.py` | Fetch author feed and dump post/facet structures | 343 - | `resolve_handle.py` | Resolve handle → DID via `com.atproto.identity` | 344 - 345 - Use the `atproto` Python SDK (`atproto` on PyPI). Each script should accept 346 - CLI args (handle, limit, etc.) and output JSON to stdout.
-7
docs/tasks/phase-1.md
··· 39 39 - [x] Theme picker in settings screen (all 4 palettes × 2 variants + system) 40 40 - [x] Respect system theme when set to "system" 41 41 - NOTE: sans font changed to dmsans from geist 42 - 43 - ## M4 — Dev Scripts 44 - 45 - - [ ] Create `uv` project in directory `scripts` 46 - - [ ] `scripts/fetch_profile.py` — pretty-print profile JSON 47 - - [ ] `scripts/fetch_feed.py` — dump post + facet structures 48 - - [ ] `scripts/resolve_handle.py` — resolve handle → DID
+20 -16
docs/tasks/phase-2.md
··· 1 1 # Phase 2 Milestones 2 2 3 - ## M5 — Logging 3 + ## M4 — Logging 4 4 5 - - [ ] Add `logger` package dependency 6 - - [ ] `AppLogger` wrapper — singleton with `DevelopmentFilter` + `PrettyPrinter` for console, `AdvancedFileOutput` + `SimplePrinter` for file 7 - - [ ] File rotation — daily log files in app documents dir (`lazurite_YYYY-MM-DD.log`), 3-day retention 8 - - [ ] `LoggingBlocObserver` — log BLoC state transitions at `debug` level 9 - - [ ] HTTP logging interceptor — request/response summaries, redact `Authorization` header, truncate bodies 10 - - [ ] `NavigatorObserver` subclass — log route changes at `info` level 11 - - [ ] Log viewer screen — scrollable list reading from log files on disk 12 - - [ ] Level filter chip bar — toggle visibility per log level 13 - - [ ] Free-text search across log messages 14 - - [ ] Share button — export current day's log file via system share sheet 15 - - [ ] Clear all logs with confirmation dialog 16 - - [ ] Add "Logs" entry under Dev Tools in Settings screen 5 + - [x] Add `logger` package dependency 6 + - [x] `AppLogger` wrapper — singleton with `DevelopmentFilter` + `PrettyPrinter` for console, `AdvancedFileOutput` + `SimplePrinter` for file 7 + - [x] File rotation — daily log files in app documents dir (`lazurite_YYYY-MM-DD.log`), 3-day retention 8 + - [x] `LoggingBlocObserver` — log BLoC state transitions at `debug` level 9 + - [x] HTTP logging interceptor — request/response summaries, redact `Authorization` header, truncate bodies 10 + - [x] `NavigatorObserver` subclass — log route changes at `info` level 11 + - [x] Log viewer screen — scrollable list reading from log files on disk 12 + - [x] Level filter chip bar — toggle visibility per log level 13 + - [x] Free-text search across log messages 14 + - [x] Share button — export current day's log file via system share sheet 15 + - [x] Clear all logs with confirmation dialog 16 + - [x] Add "Logs" entry under Dev Tools in Settings screen 17 17 18 - ## M6 — Feeds 18 + ## M5 — Feeds 19 19 20 20 - [ ] Build home screen with horizontally-swipable tab bar (one tab per pinned feed) 21 21 - [ ] Implement timeline feed via `getTimeline` with cursor pagination ··· 25 25 - [ ] Feed discovery screen via `getSuggestedFeeds` — browse and add generators 26 26 - [ ] Feed management UI — pin/unpin, drag-to-reorder, remove saved feeds 27 27 28 - ## M7 — Search 28 + ## M6 — Search 29 29 30 30 - [ ] Search screen with text input, sort toggle (`top` / `latest`), and result tabs (posts / actors) 31 31 - [ ] `SearchBloc` — events: `QuerySubmitted`, `TypeaheadRequested`, `HistoryCleared`, `HistoryEntryDeleted` ··· 35 35 - [ ] Drift migration: add `search_history` table (query, type, searched_at, account_did) 36 36 - [ ] Persisted search history — display recent queries, tap to re-execute, swipe to delete, cap at 50 per account 37 37 38 - ## M8 — Dev Tools (PDS Explorer) 38 + ## M7 — Dev Tools (PDS Explorer) 39 39 40 40 - [ ] `DevToolsCubit` with request/response state for stateless exploration 41 41 - [ ] Handle / DID input with resolution via `resolveHandle` ··· 44 44 - [ ] Record inspector via `getRecord` — pretty-printed JSON with syntax highlighting 45 45 - [ ] AT-URI input — paste `at://` URI to jump directly to a record 46 46 - [ ] Add Dev Tools entry in Settings screen, navigable by all users 47 + - [ ] Include link to <https://pds.ls> as inspiration (pdsls) 48 + - [ ] Construct <https://aturi.to> links from AT-URI. 49 + - ex. `at://did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3m6mwoadjbp2d` becomes 50 + <https://aturi.to/did:plc:ewvi7nxzyoun6zhxrhs64oiz/app.bsky.feed.post/3m6mwoadjbp2d>
+163
lib/core/logging/app_logger.dart
··· 1 + import 'dart:io'; 2 + 3 + import 'package:logger/logger.dart'; 4 + import 'package:path_provider/path_provider.dart'; 5 + 6 + class AppLogger { 7 + AppLogger._(); 8 + 9 + static final AppLogger _instance = AppLogger._(); 10 + static AppLogger get instance => _instance; 11 + 12 + Logger? _logger; 13 + String? _logDirectory; 14 + 15 + Future<void> initialize() async { 16 + _logDirectory = await _getLogDirectory(); 17 + final logDir = Directory(_logDirectory!); 18 + if (!await logDir.exists()) { 19 + await logDir.create(recursive: true); 20 + } 21 + 22 + await _cleanupOldLogs(); 23 + 24 + _logger = Logger( 25 + filter: DevelopmentFilter(), 26 + printer: PrettyPrinter( 27 + methodCount: 2, 28 + errorMethodCount: 8, 29 + lineLength: 120, 30 + colors: true, 31 + printEmojis: true, 32 + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, 33 + ), 34 + output: MultiOutput([ 35 + ConsoleOutput(), 36 + AdvancedFileOutput( 37 + path: _logDirectory!, 38 + maxFileSizeKB: -1, 39 + fileNameFormatter: _dailyFileNameFormatter, 40 + latestFileName: _todayFileName(), 41 + maxRotatedFilesCount: 3, 42 + overrideExisting: false, 43 + ), 44 + ]), 45 + ); 46 + 47 + await _logger!.init; 48 + } 49 + 50 + Future<String> _getLogDirectory() async { 51 + final docsDir = await getApplicationDocumentsDirectory(); 52 + return '${docsDir.path}/logs'; 53 + } 54 + 55 + static String _dailyFileNameFormatter(DateTime timestamp) { 56 + return 'lazurite_${_formatDate(timestamp)}.log'; 57 + } 58 + 59 + static String _formatDate(DateTime date) { 60 + return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; 61 + } 62 + 63 + static String _todayFileName() { 64 + return _dailyFileNameFormatter(DateTime.now()); 65 + } 66 + 67 + Future<void> _cleanupOldLogs() async { 68 + if (_logDirectory == null) return; 69 + 70 + final logDir = Directory(_logDirectory!); 71 + if (!await logDir.exists()) return; 72 + 73 + const retentionDays = 3; 74 + final cutoffDate = DateTime.now().subtract(const Duration(days: retentionDays)); 75 + 76 + await for (final entity in logDir.list()) { 77 + if (entity is File && entity.path.endsWith('.log')) { 78 + final fileName = entity.uri.pathSegments.last; 79 + final dateMatch = RegExp(r'lazurite_(\d{4}-\d{2}-\d{2})\.log').firstMatch(fileName); 80 + if (dateMatch != null) { 81 + final fileDate = DateTime.parse(dateMatch.group(1)!); 82 + if (fileDate.isBefore(cutoffDate)) { 83 + await entity.delete(); 84 + } 85 + } 86 + } 87 + } 88 + } 89 + 90 + String? get logDirectory => _logDirectory; 91 + 92 + void t(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 93 + _logger?.t(message, time: time, error: error, stackTrace: stackTrace); 94 + } 95 + 96 + void d(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 97 + _logger?.d(message, time: time, error: error, stackTrace: stackTrace); 98 + } 99 + 100 + void i(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 101 + _logger?.i(message, time: time, error: error, stackTrace: stackTrace); 102 + } 103 + 104 + void w(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 105 + _logger?.w(message, time: time, error: error, stackTrace: stackTrace); 106 + } 107 + 108 + void e(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 109 + _logger?.e(message, time: time, error: error, stackTrace: stackTrace); 110 + } 111 + 112 + void f(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 113 + _logger?.f(message, time: time, error: error, stackTrace: stackTrace); 114 + } 115 + 116 + Future<void> dispose() async { 117 + await _logger?.close(); 118 + _logger = null; 119 + } 120 + 121 + Future<List<File>> getLogFiles() async { 122 + if (_logDirectory == null) return []; 123 + 124 + final logDir = Directory(_logDirectory!); 125 + if (!await logDir.exists()) return []; 126 + 127 + final files = <File>[]; 128 + await for (final entity in logDir.list()) { 129 + if (entity is File && entity.path.endsWith('.log')) { 130 + files.add(entity); 131 + } 132 + } 133 + 134 + files.sort((a, b) => b.path.compareTo(a.path)); 135 + return files; 136 + } 137 + 138 + Future<void> clearAllLogs() async { 139 + if (_logDirectory == null) return; 140 + 141 + final logDir = Directory(_logDirectory!); 142 + if (!await logDir.exists()) return; 143 + 144 + await for (final entity in logDir.list()) { 145 + if (entity is File && entity.path.endsWith('.log')) { 146 + await entity.delete(); 147 + } 148 + } 149 + } 150 + 151 + Future<File?> getTodaysLogFile() async { 152 + final files = await getLogFiles(); 153 + final todayName = _todayFileName(); 154 + for (final file in files) { 155 + if (file.uri.pathSegments.last == todayName) { 156 + return file; 157 + } 158 + } 159 + return null; 160 + } 161 + } 162 + 163 + final log = AppLogger.instance;
+37
lib/core/logging/http_logger.dart
··· 1 + class HttpLogger { 2 + static const int _maxBodyLength = 200; 3 + 4 + static String redactAuthorizationHeader(Map<String, String> headers) { 5 + final redacted = <String, String>{}; 6 + headers.forEach((key, value) { 7 + if (key.toLowerCase() == 'authorization') { 8 + redacted[key] = '[REDACTED]'; 9 + } else { 10 + redacted[key] = value; 11 + } 12 + }); 13 + return redacted.entries.map((e) => '${e.key}: ${e.value}').join(', '); 14 + } 15 + 16 + static String truncateBody(String? body) { 17 + if (body == null || body.isEmpty) return '<empty>'; 18 + if (body.length <= _maxBodyLength) return body; 19 + return '${body.substring(0, _maxBodyLength)}...'; 20 + } 21 + 22 + static String formatRequest({ 23 + required String method, 24 + required String path, 25 + Map<String, String>? headers, 26 + String? body, 27 + }) { 28 + final headerStr = headers != null ? redactAuthorizationHeader(headers) : ''; 29 + final bodyStr = truncateBody(body); 30 + return '$method $path${headerStr.isNotEmpty ? ' | Headers: $headerStr' : ''}${bodyStr.isNotEmpty && bodyStr != '<empty>' ? ' | Body: $bodyStr' : ''}'; 31 + } 32 + 33 + static String formatResponse({required int statusCode, required Duration duration, String? body}) { 34 + final bodyStr = truncateBody(body); 35 + return '$statusCode (${duration.inMilliseconds}ms)${bodyStr.isNotEmpty && bodyStr != '<empty>' ? ' | Body: $bodyStr' : ''}'; 36 + } 37 + }
+36
lib/core/logging/logging_bloc_observer.dart
··· 1 + import 'package:flutter_bloc/flutter_bloc.dart'; 2 + import 'package:lazurite/core/logging/app_logger.dart'; 3 + 4 + class LoggingBlocObserver extends BlocObserver { 5 + @override 6 + void onCreate(BlocBase bloc) { 7 + super.onCreate(bloc); 8 + log.d('[${bloc.runtimeType}] Created'); 9 + } 10 + 11 + @override 12 + void onChange(BlocBase bloc, Change change) { 13 + super.onChange(bloc, change); 14 + if (bloc is Bloc) { 15 + log.d('[${bloc.runtimeType}] Transition: ${change.currentState.runtimeType} → ${change.nextState.runtimeType}'); 16 + } 17 + } 18 + 19 + @override 20 + void onEvent(Bloc bloc, Object? event) { 21 + super.onEvent(bloc, event); 22 + log.t('[${bloc.runtimeType}] Event: ${event.runtimeType}'); 23 + } 24 + 25 + @override 26 + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { 27 + super.onError(bloc, error, stackTrace); 28 + log.e('[${bloc.runtimeType}] Error: $error', error: error, stackTrace: stackTrace); 29 + } 30 + 31 + @override 32 + void onClose(BlocBase bloc) { 33 + super.onClose(bloc); 34 + log.d('[${bloc.runtimeType}] Closed'); 35 + } 36 + }
+35
lib/core/logging/logging_navigator_observer.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:lazurite/core/logging/app_logger.dart'; 3 + 4 + class LoggingNavigatorObserver extends NavigatorObserver { 5 + @override 6 + void didPush(Route route, Route? previousRoute) { 7 + super.didPush(route, previousRoute); 8 + final routeName = route.settings.name ?? route.runtimeType.toString(); 9 + final previousName = previousRoute?.settings.name ?? previousRoute?.runtimeType.toString() ?? 'root'; 10 + log.i('Route pushed: $routeName (from $previousName)', time: DateTime.now()); 11 + } 12 + 13 + @override 14 + void didPop(Route route, Route? previousRoute) { 15 + super.didPop(route, previousRoute); 16 + final routeName = route.settings.name ?? route.runtimeType.toString(); 17 + final previousName = previousRoute?.settings.name ?? previousRoute?.runtimeType.toString() ?? 'root'; 18 + log.i('Route popped: $routeName (to $previousName)', time: DateTime.now()); 19 + } 20 + 21 + @override 22 + void didReplace({Route? newRoute, Route? oldRoute}) { 23 + super.didReplace(newRoute: newRoute, oldRoute: oldRoute); 24 + final newName = newRoute?.settings.name ?? newRoute?.runtimeType.toString() ?? 'unknown'; 25 + final oldName = oldRoute?.settings.name ?? oldRoute?.runtimeType.toString() ?? 'unknown'; 26 + log.i('Route replaced: $oldName → $newName', time: DateTime.now()); 27 + } 28 + 29 + @override 30 + void didRemove(Route route, Route? previousRoute) { 31 + super.didRemove(route, previousRoute); 32 + final routeName = route.settings.name ?? route.runtimeType.toString(); 33 + log.i('Route removed: $routeName', time: DateTime.now()); 34 + } 35 + }
+5 -1
lib/core/router/app_router.dart
··· 5 5 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; 6 6 import 'package:lazurite/features/auth/presentation/home_screen.dart'; 7 7 import 'package:lazurite/features/auth/presentation/login_screen.dart'; 8 + import 'package:lazurite/features/logs/presentation/logs_screen.dart'; 8 9 import 'package:lazurite/features/profile/presentation/profile_screen.dart'; 9 10 import 'package:lazurite/features/settings/presentation/settings_screen.dart'; 10 11 11 12 class AppRouter { 12 - AppRouter({required this.authBloc}); 13 + AppRouter({required this.authBloc, this.navigatorObserver}); 13 14 final AuthBloc authBloc; 15 + final NavigatorObserver? navigatorObserver; 14 16 15 17 GoRouter get router => GoRouter( 16 18 refreshListenable: GoRouterRefreshStream(authBloc.stream), 19 + observers: navigatorObserver != null ? [navigatorObserver!] : null, 17 20 redirect: (context, state) { 18 21 final isAuthenticated = authBloc.state.isAuthenticated; 19 22 final isLoggingIn = state.uri.path == '/login'; ··· 36 39 builder: (context, state) => ProfileScreen(actor: state.uri.queryParameters['actor']), 37 40 ), 38 41 GoRoute(path: '/settings', builder: (context, state) => const SettingsScreen()), 42 + GoRoute(path: '/logs', builder: (context, state) => const LogsScreen()), 39 43 ], 40 44 ); 41 45 }
+5 -4
lib/features/auth/data/auth_repository.dart
··· 7 7 import 'package:bluesky/bluesky.dart'; 8 8 import 'package:drift/drift.dart'; 9 9 import 'package:lazurite/core/database/app_database.dart'; 10 + import 'package:lazurite/core/logging/app_logger.dart'; 10 11 import 'package:lazurite/features/auth/data/models/auth_models.dart'; 11 12 import 'package:url_launcher/url_launcher.dart'; 12 13 ··· 286 287 try { 287 288 final authSession = await atp.ATProto.fromOAuthSession(session, service: service).server.getSession(); 288 289 resolvedHandle = authSession.data.handle; 289 - } catch (_) { 290 - // TODO: log this -> Fall back to the login hint if the server session lookup fails. 290 + } catch (e, s) { 291 + log.w('Failed to resolve handle from session, falling back to login hint', error: e, stackTrace: s); 291 292 } 292 293 293 294 try { 294 295 final profile = await Bluesky.fromOAuthSession(session, service: service).actor.getProfile(actor: session.sub); 295 296 displayName = profile.data.displayName; 296 - } catch (_) { 297 - // TODO: log this -> Display name is optional and should not block session persistence. 297 + } catch (e, s) { 298 + log.w('Failed to fetch display name, continuing without it', error: e, stackTrace: s); 298 299 } 299 300 300 301 return AuthTokens(
+87
lib/features/logs/cubit/log_viewer_cubit.dart
··· 1 + import 'dart:io'; 2 + 3 + import 'package:equatable/equatable.dart'; 4 + import 'package:flutter_bloc/flutter_bloc.dart'; 5 + import 'package:logger/logger.dart'; 6 + import 'package:lazurite/core/logging/app_logger.dart'; 7 + import 'package:lazurite/features/logs/data/log_entry.dart'; 8 + 9 + part 'log_viewer_state.dart'; 10 + 11 + class LogViewerCubit extends Cubit<LogViewerState> { 12 + LogViewerCubit() : super(LogViewerState.initial()) { 13 + loadLogs(); 14 + } 15 + 16 + Future<void> loadLogs() async { 17 + emit(state.copyWith(status: LogViewerStatus.loading)); 18 + 19 + try { 20 + final files = await log.getLogFiles(); 21 + final entries = <LogEntry>[]; 22 + 23 + for (final file in files) { 24 + final content = await file.readAsString(); 25 + final lines = content.split('\n'); 26 + for (final line in lines) { 27 + final entry = LogEntry.tryParse(line); 28 + if (entry != null) { 29 + entries.add(entry); 30 + } 31 + } 32 + } 33 + 34 + entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); 35 + 36 + emit( 37 + state.copyWith( 38 + status: LogViewerStatus.loaded, 39 + entries: entries, 40 + filteredEntries: _applyFilters(entries, state.enabledLevels, state.searchQuery), 41 + ), 42 + ); 43 + } catch (e) { 44 + emit(state.copyWith(status: LogViewerStatus.error, errorMessage: e.toString())); 45 + } 46 + } 47 + 48 + void toggleLevel(Level level) { 49 + final newLevels = Set<Level>.from(state.enabledLevels); 50 + if (newLevels.contains(level)) { 51 + newLevels.remove(level); 52 + } else { 53 + newLevels.add(level); 54 + } 55 + emit( 56 + state.copyWith( 57 + enabledLevels: newLevels, 58 + filteredEntries: _applyFilters(state.entries, newLevels, state.searchQuery), 59 + ), 60 + ); 61 + } 62 + 63 + void setSearchQuery(String query) { 64 + emit(state.copyWith(searchQuery: query, filteredEntries: _applyFilters(state.entries, state.enabledLevels, query))); 65 + } 66 + 67 + List<LogEntry> _applyFilters(List<LogEntry> entries, Set<Level> enabledLevels, String searchQuery) { 68 + var filtered = entries.where((e) => enabledLevels.contains(e.level)).toList(); 69 + 70 + if (searchQuery.isNotEmpty) { 71 + final query = searchQuery.toLowerCase(); 72 + filtered = filtered.where((e) { 73 + return e.message.toLowerCase().contains(query) || (e.source?.toLowerCase().contains(query) ?? false); 74 + }).toList(); 75 + } 76 + 77 + return filtered; 78 + } 79 + 80 + Future<File?> getTodaysLogFile() => log.getTodaysLogFile(); 81 + 82 + Future<void> clearAllLogs() async { 83 + await log.clearAllLogs(); 84 + emit(state.copyWith(entries: [], filteredEntries: [])); 85 + await loadLogs(); 86 + } 87 + }
+44
lib/features/logs/cubit/log_viewer_state.dart
··· 1 + part of 'log_viewer_cubit.dart'; 2 + 3 + enum LogViewerStatus { initial, loading, loaded, error } 4 + 5 + class LogViewerState extends Equatable { 6 + const LogViewerState({ 7 + this.status = LogViewerStatus.initial, 8 + this.entries = const [], 9 + this.filteredEntries = const [], 10 + this.enabledLevels = const {Level.trace, Level.debug, Level.info, Level.warning, Level.error, Level.fatal}, 11 + this.searchQuery = '', 12 + this.errorMessage, 13 + }); 14 + 15 + factory LogViewerState.initial() => const LogViewerState(); 16 + 17 + final LogViewerStatus status; 18 + final List<LogEntry> entries; 19 + final List<LogEntry> filteredEntries; 20 + final Set<Level> enabledLevels; 21 + final String searchQuery; 22 + final String? errorMessage; 23 + 24 + LogViewerState copyWith({ 25 + LogViewerStatus? status, 26 + List<LogEntry>? entries, 27 + List<LogEntry>? filteredEntries, 28 + Set<Level>? enabledLevels, 29 + String? searchQuery, 30 + String? errorMessage, 31 + }) { 32 + return LogViewerState( 33 + status: status ?? this.status, 34 + entries: entries ?? this.entries, 35 + filteredEntries: filteredEntries ?? this.filteredEntries, 36 + enabledLevels: enabledLevels ?? this.enabledLevels, 37 + searchQuery: searchQuery ?? this.searchQuery, 38 + errorMessage: errorMessage ?? this.errorMessage, 39 + ); 40 + } 41 + 42 + @override 43 + List<Object?> get props => [status, entries, filteredEntries, enabledLevels, searchQuery, errorMessage]; 44 + }
+115
lib/features/logs/data/log_entry.dart
··· 1 + import 'package:equatable/equatable.dart'; 2 + import 'package:logger/logger.dart'; 3 + 4 + class LogEntry extends Equatable { 5 + const LogEntry({required this.timestamp, required this.level, required this.message, this.source}); 6 + 7 + final DateTime timestamp; 8 + final Level level; 9 + final String message; 10 + final String? source; 11 + 12 + static LogEntry? tryParse(String line) { 13 + final trimmed = line.trim(); 14 + if (trimmed.isEmpty) return null; 15 + 16 + DateTime? timestamp; 17 + var remaining = trimmed; 18 + 19 + final timestampPattern = RegExp(r'^(\d{2}:\d{2}:\d{2}\.\d{3})\s*'); 20 + final timestampMatch = timestampPattern.firstMatch(remaining); 21 + if (timestampMatch != null) { 22 + try { 23 + final timeStr = timestampMatch.group(1)!; 24 + final parts = timeStr.split(':'); 25 + final secondsParts = parts[2].split('.'); 26 + final now = DateTime.now(); 27 + timestamp = DateTime( 28 + now.year, 29 + now.month, 30 + now.day, 31 + int.parse(parts[0]), 32 + int.parse(parts[1]), 33 + int.parse(secondsParts[0]), 34 + int.parse(secondsParts[1]), 35 + ); 36 + remaining = remaining.substring(timestampMatch.end); 37 + } catch (_) {} 38 + } 39 + 40 + final levelPattern = RegExp(r'^\[([A-Z])\]\s*'); 41 + final levelMatch = levelPattern.firstMatch(remaining); 42 + Level level = Level.debug; 43 + 44 + if (levelMatch != null) { 45 + level = _parseLevel(levelMatch.group(1)); 46 + remaining = remaining.substring(levelMatch.end); 47 + } 48 + 49 + final timeTagPattern = RegExp(r'^TIME:\s*[\d\-T:.Z]+\s*'); 50 + remaining = remaining.replaceFirst(timeTagPattern, ''); 51 + 52 + String message; 53 + String? source; 54 + 55 + final colonIndex = remaining.indexOf(':'); 56 + if (colonIndex > 0 && colonIndex < 30 && !remaining.substring(0, colonIndex).contains(' ')) { 57 + source = remaining.substring(0, colonIndex).trim(); 58 + message = remaining.substring(colonIndex + 1).trim(); 59 + } else { 60 + message = remaining.trim(); 61 + } 62 + 63 + if (message.isEmpty && source == null) return null; 64 + 65 + return LogEntry(timestamp: timestamp ?? DateTime.now(), level: level, message: message, source: source); 66 + } 67 + 68 + static Level _parseLevel(String? levelChar) { 69 + switch (levelChar) { 70 + case 'T': 71 + return Level.trace; 72 + case 'D': 73 + return Level.debug; 74 + case 'I': 75 + return Level.info; 76 + case 'W': 77 + return Level.warning; 78 + case 'E': 79 + return Level.error; 80 + case 'F': 81 + return Level.fatal; 82 + default: 83 + return Level.debug; 84 + } 85 + } 86 + 87 + String get levelPrefix { 88 + switch (level) { 89 + case Level.trace: 90 + return 'T'; 91 + case Level.debug: 92 + return 'D'; 93 + case Level.info: 94 + return 'I'; 95 + case Level.warning: 96 + return 'W'; 97 + case Level.error: 98 + return 'E'; 99 + case Level.fatal: 100 + return 'F'; 101 + default: 102 + return 'D'; 103 + } 104 + } 105 + 106 + String formatTimestamp() { 107 + return '${timestamp.hour.toString().padLeft(2, '0')}:' 108 + '${timestamp.minute.toString().padLeft(2, '0')}:' 109 + '${timestamp.second.toString().padLeft(2, '0')}.' 110 + '${timestamp.millisecond.toString().padLeft(3, '0')}'; 111 + } 112 + 113 + @override 114 + List<Object?> get props => [timestamp, level, message, source]; 115 + }
+310
lib/features/logs/presentation/logs_screen.dart
··· 1 + import 'package:flutter/material.dart'; 2 + import 'package:flutter_bloc/flutter_bloc.dart'; 3 + import 'package:logger/logger.dart'; 4 + import 'package:lazurite/features/logs/cubit/log_viewer_cubit.dart'; 5 + import 'package:lazurite/features/logs/data/log_entry.dart'; 6 + import 'package:share_plus/share_plus.dart'; 7 + 8 + class LogsScreen extends StatelessWidget { 9 + const LogsScreen({super.key}); 10 + 11 + @override 12 + Widget build(BuildContext context) { 13 + return BlocProvider(create: (_) => LogViewerCubit(), child: const _LogsScreenContent()); 14 + } 15 + } 16 + 17 + class _LogsScreenContent extends StatelessWidget { 18 + const _LogsScreenContent(); 19 + 20 + @override 21 + Widget build(BuildContext context) { 22 + return Scaffold( 23 + appBar: AppBar( 24 + title: const Text('Logs'), 25 + actions: [ 26 + IconButton( 27 + icon: const Icon(Icons.share_outlined), 28 + tooltip: 'Share log file', 29 + onPressed: () => _shareLogs(context), 30 + ), 31 + IconButton( 32 + icon: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 33 + tooltip: 'Clear all logs', 34 + onPressed: () => _confirmClearLogs(context), 35 + ), 36 + ], 37 + ), 38 + body: Column( 39 + children: [ 40 + _SearchBar(), 41 + _LevelFilterChips(), 42 + Expanded(child: _LogList()), 43 + ], 44 + ), 45 + ); 46 + } 47 + 48 + Future<void> _shareLogs(BuildContext context) async { 49 + final cubit = context.read<LogViewerCubit>(); 50 + final file = await cubit.getTodaysLogFile(); 51 + if (file != null && await file.exists()) { 52 + await Share.shareXFiles([XFile(file.path)], subject: 'Lazurite logs'); 53 + } else { 54 + if (context.mounted) { 55 + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('No log file available'))); 56 + } 57 + } 58 + } 59 + 60 + Future<void> _confirmClearLogs(BuildContext context) async { 61 + final confirmed = await showDialog<bool>( 62 + context: context, 63 + builder: (context) => AlertDialog( 64 + title: const Text('Clear all logs?'), 65 + content: const Text('This will permanently delete all log files. This action cannot be undone.'), 66 + actions: [ 67 + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), 68 + TextButton( 69 + onPressed: () => Navigator.pop(context, true), 70 + child: Text('Clear', style: TextStyle(color: Theme.of(context).colorScheme.error)), 71 + ), 72 + ], 73 + ), 74 + ); 75 + 76 + if (confirmed == true && context.mounted) { 77 + await context.read<LogViewerCubit>().clearAllLogs(); 78 + } 79 + } 80 + } 81 + 82 + class _SearchBar extends StatelessWidget { 83 + @override 84 + Widget build(BuildContext context) { 85 + return Padding( 86 + padding: const EdgeInsets.all(12), 87 + child: TextField( 88 + decoration: InputDecoration( 89 + hintText: 'Filter logs...', 90 + prefixIcon: const Icon(Icons.search, size: 20), 91 + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), 92 + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), 93 + isDense: true, 94 + ), 95 + style: TextStyle(fontFamily: 'JetBrains Mono', fontSize: 13, color: Theme.of(context).colorScheme.onSurface), 96 + onChanged: (query) => context.read<LogViewerCubit>().setSearchQuery(query), 97 + ), 98 + ); 99 + } 100 + } 101 + 102 + class _LevelFilterChips extends StatelessWidget { 103 + static const _levels = [ 104 + (Level.fatal, 'Fatal', Colors.orange), 105 + (Level.error, 'Error', Colors.red), 106 + (Level.warning, 'Warning', Colors.yellow), 107 + (Level.info, 'Info', Colors.blue), 108 + (Level.debug, 'Debug', Colors.green), 109 + (Level.trace, 'Trace', Colors.purple), 110 + ]; 111 + 112 + @override 113 + Widget build(BuildContext context) { 114 + return BlocBuilder<LogViewerCubit, LogViewerState>( 115 + buildWhen: (prev, curr) => prev.enabledLevels != curr.enabledLevels, 116 + builder: (context, state) { 117 + return SizedBox( 118 + height: 40, 119 + child: ListView.separated( 120 + scrollDirection: Axis.horizontal, 121 + padding: const EdgeInsets.symmetric(horizontal: 12), 122 + itemCount: _levels.length, 123 + separatorBuilder: (context, index) => const SizedBox(width: 6), 124 + itemBuilder: (context, index) { 125 + final (level, label, color) = _levels[index]; 126 + final isEnabled = state.enabledLevels.contains(level); 127 + return FilterChip( 128 + label: Text(label), 129 + selected: isEnabled, 130 + onSelected: (selected) => context.read<LogViewerCubit>().toggleLevel(level), 131 + avatar: Container( 132 + width: 6, 133 + height: 6, 134 + decoration: BoxDecoration(color: color, shape: BoxShape.circle), 135 + ), 136 + labelStyle: TextStyle( 137 + fontSize: 12, 138 + fontWeight: FontWeight.w600, 139 + color: isEnabled ? Theme.of(context).colorScheme.onPrimary : null, 140 + ), 141 + selectedColor: Theme.of(context).colorScheme.primary, 142 + backgroundColor: Theme.of(context).colorScheme.surface, 143 + side: BorderSide(color: Theme.of(context).colorScheme.outlineVariant), 144 + showCheckmark: false, 145 + ); 146 + }, 147 + ), 148 + ); 149 + }, 150 + ); 151 + } 152 + } 153 + 154 + class _LogList extends StatelessWidget { 155 + @override 156 + Widget build(BuildContext context) { 157 + return BlocBuilder<LogViewerCubit, LogViewerState>( 158 + builder: (context, state) { 159 + if (state.status == LogViewerStatus.loading) { 160 + return const Center(child: CircularProgressIndicator()); 161 + } 162 + 163 + if (state.status == LogViewerStatus.error) { 164 + return Center(child: Text('Error: ${state.errorMessage}')); 165 + } 166 + 167 + if (state.filteredEntries.isEmpty) { 168 + return Center( 169 + child: Column( 170 + mainAxisSize: MainAxisSize.min, 171 + children: [ 172 + Icon(Icons.description_outlined, size: 48, color: Theme.of(context).colorScheme.outline), 173 + const SizedBox(height: 16), 174 + Text('No logs yet', style: Theme.of(context).textTheme.titleMedium), 175 + const SizedBox(height: 4), 176 + Text('Log entries will appear here', style: Theme.of(context).textTheme.bodySmall), 177 + ], 178 + ), 179 + ); 180 + } 181 + 182 + return ListView.separated( 183 + itemCount: state.filteredEntries.length, 184 + separatorBuilder: (context, index) => Divider(height: 1, color: Theme.of(context).colorScheme.outlineVariant), 185 + itemBuilder: (context, index) { 186 + return _LogEntryTile(entry: state.filteredEntries[index]); 187 + }, 188 + ); 189 + }, 190 + ); 191 + } 192 + } 193 + 194 + class _LogEntryTile extends StatefulWidget { 195 + const _LogEntryTile({required this.entry}); 196 + 197 + final LogEntry entry; 198 + 199 + @override 200 + State<_LogEntryTile> createState() => _LogEntryTileState(); 201 + } 202 + 203 + class _LogEntryTileState extends State<_LogEntryTile> { 204 + bool _expanded = false; 205 + 206 + @override 207 + Widget build(BuildContext context) { 208 + final levelColor = _getLevelColor(context, widget.entry.level); 209 + final badgeColor = _getBadgeColor(context, widget.entry.level); 210 + 211 + return InkWell( 212 + onTap: () => setState(() => _expanded = !_expanded), 213 + child: Padding( 214 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 215 + child: Row( 216 + crossAxisAlignment: CrossAxisAlignment.start, 217 + children: [ 218 + Text( 219 + widget.entry.formatTimestamp(), 220 + style: TextStyle( 221 + fontFamily: 'JetBrains Mono', 222 + fontSize: 11, 223 + color: Theme.of(context).colorScheme.outline, 224 + ), 225 + ), 226 + const SizedBox(width: 8), 227 + Container( 228 + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), 229 + decoration: BoxDecoration(color: badgeColor, borderRadius: BorderRadius.circular(3)), 230 + child: Text( 231 + widget.entry.levelPrefix, 232 + style: TextStyle( 233 + fontFamily: 'JetBrains Mono', 234 + fontSize: 10, 235 + fontWeight: FontWeight.w700, 236 + color: _isFatalOrError(widget.entry.level) ? Colors.white : levelColor, 237 + ), 238 + ), 239 + ), 240 + const SizedBox(width: 8), 241 + Expanded( 242 + child: Column( 243 + crossAxisAlignment: CrossAxisAlignment.start, 244 + children: [ 245 + Text( 246 + widget.entry.message, 247 + style: TextStyle( 248 + fontFamily: 'JetBrains Mono', 249 + fontSize: 12, 250 + color: Theme.of(context).colorScheme.onSurface, 251 + ), 252 + maxLines: _expanded ? null : 2, 253 + overflow: _expanded ? null : TextOverflow.ellipsis, 254 + ), 255 + if (widget.entry.source != null) 256 + Padding( 257 + padding: const EdgeInsets.only(top: 2), 258 + child: Text( 259 + widget.entry.source!, 260 + style: TextStyle( 261 + fontFamily: 'JetBrains Mono', 262 + fontSize: 11, 263 + color: Theme.of(context).colorScheme.outline, 264 + ), 265 + ), 266 + ), 267 + ], 268 + ), 269 + ), 270 + ], 271 + ), 272 + ), 273 + ); 274 + } 275 + 276 + Color _getLevelColor(BuildContext context, Level level) { 277 + final colorScheme = Theme.of(context).colorScheme; 278 + switch (level) { 279 + case Level.fatal: 280 + case Level.error: 281 + return colorScheme.error; 282 + case Level.warning: 283 + return Colors.orange; 284 + case Level.info: 285 + return colorScheme.primary; 286 + default: 287 + return colorScheme.outline; 288 + } 289 + } 290 + 291 + Color _getBadgeColor(BuildContext context, Level level) { 292 + final colorScheme = Theme.of(context).colorScheme; 293 + switch (level) { 294 + case Level.fatal: 295 + return colorScheme.error; 296 + case Level.error: 297 + return colorScheme.error.withAlpha(38); 298 + case Level.warning: 299 + return Colors.orange.withAlpha(38); 300 + case Level.info: 301 + return colorScheme.primary.withAlpha(25); 302 + case Level.debug: 303 + return colorScheme.surfaceContainerHighest; 304 + default: 305 + return colorScheme.surface; 306 + } 307 + } 308 + 309 + bool _isFatalOrError(Level level) => level == Level.fatal || level == Level.error; 310 + }
+6 -1
lib/features/settings/presentation/settings_screen.dart
··· 64 64 const SizedBox(height: 24), 65 65 _buildSectionHeader(context, 'About'), 66 66 _SettingsTile(icon: Icons.code_outlined, title: 'Dev Tools', subtitle: 'PDS Explorer', onTap: () {}), 67 - _SettingsTile(icon: Icons.description_outlined, title: 'Logs', subtitle: 'View app log files', onTap: () {}), 67 + _SettingsTile( 68 + icon: Icons.description_outlined, 69 + title: 'Logs', 70 + subtitle: 'View app log files', 71 + onTap: () => context.push('/logs'), 72 + ), 68 73 _SettingsTile(icon: Icons.help_outline, title: 'Help & Support', onTap: () {}), 69 74 _SettingsTile(icon: Icons.security_outlined, title: 'Privacy Policy', onTap: () {}), 70 75 const SizedBox(height: 24),
+11 -3
lib/main.dart
··· 3 3 import 'package:flutter/material.dart'; 4 4 import 'package:flutter_bloc/flutter_bloc.dart'; 5 5 import 'package:lazurite/core/database/app_database.dart'; 6 + import 'package:lazurite/core/logging/app_logger.dart'; 7 + import 'package:lazurite/core/logging/logging_bloc_observer.dart'; 8 + import 'package:lazurite/core/logging/logging_navigator_observer.dart'; 6 9 import 'package:lazurite/core/router/app_router.dart'; 7 10 import 'package:lazurite/core/theme/app_theme.dart'; 8 11 import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; ··· 17 20 Future<void> main() async { 18 21 WidgetsFlutterBinding.ensureInitialized(); 19 22 23 + await log.initialize(); 24 + Bloc.observer = LoggingBlocObserver(); 25 + 20 26 final database = AppDatabase(); 21 27 final authRepository = AuthRepository(database: database); 22 28 final restoredSession = await authRepository.restoreSession(); ··· 30 36 final settingsCubit = SettingsCubit(database: database); 31 37 await settingsCubit.loadSettings(); 32 38 39 + log.i('App started'); 40 + 33 41 runApp(LazuriteApp(authBloc: authBloc, database: database, settingsCubit: settingsCubit)); 34 42 } 35 43 ··· 39 47 final AuthBloc authBloc; 40 48 final AppDatabase database; 41 49 final SettingsCubit settingsCubit; 50 + 51 + static final _navigatorObserver = LoggingNavigatorObserver(); 42 52 43 53 Bluesky? _createBluesky(AuthState state) { 44 54 if (!state.isAuthenticated || state.tokens == null) { ··· 78 88 79 89 @override 80 90 Widget build(BuildContext context) { 81 - final router = AppRouter(authBloc: authBloc).router; 82 - 83 91 return MultiBlocProvider( 84 92 providers: [ 85 93 BlocProvider.value(value: authBloc), ··· 117 125 theme: lightTheme, 118 126 darkTheme: darkTheme, 119 127 themeMode: themeMode, 120 - routerConfig: router, 128 + routerConfig: AppRouter(authBloc: authBloc, navigatorObserver: _navigatorObserver).router, 121 129 ); 122 130 }, 123 131 ),
+48
pubspec.lock
··· 257 257 url: "https://pub.dev" 258 258 source: hosted 259 259 version: "1.15.0" 260 + cross_file: 261 + dependency: transitive 262 + description: 263 + name: cross_file 264 + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" 265 + url: "https://pub.dev" 266 + source: hosted 267 + version: "0.3.5+2" 260 268 crypto: 261 269 dependency: "direct main" 262 270 description: ··· 552 560 url: "https://pub.dev" 553 561 source: hosted 554 562 version: "6.1.0" 563 + logger: 564 + dependency: "direct main" 565 + description: 566 + name: logger 567 + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" 568 + url: "https://pub.dev" 569 + source: hosted 570 + version: "2.7.0" 555 571 logging: 556 572 dependency: transitive 557 573 description: ··· 760 776 url: "https://pub.dev" 761 777 source: hosted 762 778 version: "4.1.0" 779 + share_plus: 780 + dependency: "direct main" 781 + description: 782 + name: share_plus 783 + sha256: fce43200aa03ea87b91ce4c3ac79f0cecd52e2a7a56c7a4185023c271fbfa6da 784 + url: "https://pub.dev" 785 + source: hosted 786 + version: "10.1.4" 787 + share_plus_platform_interface: 788 + dependency: transitive 789 + description: 790 + name: share_plus_platform_interface 791 + sha256: cc012a23fc2d479854e6c80150696c4a5f5bb62cb89af4de1c505cf78d0a5d0b 792 + url: "https://pub.dev" 793 + source: hosted 794 + version: "5.0.2" 763 795 shelf: 764 796 dependency: transitive 765 797 description: ··· 997 1029 url: "https://pub.dev" 998 1030 source: hosted 999 1031 version: "3.1.5" 1032 + uuid: 1033 + dependency: transitive 1034 + description: 1035 + name: uuid 1036 + sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489" 1037 + url: "https://pub.dev" 1038 + source: hosted 1039 + version: "4.5.3" 1000 1040 vector_math: 1001 1041 dependency: transitive 1002 1042 description: ··· 1053 1093 url: "https://pub.dev" 1054 1094 source: hosted 1055 1095 version: "1.2.1" 1096 + win32: 1097 + dependency: transitive 1098 + description: 1099 + name: win32 1100 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e 1101 + url: "https://pub.dev" 1102 + source: hosted 1103 + version: "5.15.0" 1056 1104 xdg_directories: 1057 1105 dependency: transitive 1058 1106 description:
+2
pubspec.yaml
··· 28 28 url_launcher: ^6.3.1 29 29 intl: ^0.19.0 30 30 google_fonts: ^6.2.1 31 + logger: ^2.6.2 32 + share_plus: ^10.1.4 31 33 32 34 dev_dependencies: 33 35 flutter_test:
-1
scripts/.python-version
··· 1 - 3.10
-1
scripts/README.md
··· 1 - # ATProto/BlueSky API Scripts
-6
scripts/main.py
··· 1 - def main(): 2 - print("Hello from scripts!") 3 - 4 - 5 - if __name__ == "__main__": 6 - main()
-7
scripts/pyproject.toml
··· 1 - [project] 2 - name = "scripts" 3 - version = "0.1.0" 4 - description = "Add your description here" 5 - readme = "README.md" 6 - requires-python = ">=3.10" 7 - dependencies = []
+143
test/core/logging/http_logger_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:lazurite/core/logging/http_logger.dart'; 3 + 4 + void main() { 5 + group('HttpLogger', () { 6 + group('redactAuthorizationHeader', () { 7 + test('redacts Authorization header', () { 8 + final headers = {'Authorization': 'Bearer secret_token', 'Content-Type': 'application/json'}; 9 + final result = HttpLogger.redactAuthorizationHeader(headers); 10 + expect(result.contains('Authorization: [REDACTED]'), isTrue); 11 + expect(result.contains('secret_token'), isFalse); 12 + expect(result.contains('Content-Type: application/json'), isTrue); 13 + }); 14 + 15 + test('handles case-insensitive Authorization header', () { 16 + final headers = {'authorization': 'Bearer secret_token'}; 17 + final result = HttpLogger.redactAuthorizationHeader(headers); 18 + expect(result.contains('[REDACTED]'), isTrue); 19 + expect(result.contains('secret_token'), isFalse); 20 + }); 21 + 22 + test('returns empty string for empty headers', () { 23 + final result = HttpLogger.redactAuthorizationHeader({}); 24 + expect(result, isEmpty); 25 + }); 26 + 27 + test('preserves non-authorization headers', () { 28 + final headers = {'Content-Type': 'application/json', 'Accept': '*/*'}; 29 + final result = HttpLogger.redactAuthorizationHeader(headers); 30 + expect(result.contains('Content-Type: application/json'), isTrue); 31 + expect(result.contains('Accept: */*'), isTrue); 32 + }); 33 + }); 34 + 35 + group('truncateBody', () { 36 + test('returns <empty> for null body', () { 37 + final result = HttpLogger.truncateBody(null); 38 + expect(result, '<empty>'); 39 + }); 40 + 41 + test('returns <empty> for empty string', () { 42 + final result = HttpLogger.truncateBody(''); 43 + expect(result, '<empty>'); 44 + }); 45 + 46 + test('returns body as-is when under limit', () { 47 + const shortBody = 'short body'; 48 + final result = HttpLogger.truncateBody(shortBody); 49 + expect(result, shortBody); 50 + }); 51 + 52 + test('truncates body when over limit', () { 53 + final longBody = 'a' * 300; 54 + final result = HttpLogger.truncateBody(longBody); 55 + expect(result.length, lessThanOrEqualTo(203)); 56 + expect(result.endsWith('...'), isTrue); 57 + }); 58 + 59 + test('truncates exactly at 200 characters', () { 60 + final body200 = 'a' * 200; 61 + final result = HttpLogger.truncateBody(body200); 62 + expect(result, body200); 63 + expect(result.contains('...'), isFalse); 64 + }); 65 + }); 66 + 67 + group('formatRequest', () { 68 + test('formats basic request', () { 69 + final result = HttpLogger.formatRequest(method: 'GET', path: '/api/users'); 70 + expect(result, 'GET /api/users'); 71 + }); 72 + 73 + test('formats request with headers', () { 74 + final result = HttpLogger.formatRequest( 75 + method: 'POST', 76 + path: '/api/users', 77 + headers: {'Content-Type': 'application/json'}, 78 + ); 79 + expect(result.contains('POST /api/users'), isTrue); 80 + expect(result.contains('Content-Type: application/json'), isTrue); 81 + }); 82 + 83 + test('redacts Authorization header in request', () { 84 + final result = HttpLogger.formatRequest( 85 + method: 'GET', 86 + path: '/api/users', 87 + headers: {'Authorization': 'Bearer secret_token'}, 88 + ); 89 + expect(result.contains('[REDACTED]'), isTrue); 90 + expect(result.contains('secret_token'), isFalse); 91 + }); 92 + 93 + test('formats request with body', () { 94 + final result = HttpLogger.formatRequest(method: 'POST', path: '/api/users', body: '{"name":"test"}'); 95 + expect(result.contains('Body: {"name":"test"}'), isTrue); 96 + }); 97 + 98 + test('formats complete request', () { 99 + final result = HttpLogger.formatRequest( 100 + method: 'POST', 101 + path: '/api/users', 102 + headers: {'Content-Type': 'application/json', 'Authorization': 'Bearer token'}, 103 + body: '{"name":"test"}', 104 + ); 105 + expect(result.contains('POST /api/users'), isTrue); 106 + expect(result.contains('[REDACTED]'), isTrue); 107 + expect(result.contains('Body:'), isTrue); 108 + }); 109 + }); 110 + 111 + group('formatResponse', () { 112 + test('formats basic response', () { 113 + final result = HttpLogger.formatResponse(statusCode: 200, duration: const Duration(milliseconds: 150)); 114 + expect(result, '200 (150ms)'); 115 + }); 116 + 117 + test('formats response with body', () { 118 + final result = HttpLogger.formatResponse( 119 + statusCode: 200, 120 + duration: const Duration(milliseconds: 150), 121 + body: '{"id":1}', 122 + ); 123 + expect(result.contains('200 (150ms)'), isTrue); 124 + expect(result.contains('Body: {"id":1}'), isTrue); 125 + }); 126 + 127 + test('truncates long response body', () { 128 + final longBody = 'a' * 300; 129 + final result = HttpLogger.formatResponse( 130 + statusCode: 200, 131 + duration: const Duration(milliseconds: 150), 132 + body: longBody, 133 + ); 134 + expect(result.contains('...'), isTrue); 135 + }); 136 + 137 + test('handles empty response body', () { 138 + final result = HttpLogger.formatResponse(statusCode: 204, duration: const Duration(milliseconds: 50)); 139 + expect(result, '204 (50ms)'); 140 + }); 141 + }); 142 + }); 143 + }
+68
test/features/logs/cubit/log_viewer_cubit_test.dart
··· 1 + import 'package:bloc_test/bloc_test.dart'; 2 + import 'package:flutter_test/flutter_test.dart'; 3 + import 'package:logger/logger.dart'; 4 + import 'package:lazurite/features/logs/cubit/log_viewer_cubit.dart'; 5 + 6 + void main() { 7 + group('LogViewerState', () { 8 + test('initial state has default values', () { 9 + final state = LogViewerState.initial(); 10 + expect(state.status, LogViewerStatus.initial); 11 + expect(state.entries, isEmpty); 12 + expect(state.filteredEntries, isEmpty); 13 + expect(state.searchQuery, isEmpty); 14 + expect( 15 + state.enabledLevels, 16 + containsAll([Level.trace, Level.debug, Level.info, Level.warning, Level.error, Level.fatal]), 17 + ); 18 + }); 19 + 20 + test('copyWith preserves values when not specified', () { 21 + final state = LogViewerState.initial().copyWith(status: LogViewerStatus.loaded); 22 + expect(state.status, LogViewerStatus.loaded); 23 + expect(state.entries, isEmpty); 24 + expect(state.enabledLevels.length, 6); 25 + }); 26 + 27 + test('copyWith updates specified values', () { 28 + final state = LogViewerState.initial().copyWith(status: LogViewerStatus.error, errorMessage: 'Test error'); 29 + expect(state.status, LogViewerStatus.error); 30 + expect(state.errorMessage, 'Test error'); 31 + }); 32 + }); 33 + 34 + group('LogViewerCubit', () { 35 + blocTest<LogViewerCubit, LogViewerState>( 36 + 'toggleLevel removes level when enabled', 37 + build: () => LogViewerCubit(), 38 + wait: const Duration(milliseconds: 100), 39 + act: (cubit) => cubit.toggleLevel(Level.info), 40 + verify: (cubit) { 41 + expect(cubit.state.enabledLevels.contains(Level.info), isFalse); 42 + }, 43 + ); 44 + 45 + blocTest<LogViewerCubit, LogViewerState>( 46 + 'toggleLevel adds level when disabled', 47 + build: () => LogViewerCubit(), 48 + wait: const Duration(milliseconds: 100), 49 + act: (cubit) { 50 + cubit.toggleLevel(Level.info); 51 + cubit.toggleLevel(Level.info); 52 + }, 53 + verify: (cubit) { 54 + expect(cubit.state.enabledLevels.contains(Level.info), isTrue); 55 + }, 56 + ); 57 + 58 + blocTest<LogViewerCubit, LogViewerState>( 59 + 'setSearchQuery updates search query', 60 + build: () => LogViewerCubit(), 61 + wait: const Duration(milliseconds: 100), 62 + act: (cubit) => cubit.setSearchQuery('test query'), 63 + verify: (cubit) { 64 + expect(cubit.state.searchQuery, 'test query'); 65 + }, 66 + ); 67 + }); 68 + }
+114
test/features/logs/data/log_entry_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:logger/logger.dart'; 3 + import 'package:lazurite/features/logs/data/log_entry.dart'; 4 + 5 + void main() { 6 + group('LogEntry', () { 7 + group('tryParse', () { 8 + test('parses simple log line with level prefix', () { 9 + final entry = LogEntry.tryParse('[I] This is an info message'); 10 + expect(entry, isNotNull); 11 + expect(entry!.level, Level.info); 12 + expect(entry.message, 'This is an info message'); 13 + }); 14 + 15 + test('parses log line with all level prefixes', () { 16 + final levels = { 17 + 'T': Level.trace, 18 + 'D': Level.debug, 19 + 'I': Level.info, 20 + 'W': Level.warning, 21 + 'E': Level.error, 22 + 'F': Level.fatal, 23 + }; 24 + 25 + for (final entry in levels.entries) { 26 + final logEntry = LogEntry.tryParse('[${entry.key}] Message'); 27 + expect(logEntry, isNotNull, reason: 'Failed to parse [${entry.key}]'); 28 + expect(logEntry!.level, entry.value, reason: 'Wrong level for [${entry.key}]'); 29 + } 30 + }); 31 + 32 + test('parses log line with source prefix', () { 33 + final entry = LogEntry.tryParse('[I] AuthBloc: User logged in'); 34 + expect(entry, isNotNull); 35 + expect(entry!.level, Level.info); 36 + expect(entry.message, 'User logged in'); 37 + expect(entry.source, 'AuthBloc'); 38 + }); 39 + 40 + test('parses log line with timestamp', () { 41 + final entry = LogEntry.tryParse('14:32:01.123 [I] App started'); 42 + expect(entry, isNotNull); 43 + expect(entry!.level, Level.info); 44 + expect(entry.message, 'App started'); 45 + expect(entry.formatTimestamp(), '14:32:01.123'); 46 + }); 47 + 48 + test('returns null for empty line', () { 49 + final entry = LogEntry.tryParse(''); 50 + expect(entry, isNull); 51 + }); 52 + 53 + test('returns null for whitespace-only line', () { 54 + final entry = LogEntry.tryParse(' '); 55 + expect(entry, isNull); 56 + }); 57 + 58 + test('creates entry for line without level prefix', () { 59 + final entry = LogEntry.tryParse('Some random log message'); 60 + expect(entry, isNotNull); 61 + expect(entry!.level, Level.debug); 62 + expect(entry.message, 'Some random log message'); 63 + }); 64 + }); 65 + 66 + group('levelPrefix', () { 67 + test('returns correct prefix for each level', () { 68 + final prefixes = { 69 + Level.trace: 'T', 70 + Level.debug: 'D', 71 + Level.info: 'I', 72 + Level.warning: 'W', 73 + Level.error: 'E', 74 + Level.fatal: 'F', 75 + }; 76 + 77 + for (final entry in prefixes.entries) { 78 + final logEntry = LogEntry(timestamp: DateTime.now(), level: entry.key, message: 'test'); 79 + expect(logEntry.levelPrefix, entry.value); 80 + } 81 + }); 82 + }); 83 + 84 + group('formatTimestamp', () { 85 + test('formats timestamp correctly', () { 86 + final timestamp = DateTime(2024, 1, 15, 14, 32, 1, 123); 87 + final entry = LogEntry(timestamp: timestamp, level: Level.info, message: 'test'); 88 + expect(entry.formatTimestamp(), '14:32:01.123'); 89 + }); 90 + 91 + test('pads single digits with zeros', () { 92 + final timestamp = DateTime(2024, 1, 15, 9, 5, 3, 5); 93 + final entry = LogEntry(timestamp: timestamp, level: Level.info, message: 'test'); 94 + expect(entry.formatTimestamp(), '09:05:03.005'); 95 + }); 96 + }); 97 + 98 + group('equality', () { 99 + test('entries with same values are equal', () { 100 + final timestamp = DateTime.now(); 101 + final entry1 = LogEntry(timestamp: timestamp, level: Level.info, message: 'test', source: 'App'); 102 + final entry2 = LogEntry(timestamp: timestamp, level: Level.info, message: 'test', source: 'App'); 103 + expect(entry1, equals(entry2)); 104 + }); 105 + 106 + test('entries with different values are not equal', () { 107 + final timestamp = DateTime.now(); 108 + final entry1 = LogEntry(timestamp: timestamp, level: Level.info, message: 'test'); 109 + final entry2 = LogEntry(timestamp: timestamp, level: Level.error, message: 'test'); 110 + expect(entry1, isNot(equals(entry2))); 111 + }); 112 + }); 113 + }); 114 + }