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
|
||||
)
|
||||
}
|
||||
},
|
||||
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)
|
||||
@ -392,7 +402,7 @@ struct BookmarkDetailLegacyView: View {
|
||||
}) {
|
||||
HStack {
|
||||
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())
|
||||
.frame(maxWidth: .infinity)
|
||||
|
||||
@ -475,6 +475,16 @@ struct BookmarkDetailView2: View {
|
||||
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)
|
||||
|
||||
@ -13,6 +13,7 @@ struct NativeWebView: View {
|
||||
var onScroll: ((Double) -> Void)? = nil
|
||||
var selectedAnnotationId: String?
|
||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||
var onScrollToPosition: ((CGFloat) -> Void)? = nil
|
||||
|
||||
@State private var webPage = WebPage()
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ -23,6 +24,7 @@ struct NativeWebView: View {
|
||||
.onAppear {
|
||||
loadStyledContent()
|
||||
setupAnnotationMessageHandler()
|
||||
setupScrollToPositionHandler()
|
||||
}
|
||||
.onChange(of: htmlContent) { _, _ in
|
||||
loadStyledContent()
|
||||
@ -83,6 +85,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 {
|
||||
var lastHeight: CGFloat = 0
|
||||
|
||||
@ -627,8 +661,15 @@ struct NativeWebView: View {
|
||||
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
||||
if (selectedElement) {
|
||||
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(() => {
|
||||
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
window.__pendingScrollPosition = elementTop;
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ struct WebView: UIViewRepresentable {
|
||||
var onScroll: ((Double) -> Void)? = nil
|
||||
var selectedAnnotationId: String?
|
||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)? = nil
|
||||
var onScrollToPosition: ((CGFloat) -> Void)? = nil
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
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: "scrollProgress")
|
||||
webView.configuration.userContentController.add(context.coordinator, name: "annotationCreated")
|
||||
webView.configuration.userContentController.add(context.coordinator, name: "scrollToPosition")
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||
context.coordinator.onScrollToPosition = onScrollToPosition
|
||||
context.coordinator.webView = webView
|
||||
|
||||
return webView
|
||||
@ -43,6 +46,7 @@ struct WebView: UIViewRepresentable {
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
context.coordinator.onScroll = onScroll
|
||||
context.coordinator.onAnnotationCreated = onAnnotationCreated
|
||||
context.coordinator.onScrollToPosition = onScrollToPosition
|
||||
|
||||
let isDarkMode = colorScheme == .dark
|
||||
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: "scrollProgress")
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "annotationCreated")
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "scrollToPosition")
|
||||
webView.loadHTMLString("", baseURL: nil)
|
||||
coordinator.cleanup()
|
||||
}
|
||||
@ -379,8 +384,15 @@ struct WebView: UIViewRepresentable {
|
||||
const selectedElement = document.querySelector('rd-annotation[data-annotation-id-value="\(selectedId)"]');
|
||||
if (selectedElement) {
|
||||
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(() => {
|
||||
selectedElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
window.webkit.messageHandlers.scrollToPosition.postMessage(elementTop);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
@ -647,6 +659,7 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
var onHeightChange: ((CGFloat) -> Void)?
|
||||
var onScroll: ((Double) -> Void)?
|
||||
var onAnnotationCreated: ((String, String, Int, Int, String, String) -> Void)?
|
||||
var onScrollToPosition: ((CGFloat) -> Void)?
|
||||
|
||||
// WebView reference
|
||||
weak var webView: WKWebView?
|
||||
@ -702,6 +715,11 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
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) {
|
||||
@ -778,5 +796,6 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
|
||||
onHeightChange = nil
|
||||
onScroll = 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