Fix annotation navigation by scrolling outer ScrollView instead of WebView
The JavaScript was executing scrollIntoView() but the WebView itself cannot scroll (isScrollEnabled = false). Fixed by calculating the annotation's Y position in the WebView and scrolling the outer ScrollView to the correct position instead. Changes: - WebView.swift: Added onScrollToPosition callback and scrollToPosition message handler. JavaScript now calculates and sends annotation position to Swift instead of using scrollIntoView(). - NativeWebView.swift: Same changes for iOS 26+ with polling mechanism for window.__pendingScrollPosition. - BookmarkDetailLegacyView.swift: Implemented onScrollToPosition callback that calculates final scroll position (header height + annotation position) and scrolls the outer ScrollView. - BookmarkDetailView2.swift: Same implementation as BookmarkDetailLegacyView.
This commit is contained in:
parent
87464943ac
commit
cdfa6dc4c5
@ -101,6 +101,16 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
endSelector: endSelector
|
endSelector: endSelector
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onScrollToPosition: { position in
|
||||||
|
// Calculate scroll position: add header height and webview offset
|
||||||
|
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
|
||||||
|
let targetPosition = imageHeight + position
|
||||||
|
|
||||||
|
// Scroll to the annotation
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
scrollPosition = ScrollPosition(y: targetPosition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
@ -392,7 +402,7 @@ struct BookmarkDetailLegacyView: View {
|
|||||||
}) {
|
}) {
|
||||||
HStack {
|
HStack {
|
||||||
Image(systemName: "safari")
|
Image(systemName: "safari")
|
||||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + " open")
|
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Open original page") + "open")
|
||||||
}
|
}
|
||||||
.font(.title3.bold())
|
.font(.title3.bold())
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
|||||||
@ -475,6 +475,16 @@ struct BookmarkDetailView2: View {
|
|||||||
endSelector: endSelector
|
endSelector: endSelector
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
onScrollToPosition: { position in
|
||||||
|
// Calculate scroll position: add header height and webview offset
|
||||||
|
let imageHeight: CGFloat = viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight
|
||||||
|
let targetPosition = imageHeight + position
|
||||||
|
|
||||||
|
// Scroll to the annotation
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||||
|
scrollPosition = ScrollPosition(y: targetPosition)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.frame(height: webViewHeight)
|
.frame(height: webViewHeight)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ struct NativeWebView: View {
|
|||||||
var onScroll: ((Double) -> Void)? = nil
|
var onScroll: ((Double) -> Void)? = nil
|
||||||
var selectedAnnotationId: String?
|
var selectedAnnotationId: String?
|
||||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||||
|
var onScrollToPosition: ((CGFloat) -> Void)? = nil
|
||||||
|
|
||||||
@State private var webPage = WebPage()
|
@State private var webPage = WebPage()
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
@ -23,6 +24,7 @@ struct NativeWebView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
setupAnnotationMessageHandler()
|
setupAnnotationMessageHandler()
|
||||||
|
setupScrollToPositionHandler()
|
||||||
}
|
}
|
||||||
.onChange(of: htmlContent) { _, _ in
|
.onChange(of: htmlContent) { _, _ in
|
||||||
loadStyledContent()
|
loadStyledContent()
|
||||||
@ -82,6 +84,38 @@ struct NativeWebView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func setupScrollToPositionHandler() {
|
||||||
|
guard let onScrollToPosition = onScrollToPosition else { return }
|
||||||
|
|
||||||
|
// Poll for scroll position messages from JavaScript
|
||||||
|
Task { @MainActor in
|
||||||
|
let page = webPage
|
||||||
|
|
||||||
|
while true {
|
||||||
|
try? await Task.sleep(nanoseconds: 100_000_000) // Check every 0.1s
|
||||||
|
|
||||||
|
let script = """
|
||||||
|
return (function() {
|
||||||
|
if (window.__pendingScrollPosition !== undefined) {
|
||||||
|
const position = window.__pendingScrollPosition;
|
||||||
|
window.__pendingScrollPosition = undefined;
|
||||||
|
return position;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
|
||||||
|
do {
|
||||||
|
if let position = try await page.callJavaScript(script) as? Double {
|
||||||
|
onScrollToPosition(CGFloat(position))
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently continue polling
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func updateContentHeightWithJS() async {
|
private func updateContentHeightWithJS() async {
|
||||||
var lastHeight: CGFloat = 0
|
var lastHeight: CGFloat = 0
|
||||||
@ -627,8 +661,15 @@ struct NativeWebView: View {
|
|||||||
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
||||||
if (selectedElement) {
|
if (selectedElement) {
|
||||||
selectedElement.classList.add('selected');
|
selectedElement.classList.add('selected');
|
||||||
|
|
||||||
|
// Get the element's position relative to the document
|
||||||
|
const rect = selectedElement.getBoundingClientRect();
|
||||||
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||||
|
const elementTop = rect.top + scrollTop;
|
||||||
|
|
||||||
|
// Send position to Swift via polling mechanism
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
window.__pendingScrollPosition = elementTop;
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
var onScroll: ((Double) -> Void)? = nil
|
var onScroll: ((Double) -> Void)? = nil
|
||||||
var selectedAnnotationId: String?
|
var selectedAnnotationId: String?
|
||||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||||
|
var onScrollToPosition: ((CGFloat) -> Void)? = nil
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
func makeUIView(context: Context) -> WKWebView {
|
func makeUIView(context: Context) -> WKWebView {
|
||||||
@ -31,9 +32,11 @@ struct WebView: UIViewRepresentable {
|
|||||||
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
webView.configuration.userContentController.add(context.coordinator, name: "scrollProgress")
|
||||||
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
|
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
|
||||||
|
webView.configuration.userContentController.add(context.coordinator, name: "scrollToPosition")
|
||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||||
|
context.coordinator.onScrollToPosition = onScrollToPosition
|
||||||
context.coordinator.webView = webView
|
context.coordinator.webView = webView
|
||||||
|
|
||||||
return webView
|
return webView
|
||||||
@ -43,6 +46,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
context.coordinator.onHeightChange = onHeightChange
|
context.coordinator.onHeightChange = onHeightChange
|
||||||
context.coordinator.onScroll = onScroll
|
context.coordinator.onScroll = onScroll
|
||||||
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||||
|
context.coordinator.onScrollToPosition = onScrollToPosition
|
||||||
|
|
||||||
let isDarkMode = colorScheme == .dark
|
let isDarkMode = colorScheme == .dark
|
||||||
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
let fontSize = getFontSize(from: settings.fontSize ?? .extraLarge)
|
||||||
@ -332,6 +336,7 @@ struct WebView: UIViewRepresentable {
|
|||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "heightUpdate")
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollProgress")
|
||||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
|
||||||
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollToPosition")
|
||||||
webView.loadHTMLString("", baseURL: nil)
|
webView.loadHTMLString("", baseURL: nil)
|
||||||
coordinator.cleanup()
|
coordinator.cleanup()
|
||||||
}
|
}
|
||||||
@ -379,8 +384,15 @@ struct WebView: UIViewRepresentable {
|
|||||||
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
||||||
if (selectedElement) {
|
if (selectedElement) {
|
||||||
selectedElement.classList.add('selected');
|
selectedElement.classList.add('selected');
|
||||||
|
|
||||||
|
// Get the element's position relative to the document
|
||||||
|
const rect = selectedElement.getBoundingClientRect();
|
||||||
|
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
|
||||||
|
const elementTop = rect.top + scrollTop;
|
||||||
|
|
||||||
|
// Send position to Swift
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
window.webkit.messageHandlers.scrollToPosition.postMessage(elementTop);
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -647,6 +659,7 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
var onHeightChange: ((CGFloat) -> Void)?
|
var onHeightChange: ((CGFloat) -> Void)?
|
||||||
var onScroll: ((Double) -> Void)?
|
var onScroll: ((Double) -> Void)?
|
||||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
|
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
|
||||||
|
var onScrollToPosition: ((CGFloat) -> Void)?
|
||||||
|
|
||||||
// WebView reference
|
// WebView reference
|
||||||
weak var webView: WKWebView?
|
weak var webView: WKWebView?
|
||||||
@ -702,6 +715,11 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
|
self.onAnnotationCreated?(color, text, startOffset, endOffset, startSelector, endSelector)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if message.name == "scrollToPosition", let position = message.body as? Double {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self.onScrollToPosition?(CGFloat(position))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleHeightUpdate(height: CGFloat) {
|
private func handleHeightUpdate(height: CGFloat) {
|
||||||
@ -778,5 +796,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
|||||||
onHeightChange = nil
|
onHeightChange = nil
|
||||||
onScroll = nil
|
onScroll = nil
|
||||||
onAnnotationCreated = nil
|
onAnnotationCreated = nil
|
||||||
|
onScrollToPosition = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
readeck/UI/Resources/RELEASE_NOTES.md
Normal file
120
readeck/UI/Resources/RELEASE_NOTES.md
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
# Release Notes
|
||||||
|
|
||||||
|
Thanks for using the Readeck iOS app! Below are the release notes for each version.
|
||||||
|
|
||||||
|
**AppStore:** The App is now in the App Store! [Get it here](https://apps.apple.com/de/app/readeck/id6748764703) for all TestFlight users. If you wish a more stable Version, please download it from there. Or you can continue using TestFlight for the latest features.
|
||||||
|
|
||||||
|
## Version 1.2.0
|
||||||
|
|
||||||
|
### Annotations & Highlighting
|
||||||
|
|
||||||
|
- **Highlight important passages** directly in your articles
|
||||||
|
- Select text to bring up a beautiful color picker overlay
|
||||||
|
- Choose from four distinct colors: yellow, green, blue, and red
|
||||||
|
- Your highlights are saved and synced across devices
|
||||||
|
- Tap on annotations in the list to jump directly to that passage in the article
|
||||||
|
- Glass morphism design for a modern, elegant look
|
||||||
|
|
||||||
|
### Performance Improvements
|
||||||
|
|
||||||
|
- **Dramatically faster label loading** - especially with 1000+ labels
|
||||||
|
- Labels now load instantly from local cache, then sync in background
|
||||||
|
- Optimized label management to prevent crashes and lag
|
||||||
|
- Share Extension now loads labels without delay
|
||||||
|
- Reduced memory usage when working with large label collections
|
||||||
|
- Better offline support - labels always available even without internet
|
||||||
|
|
||||||
|
### Fixes & Improvements
|
||||||
|
|
||||||
|
- Centralized color management for consistent appearance
|
||||||
|
- Improved annotation creation workflow
|
||||||
|
- Better text selection handling in article view
|
||||||
|
- Implemented lazy loading for label lists
|
||||||
|
- Switched to Core Data as primary source for labels
|
||||||
|
- Batch operations for faster database queries
|
||||||
|
- Background sync to keep labels up-to-date without blocking the UI
|
||||||
|
- Fixed duplicate ID warnings in label lists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version 1.1.0
|
||||||
|
|
||||||
|
There is a lot of feature reqeusts and improvements in this release which are based on your feedback. Thank you so much for that! If you like the new features, please consider leaving a review on the App Store to support further development.
|
||||||
|
|
||||||
|
### Modern Reading Experience (iOS 26+)
|
||||||
|
|
||||||
|
- **Completely rebuilt article view** for the latest iOS version
|
||||||
|
- Smoother scrolling and faster page loading
|
||||||
|
- Better battery life and memory usage
|
||||||
|
- Native iOS integration for the best experience
|
||||||
|
|
||||||
|
### Quick Actions
|
||||||
|
|
||||||
|
- **Smart action buttons** appear automatically when you're almost done reading
|
||||||
|
- Beautiful, modern design that blends with your content
|
||||||
|
- Quickly favorite or archive articles without scrolling back up
|
||||||
|
- Buttons fade away elegantly when you scroll back
|
||||||
|
- Your progress bar now reflects the entire article length
|
||||||
|
|
||||||
|
### Beautiful Article Images
|
||||||
|
|
||||||
|
- **Article header images now display properly** without awkward cropping
|
||||||
|
- Full images with a subtle blurred background
|
||||||
|
- Tap to view images in full screen
|
||||||
|
|
||||||
|
### Smoother Performance
|
||||||
|
|
||||||
|
- **Dramatically improved scrolling** - no more stuttering or lag
|
||||||
|
- Faster article loading times
|
||||||
|
- Better handling of long articles with many images
|
||||||
|
- Overall snappier app experience
|
||||||
|
|
||||||
|
### Open Links Your Way
|
||||||
|
|
||||||
|
- **Choose your preferred browser** for opening links
|
||||||
|
- Open in Safari or in-app browser
|
||||||
|
- Thanks to christian-putzke for this contribution!
|
||||||
|
|
||||||
|
### Fixes & Improvements
|
||||||
|
|
||||||
|
- Articles no longer overflow the screen width
|
||||||
|
- Fixed spacing issues in article view
|
||||||
|
- Improved progress calculation accuracy
|
||||||
|
- Better handling of article content
|
||||||
|
- Fixed issues with label names containing spaces
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Version 1.0 (Initial Release)
|
||||||
|
|
||||||
|
### Core Features
|
||||||
|
|
||||||
|
- Browse and read saved articles
|
||||||
|
- Bookmark management with labels
|
||||||
|
- Full article view with custom fonts
|
||||||
|
- Text-to-speech support (Beta)
|
||||||
|
- Archive and favorite functionality
|
||||||
|
- Choose different Layouts (Compact, Magazine, Natural)
|
||||||
|
|
||||||
|
### Reading Experience
|
||||||
|
|
||||||
|
- Clean, distraction-free reading interface
|
||||||
|
- Customizable font settings
|
||||||
|
- Header Image viewer with zoom support
|
||||||
|
- Progress tracking per article
|
||||||
|
- Dark mode support
|
||||||
|
|
||||||
|
### Organization
|
||||||
|
|
||||||
|
- Label system for categorization (multi-select)
|
||||||
|
- Search
|
||||||
|
- Archive completed articles
|
||||||
|
- Jump to last read position
|
||||||
|
|
||||||
|
### Share Extension
|
||||||
|
|
||||||
|
- Save articles from other apps
|
||||||
|
- Quick access to save and label bookmarks
|
||||||
|
- Save Bookmarks offline if your server is not reachable and sync later
|
||||||
|
|
||||||
|
|
||||||
262
readeck/Utils/Logger.swift
Normal file
262
readeck/Utils/Logger.swift
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
//
|
||||||
|
// Logger.swift
|
||||||
|
// readeck
|
||||||
|
//
|
||||||
|
// Created by Ilyas Hallak on 16.08.25.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import os
|
||||||
|
|
||||||
|
// MARK: - Log Configuration
|
||||||
|
|
||||||
|
enum LogLevel: Int, CaseIterable {
|
||||||
|
case debug = 0
|
||||||
|
case info = 1
|
||||||
|
case notice = 2
|
||||||
|
case warning = 3
|
||||||
|
case error = 4
|
||||||
|
case critical = 5
|
||||||
|
|
||||||
|
var emoji: String {
|
||||||
|
switch self {
|
||||||
|
case .debug: return "🔍"
|
||||||
|
case .info: return "ℹ️"
|
||||||
|
case .notice: return "📢"
|
||||||
|
case .warning: return "⚠️"
|
||||||
|
case .error: return "❌"
|
||||||
|
case .critical: return "💥"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum LogCategory: String, CaseIterable {
|
||||||
|
case network = "Network"
|
||||||
|
case ui = "UI"
|
||||||
|
case data = "Data"
|
||||||
|
case auth = "Authentication"
|
||||||
|
case performance = "Performance"
|
||||||
|
case general = "General"
|
||||||
|
case manual = "Manual"
|
||||||
|
case viewModel = "ViewModel"
|
||||||
|
}
|
||||||
|
|
||||||
|
class LogConfiguration: ObservableObject {
|
||||||
|
static let shared = LogConfiguration()
|
||||||
|
|
||||||
|
@Published private var categoryLevels: [LogCategory: LogLevel] = [:]
|
||||||
|
@Published var globalMinLevel: LogLevel = .debug
|
||||||
|
@Published var showPerformanceLogs = true
|
||||||
|
@Published var showTimestamps = true
|
||||||
|
@Published var includeSourceLocation = true
|
||||||
|
|
||||||
|
private init() {
|
||||||
|
loadConfiguration()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setLevel(_ level: LogLevel, for category: LogCategory) {
|
||||||
|
categoryLevels[category] = level
|
||||||
|
saveConfiguration()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getLevel(for category: LogCategory) -> LogLevel {
|
||||||
|
return categoryLevels[category] ?? globalMinLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldLog(_ level: LogLevel, for category: LogCategory) -> Bool {
|
||||||
|
let categoryLevel = getLevel(for: category)
|
||||||
|
return level.rawValue >= categoryLevel.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadConfiguration() {
|
||||||
|
// Load from UserDefaults
|
||||||
|
if let data = UserDefaults.standard.data(forKey: "LogConfiguration"),
|
||||||
|
let config = try? JSONDecoder().decode([String: Int].self, from: data) {
|
||||||
|
for (categoryString, levelInt) in config {
|
||||||
|
if let category = LogCategory(rawValue: categoryString),
|
||||||
|
let level = LogLevel(rawValue: levelInt) {
|
||||||
|
categoryLevels[category] = level
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
globalMinLevel = LogLevel(rawValue: UserDefaults.standard.integer(forKey: "LogGlobalLevel")) ?? .debug
|
||||||
|
showPerformanceLogs = UserDefaults.standard.bool(forKey: "LogShowPerformance")
|
||||||
|
showTimestamps = UserDefaults.standard.bool(forKey: "LogShowTimestamps")
|
||||||
|
includeSourceLocation = UserDefaults.standard.bool(forKey: "LogIncludeSourceLocation")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveConfiguration() {
|
||||||
|
let config = categoryLevels.mapKeys { $0.rawValue }.mapValues { $0.rawValue }
|
||||||
|
if let data = try? JSONEncoder().encode(config) {
|
||||||
|
UserDefaults.standard.set(data, forKey: "LogConfiguration")
|
||||||
|
}
|
||||||
|
|
||||||
|
UserDefaults.standard.set(globalMinLevel.rawValue, forKey: "LogGlobalLevel")
|
||||||
|
UserDefaults.standard.set(showPerformanceLogs, forKey: "LogShowPerformance")
|
||||||
|
UserDefaults.standard.set(showTimestamps, forKey: "LogShowTimestamps")
|
||||||
|
UserDefaults.standard.set(includeSourceLocation, forKey: "LogIncludeSourceLocation")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Logger {
|
||||||
|
private let logger: os.Logger
|
||||||
|
private let category: LogCategory
|
||||||
|
private let config = LogConfiguration.shared
|
||||||
|
|
||||||
|
init(subsystem: String = Bundle.main.bundleIdentifier ?? "com.romm.app", category: LogCategory) {
|
||||||
|
self.logger = os.Logger(subsystem: subsystem, category: category.rawValue)
|
||||||
|
self.category = category
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Log Levels
|
||||||
|
|
||||||
|
func debug(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
|
guard config.shouldLog(.debug, for: category) else { return }
|
||||||
|
let formattedMessage = formatMessage(message, level: .debug, file: file, function: function, line: line)
|
||||||
|
logger.debug("\(formattedMessage)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
|
guard config.shouldLog(.info, for: category) else { return }
|
||||||
|
let formattedMessage = formatMessage(message, level: .info, file: file, function: function, line: line)
|
||||||
|
logger.info("\(formattedMessage)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func notice(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
|
guard config.shouldLog(.notice, for: category) else { return }
|
||||||
|
let formattedMessage = formatMessage(message, level: .notice, file: file, function: function, line: line)
|
||||||
|
logger.notice("\(formattedMessage)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func warning(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
|
guard config.shouldLog(.warning, for: category) else { return }
|
||||||
|
let formattedMessage = formatMessage(message, level: .warning, file: file, function: function, line: line)
|
||||||
|
logger.warning("\(formattedMessage)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func error(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
|
guard config.shouldLog(.error, for: category) else { return }
|
||||||
|
let formattedMessage = formatMessage(message, level: .error, file: file, function: function, line: line)
|
||||||
|
logger.error("\(formattedMessage)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func critical(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
|
||||||
|
guard config.shouldLog(.critical, for: category) else { return }
|
||||||
|
let formattedMessage = formatMessage(message, level: .critical, file: file, function: function, line: line)
|
||||||
|
logger.critical("\(formattedMessage)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Convenience Methods
|
||||||
|
|
||||||
|
func logNetworkRequest(method: String, url: String, statusCode: Int? = nil) {
|
||||||
|
guard config.shouldLog(.info, for: category) else { return }
|
||||||
|
if let statusCode = statusCode {
|
||||||
|
info("🌐 \(method) \(url) - Status: \(statusCode)")
|
||||||
|
} else {
|
||||||
|
info("🌐 \(method) \(url)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func logNetworkError(method: String, url: String, error: Error) {
|
||||||
|
guard config.shouldLog(.error, for: category) else { return }
|
||||||
|
self.error("❌ \(method) \(url) - Error: \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func logPerformance(_ operation: String, duration: TimeInterval) {
|
||||||
|
guard config.showPerformanceLogs && config.shouldLog(.info, for: category) else { return }
|
||||||
|
info("⏱️ \(operation) completed in \(String(format: "%.3f", duration))s")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Helpers
|
||||||
|
|
||||||
|
private func formatMessage(_ message: String, level: LogLevel, file: String, function: String, line: Int) -> String {
|
||||||
|
var components: [String] = []
|
||||||
|
|
||||||
|
if config.showTimestamps {
|
||||||
|
let timestamp = DateFormatter.logTimestamp.string(from: Date())
|
||||||
|
components.append(timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
components.append(level.emoji)
|
||||||
|
components.append("[\(category.rawValue)]")
|
||||||
|
|
||||||
|
if config.includeSourceLocation {
|
||||||
|
components.append("[\(sourceFileName(filePath: file)):\(line)]")
|
||||||
|
components.append(function)
|
||||||
|
}
|
||||||
|
|
||||||
|
components.append("-")
|
||||||
|
components.append(message)
|
||||||
|
|
||||||
|
return components.joined(separator: " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sourceFileName(filePath: String) -> String {
|
||||||
|
return URL(fileURLWithPath: filePath).lastPathComponent.replacingOccurrences(of: ".swift", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Category-specific Loggers
|
||||||
|
|
||||||
|
extension Logger {
|
||||||
|
static let network = Logger(category: .network)
|
||||||
|
static let ui = Logger(category: .ui)
|
||||||
|
static let data = Logger(category: .data)
|
||||||
|
static let auth = Logger(category: .auth)
|
||||||
|
static let performance = Logger(category: .performance)
|
||||||
|
static let general = Logger(category: .general)
|
||||||
|
static let manual = Logger(category: .manual)
|
||||||
|
static let viewModel = Logger(category: .viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Performance Measurement Helper
|
||||||
|
|
||||||
|
struct PerformanceMeasurement {
|
||||||
|
private let startTime = CFAbsoluteTimeGetCurrent()
|
||||||
|
private let operation: String
|
||||||
|
private let logger: Logger
|
||||||
|
|
||||||
|
init(operation: String, logger: Logger = .performance) {
|
||||||
|
self.operation = operation
|
||||||
|
self.logger = logger
|
||||||
|
logger.debug("🚀 Starting \(operation)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func end() {
|
||||||
|
let duration = CFAbsoluteTimeGetCurrent() - startTime
|
||||||
|
logger.logPerformance(operation, duration: duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - DateFormatter Extension
|
||||||
|
|
||||||
|
extension DateFormatter {
|
||||||
|
static let logTimestamp: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "HH:mm:ss.SSS"
|
||||||
|
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Dictionary Extension
|
||||||
|
|
||||||
|
extension Dictionary {
|
||||||
|
func mapKeys<T>(_ transform: (Key) throws -> T) rethrows -> [T: Value] {
|
||||||
|
return try Dictionary<T, Value>(uniqueKeysWithValues: map { (try transform($0.key), $0.value) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Debug Build Detection
|
||||||
|
|
||||||
|
extension Bundle {
|
||||||
|
var isDebugBuild: Bool {
|
||||||
|
#if DEBUG
|
||||||
|
return true
|
||||||
|
#else
|
||||||
|
return false
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user