diff --git a/devtools_options.yaml b/devtools_options.yaml new file mode 100644 index 0000000..fa0b357 --- /dev/null +++ b/devtools_options.yaml @@ -0,0 +1,3 @@ +description: This file stores settings for Dart & Flutter DevTools. +documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states +extensions: diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..b056906 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,69 @@ +PODS: + - Flutter (1.0.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - share_plus (0.0.1): + - Flutter + - sqlite3 (3.52.0): + - sqlite3/common (= 3.52.0) + - sqlite3/common (3.52.0) + - sqlite3/dbstatvtab (3.52.0): + - sqlite3/common + - sqlite3/fts5 (3.52.0): + - sqlite3/common + - sqlite3/math (3.52.0): + - sqlite3/common + - sqlite3/perf-threadsafe (3.52.0): + - sqlite3/common + - sqlite3/rtree (3.52.0): + - sqlite3/common + - sqlite3/session (3.52.0): + - sqlite3/common + - sqlite3_flutter_libs (0.0.1): + - Flutter + - FlutterMacOS + - sqlite3 (~> 3.52.0) + - sqlite3/dbstatvtab + - sqlite3/fts5 + - sqlite3/math + - sqlite3/perf-threadsafe + - sqlite3/rtree + - sqlite3/session + - url_launcher_ios (0.0.1): + - Flutter + +DEPENDENCIES: + - Flutter (from `Flutter`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + +SPEC REPOS: + trunk: + - sqlite3 + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + sqlite3_flutter_libs: + :path: ".symlinks/plugins/sqlite3_flutter_libs/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + +SPEC CHECKSUMS: + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 + path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba + share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f + sqlite3: a51c07cf16e023d6c48abd5e5791a61a47354921 + sqlite3_flutter_libs: f9114e4bbe1f2e03dd543373c53d23245982ca13 + url_launcher_ios: bb13df5870e8c4234ca12609d04010a21be43dfa + +PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index c89fc3e..139ef20 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -9,7 +9,9 @@ /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3681913A96A36188ECBC1602 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D8BAC14F436233DB60FD42F5 /* Pods_Runner.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 62EEA636832BD30DA6CBEFB6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30736DB2E18CAB086461C486 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -42,12 +44,17 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 30736DB2E18CAB086461C486 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 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 = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,13 +62,25 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 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 = ""; }; + D8BAC14F436233DB60FD42F5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 69FDE1518F75C59F0767A0F9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 62EEA636832BD30DA6CBEFB6 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3681913A96A36188ECBC1602 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -94,6 +113,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + ECF13CAC20BC1145817075FE /* Pods */, + D8C8796E051ACD62389767AD /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +142,29 @@ path = Runner; sourceTree = ""; }; + D8C8796E051ACD62389767AD /* Frameworks */ = { + isa = PBXGroup; + children = ( + D8BAC14F436233DB60FD42F5 /* Pods_Runner.framework */, + 30736DB2E18CAB086461C486 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + ECF13CAC20BC1145817075FE /* Pods */ = { + isa = PBXGroup; + children = ( + 420DA8205F7F2E7EF8664FD5 /* Pods-Runner.debug.xcconfig */, + 218A62E7E6B03A7A2B52862B /* Pods-Runner.release.xcconfig */, + 2B0D118EB73867598835F2DD /* Pods-Runner.profile.xcconfig */, + E85E7FAF00CB12EDA75D52D5 /* Pods-RunnerTests.debug.xcconfig */, + 90899372CAA28BD74B2D49A1 /* Pods-RunnerTests.release.xcconfig */, + AA7C2CE85592DCD41249030A /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 3003FE2FF11F279B38D36812 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 69FDE1518F75C59F0767A0F9 /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,14 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + C67D8A4AC1F1BC2EE3E67CCE /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + D83A47AAE1B8A38105D97A73 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -222,6 +270,28 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 3003FE2FF11F279B38D36812 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + 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"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +323,45 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + C67D8A4AC1F1BC2EE3E67CCE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + 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"; + showEnvVarsInLog = 0; + }; + D83A47AAE1B8A38105D97A73 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -378,6 +487,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = E85E7FAF00CB12EDA75D52D5 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +505,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 90899372CAA28BD74B2D49A1 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +521,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = AA7C2CE85592DCD41249030A /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/core/logging/app_file_log_printer.dart b/lib/core/logging/app_file_log_printer.dart new file mode 100644 index 0000000..f3b30dc --- /dev/null +++ b/lib/core/logging/app_file_log_printer.dart @@ -0,0 +1,72 @@ +import 'dart:convert'; + +import 'package:logger/logger.dart'; + +class AppFileLogPrinter extends LogPrinter { + AppFileLogPrinter(); + + @override + List log(LogEvent event) { + final buffer = StringBuffer() + ..write(_labelFor(event.level)) + ..write(' TIME: ') + ..write(event.time.toIso8601String()) + ..write(' ') + ..write(_sanitize(_stringifyMessage(event.message))); + + if (event.error != null) { + buffer + ..write(' ERROR: ') + ..write(_sanitize(event.error.toString())); + } + + final stackTrace = _sanitizeStackTrace(event.stackTrace); + if (stackTrace != null) { + buffer + ..write(' STACK: ') + ..write(stackTrace); + } + + return [buffer.toString()]; + } + + String _labelFor(Level level) { + switch (level) { + case Level.trace: + return '[T]'; + case Level.debug: + return '[D]'; + case Level.info: + return '[I]'; + case Level.warning: + return '[W]'; + case Level.error: + return '[E]'; + case Level.fatal: + return '[FATAL]'; + default: + return '[D]'; + } + } + + String _stringifyMessage(dynamic message) { + final resolvedMessage = message is Function ? message() : message; + if (resolvedMessage is Map || resolvedMessage is Iterable) { + return const JsonEncoder.withIndent(null).convert(resolvedMessage); + } + return resolvedMessage.toString(); + } + + String _sanitize(String value) { + return value.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).join(' | '); + } + + String? _sanitizeStackTrace(StackTrace? stackTrace) { + if (stackTrace == null) { + return null; + } + + final sanitized = _sanitize(stackTrace.toString()); + return sanitized.isEmpty ? null : sanitized; + } +} diff --git a/lib/core/logging/app_logger.dart b/lib/core/logging/app_logger.dart index 171f681..4778602 100644 --- a/lib/core/logging/app_logger.dart +++ b/lib/core/logging/app_logger.dart @@ -1,7 +1,10 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:lazurite/core/logging/app_file_log_printer.dart'; +import 'package:lazurite/core/logging/daily_log_file_output.dart'; class AppLogger { AppLogger._(); @@ -9,42 +12,43 @@ class AppLogger { static final AppLogger _instance = AppLogger._(); static AppLogger get instance => _instance; - Logger? _logger; + Logger? _consoleLogger; + Logger? _fileLogger; + DailyLogFileOutput? _fileOutput; String? _logDirectory; Future initialize() async { + await dispose(); + _logDirectory = await _getLogDirectory(); - final logDir = Directory(_logDirectory!); - if (!await logDir.exists()) { - await logDir.create(recursive: true); - } + _fileOutput = DailyLogFileOutput(directoryPath: _logDirectory!, retentionDays: 3); + _fileLogger = Logger( + filter: ProductionFilter(), + printer: AppFileLogPrinter(), + output: _fileOutput!, + level: Level.trace, + ); - await _cleanupOldLogs(); - - _logger = Logger( - filter: DevelopmentFilter(), - printer: PrettyPrinter( - methodCount: 2, - errorMethodCount: 8, - lineLength: 120, - colors: true, - printEmojis: true, - dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, - ), - output: MultiOutput([ - ConsoleOutput(), - AdvancedFileOutput( - path: _logDirectory!, - maxFileSizeKB: -1, - fileNameFormatter: _dailyFileNameFormatter, - latestFileName: _todayFileName(), - maxRotatedFilesCount: 3, - overrideExisting: false, + final initFutures = >[_fileLogger!.init]; + + if (kDebugMode) { + _consoleLogger = Logger( + filter: DevelopmentFilter(), + printer: PrettyPrinter( + methodCount: 2, + errorMethodCount: 8, + lineLength: 120, + colors: true, + printEmojis: true, + dateTimeFormat: DateTimeFormat.onlyTimeAndSinceStart, ), - ]), - ); + output: ConsoleOutput(), + level: Level.trace, + ); + initFutures.add(_consoleLogger!.init); + } - await _logger!.init; + await Future.wait(initFutures); } Future _getLogDirectory() async { @@ -52,70 +56,48 @@ class AppLogger { return '${docsDir.path}/logs'; } - static String _dailyFileNameFormatter(DateTime timestamp) { - return 'lazurite_${_formatDate(timestamp)}.log'; - } - - static String _formatDate(DateTime date) { - return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; - } - static String _todayFileName() { - return _dailyFileNameFormatter(DateTime.now()); + return DailyLogFileOutput.fileNameFor(DateTime.now()); } - Future _cleanupOldLogs() async { - if (_logDirectory == null) return; - - final logDir = Directory(_logDirectory!); - if (!await logDir.exists()) return; - - const retentionDays = 3; - final cutoffDate = DateTime.now().subtract(const Duration(days: retentionDays)); - - await for (final entity in logDir.list()) { - if (entity is File && entity.path.endsWith('.log')) { - final fileName = entity.uri.pathSegments.last; - final dateMatch = RegExp(r'lazurite_(\d{4}-\d{2}-\d{2})\.log').firstMatch(fileName); - if (dateMatch != null) { - final fileDate = DateTime.parse(dateMatch.group(1)!); - if (fileDate.isBefore(cutoffDate)) { - await entity.delete(); - } - } - } - } + void _log(Level level, dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { + final logTime = time ?? DateTime.now(); + _fileLogger?.log(level, message, time: logTime, error: error, stackTrace: stackTrace); + _consoleLogger?.log(level, message, time: logTime, error: error, stackTrace: stackTrace); } String? get logDirectory => _logDirectory; void t(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { - _logger?.t(message, time: time, error: error, stackTrace: stackTrace); + _log(Level.trace, message, time: time, error: error, stackTrace: stackTrace); } void d(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { - _logger?.d(message, time: time, error: error, stackTrace: stackTrace); + _log(Level.debug, message, time: time, error: error, stackTrace: stackTrace); } void i(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { - _logger?.i(message, time: time, error: error, stackTrace: stackTrace); + _log(Level.info, message, time: time, error: error, stackTrace: stackTrace); } void w(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { - _logger?.w(message, time: time, error: error, stackTrace: stackTrace); + _log(Level.warning, message, time: time, error: error, stackTrace: stackTrace); } void e(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { - _logger?.e(message, time: time, error: error, stackTrace: stackTrace); + _log(Level.error, message, time: time, error: error, stackTrace: stackTrace); } void f(dynamic message, {DateTime? time, Object? error, StackTrace? stackTrace}) { - _logger?.f(message, time: time, error: error, stackTrace: stackTrace); + _log(Level.fatal, message, time: time, error: error, stackTrace: stackTrace); } Future dispose() async { - await _logger?.close(); - _logger = null; + await _consoleLogger?.close(); + await _fileLogger?.close(); + _consoleLogger = null; + _fileLogger = null; + _fileOutput = null; } Future> getLogFiles() async { @@ -136,16 +118,7 @@ class AppLogger { } Future clearAllLogs() async { - if (_logDirectory == null) return; - - final logDir = Directory(_logDirectory!); - if (!await logDir.exists()) return; - - await for (final entity in logDir.list()) { - if (entity is File && entity.path.endsWith('.log')) { - await entity.delete(); - } - } + await _fileOutput?.clearAllLogs(); } Future getTodaysLogFile() async { diff --git a/lib/core/logging/daily_log_file_output.dart b/lib/core/logging/daily_log_file_output.dart new file mode 100644 index 0000000..12a7617 --- /dev/null +++ b/lib/core/logging/daily_log_file_output.dart @@ -0,0 +1,106 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:logger/logger.dart'; +import 'package:path/path.dart' as p; + +class DailyLogFileOutput extends LogOutput { + DailyLogFileOutput({required this.directoryPath, this.retentionDays = 3}); + + final String directoryPath; + final int retentionDays; + + String? _lastCleanupDateKey; + + @override + Future init() async { + final directory = Directory(directoryPath); + if (!directory.existsSync()) { + directory.createSync(recursive: true); + } + + await cleanupOldLogs(); + _lastCleanupDateKey = _dateKey(DateTime.now()); + } + + @override + void output(OutputEvent event) { + final localTime = event.origin.time.toLocal(); + final currentDateKey = _dateKey(localTime); + if (_lastCleanupDateKey != currentDateKey) { + _lastCleanupDateKey = currentDateKey; + unawaited(cleanupOldLogs(referenceTime: localTime)); + } + + final file = File(p.join(directoryPath, fileNameFor(localTime))); + if (!file.parent.existsSync()) { + file.parent.createSync(recursive: true); + } + + final separator = Platform.isWindows ? '\r\n' : '\n'; + final content = '${event.lines.join(separator)}$separator'; + file.writeAsStringSync(content, mode: FileMode.writeOnlyAppend, flush: event.level.index >= Level.warning.index); + } + + Future clearAllLogs() async { + final directory = Directory(directoryPath); + if (!await directory.exists()) { + return; + } + + await for (final entity in directory.list()) { + if (entity is File && entity.path.endsWith('.log')) { + await entity.delete(); + } + } + } + + Future cleanupOldLogs({DateTime? referenceTime}) async { + final directory = Directory(directoryPath); + if (!await directory.exists()) { + return; + } + + final referenceDate = _normalizeDate((referenceTime ?? DateTime.now()).toLocal()); + final oldestRetainedDate = referenceDate.subtract(Duration(days: retentionDays - 1)); + + await for (final entity in directory.list()) { + if (entity is! File || !entity.path.endsWith('.log')) { + continue; + } + + final fileDate = parseDateFromFileName(entity.path); + if (fileDate != null && fileDate.isBefore(oldestRetainedDate)) { + await entity.delete(); + } + } + } + + @override + Future destroy() async {} + + static String fileNameFor(DateTime timestamp) { + return 'lazurite_${_dateKey(timestamp.toLocal())}.log'; + } + + static DateTime? parseDateFromFileName(String filePath) { + final match = RegExp(r'lazurite_(\d{4}-\d{2}-\d{2})\.log$').firstMatch(p.basename(filePath)); + if (match == null) { + return null; + } + + final parsed = DateTime.tryParse(match.group(1)!); + return parsed == null ? null : _normalizeDate(parsed); + } + + static String _dateKey(DateTime timestamp) { + return '${timestamp.year}-' + '${timestamp.month.toString().padLeft(2, '0')}-' + '${timestamp.day.toString().padLeft(2, '0')}'; + } + + static DateTime _normalizeDate(DateTime timestamp) { + final localTimestamp = timestamp.toLocal(); + return DateTime(localTimestamp.year, localTimestamp.month, localTimestamp.day); + } +} diff --git a/lib/core/logging/logging_bloc_observer.dart b/lib/core/logging/logging_bloc_observer.dart index db8cd45..07ce9c3 100644 --- a/lib/core/logging/logging_bloc_observer.dart +++ b/lib/core/logging/logging_bloc_observer.dart @@ -5,32 +5,32 @@ class LoggingBlocObserver extends BlocObserver { @override void onCreate(BlocBase bloc) { super.onCreate(bloc); - log.d('[${bloc.runtimeType}] Created'); + log.d('${bloc.runtimeType}: Created'); } @override void onChange(BlocBase bloc, Change change) { super.onChange(bloc, change); if (bloc is Bloc) { - log.d('[${bloc.runtimeType}] Transition: ${change.currentState.runtimeType} → ${change.nextState.runtimeType}'); + log.d('${bloc.runtimeType}: Transition: ${change.currentState.runtimeType} → ${change.nextState.runtimeType}'); } } @override void onEvent(Bloc bloc, Object? event) { super.onEvent(bloc, event); - log.t('[${bloc.runtimeType}] Event: ${event.runtimeType}'); + log.t('${bloc.runtimeType}: Event: ${event.runtimeType}'); } @override void onError(BlocBase bloc, Object error, StackTrace stackTrace) { super.onError(bloc, error, stackTrace); - log.e('[${bloc.runtimeType}] Error: $error', error: error, stackTrace: stackTrace); + log.e('${bloc.runtimeType}: Error: $error', error: error, stackTrace: stackTrace); } @override void onClose(BlocBase bloc) { super.onClose(bloc); - log.d('[${bloc.runtimeType}] Closed'); + log.d('${bloc.runtimeType}: Closed'); } } diff --git a/lib/core/logging/logging_navigator_observer.dart b/lib/core/logging/logging_navigator_observer.dart index c33532d..7e06f26 100644 --- a/lib/core/logging/logging_navigator_observer.dart +++ b/lib/core/logging/logging_navigator_observer.dart @@ -7,7 +7,7 @@ class LoggingNavigatorObserver extends NavigatorObserver { super.didPush(route, previousRoute); final routeName = route.settings.name ?? route.runtimeType.toString(); final previousName = previousRoute?.settings.name ?? previousRoute?.runtimeType.toString() ?? 'root'; - log.i('Route pushed: $routeName (from $previousName)', time: DateTime.now()); + log.i('NavObserver: Route pushed: $routeName (from $previousName)', time: DateTime.now()); } @override @@ -15,7 +15,7 @@ class LoggingNavigatorObserver extends NavigatorObserver { super.didPop(route, previousRoute); final routeName = route.settings.name ?? route.runtimeType.toString(); final previousName = previousRoute?.settings.name ?? previousRoute?.runtimeType.toString() ?? 'root'; - log.i('Route popped: $routeName (to $previousName)', time: DateTime.now()); + log.i('NavObserver: Route popped: $routeName (to $previousName)', time: DateTime.now()); } @override @@ -23,13 +23,13 @@ class LoggingNavigatorObserver extends NavigatorObserver { super.didReplace(newRoute: newRoute, oldRoute: oldRoute); final newName = newRoute?.settings.name ?? newRoute?.runtimeType.toString() ?? 'unknown'; final oldName = oldRoute?.settings.name ?? oldRoute?.runtimeType.toString() ?? 'unknown'; - log.i('Route replaced: $oldName → $newName', time: DateTime.now()); + log.i('NavObserver: Route replaced: $oldName → $newName', time: DateTime.now()); } @override void didRemove(Route route, Route? previousRoute) { super.didRemove(route, previousRoute); final routeName = route.settings.name ?? route.runtimeType.toString(); - log.i('Route removed: $routeName', time: DateTime.now()); + log.i('NavObserver: Route removed: $routeName', time: DateTime.now()); } } diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 48896b4..44d1c60 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -288,14 +288,18 @@ class AuthRepository { final authSession = await atp.ATProto.fromOAuthSession(session, service: service).server.getSession(); resolvedHandle = authSession.data.handle; } catch (e, s) { - log.w('Failed to resolve handle from session, falling back to login hint', error: e, stackTrace: s); + log.w( + 'AuthRepository: Failed to resolve handle from session, falling back to login hint', + error: e, + stackTrace: s, + ); } try { final profile = await Bluesky.fromOAuthSession(session, service: service).actor.getProfile(actor: session.sub); displayName = profile.data.displayName; } catch (e, s) { - log.w('Failed to fetch display name, continuing without it', error: e, stackTrace: s); + log.w('AuthRepository: Failed to fetch display name, continuing without it', error: e, stackTrace: s); } return AuthTokens( diff --git a/lib/features/logs/cubit/log_viewer_cubit.dart b/lib/features/logs/cubit/log_viewer_cubit.dart index c9324ef..92975f8 100644 --- a/lib/features/logs/cubit/log_viewer_cubit.dart +++ b/lib/features/logs/cubit/log_viewer_cubit.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; import 'package:equatable/equatable.dart'; @@ -9,12 +10,23 @@ import 'package:lazurite/features/logs/data/log_entry.dart'; part 'log_viewer_state.dart'; class LogViewerCubit extends Cubit { - LogViewerCubit() : super(LogViewerState.initial()) { - loadLogs(); + LogViewerCubit({Duration refreshInterval = const Duration(seconds: 1)}) : super(LogViewerState.initial()) { + unawaited(loadLogs()); + _refreshTimer = Timer.periodic(refreshInterval, (_) => unawaited(loadLogs(showLoading: false))); } - Future loadLogs() async { - emit(state.copyWith(status: LogViewerStatus.loading)); + Timer? _refreshTimer; + bool _isLoading = false; + + Future loadLogs({bool showLoading = true}) async { + if (_isLoading) { + return; + } + + _isLoading = true; + if (showLoading && state.status == LogViewerStatus.initial) { + emit(state.copyWith(status: LogViewerStatus.loading, errorMessage: null)); + } try { final files = await log.getLogFiles(); @@ -31,20 +43,33 @@ class LogViewerCubit extends Cubit { } } - entries.sort((a, b) => b.timestamp.compareTo(a.timestamp)); + entries.sort((a, b) => a.timestamp.compareTo(b.timestamp)); - emit( - state.copyWith( - status: LogViewerStatus.loaded, - entries: entries, - filteredEntries: _applyFilters(entries, state.enabledLevels, state.searchQuery), - ), + final nextState = state.copyWith( + status: LogViewerStatus.loaded, + entries: entries, + filteredEntries: _applyFilters(entries, state.enabledLevels, state.searchQuery), + errorMessage: null, ); + if (nextState != state) { + emit(nextState); + } } catch (e) { - emit(state.copyWith(status: LogViewerStatus.error, errorMessage: e.toString())); + final nextState = state.copyWith(status: LogViewerStatus.error, errorMessage: e.toString()); + if (nextState != state) { + emit(nextState); + } + } finally { + _isLoading = false; } } + @override + Future close() async { + _refreshTimer?.cancel(); + await super.close(); + } + void toggleLevel(Level level) { final newLevels = Set.from(state.enabledLevels); if (newLevels.contains(level)) { @@ -52,25 +77,33 @@ class LogViewerCubit extends Cubit { } else { newLevels.add(level); } - emit( - state.copyWith( - enabledLevels: newLevels, - filteredEntries: _applyFilters(state.entries, newLevels, state.searchQuery), - ), + + final nextState = state.copyWith( + enabledLevels: newLevels, + filteredEntries: _applyFilters(state.entries, newLevels, state.searchQuery), ); + if (nextState != state) { + emit(nextState); + } } void setSearchQuery(String query) { - emit(state.copyWith(searchQuery: query, filteredEntries: _applyFilters(state.entries, state.enabledLevels, query))); + final nextState = state.copyWith( + searchQuery: query, + filteredEntries: _applyFilters(state.entries, state.enabledLevels, query), + ); + if (nextState != state) { + emit(nextState); + } } List _applyFilters(List entries, Set enabledLevels, String searchQuery) { - var filtered = entries.where((e) => enabledLevels.contains(e.level)).toList(); + var filtered = entries.where((entry) => enabledLevels.contains(entry.level)).toList(); if (searchQuery.isNotEmpty) { final query = searchQuery.toLowerCase(); - filtered = filtered.where((e) { - return e.message.toLowerCase().contains(query) || (e.source?.toLowerCase().contains(query) ?? false); + filtered = filtered.where((entry) { + return entry.message.toLowerCase().contains(query) || (entry.source?.toLowerCase().contains(query) ?? false); }).toList(); } @@ -81,7 +114,6 @@ class LogViewerCubit extends Cubit { Future clearAllLogs() async { await log.clearAllLogs(); - emit(state.copyWith(entries: [], filteredEntries: [])); - await loadLogs(); + await loadLogs(showLoading: false); } } diff --git a/lib/features/logs/cubit/log_viewer_state.dart b/lib/features/logs/cubit/log_viewer_state.dart index b6338e6..0eda1fa 100644 --- a/lib/features/logs/cubit/log_viewer_state.dart +++ b/lib/features/logs/cubit/log_viewer_state.dart @@ -2,6 +2,8 @@ part of 'log_viewer_cubit.dart'; enum LogViewerStatus { initial, loading, loaded, error } +const _logViewerStateNoChange = Object(); + class LogViewerState extends Equatable { const LogViewerState({ this.status = LogViewerStatus.initial, @@ -27,7 +29,7 @@ class LogViewerState extends Equatable { List? filteredEntries, Set? enabledLevels, String? searchQuery, - String? errorMessage, + Object? errorMessage = _logViewerStateNoChange, }) { return LogViewerState( status: status ?? this.status, @@ -35,7 +37,7 @@ class LogViewerState extends Equatable { filteredEntries: filteredEntries ?? this.filteredEntries, enabledLevels: enabledLevels ?? this.enabledLevels, searchQuery: searchQuery ?? this.searchQuery, - errorMessage: errorMessage ?? this.errorMessage, + errorMessage: identical(errorMessage, _logViewerStateNoChange) ? this.errorMessage : errorMessage as String?, ); } diff --git a/lib/features/logs/data/log_entry.dart b/lib/features/logs/data/log_entry.dart index bea0877..e8581bf 100644 --- a/lib/features/logs/data/log_entry.dart +++ b/lib/features/logs/data/log_entry.dart @@ -16,28 +16,7 @@ class LogEntry extends Equatable { DateTime? timestamp; var remaining = trimmed; - final timestampPattern = RegExp(r'^(\d{2}:\d{2}:\d{2}\.\d{3})\s*'); - final timestampMatch = timestampPattern.firstMatch(remaining); - if (timestampMatch != null) { - try { - final timeStr = timestampMatch.group(1)!; - final parts = timeStr.split(':'); - final secondsParts = parts[2].split('.'); - final now = DateTime.now(); - timestamp = DateTime( - now.year, - now.month, - now.day, - int.parse(parts[0]), - int.parse(parts[1]), - int.parse(secondsParts[0]), - int.parse(secondsParts[1]), - ); - remaining = remaining.substring(timestampMatch.end); - } catch (_) {} - } - - final levelPattern = RegExp(r'^\[([A-Z])\]\s*'); + final levelPattern = RegExp(r'^\[([A-Z]+)\]\s*'); final levelMatch = levelPattern.firstMatch(remaining); Level level = Level.debug; @@ -46,8 +25,12 @@ class LogEntry extends Equatable { remaining = remaining.substring(levelMatch.end); } - final timeTagPattern = RegExp(r'^TIME:\s*[\d\-T:.Z]+\s*'); - remaining = remaining.replaceFirst(timeTagPattern, ''); + final timeTagPattern = RegExp(r'^TIME:\s*([^\s]+)\s*'); + final timeTagMatch = timeTagPattern.firstMatch(remaining); + if (timeTagMatch != null) { + timestamp ??= DateTime.tryParse(timeTagMatch.group(1)!)?.toLocal(); + remaining = remaining.substring(timeTagMatch.end); + } String message; String? source; @@ -66,17 +49,23 @@ class LogEntry extends Equatable { } static Level _parseLevel(String? levelChar) { - switch (levelChar) { + switch (levelChar?.toUpperCase()) { + case 'TRACE': case 'T': return Level.trace; + case 'DEBUG': case 'D': return Level.debug; + case 'INFO': case 'I': return Level.info; + case 'WARNING': case 'W': return Level.warning; + case 'ERROR': case 'E': return Level.error; + case 'FATAL': case 'F': return Level.fatal; default: diff --git a/lib/features/logs/presentation/logs_screen.dart b/lib/features/logs/presentation/logs_screen.dart index d3dc13d..73aab8a 100644 --- a/lib/features/logs/presentation/logs_screen.dart +++ b/lib/features/logs/presentation/logs_screen.dart @@ -14,33 +14,98 @@ class LogsScreen extends StatelessWidget { } } -class _LogsScreenContent extends StatelessWidget { +class _LogsScreenContent extends StatefulWidget { const _LogsScreenContent(); + @override + State<_LogsScreenContent> createState() => _LogsScreenContentState(); +} + +class _LogsScreenContentState extends State<_LogsScreenContent> { + late final ScrollController _scrollController; + bool _autoScroll = true; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController()..addListener(_handleScroll); + } + + @override + void dispose() { + _scrollController + ..removeListener(_handleScroll) + ..dispose(); + super.dispose(); + } + + void _handleScroll() { + if (!_scrollController.hasClients) { + return; + } + + final maxScrollExtent = _scrollController.position.maxScrollExtent; + final isAtBottom = _scrollController.offset >= (maxScrollExtent - 24); + if (isAtBottom != _autoScroll) { + setState(() => _autoScroll = isAtBottom); + } + } + + void _scrollToBottom({bool animated = false}) { + if (!_scrollController.hasClients) { + return; + } + + final targetOffset = _scrollController.position.maxScrollExtent; + if (animated) { + _scrollController.animateTo(targetOffset, duration: const Duration(milliseconds: 180), curve: Curves.easeOut); + } else { + _scrollController.jumpTo(targetOffset); + } + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Logs'), - actions: [ - IconButton( - icon: const Icon(Icons.share_outlined), - tooltip: 'Share log file', - onPressed: () => _shareLogs(context), - ), - IconButton( - icon: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), - tooltip: 'Clear all logs', - onPressed: () => _confirmClearLogs(context), - ), - ], - ), - body: Column( - children: [ - _SearchBar(), - _LevelFilterChips(), - Expanded(child: _LogList()), - ], + return BlocListener( + listenWhen: (previous, current) => + previous.filteredEntries != current.filteredEntries || previous.status != current.status, + listener: (context, state) { + if (!_autoScroll || state.status != LogViewerStatus.loaded) { + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom()); + }, + child: Scaffold( + appBar: AppBar( + title: const Text('Logs'), + actions: [ + IconButton( + icon: const Icon(Icons.share_outlined), + tooltip: 'Share log file', + onPressed: () => _shareLogs(context), + ), + IconButton( + icon: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), + tooltip: 'Clear all logs', + onPressed: () => _confirmClearLogs(context), + ), + ], + ), + body: Column( + children: [ + _SearchBar(), + _LevelFilterChips(), + Expanded(child: _LogList(controller: _scrollController)), + _AutoScrollIndicator( + isActive: _autoScroll, + onTap: () { + setState(() => _autoScroll = true); + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToBottom(animated: true)); + }, + ), + ], + ), ), ); } @@ -79,6 +144,47 @@ class _LogsScreenContent extends StatelessWidget { } } +class _AutoScrollIndicator extends StatelessWidget { + const _AutoScrollIndicator({required this.isActive, required this.onTap}); + + final bool isActive; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return Material( + color: colorScheme.surface, + child: InkWell( + onTap: onTap, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + border: Border(top: BorderSide(color: colorScheme.outlineVariant)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.keyboard_arrow_down_rounded, + size: 16, + color: isActive ? colorScheme.primary : colorScheme.outline, + ), + const SizedBox(width: 4), + Text( + 'Auto-scroll', + style: TextStyle(fontSize: 12, color: isActive ? colorScheme.primary : colorScheme.outline), + ), + ], + ), + ), + ), + ); + } +} + class _SearchBar extends StatelessWidget { @override Widget build(BuildContext context) { @@ -152,6 +258,10 @@ class _LevelFilterChips extends StatelessWidget { } class _LogList extends StatelessWidget { + const _LogList({required this.controller}); + + final ScrollController controller; + @override Widget build(BuildContext context) { return BlocBuilder( @@ -180,6 +290,7 @@ class _LogList extends StatelessWidget { } return ListView.separated( + controller: controller, itemCount: state.filteredEntries.length, separatorBuilder: (context, index) => Divider(height: 1, color: Theme.of(context).colorScheme.outlineVariant), itemBuilder: (context, index) { diff --git a/lib/main.dart b/lib/main.dart index 32cb3c3..7f9070b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -36,7 +36,7 @@ Future main() async { final settingsCubit = SettingsCubit(database: database); await settingsCubit.loadSettings(); - log.i('App started'); + log.i('AppLogger: App started'); runApp(LazuriteApp(authBloc: authBloc, database: database, settingsCubit: settingsCubit)); } diff --git a/test/core/logging/app_file_log_printer_test.dart b/test/core/logging/app_file_log_printer_test.dart new file mode 100644 index 0000000..9a81360 --- /dev/null +++ b/test/core/logging/app_file_log_printer_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:lazurite/core/logging/app_file_log_printer.dart'; + +void main() { + group('AppFileLogPrinter', () { + test('prints one-line log entries with time, source, error, and stack trace', () { + final printer = AppFileLogPrinter(); + final lines = printer.log( + LogEvent( + Level.error, + 'FeedBloc: Failed to decode feed post', + time: DateTime(2026, 3, 16, 14, 32, 5, 220), + error: StateError('boom'), + stackTrace: StackTrace.fromString('#0 FeedBloc.load\n#1 main'), + ), + ); + + expect(lines, hasLength(1)); + expect(lines.single, contains('[E] TIME: 2026-03-16T14:32:05.220')); + expect(lines.single, contains('FeedBloc: Failed to decode feed post')); + expect(lines.single, contains('ERROR: Bad state: boom')); + expect(lines.single, contains('STACK: #0 FeedBloc.load | #1 main')); + }); + + test('uses fatal label for fatal logs', () { + final printer = AppFileLogPrinter(); + final lines = printer.log( + LogEvent(Level.fatal, 'AppLogger: Unhandled exception in zone', time: DateTime(2026, 3, 16, 14, 32, 12, 450)), + ); + + expect(lines.single, startsWith('[FATAL] TIME: 2026-03-16T14:32:12.450')); + }); + }); +} diff --git a/test/core/logging/daily_log_file_output_test.dart b/test/core/logging/daily_log_file_output_test.dart new file mode 100644 index 0000000..44897b8 --- /dev/null +++ b/test/core/logging/daily_log_file_output_test.dart @@ -0,0 +1,69 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:lazurite/core/logging/app_file_log_printer.dart'; +import 'package:lazurite/core/logging/daily_log_file_output.dart'; + +void main() { + group('DailyLogFileOutput', () { + late Directory tempDirectory; + + setUp(() async { + tempDirectory = await Directory.systemTemp.createTemp('lazurite_logs_test_'); + }); + + tearDown(() async { + if (await tempDirectory.exists()) { + await tempDirectory.delete(recursive: true); + } + }); + + test('writes logs to a daily file in the target directory', () async { + final output = DailyLogFileOutput(directoryPath: tempDirectory.path); + final logger = Logger( + filter: ProductionFilter(), + printer: AppFileLogPrinter(), + output: output, + level: Level.trace, + ); + await logger.init; + + logger.i('AppLogger: App started', time: DateTime(2026, 3, 16, 14, 32, 1, 123)); + await logger.close(); + + final file = File( + '${tempDirectory.path}/${DailyLogFileOutput.fileNameFor(DateTime(2026, 3, 16, 14, 32, 1, 123))}', + ); + expect(await file.exists(), isTrue); + expect(await file.readAsString(), contains('[I] TIME: 2026-03-16T14:32:01.123 AppLogger: App started')); + }); + + test('clears all log files', () async { + final output = DailyLogFileOutput(directoryPath: tempDirectory.path); + await output.init(); + + final logFile = File('${tempDirectory.path}/lazurite_2026-03-16.log'); + await logFile.writeAsString('test'); + + await output.clearAllLogs(); + + expect(await logFile.exists(), isFalse); + }); + + test('removes files older than retention window', () async { + final output = DailyLogFileOutput(directoryPath: tempDirectory.path, retentionDays: 3); + await output.init(); + + final staleFile = File('${tempDirectory.path}/lazurite_2026-03-12.log'); + final keptFile = File('${tempDirectory.path}/lazurite_2026-03-14.log'); + await staleFile.writeAsString('old'); + await keptFile.writeAsString('keep'); + + await output.cleanupOldLogs(referenceTime: DateTime(2026, 3, 16, 12)); + + expect(await staleFile.exists(), isFalse); + expect(await keptFile.exists(), isTrue); + }); + }); +} diff --git a/test/features/logs/cubit/log_viewer_cubit_test.dart b/test/features/logs/cubit/log_viewer_cubit_test.dart index 4dd9e62..890544c 100644 --- a/test/features/logs/cubit/log_viewer_cubit_test.dart +++ b/test/features/logs/cubit/log_viewer_cubit_test.dart @@ -29,6 +29,13 @@ void main() { expect(state.status, LogViewerStatus.error); expect(state.errorMessage, 'Test error'); }); + + test('copyWith clears error message when null is provided', () { + final state = LogViewerState.initial().copyWith(status: LogViewerStatus.error, errorMessage: 'Test error'); + final cleared = state.copyWith(status: LogViewerStatus.loaded, errorMessage: null); + expect(cleared.status, LogViewerStatus.loaded); + expect(cleared.errorMessage, isNull); + }); }); group('LogViewerCubit', () { diff --git a/test/features/logs/data/log_entry_test.dart b/test/features/logs/data/log_entry_test.dart index d9ca68f..616efed 100644 --- a/test/features/logs/data/log_entry_test.dart +++ b/test/features/logs/data/log_entry_test.dart @@ -37,14 +37,23 @@ void main() { expect(entry.source, 'AuthBloc'); }); - test('parses log line with timestamp', () { - final entry = LogEntry.tryParse('14:32:01.123 [I] App started'); + test('parses log line with ISO time tag and source prefix', () { + final entry = LogEntry.tryParse('[I] TIME: 2026-03-16T14:32:01.123 AppLogger: App started'); expect(entry, isNotNull); expect(entry!.level, Level.info); + expect(entry.source, 'AppLogger'); expect(entry.message, 'App started'); expect(entry.formatTimestamp(), '14:32:01.123'); }); + test('parses fatal log lines written by the file printer', () { + final entry = LogEntry.tryParse('[FATAL] TIME: 2026-03-16T14:32:12.450 AppLogger: Unhandled exception'); + expect(entry, isNotNull); + expect(entry!.level, Level.fatal); + expect(entry.source, 'AppLogger'); + expect(entry.message, 'Unhandled exception'); + }); + test('returns null for empty line', () { final entry = LogEntry.tryParse(''); expect(entry, isNull);