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: log parsing for viewer screen

* fix filesystem pems

+759 -163
+3
devtools_options.yaml
··· 1 + description: This file stores settings for Dart & Flutter DevTools. 2 + documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states 3 + extensions:
+69
ios/Podfile.lock
··· 1 + PODS: 2 + - Flutter (1.0.0) 3 + - path_provider_foundation (0.0.1): 4 + - Flutter 5 + - FlutterMacOS 6 + - share_plus (0.0.1): 7 + - Flutter 8 + - sqlite3 (3.52.0): 9 + - sqlite3/common (= 3.52.0) 10 + - sqlite3/common (3.52.0) 11 + - sqlite3/dbstatvtab (3.52.0): 12 + - sqlite3/common 13 + - sqlite3/fts5 (3.52.0): 14 + - sqlite3/common 15 + - sqlite3/math (3.52.0): 16 + - sqlite3/common 17 + - sqlite3/perf-threadsafe (3.52.0): 18 + - sqlite3/common 19 + - sqlite3/rtree (3.52.0): 20 + - sqlite3/common 21 + - sqlite3/session (3.52.0): 22 + - sqlite3/common 23 + - sqlite3_flutter_libs (0.0.1): 24 + - Flutter 25 + - FlutterMacOS 26 + - sqlite3 (~> 3.52.0) 27 + - sqlite3/dbstatvtab 28 + - sqlite3/fts5 29 + - sqlite3/math 30 + - sqlite3/perf-threadsafe 31 + - sqlite3/rtree 32 + - sqlite3/session 33 + - url_launcher_ios (0.0.1): 34 + - Flutter 35 + 36 + DEPENDENCIES: 37 + - Flutter (from `Flutter`) 38 + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) 39 + - share_plus (from `.symlinks/plugins/share_plus/ios`) 40 + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) 41 + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) 42 + 43 + SPEC REPOS: 44 + trunk: 45 + - sqlite3 46 + 47 + EXTERNAL SOURCES: 48 + Flutter: 49 + :path: Flutter 50 + path_provider_foundation: 51 + :path: ".symlinks/plugins/path_provider_foundation/darwin" 52 + share_plus: 53 + :path: ".symlinks/plugins/share_plus/ios" 54 + sqlite3_flutter_libs: 55 + :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" 56 + url_launcher_ios: 57 + :path: ".symlinks/plugins/url_launcher_ios/ios" 58 + 59 + SPEC CHECKSUMS: 60 + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 61 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba 62 + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f 63 + sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 64 + sqlite3_flutter_libs: f9114e4bbe1f2e03dd543373c53d23245982ca13 65 + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa 66 + 67 + PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e 68 + 69 + COCOAPODS: 1.16.2
+112
ios/Runner.xcodeproj/project.pbxproj
··· 9 9 /* Begin PBXBuildFile section */ 10 10 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 11 11 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 12 + 3681913A96A36188ECBC1602 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8BAC14F436233DB60FD42F5 /* Pods_Runner.framework */; }; 12 13 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 14 + 62EEA636832BD30DA6CBEFB6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30736DB2E18CAB086461C486 /* Pods_RunnerTests.framework */; }; 13 15 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 14 16 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 15 17 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; ··· 42 44 /* Begin PBXFileReference section */ 43 45 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; }; 44 46 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; }; 47 + 218A62E7E6B03A7A2B52862B /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; }; 48 + 2B0D118EB73867598835F2DD /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; }; 49 + 30736DB2E18CAB086461C486 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 45 50 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; }; 46 51 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 47 52 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; }; 53 + 420DA8205F7F2E7EF8664FD5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; }; 48 54 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; }; 49 55 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; }; 50 56 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; }; 57 + 90899372CAA28BD74B2D49A1 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; }; 51 58 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; }; 52 59 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; }; 53 60 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; ··· 55 62 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; 56 63 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; }; 57 64 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 65 + AA7C2CE85592DCD41249030A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; }; 66 + D8BAC14F436233DB60FD42F5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 67 + E85E7FAF00CB12EDA75D52D5 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; }; 58 68 /* End PBXFileReference section */ 59 69 60 70 /* Begin PBXFrameworksBuildPhase section */ 71 + 69FDE1518F75C59F0767A0F9 /* Frameworks */ = { 72 + isa = PBXFrameworksBuildPhase; 73 + buildActionMask = 2147483647; 74 + files = ( 75 + 62EEA636832BD30DA6CBEFB6 /* Pods_RunnerTests.framework in Frameworks */, 76 + ); 77 + runOnlyForDeploymentPostprocessing = 0; 78 + }; 61 79 97C146EB1CF9000F007C117D /* Frameworks */ = { 62 80 isa = PBXFrameworksBuildPhase; 63 81 buildActionMask = 2147483647; 64 82 files = ( 83 + 3681913A96A36188ECBC1602 /* Pods_Runner.framework in Frameworks */, 65 84 ); 66 85 runOnlyForDeploymentPostprocessing = 0; 67 86 }; ··· 94 113 97C146F01CF9000F007C117D /* Runner */, 95 114 97C146EF1CF9000F007C117D /* Products */, 96 115 331C8082294A63A400263BE5 /* RunnerTests */, 116 + ECF13CAC20BC1145817075FE /* Pods */, 117 + D8C8796E051ACD62389767AD /* Frameworks */, 97 118 ); 98 119 sourceTree = "<group>"; 99 120 }; ··· 121 142 path = Runner; 122 143 sourceTree = "<group>"; 123 144 }; 145 + D8C8796E051ACD62389767AD /* Frameworks */ = { 146 + isa = PBXGroup; 147 + children = ( 148 + D8BAC14F436233DB60FD42F5 /* Pods_Runner.framework */, 149 + 30736DB2E18CAB086461C486 /* Pods_RunnerTests.framework */, 150 + ); 151 + name = Frameworks; 152 + sourceTree = "<group>"; 153 + }; 154 + ECF13CAC20BC1145817075FE /* Pods */ = { 155 + isa = PBXGroup; 156 + children = ( 157 + 420DA8205F7F2E7EF8664FD5 /* Pods-Runner.debug.xcconfig */, 158 + 218A62E7E6B03A7A2B52862B /* Pods-Runner.release.xcconfig */, 159 + 2B0D118EB73867598835F2DD /* Pods-Runner.profile.xcconfig */, 160 + E85E7FAF00CB12EDA75D52D5 /* Pods-RunnerTests.debug.xcconfig */, 161 + 90899372CAA28BD74B2D49A1 /* Pods-RunnerTests.release.xcconfig */, 162 + AA7C2CE85592DCD41249030A /* Pods-RunnerTests.profile.xcconfig */, 163 + ); 164 + name = Pods; 165 + path = Pods; 166 + sourceTree = "<group>"; 167 + }; 124 168 /* End PBXGroup section */ 125 169 126 170 /* Begin PBXNativeTarget section */ ··· 128 172 isa = PBXNativeTarget; 129 173 buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; 130 174 buildPhases = ( 175 + 3003FE2FF11F279B38D36812 /* [CP] Check Pods Manifest.lock */, 131 176 331C807D294A63A400263BE5 /* Sources */, 132 177 331C807F294A63A400263BE5 /* Resources */, 178 + 69FDE1518F75C59F0767A0F9 /* Frameworks */, 133 179 ); 134 180 buildRules = ( 135 181 ); ··· 145 191 isa = PBXNativeTarget; 146 192 buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; 147 193 buildPhases = ( 194 + C67D8A4AC1F1BC2EE3E67CCE /* [CP] Check Pods Manifest.lock */, 148 195 9740EEB61CF901F6004384FC /* Run Script */, 149 196 97C146EA1CF9000F007C117D /* Sources */, 150 197 97C146EB1CF9000F007C117D /* Frameworks */, 151 198 97C146EC1CF9000F007C117D /* Resources */, 152 199 9705A1C41CF9048500538489 /* Embed Frameworks */, 153 200 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 201 + D83A47AAE1B8A38105D97A73 /* [CP] Embed Pods Frameworks */, 154 202 ); 155 203 buildRules = ( 156 204 ); ··· 222 270 /* End PBXResourcesBuildPhase section */ 223 271 224 272 /* Begin PBXShellScriptBuildPhase section */ 273 + 3003FE2FF11F279B38D36812 /* [CP] Check Pods Manifest.lock */ = { 274 + isa = PBXShellScriptBuildPhase; 275 + buildActionMask = 2147483647; 276 + files = ( 277 + ); 278 + inputFileListPaths = ( 279 + ); 280 + inputPaths = ( 281 + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 282 + "${PODS_ROOT}/Manifest.lock", 283 + ); 284 + name = "[CP] Check Pods Manifest.lock"; 285 + outputFileListPaths = ( 286 + ); 287 + outputPaths = ( 288 + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", 289 + ); 290 + runOnlyForDeploymentPostprocessing = 0; 291 + shellPath = /bin/sh; 292 + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 293 + showEnvVarsInLog = 0; 294 + }; 225 295 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { 226 296 isa = PBXShellScriptBuildPhase; 227 297 alwaysOutOfDate = 1; ··· 252 322 runOnlyForDeploymentPostprocessing = 0; 253 323 shellPath = /bin/sh; 254 324 shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; 325 + }; 326 + C67D8A4AC1F1BC2EE3E67CCE /* [CP] Check Pods Manifest.lock */ = { 327 + isa = PBXShellScriptBuildPhase; 328 + buildActionMask = 2147483647; 329 + files = ( 330 + ); 331 + inputFileListPaths = ( 332 + ); 333 + inputPaths = ( 334 + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 335 + "${PODS_ROOT}/Manifest.lock", 336 + ); 337 + name = "[CP] Check Pods Manifest.lock"; 338 + outputFileListPaths = ( 339 + ); 340 + outputPaths = ( 341 + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", 342 + ); 343 + runOnlyForDeploymentPostprocessing = 0; 344 + shellPath = /bin/sh; 345 + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 346 + showEnvVarsInLog = 0; 347 + }; 348 + D83A47AAE1B8A38105D97A73 /* [CP] Embed Pods Frameworks */ = { 349 + isa = PBXShellScriptBuildPhase; 350 + buildActionMask = 2147483647; 351 + files = ( 352 + ); 353 + inputFileListPaths = ( 354 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", 355 + ); 356 + name = "[CP] Embed Pods Frameworks"; 357 + outputFileListPaths = ( 358 + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", 359 + ); 360 + runOnlyForDeploymentPostprocessing = 0; 361 + shellPath = /bin/sh; 362 + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; 363 + showEnvVarsInLog = 0; 255 364 }; 256 365 /* End PBXShellScriptBuildPhase section */ 257 366 ··· 378 487 }; 379 488 331C8088294A63A400263BE5 /* Debug */ = { 380 489 isa = XCBuildConfiguration; 490 + baseConfigurationReference = E85E7FAF00CB12EDA75D52D5 /* Pods-RunnerTests.debug.xcconfig */; 381 491 buildSettings = { 382 492 BUNDLE_LOADER = "$(TEST_HOST)"; 383 493 CODE_SIGN_STYLE = Automatic; ··· 395 505 }; 396 506 331C8089294A63A400263BE5 /* Release */ = { 397 507 isa = XCBuildConfiguration; 508 + baseConfigurationReference = 90899372CAA28BD74B2D49A1 /* Pods-RunnerTests.release.xcconfig */; 398 509 buildSettings = { 399 510 BUNDLE_LOADER = "$(TEST_HOST)"; 400 511 CODE_SIGN_STYLE = Automatic; ··· 410 521 }; 411 522 331C808A294A63A400263BE5 /* Profile */ = { 412 523 isa = XCBuildConfiguration; 524 + baseConfigurationReference = AA7C2CE85592DCD41249030A /* Pods-RunnerTests.profile.xcconfig */; 413 525 buildSettings = { 414 526 BUNDLE_LOADER = "$(TEST_HOST)"; 415 527 CODE_SIGN_STYLE = Automatic;
+3
ios/Runner.xcworkspace/contents.xcworkspacedata
··· 4 4 <FileRef 5 5 location = "group:Runner.xcodeproj"> 6 6 </FileRef> 7 + <FileRef 8 + location = "group:Pods/Pods.xcodeproj"> 9 + </FileRef> 7 10 </Workspace>
+72
lib/core/logging/app_file_log_printer.dart
··· 1 + import 'dart:convert'; 2 + 3 + import 'package:logger/logger.dart'; 4 + 5 + class AppFileLogPrinter extends LogPrinter { 6 + AppFileLogPrinter(); 7 + 8 + @override 9 + List<String> log(LogEvent event) { 10 + final buffer = StringBuffer() 11 + ..write(_labelFor(event.level)) 12 + ..write(' TIME: ') 13 + ..write(event.time.toIso8601String()) 14 + ..write(' ') 15 + ..write(_sanitize(_stringifyMessage(event.message))); 16 + 17 + if (event.error != null) { 18 + buffer 19 + ..write(' ERROR: ') 20 + ..write(_sanitize(event.error.toString())); 21 + } 22 + 23 + final stackTrace = _sanitizeStackTrace(event.stackTrace); 24 + if (stackTrace != null) { 25 + buffer 26 + ..write(' STACK: ') 27 + ..write(stackTrace); 28 + } 29 + 30 + return [buffer.toString()]; 31 + } 32 + 33 + String _labelFor(Level level) { 34 + switch (level) { 35 + case Level.trace: 36 + return '[T]'; 37 + case Level.debug: 38 + return '[D]'; 39 + case Level.info: 40 + return '[I]'; 41 + case Level.warning: 42 + return '[W]'; 43 + case Level.error: 44 + return '[E]'; 45 + case Level.fatal: 46 + return '[FATAL]'; 47 + default: 48 + return '[D]'; 49 + } 50 + } 51 + 52 + String _stringifyMessage(dynamic message) { 53 + final resolvedMessage = message is Function ? message() : message; 54 + if (resolvedMessage is Map || resolvedMessage is Iterable) { 55 + return const JsonEncoder.withIndent(null).convert(resolvedMessage); 56 + } 57 + return resolvedMessage.toString(); 58 + } 59 + 60 + String _sanitize(String value) { 61 + return value.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).join(' | '); 62 + } 63 + 64 + String? _sanitizeStackTrace(StackTrace? stackTrace) { 65 + if (stackTrace == null) { 66 + return null; 67 + } 68 + 69 + final sanitized = _sanitize(stackTrace.toString()); 70 + return sanitized.isEmpty ? null : sanitized; 71 + } 72 + }
+49 -76
lib/core/logging/app_logger.dart
··· 1 1 import 'dart:io'; 2 2 3 + import 'package:flutter/foundation.dart'; 3 4 import 'package:logger/logger.dart'; 4 5 import 'package:path_provider/path_provider.dart'; 6 + import 'package:lazurite/core/logging/app_file_log_printer.dart'; 7 + import 'package:lazurite/core/logging/daily_log_file_output.dart'; 5 8 6 9 class AppLogger { 7 10 AppLogger._(); ··· 9 12 static final AppLogger _instance = AppLogger._(); 10 13 static AppLogger get instance => _instance; 11 14 12 - Logger? _logger; 15 + Logger? _consoleLogger; 16 + Logger? _fileLogger; 17 + DailyLogFileOutput? _fileOutput; 13 18 String? _logDirectory; 14 19 15 20 Future<void> initialize() async { 21 + await dispose(); 22 + 16 23 _logDirectory = await _getLogDirectory(); 17 - final logDir = Directory(_logDirectory!); 18 - if (!await logDir.exists()) { 19 - await logDir.create(recursive: true); 20 - } 24 + _fileOutput = DailyLogFileOutput(directoryPath: _logDirectory!, retentionDays: 3); 25 + _fileLogger = Logger( 26 + filter: ProductionFilter(), 27 + printer: AppFileLogPrinter(), 28 + output: _fileOutput!, 29 + level: Level.trace, 30 + ); 21 31 22 - await _cleanupOldLogs(); 32 + final initFutures = <Future<void>>[_fileLogger!.init]; 23 33 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, 34 + if (kDebugMode) { 35 + _consoleLogger = Logger( 36 + filter: DevelopmentFilter(), 37 + printer: PrettyPrinter( 38 + methodCount: 2, 39 + errorMethodCount: 8, 40 + lineLength: 120, 41 + colors: true, 42 + printEmojis: true, 43 + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, 43 44 ), 44 - ]), 45 - ); 45 + output: ConsoleOutput(), 46 + level: Level.trace, 47 + ); 48 + initFutures.add(_consoleLogger!.init); 49 + } 46 50 47 - await _logger!.init; 51 + await Future.wait(initFutures); 48 52 } 49 53 50 54 Future<String> _getLogDirectory() async { ··· 52 56 return '${docsDir.path}/logs'; 53 57 } 54 58 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 59 static String _todayFileName() { 64 - return _dailyFileNameFormatter(DateTime.now()); 60 + return DailyLogFileOutput.fileNameFor(DateTime.now()); 65 61 } 66 62 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 - } 63 + void _log(Level level, dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 64 + final logTime = time ?? DateTime.now(); 65 + _fileLogger?.log(level, message, time: logTime, error: error, stackTrace: stackTrace); 66 + _consoleLogger?.log(level, message, time: logTime, error: error, stackTrace: stackTrace); 88 67 } 89 68 90 69 String? get logDirectory => _logDirectory; 91 70 92 71 void t(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 93 - _logger?.t(message, time: time, error: error, stackTrace: stackTrace); 72 + _log(Level.trace, message, time: time, error: error, stackTrace: stackTrace); 94 73 } 95 74 96 75 void d(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 97 - _logger?.d(message, time: time, error: error, stackTrace: stackTrace); 76 + _log(Level.debug, message, time: time, error: error, stackTrace: stackTrace); 98 77 } 99 78 100 79 void i(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 101 - _logger?.i(message, time: time, error: error, stackTrace: stackTrace); 80 + _log(Level.info, message, time: time, error: error, stackTrace: stackTrace); 102 81 } 103 82 104 83 void w(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 105 - _logger?.w(message, time: time, error: error, stackTrace: stackTrace); 84 + _log(Level.warning, message, time: time, error: error, stackTrace: stackTrace); 106 85 } 107 86 108 87 void e(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 109 - _logger?.e(message, time: time, error: error, stackTrace: stackTrace); 88 + _log(Level.error, message, time: time, error: error, stackTrace: stackTrace); 110 89 } 111 90 112 91 void f(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { 113 - _logger?.f(message, time: time, error: error, stackTrace: stackTrace); 92 + _log(Level.fatal, message, time: time, error: error, stackTrace: stackTrace); 114 93 } 115 94 116 95 Future<void> dispose() async { 117 - await _logger?.close(); 118 - _logger = null; 96 + await _consoleLogger?.close(); 97 + await _fileLogger?.close(); 98 + _consoleLogger = null; 99 + _fileLogger = null; 100 + _fileOutput = null; 119 101 } 120 102 121 103 Future<List<File>> getLogFiles() async { ··· 136 118 } 137 119 138 120 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 - } 121 + await _fileOutput?.clearAllLogs(); 149 122 } 150 123 151 124 Future<File?> getTodaysLogFile() async {
+106
lib/core/logging/daily_log_file_output.dart
··· 1 + import 'dart:async'; 2 + import 'dart:io'; 3 + 4 + import 'package:logger/logger.dart'; 5 + import 'package:path/path.dart' as p; 6 + 7 + class DailyLogFileOutput extends LogOutput { 8 + DailyLogFileOutput({required this.directoryPath, this.retentionDays = 3}); 9 + 10 + final String directoryPath; 11 + final int retentionDays; 12 + 13 + String? _lastCleanupDateKey; 14 + 15 + @override 16 + Future<void> init() async { 17 + final directory = Directory(directoryPath); 18 + if (!directory.existsSync()) { 19 + directory.createSync(recursive: true); 20 + } 21 + 22 + await cleanupOldLogs(); 23 + _lastCleanupDateKey = _dateKey(DateTime.now()); 24 + } 25 + 26 + @override 27 + void output(OutputEvent event) { 28 + final localTime = event.origin.time.toLocal(); 29 + final currentDateKey = _dateKey(localTime); 30 + if (_lastCleanupDateKey != currentDateKey) { 31 + _lastCleanupDateKey = currentDateKey; 32 + unawaited(cleanupOldLogs(referenceTime: localTime)); 33 + } 34 + 35 + final file = File(p.join(directoryPath, fileNameFor(localTime))); 36 + if (!file.parent.existsSync()) { 37 + file.parent.createSync(recursive: true); 38 + } 39 + 40 + final separator = Platform.isWindows ? '\r\n' : '\n'; 41 + final content = '${event.lines.join(separator)}$separator'; 42 + file.writeAsStringSync(content, mode: FileMode.writeOnlyAppend, flush: event.level.index >= Level.warning.index); 43 + } 44 + 45 + Future<void> clearAllLogs() async { 46 + final directory = Directory(directoryPath); 47 + if (!await directory.exists()) { 48 + return; 49 + } 50 + 51 + await for (final entity in directory.list()) { 52 + if (entity is File && entity.path.endsWith('.log')) { 53 + await entity.delete(); 54 + } 55 + } 56 + } 57 + 58 + Future<void> cleanupOldLogs({DateTime? referenceTime}) async { 59 + final directory = Directory(directoryPath); 60 + if (!await directory.exists()) { 61 + return; 62 + } 63 + 64 + final referenceDate = _normalizeDate((referenceTime ?? DateTime.now()).toLocal()); 65 + final oldestRetainedDate = referenceDate.subtract(Duration(days: retentionDays - 1)); 66 + 67 + await for (final entity in directory.list()) { 68 + if (entity is! File || !entity.path.endsWith('.log')) { 69 + continue; 70 + } 71 + 72 + final fileDate = parseDateFromFileName(entity.path); 73 + if (fileDate != null && fileDate.isBefore(oldestRetainedDate)) { 74 + await entity.delete(); 75 + } 76 + } 77 + } 78 + 79 + @override 80 + Future<void> destroy() async {} 81 + 82 + static String fileNameFor(DateTime timestamp) { 83 + return 'lazurite_${_dateKey(timestamp.toLocal())}.log'; 84 + } 85 + 86 + static DateTime? parseDateFromFileName(String filePath) { 87 + final match = RegExp(r'lazurite_(\d{4}-\d{2}-\d{2})\.log$').firstMatch(p.basename(filePath)); 88 + if (match == null) { 89 + return null; 90 + } 91 + 92 + final parsed = DateTime.tryParse(match.group(1)!); 93 + return parsed == null ? null : _normalizeDate(parsed); 94 + } 95 + 96 + static String _dateKey(DateTime timestamp) { 97 + return '${timestamp.year}-' 98 + '${timestamp.month.toString().padLeft(2, '0')}-' 99 + '${timestamp.day.toString().padLeft(2, '0')}'; 100 + } 101 + 102 + static DateTime _normalizeDate(DateTime timestamp) { 103 + final localTimestamp = timestamp.toLocal(); 104 + return DateTime(localTimestamp.year, localTimestamp.month, localTimestamp.day); 105 + } 106 + }
+5 -5
lib/core/logging/logging_bloc_observer.dart
··· 5 5 @override 6 6 void onCreate(BlocBase bloc) { 7 7 super.onCreate(bloc); 8 - log.d('[${bloc.runtimeType}] Created'); 8 + log.d('${bloc.runtimeType}: Created'); 9 9 } 10 10 11 11 @override 12 12 void onChange(BlocBase bloc, Change change) { 13 13 super.onChange(bloc, change); 14 14 if (bloc is Bloc) { 15 - log.d('[${bloc.runtimeType}] Transition: ${change.currentState.runtimeType} → ${change.nextState.runtimeType}'); 15 + log.d('${bloc.runtimeType}: Transition: ${change.currentState.runtimeType} → ${change.nextState.runtimeType}'); 16 16 } 17 17 } 18 18 19 19 @override 20 20 void onEvent(Bloc bloc, Object? event) { 21 21 super.onEvent(bloc, event); 22 - log.t('[${bloc.runtimeType}] Event: ${event.runtimeType}'); 22 + log.t('${bloc.runtimeType}: Event: ${event.runtimeType}'); 23 23 } 24 24 25 25 @override 26 26 void onError(BlocBase bloc, Object error, StackTrace stackTrace) { 27 27 super.onError(bloc, error, stackTrace); 28 - log.e('[${bloc.runtimeType}] Error: $error', error: error, stackTrace: stackTrace); 28 + log.e('${bloc.runtimeType}: Error: $error', error: error, stackTrace: stackTrace); 29 29 } 30 30 31 31 @override 32 32 void onClose(BlocBase bloc) { 33 33 super.onClose(bloc); 34 - log.d('[${bloc.runtimeType}] Closed'); 34 + log.d('${bloc.runtimeType}: Closed'); 35 35 } 36 36 }
+4 -4
lib/core/logging/logging_navigator_observer.dart
··· 7 7 super.didPush(route, previousRoute); 8 8 final routeName = route.settings.name ?? route.runtimeType.toString(); 9 9 final previousName = previousRoute?.settings.name ?? previousRoute?.runtimeType.toString() ?? 'root'; 10 - log.i('Route pushed: $routeName (from $previousName)', time: DateTime.now()); 10 + log.i('NavObserver: Route pushed: $routeName (from $previousName)', time: DateTime.now()); 11 11 } 12 12 13 13 @override ··· 15 15 super.didPop(route, previousRoute); 16 16 final routeName = route.settings.name ?? route.runtimeType.toString(); 17 17 final previousName = previousRoute?.settings.name ?? previousRoute?.runtimeType.toString() ?? 'root'; 18 - log.i('Route popped: $routeName (to $previousName)', time: DateTime.now()); 18 + log.i('NavObserver: Route popped: $routeName (to $previousName)', time: DateTime.now()); 19 19 } 20 20 21 21 @override ··· 23 23 super.didReplace(newRoute: newRoute, oldRoute: oldRoute); 24 24 final newName = newRoute?.settings.name ?? newRoute?.runtimeType.toString() ?? 'unknown'; 25 25 final oldName = oldRoute?.settings.name ?? oldRoute?.runtimeType.toString() ?? 'unknown'; 26 - log.i('Route replaced: $oldName → $newName', time: DateTime.now()); 26 + log.i('NavObserver: Route replaced: $oldName → $newName', time: DateTime.now()); 27 27 } 28 28 29 29 @override 30 30 void didRemove(Route route, Route? previousRoute) { 31 31 super.didRemove(route, previousRoute); 32 32 final routeName = route.settings.name ?? route.runtimeType.toString(); 33 - log.i('Route removed: $routeName', time: DateTime.now()); 33 + log.i('NavObserver: Route removed: $routeName', time: DateTime.now()); 34 34 } 35 35 }
+6 -2
lib/features/auth/data/auth_repository.dart
··· 288 288 final authSession = await atp.ATProto.fromOAuthSession(session, service: service).server.getSession(); 289 289 resolvedHandle = authSession.data.handle; 290 290 } catch (e, s) { 291 - log.w('Failed to resolve handle from session, falling back to login hint', error: e, stackTrace: s); 291 + log.w( 292 + 'AuthRepository: Failed to resolve handle from session, falling back to login hint', 293 + error: e, 294 + stackTrace: s, 295 + ); 292 296 } 293 297 294 298 try { 295 299 final profile = await Bluesky.fromOAuthSession(session, service: service).actor.getProfile(actor: session.sub); 296 300 displayName = profile.data.displayName; 297 301 } catch (e, s) { 298 - log.w('Failed to fetch display name, continuing without it', error: e, stackTrace: s); 302 + log.w('AuthRepository: Failed to fetch display name, continuing without it', error: e, stackTrace: s); 299 303 } 300 304 301 305 return AuthTokens(
+55 -23
lib/features/logs/cubit/log_viewer_cubit.dart
··· 1 + import 'dart:async'; 1 2 import 'dart:io'; 2 3 3 4 import 'package:equatable/equatable.dart'; ··· 9 10 part 'log_viewer_state.dart'; 10 11 11 12 class LogViewerCubit extends Cubit<LogViewerState> { 12 - LogViewerCubit() : super(LogViewerState.initial()) { 13 - loadLogs(); 13 + LogViewerCubit({Duration refreshInterval = const Duration(seconds: 1)}) : super(LogViewerState.initial()) { 14 + unawaited(loadLogs()); 15 + _refreshTimer = Timer.periodic(refreshInterval, (_) => unawaited(loadLogs(showLoading: false))); 14 16 } 15 17 16 - Future<void> loadLogs() async { 17 - emit(state.copyWith(status: LogViewerStatus.loading)); 18 + Timer? _refreshTimer; 19 + bool _isLoading = false; 20 + 21 + Future<void> loadLogs({bool showLoading = true}) async { 22 + if (_isLoading) { 23 + return; 24 + } 25 + 26 + _isLoading = true; 27 + if (showLoading && state.status == LogViewerStatus.initial) { 28 + emit(state.copyWith(status: LogViewerStatus.loading, errorMessage: null)); 29 + } 18 30 19 31 try { 20 32 final files = await log.getLogFiles(); ··· 31 43 } 32 44 } 33 45 34 - entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); 46 + entries.sort((a, b) => a.timestamp.compareTo(b.timestamp)); 35 47 36 - emit( 37 - state.copyWith( 38 - status: LogViewerStatus.loaded, 39 - entries: entries, 40 - filteredEntries: _applyFilters(entries, state.enabledLevels, state.searchQuery), 41 - ), 48 + final nextState = state.copyWith( 49 + status: LogViewerStatus.loaded, 50 + entries: entries, 51 + filteredEntries: _applyFilters(entries, state.enabledLevels, state.searchQuery), 52 + errorMessage: null, 42 53 ); 54 + if (nextState != state) { 55 + emit(nextState); 56 + } 43 57 } catch (e) { 44 - emit(state.copyWith(status: LogViewerStatus.error, errorMessage: e.toString())); 58 + final nextState = state.copyWith(status: LogViewerStatus.error, errorMessage: e.toString()); 59 + if (nextState != state) { 60 + emit(nextState); 61 + } 62 + } finally { 63 + _isLoading = false; 45 64 } 65 + } 66 + 67 + @override 68 + Future<void> close() async { 69 + _refreshTimer?.cancel(); 70 + await super.close(); 46 71 } 47 72 48 73 void toggleLevel(Level level) { ··· 52 77 } else { 53 78 newLevels.add(level); 54 79 } 55 - emit( 56 - state.copyWith( 57 - enabledLevels: newLevels, 58 - filteredEntries: _applyFilters(state.entries, newLevels, state.searchQuery), 59 - ), 80 + 81 + final nextState = state.copyWith( 82 + enabledLevels: newLevels, 83 + filteredEntries: _applyFilters(state.entries, newLevels, state.searchQuery), 60 84 ); 85 + if (nextState != state) { 86 + emit(nextState); 87 + } 61 88 } 62 89 63 90 void setSearchQuery(String query) { 64 - emit(state.copyWith(searchQuery: query, filteredEntries: _applyFilters(state.entries, state.enabledLevels, query))); 91 + final nextState = state.copyWith( 92 + searchQuery: query, 93 + filteredEntries: _applyFilters(state.entries, state.enabledLevels, query), 94 + ); 95 + if (nextState != state) { 96 + emit(nextState); 97 + } 65 98 } 66 99 67 100 List<LogEntry> _applyFilters(List<LogEntry> entries, Set<Level> enabledLevels, String searchQuery) { 68 - var filtered = entries.where((e) => enabledLevels.contains(e.level)).toList(); 101 + var filtered = entries.where((entry) => enabledLevels.contains(entry.level)).toList(); 69 102 70 103 if (searchQuery.isNotEmpty) { 71 104 final query = searchQuery.toLowerCase(); 72 - filtered = filtered.where((e) { 73 - return e.message.toLowerCase().contains(query) || (e.source?.toLowerCase().contains(query) ?? false); 105 + filtered = filtered.where((entry) { 106 + return entry.message.toLowerCase().contains(query) || (entry.source?.toLowerCase().contains(query) ?? false); 74 107 }).toList(); 75 108 } 76 109 ··· 81 114 82 115 Future<void> clearAllLogs() async { 83 116 await log.clearAllLogs(); 84 - emit(state.copyWith(entries: [], filteredEntries: [])); 85 - await loadLogs(); 117 + await loadLogs(showLoading: false); 86 118 } 87 119 }
+4 -2
lib/features/logs/cubit/log_viewer_state.dart
··· 2 2 3 3 enum LogViewerStatus { initial, loading, loaded, error } 4 4 5 + const _logViewerStateNoChange = Object(); 6 + 5 7 class LogViewerState extends Equatable { 6 8 const LogViewerState({ 7 9 this.status = LogViewerStatus.initial, ··· 27 29 List<LogEntry>? filteredEntries, 28 30 Set<Level>? enabledLevels, 29 31 String? searchQuery, 30 - String? errorMessage, 32 + Object? errorMessage = _logViewerStateNoChange, 31 33 }) { 32 34 return LogViewerState( 33 35 status: status ?? this.status, ··· 35 37 filteredEntries: filteredEntries ?? this.filteredEntries, 36 38 enabledLevels: enabledLevels ?? this.enabledLevels, 37 39 searchQuery: searchQuery ?? this.searchQuery, 38 - errorMessage: errorMessage ?? this.errorMessage, 40 + errorMessage: identical(errorMessage, _logViewerStateNoChange) ? this.errorMessage : errorMessage as String?, 39 41 ); 40 42 } 41 43
+14 -25
lib/features/logs/data/log_entry.dart
··· 16 16 DateTime? timestamp; 17 17 var remaining = trimmed; 18 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*'); 19 + final levelPattern = RegExp(r'^\[([A-Z]+)\]\s*'); 41 20 final levelMatch = levelPattern.firstMatch(remaining); 42 21 Level level = Level.debug; 43 22 ··· 46 25 remaining = remaining.substring(levelMatch.end); 47 26 } 48 27 49 - final timeTagPattern = RegExp(r'^TIME:\s*[\d\-T:.Z]+\s*'); 50 - remaining = remaining.replaceFirst(timeTagPattern, ''); 28 + final timeTagPattern = RegExp(r'^TIME:\s*([^\s]+)\s*'); 29 + final timeTagMatch = timeTagPattern.firstMatch(remaining); 30 + if (timeTagMatch != null) { 31 + timestamp ??= DateTime.tryParse(timeTagMatch.group(1)!)?.toLocal(); 32 + remaining = remaining.substring(timeTagMatch.end); 33 + } 51 34 52 35 String message; 53 36 String? source; ··· 66 49 } 67 50 68 51 static Level _parseLevel(String? levelChar) { 69 - switch (levelChar) { 52 + switch (levelChar?.toUpperCase()) { 53 + case 'TRACE': 70 54 case 'T': 71 55 return Level.trace; 56 + case 'DEBUG': 72 57 case 'D': 73 58 return Level.debug; 59 + case 'INFO': 74 60 case 'I': 75 61 return Level.info; 62 + case 'WARNING': 76 63 case 'W': 77 64 return Level.warning; 65 + case 'ERROR': 78 66 case 'E': 79 67 return Level.error; 68 + case 'FATAL': 80 69 case 'F': 81 70 return Level.fatal; 82 71 default:
+134 -23
lib/features/logs/presentation/logs_screen.dart
··· 14 14 } 15 15 } 16 16 17 - class _LogsScreenContent extends StatelessWidget { 17 + class _LogsScreenContent extends StatefulWidget { 18 18 const _LogsScreenContent(); 19 19 20 20 @override 21 + State<_LogsScreenContent> createState() => _LogsScreenContentState(); 22 + } 23 + 24 + class _LogsScreenContentState extends State<_LogsScreenContent> { 25 + late final ScrollController _scrollController; 26 + bool _autoScroll = true; 27 + 28 + @override 29 + void initState() { 30 + super.initState(); 31 + _scrollController = ScrollController()..addListener(_handleScroll); 32 + } 33 + 34 + @override 35 + void dispose() { 36 + _scrollController 37 + ..removeListener(_handleScroll) 38 + ..dispose(); 39 + super.dispose(); 40 + } 41 + 42 + void _handleScroll() { 43 + if (!_scrollController.hasClients) { 44 + return; 45 + } 46 + 47 + final maxScrollExtent = _scrollController.position.maxScrollExtent; 48 + final isAtBottom = _scrollController.offset >= (maxScrollExtent - 24); 49 + if (isAtBottom != _autoScroll) { 50 + setState(() => _autoScroll = isAtBottom); 51 + } 52 + } 53 + 54 + void _scrollToBottom({bool animated = false}) { 55 + if (!_scrollController.hasClients) { 56 + return; 57 + } 58 + 59 + final targetOffset = _scrollController.position.maxScrollExtent; 60 + if (animated) { 61 + _scrollController.animateTo(targetOffset, duration: const Duration(milliseconds: 180), curve: Curves.easeOut); 62 + } else { 63 + _scrollController.jumpTo(targetOffset); 64 + } 65 + } 66 + 67 + @override 21 68 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 - ], 69 + return BlocListener<LogViewerCubit, LogViewerState>( 70 + listenWhen: (previous, current) => 71 + previous.filteredEntries != current.filteredEntries || previous.status != current.status, 72 + listener: (context, state) { 73 + if (!_autoScroll || state.status != LogViewerStatus.loaded) { 74 + return; 75 + } 76 + 77 + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); 78 + }, 79 + child: Scaffold( 80 + appBar: AppBar( 81 + title: const Text('Logs'), 82 + actions: [ 83 + IconButton( 84 + icon: const Icon(Icons.share_outlined), 85 + tooltip: 'Share log file', 86 + onPressed: () => _shareLogs(context), 87 + ), 88 + IconButton( 89 + icon: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), 90 + tooltip: 'Clear all logs', 91 + onPressed: () => _confirmClearLogs(context), 92 + ), 93 + ], 94 + ), 95 + body: Column( 96 + children: [ 97 + _SearchBar(), 98 + _LevelFilterChips(), 99 + Expanded(child: _LogList(controller: _scrollController)), 100 + _AutoScrollIndicator( 101 + isActive: _autoScroll, 102 + onTap: () { 103 + setState(() => _autoScroll = true); 104 + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom(animated: true)); 105 + }, 106 + ), 107 + ], 108 + ), 44 109 ), 45 110 ); 46 111 } ··· 79 144 } 80 145 } 81 146 147 + class _AutoScrollIndicator extends StatelessWidget { 148 + const _AutoScrollIndicator({required this.isActive, required this.onTap}); 149 + 150 + final bool isActive; 151 + final VoidCallback onTap; 152 + 153 + @override 154 + Widget build(BuildContext context) { 155 + final colorScheme = Theme.of(context).colorScheme; 156 + 157 + return Material( 158 + color: colorScheme.surface, 159 + child: InkWell( 160 + onTap: onTap, 161 + child: Container( 162 + width: double.infinity, 163 + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 164 + decoration: BoxDecoration( 165 + border: Border(top: BorderSide(color: colorScheme.outlineVariant)), 166 + ), 167 + child: Row( 168 + mainAxisAlignment: MainAxisAlignment.center, 169 + children: [ 170 + Icon( 171 + Icons.keyboard_arrow_down_rounded, 172 + size: 16, 173 + color: isActive ? colorScheme.primary : colorScheme.outline, 174 + ), 175 + const SizedBox(width: 4), 176 + Text( 177 + 'Auto-scroll', 178 + style: TextStyle(fontSize: 12, color: isActive ? colorScheme.primary : colorScheme.outline), 179 + ), 180 + ], 181 + ), 182 + ), 183 + ), 184 + ); 185 + } 186 + } 187 + 82 188 class _SearchBar extends StatelessWidget { 83 189 @override 84 190 Widget build(BuildContext context) { ··· 152 258 } 153 259 154 260 class _LogList extends StatelessWidget { 261 + const _LogList({required this.controller}); 262 + 263 + final ScrollController controller; 264 + 155 265 @override 156 266 Widget build(BuildContext context) { 157 267 return BlocBuilder<LogViewerCubit, LogViewerState>( ··· 180 290 } 181 291 182 292 return ListView.separated( 293 + controller: controller, 183 294 itemCount: state.filteredEntries.length, 184 295 separatorBuilder: (context, index) => Divider(height: 1, color: Theme.of(context).colorScheme.outlineVariant), 185 296 itemBuilder: (context, index) {
+1 -1
lib/main.dart
··· 36 36 final settingsCubit = SettingsCubit(database: database); 37 37 await settingsCubit.loadSettings(); 38 38 39 - log.i('App started'); 39 + log.i('AppLogger: App started'); 40 40 41 41 runApp(LazuriteApp(authBloc: authBloc, database: database, settingsCubit: settingsCubit)); 42 42 }
+35
test/core/logging/app_file_log_printer_test.dart
··· 1 + import 'package:flutter_test/flutter_test.dart'; 2 + import 'package:logger/logger.dart'; 3 + import 'package:lazurite/core/logging/app_file_log_printer.dart'; 4 + 5 + void main() { 6 + group('AppFileLogPrinter', () { 7 + test('prints one-line log entries with time, source, error, and stack trace', () { 8 + final printer = AppFileLogPrinter(); 9 + final lines = printer.log( 10 + LogEvent( 11 + Level.error, 12 + 'FeedBloc: Failed to decode feed post', 13 + time: DateTime(2026, 3, 16, 14, 32, 5, 220), 14 + error: StateError('boom'), 15 + stackTrace: StackTrace.fromString('#0 FeedBloc.load\n#1 main'), 16 + ), 17 + ); 18 + 19 + expect(lines, hasLength(1)); 20 + expect(lines.single, contains('[E] TIME: 2026-03-16T14:32:05.220')); 21 + expect(lines.single, contains('FeedBloc: Failed to decode feed post')); 22 + expect(lines.single, contains('ERROR: Bad state: boom')); 23 + expect(lines.single, contains('STACK: #0 FeedBloc.load | #1 main')); 24 + }); 25 + 26 + test('uses fatal label for fatal logs', () { 27 + final printer = AppFileLogPrinter(); 28 + final lines = printer.log( 29 + LogEvent(Level.fatal, 'AppLogger: Unhandled exception in zone', time: DateTime(2026, 3, 16, 14, 32, 12, 450)), 30 + ); 31 + 32 + expect(lines.single, startsWith('[FATAL] TIME: 2026-03-16T14:32:12.450')); 33 + }); 34 + }); 35 + }
+69
test/core/logging/daily_log_file_output_test.dart
··· 1 + import 'dart:io'; 2 + 3 + import 'package:flutter_test/flutter_test.dart'; 4 + import 'package:logger/logger.dart'; 5 + import 'package:lazurite/core/logging/app_file_log_printer.dart'; 6 + import 'package:lazurite/core/logging/daily_log_file_output.dart'; 7 + 8 + void main() { 9 + group('DailyLogFileOutput', () { 10 + late Directory tempDirectory; 11 + 12 + setUp(() async { 13 + tempDirectory = await Directory.systemTemp.createTemp('lazurite_logs_test_'); 14 + }); 15 + 16 + tearDown(() async { 17 + if (await tempDirectory.exists()) { 18 + await tempDirectory.delete(recursive: true); 19 + } 20 + }); 21 + 22 + test('writes logs to a daily file in the target directory', () async { 23 + final output = DailyLogFileOutput(directoryPath: tempDirectory.path); 24 + final logger = Logger( 25 + filter: ProductionFilter(), 26 + printer: AppFileLogPrinter(), 27 + output: output, 28 + level: Level.trace, 29 + ); 30 + await logger.init; 31 + 32 + logger.i('AppLogger: App started', time: DateTime(2026, 3, 16, 14, 32, 1, 123)); 33 + await logger.close(); 34 + 35 + final file = File( 36 + '${tempDirectory.path}/${DailyLogFileOutput.fileNameFor(DateTime(2026, 3, 16, 14, 32, 1, 123))}', 37 + ); 38 + expect(await file.exists(), isTrue); 39 + expect(await file.readAsString(), contains('[I] TIME: 2026-03-16T14:32:01.123 AppLogger: App started')); 40 + }); 41 + 42 + test('clears all log files', () async { 43 + final output = DailyLogFileOutput(directoryPath: tempDirectory.path); 44 + await output.init(); 45 + 46 + final logFile = File('${tempDirectory.path}/lazurite_2026-03-16.log'); 47 + await logFile.writeAsString('test'); 48 + 49 + await output.clearAllLogs(); 50 + 51 + expect(await logFile.exists(), isFalse); 52 + }); 53 + 54 + test('removes files older than retention window', () async { 55 + final output = DailyLogFileOutput(directoryPath: tempDirectory.path, retentionDays: 3); 56 + await output.init(); 57 + 58 + final staleFile = File('${tempDirectory.path}/lazurite_2026-03-12.log'); 59 + final keptFile = File('${tempDirectory.path}/lazurite_2026-03-14.log'); 60 + await staleFile.writeAsString('old'); 61 + await keptFile.writeAsString('keep'); 62 + 63 + await output.cleanupOldLogs(referenceTime: DateTime(2026, 3, 16, 12)); 64 + 65 + expect(await staleFile.exists(), isFalse); 66 + expect(await keptFile.exists(), isTrue); 67 + }); 68 + }); 69 + }
+7
test/features/logs/cubit/log_viewer_cubit_test.dart
··· 29 29 expect(state.status, LogViewerStatus.error); 30 30 expect(state.errorMessage, 'Test error'); 31 31 }); 32 + 33 + test('copyWith clears error message when null is provided', () { 34 + final state = LogViewerState.initial().copyWith(status: LogViewerStatus.error, errorMessage: 'Test error'); 35 + final cleared = state.copyWith(status: LogViewerStatus.loaded, errorMessage: null); 36 + expect(cleared.status, LogViewerStatus.loaded); 37 + expect(cleared.errorMessage, isNull); 38 + }); 32 39 }); 33 40 34 41 group('LogViewerCubit', () {
+11 -2
test/features/logs/data/log_entry_test.dart
··· 37 37 expect(entry.source, 'AuthBloc'); 38 38 }); 39 39 40 - test('parses log line with timestamp', () { 41 - final entry = LogEntry.tryParse('14:32:01.123 [I] App started'); 40 + test('parses log line with ISO time tag and source prefix', () { 41 + final entry = LogEntry.tryParse('[I] TIME: 2026-03-16T14:32:01.123 AppLogger: App started'); 42 42 expect(entry, isNotNull); 43 43 expect(entry!.level, Level.info); 44 + expect(entry.source, 'AppLogger'); 44 45 expect(entry.message, 'App started'); 45 46 expect(entry.formatTimestamp(), '14:32:01.123'); 47 + }); 48 + 49 + test('parses fatal log lines written by the file printer', () { 50 + final entry = LogEntry.tryParse('[FATAL] TIME: 2026-03-16T14:32:12.450 AppLogger: Unhandled exception'); 51 + expect(entry, isNotNull); 52 + expect(entry!.level, Level.fatal); 53 + expect(entry.source, 'AppLogger'); 54 + expect(entry.message, 'Unhandled exception'); 46 55 }); 47 56 48 57 test('returns null for empty line', () {